Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WiP: Video capture #1234

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion browser/page_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ func mapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop
rt := vu.Runtime()
maps := mapping{
"bringToFront": p.BringToFront,
"check": p.Check,
"captureVideo": func(opts goja.Value) error {
ctx := vu.Context()

popts := common.NewVidepCaptureOptions()
if err := popts.Parse(ctx, opts); err != nil {
return fmt.Errorf("parsing page screencast options: %w", err)
}

return p.CaptureVideo(popts, vu.filePersister)
},
"check": p.Check,
"click": func(selector string, opts goja.Value) (*goja.Promise, error) {
popts, err := parseFrameClickOptions(vu.Context(), opts, p.Timeout())
if err != nil {
Expand Down Expand Up @@ -163,6 +173,7 @@ func mapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop
"setExtraHTTPHeaders": p.SetExtraHTTPHeaders,
"setInputFiles": p.SetInputFiles,
"setViewportSize": p.SetViewportSize,
"stopVideoCapture": p.StopVideCapture,
"tap": func(selector string, opts goja.Value) (*goja.Promise, error) {
popts := common.NewFrameTapOptions(p.Timeout())
if err := popts.Parse(vu.Context(), opts); err != nil {
Expand Down
114 changes: 112 additions & 2 deletions common/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package common
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -229,6 +230,9 @@ type Page struct {
closedMu sync.RWMutex
closed bool

videoCaptureMu sync.RWMutex
videoCapture *videocapture

// TODO: setter change these fields (mutex?)
emulatedSize *EmulatedSize
mediaType MediaType
Expand Down Expand Up @@ -334,6 +338,7 @@ func (p *Page) initEvents() {

events := []string{
cdproto.EventRuntimeConsoleAPICalled,
cdproto.EventPageScreencastFrame,
}
p.session.on(p.ctx, events, p.eventCh)

Expand All @@ -356,8 +361,17 @@ func (p *Page) initEvents() {
"sid:%v tid:%v", p.session.ID(), p.targetID)
return
case event := <-p.eventCh:
if ev, ok := event.data.(*cdpruntime.EventConsoleAPICalled); ok {
p.onConsoleAPICalled(ev)
p.logger.Debugf("Page:initEvents:event",
"sid:%v tid:%v event:%s eventDataType:%T", p.session.ID(), p.targetID, event.typ, event.data)
switch event.typ {
case cdproto.EventPageScreencastFrame:
if ev, ok := event.data.(*page.EventScreencastFrame); ok {
p.onScreencastFrame(ev)
}
case cdproto.EventRuntimeConsoleAPICalled:
if ev, ok := event.data.(*cdpruntime.EventConsoleAPICalled); ok {
p.onConsoleAPICalled(ev)
}
}
}
}
Expand Down Expand Up @@ -1091,6 +1105,67 @@ func (p *Page) Screenshot(opts *PageScreenshotOptions, sp ScreenshotPersister) (
return buf, err
}

// CaptureVideo will start a screen cast of the current page and save it to specified file.
func (p *Page) CaptureVideo(opts *VideoCaptureOptions, scp VideoCapturePersister) error {
p.videoCaptureMu.RLock()
defer p.videoCaptureMu.RUnlock()

if p.videoCapture != nil {
return fmt.Errorf("ongoing video capture")
}

vc, err := newVideoCapture(p.ctx, p.logger, *opts, scp)
if err != nil {
return fmt.Errorf("creating video capture: %w", err)
}
p.videoCapture = vc

err = p.session.ExecuteWithoutExpectationOnReply(
p.ctx,
cdppage.CommandStartScreencast,
cdppage.StartScreencastParams{
Format: "png",
Quality: opts.Quality,
MaxWidth: opts.MaxWidth,
MaxHeight: opts.MaxHeight,
EveryNthFrame: opts.EveryNthFrame,
},
nil,
)
if err != nil {
return fmt.Errorf("starting screen cast %w", err)
}

return nil
}

// StopVideCapture stops any ongoing screen capture. In none is ongoing, is nop
func (p *Page) StopVideCapture() error {
p.videoCaptureMu.RLock()
defer p.videoCaptureMu.RUnlock()

if p.videoCapture == nil {
return nil
}

err := p.session.ExecuteWithoutExpectationOnReply(
p.ctx,
cdppage.CommandStopScreencast,
nil,
nil,
)
// don't return error to allow video to be recorded
if err != nil {
p.logger.Errorf("Page:StopVideoCapture", "sid:%v error:%v", p.sessionID(), err)
}

// prevent any pending frame to be sent to video capture while closing it
vc := p.videoCapture
p.videoCapture = nil

return vc.Close(p.ctx)
}

func (p *Page) SelectOption(selector string, values goja.Value, opts goja.Value) []string {
p.logger.Debugf("Page:SelectOption", "sid:%v selector:%s", p.sessionID(), selector)

Expand Down Expand Up @@ -1294,6 +1369,41 @@ func (p *Page) TargetID() string {
return p.targetID.String()
}

func (p *Page) onScreencastFrame(event *page.EventScreencastFrame) {
p.videoCaptureMu.RLock()
defer p.videoCaptureMu.RUnlock()

if p.videoCapture != nil {
err := p.session.ExecuteWithoutExpectationOnReply(
p.ctx,
cdppage.CommandScreencastFrameAck,
cdppage.ScreencastFrameAckParams{SessionID: event.SessionID},
nil,
)
if err != nil {
p.logger.Debugf("Page:onScreenCastFrame", "frame ack:%v", err)
return
}

frameData := make([]byte, base64.StdEncoding.DecodedLen(len(event.Data)))
_, err = base64.StdEncoding.Decode(frameData, []byte(event.Data))
if err != nil {
p.logger.Debugf("Page:onScreenCastFrame", "decoding frame :%v", err)
}
//content := base64.NewDecoder(base64.StdEncoding, bytes.NewBuffer([]byte(event.Data)))
err = p.videoCapture.handleFrame(
p.ctx,
&VideoFrame{
Content: frameData,
Timestamp: event.Metadata.Timestamp.Time().UnixMilli(),
},
)
if err != nil {
p.logger.Debugf("Page:onScreenCastFrame", "handling frame :%v", err)
}
}
}

func (p *Page) onConsoleAPICalled(event *cdpruntime.EventConsoleAPICalled) {
// If there are no handlers for EventConsoleAPICalled, return
p.eventHandlersMu.RLock()
Expand Down
61 changes: 60 additions & 1 deletion common/page_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ type PageScreenshotOptions struct {
Quality int64 `json:"quality"`
}

type VideoCaptureOptions struct {
Path string `json:"path"`
Format VideoFormat `json:"format"`
FrameRate int64 `json:"frameRate"`
Quality int64 `json:"quality"`
EveryNthFrame int64 `json:"everyNthFrame"`
MaxWidth int64 `json:"maxWidth"`
MaxHeight int64 `json:"maxHeight"`
}

func NewPageEmulateMediaOptions(defaultMedia MediaType, defaultColorScheme ColorScheme, defaultReducedMotion ReducedMotion) *PageEmulateMediaOptions {
return &PageEmulateMediaOptions{
ColorScheme: defaultColorScheme,
Expand Down Expand Up @@ -131,7 +141,7 @@ func (o *PageScreenshotOptions) Parse(ctx context.Context, opts goja.Value) erro
}
}

// Infer file format by path if format not explicitly specified (default is PNG)
// Infer file format by path if format not explicitly specified (default is jpg)
if o.Path != "" && !formatSpecified {
if strings.HasSuffix(o.Path, ".jpg") || strings.HasSuffix(o.Path, ".jpeg") {
o.Format = ImageFormatJPEG
Expand All @@ -141,3 +151,52 @@ func (o *PageScreenshotOptions) Parse(ctx context.Context, opts goja.Value) erro

return nil
}

func (o *VideoCaptureOptions) Parse(ctx context.Context, opts goja.Value) error {
rt := k6ext.Runtime(ctx)
if opts != nil && !goja.IsUndefined(opts) && !goja.IsNull(opts) {
formatSpecified := false
opts := opts.ToObject(rt)
for _, k := range opts.Keys() {
switch k {
case "everyNthFrame":
o.EveryNthFrame = opts.Get(k).ToInteger()
case "frameRate":
o.FrameRate = opts.Get(k).ToInteger()
case "maxHeigth":
o.MaxHeight = opts.Get(k).ToInteger()
case "maxWidth":
o.MaxWidth = opts.Get(k).ToInteger()
case "path":
o.Path = opts.Get(k).String()
case "quality":
o.Quality = opts.Get(k).ToInteger()
case "format":
if f, ok := videoFormatToID[opts.Get(k).String()]; ok {
o.Format = f
formatSpecified = true
}
}
}

// Infer file format by path if format not explicitly specified (default is webm)
// TODO: throw error if format is not defined
if o.Path != "" && !formatSpecified {
if strings.HasSuffix(o.Path, ".webm") {
o.Format = VideoFormatWebM
}
}
}

return nil
}

func NewVidepCaptureOptions() *VideoCaptureOptions {
return &VideoCaptureOptions{
Path: "",
Format: VideoFormatWebM,
Quality: 100,
FrameRate: 25,
EveryNthFrame: 1,
}
}
Loading
Loading