Skip to content

Commit

Permalink
Add video recording support
Browse files Browse the repository at this point in the history
  • Loading branch information
ankur22 committed Dec 9, 2024
1 parent c6ae026 commit 123121f
Show file tree
Hide file tree
Showing 4 changed files with 298 additions and 2 deletions.
4 changes: 4 additions & 0 deletions common/browser_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type BrowserOptions struct {
SelectorEngine bool
ShowInteractions bool
AutoScreenshot bool
CaptureVideo bool

isRemoteBrowser bool // some options will be ignored if browser is in a remote machine
}
Expand Down Expand Up @@ -87,6 +88,7 @@ func (bo *BrowserOptions) Parse( //nolint:cyclop
env.SelectorEngineEnabled,
env.ShowInteractionsEnabled,
env.AutoScreenshotEnabled,
env.CaptureVideo,
}

for _, e := range envOpts {
Expand All @@ -112,6 +114,8 @@ func (bo *BrowserOptions) Parse( //nolint:cyclop
bo.ShowInteractions, err = parseBoolOpt(e, ev)
case env.AutoScreenshotEnabled:
bo.AutoScreenshot, err = parseBoolOpt(e, ev)
case env.CaptureVideo:
bo.CaptureVideo, err = parseBoolOpt(e, ev)
case env.BrowserExecutablePath:
bo.ExecutablePath = ev
case env.BrowserHeadless:
Expand Down
126 changes: 124 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 @@ -243,6 +244,9 @@ type Page struct {
scriptName string
tq *taskqueue.TaskQueue
tqSet bool

videoCaptureMu sync.RWMutex
videoCapture *videocapture
}

// NewPage creates a new browser page context.
Expand Down Expand Up @@ -326,12 +330,119 @@ func NewPage(
return &p, nil
}

// CaptureVideo will start a screen cast of the current page and save it to specified file.
func (p *Page) CaptureVideo(opts *VideoCaptureOptions) 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)
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
}

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

if p.videoCapture == nil {
return nil
}

err := p.session.ExecuteWithoutExpectationOnReply(
context.Background(),
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) 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) SetScreenshotPersister(sp ScreenshotPersister) {
p.sp = sp
}

func (p *Page) SetScriptName(scriptName string) {
p.scriptName = scriptName

if p.browserCtx.browser.browserOpts.CaptureVideo {
o := NewVideoCaptureOptions()
o.Path = fmt.Sprintf("%s_screen_recording.webm", p.scriptName)
p.CaptureVideo(o)
go func() {
<-p.ctx.Done()

p.StopVideoCapture()
}()
}
}

func (p *Page) SetTaskQueue(tq *taskqueue.TaskQueue) {
Expand All @@ -345,6 +456,7 @@ func (p *Page) initEvents() {

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

Expand All @@ -367,8 +479,18 @@ 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
166 changes: 166 additions & 0 deletions common/video_capture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package common

import (
"context"
"fmt"
"io"
"os"
"os/exec"

"github.com/grafana/xk6-browser/log"
)

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 NewVideoCaptureOptions() *VideoCaptureOptions {
return &VideoCaptureOptions{
Path: "",
Format: VideoFormatWebM,
Quality: 100,
FrameRate: 25,
EveryNthFrame: 1,
}
}

// VideoCapturePersister defines the interface for persisting a video capture
type VideoCapturePersister interface {
Persist(ctx context.Context, path string, data io.Reader) (err error)
}

type VideoFrame struct {
Content []byte
Timestamp int64
}

// VideoFormat represents a video file format.
type VideoFormat string

// Valid video format options.
const (
// VideoFormatWebM stores video as a series of jpeg files
VideoFormatWebM VideoFormat = "webm"
)

// String returns the video format as a string
func (f VideoFormat) String() string {
return f.String()
}

var videoFormatToID = map[string]VideoFormat{ //nolint:gochecknoglobals
"webm": VideoFormatWebM,
}

type videocapture struct {
ctx context.Context
logger *log.Logger
opts VideoCaptureOptions
ffmpegCmd exec.Cmd
ffmpegIn io.WriteCloser
ffmpegOut io.ReadCloser
lastFrame VideoFrame
}

// creates a new videocapture for a session
func newVideoCapture(
ctx context.Context,
logger *log.Logger,
opts VideoCaptureOptions,
) (*videocapture, error) {

// construct command to start ffmpeg to convert series of images into a video
// heavily inspired by puppeteer's screen recorder
// https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/node/ScreenRecorder.ts
ffmpegCmd := exec.Command(
"ffmpeg",
// create video from sequence of images
"-f", "image2pipe",
// copy stream without conversion
"-c:v", "png",
// set frame rate
"-framerate", fmt.Sprintf("%d", opts.FrameRate),
// read from stdin
"-i", "pipe:0",
// set output format
"-f", "webm",
// set quality
//"-crf", fmt.Sprintf("%d", opts.Quality),
// optimize for speed
"-deadline", "realtime", "-cpu-used", "8",
// write to sdtout
//"pipe:1",
"-y",
opts.Path, // FIXME: send to stdout
)
ffmpegCmd.Stderr = os.Stderr // FIXME: for debugging

ffmpegIn, err := ffmpegCmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("creating ffmpeg stdin pipe: %w", err)
}

// ffmpegOut, err := ffmpegCmd.StdoutPipe()
// if err != nil {
// return nil, fmt.Errorf("creating ffmpeg stdout pipe: %w", err)
// }

err = ffmpegCmd.Start()
if err != nil {
return nil, fmt.Errorf("starting ffmpeg: %w", err)
}

return &videocapture{
ctx: ctx,
logger: logger,
opts: opts,
ffmpegCmd: *ffmpegCmd,
ffmpegIn: ffmpegIn,
// ffmpegOut: ffmpegOut,
}, nil
}

// HandleFrame sends the frame to the video stream
func (v *videocapture) handleFrame(ctx context.Context, frame *VideoFrame) error {
// time between frames (in milliseconds)
step := 1000 / v.opts.FrameRate

//normalize frame timestamp to a multiple of the step
timestamp := frame.Timestamp
if timestamp%step != 0 {
timestamp = ((timestamp + step) / step) * step
}

// repeat last frame to fill video until the current frame
if v.lastFrame.Timestamp > 0 {
for ts := v.lastFrame.Timestamp + step; ts < timestamp; ts += step {
if _, err := v.ffmpegIn.Write(v.lastFrame.Content); err != nil {
return fmt.Errorf("writing frame: %w", err)
}
}
}

if _, err := v.ffmpegIn.Write(frame.Content); err != nil {
return fmt.Errorf("writing frame: %w", err)
}

v.lastFrame = VideoFrame{Timestamp: timestamp, Content: frame.Content}

return nil
}

// Close stops the recording of the video capture
func (v *videocapture) Close(ctx context.Context) error {
err := v.ffmpegIn.Close()
if err != nil {
v.logger.Errorf("videocapture:Close", "video close failed: %v", err)
}

return nil
}
4 changes: 4 additions & 0 deletions env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ const (
// AutoScreenshotEnabled will attempt to take screenshot when the page
// loads and when an interaction on the page occurs.
AutoScreenshotEnabled = "K6_BROWSER_AUTO_SCREENSHOT"

// CaptureVideo will capture the single vu single iteration of a test
// and save it as a video.
CaptureVideo = "K6_BROWSER_CAPTURE_VIDEO"
)

// Tracing.
Expand Down

0 comments on commit 123121f

Please sign in to comment.