Skip to content

Commit

Permalink
Implement video capture
Browse files Browse the repository at this point in the history
Signed-off-by: Pablo Chacin <pablochacin@gmail.com>
  • Loading branch information
pablochacin committed Dec 9, 2024
1 parent c5c9435 commit 04b1b19
Show file tree
Hide file tree
Showing 4 changed files with 334 additions and 4 deletions.
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

0 comments on commit 04b1b19

Please sign in to comment.