From 123121f199e0022b0c0cf12c9bea50c94871f856 Mon Sep 17 00:00:00 2001 From: ankur22 Date: Mon, 9 Dec 2024 12:03:17 +0000 Subject: [PATCH] Add video recording support --- common/browser_options.go | 4 + common/page.go | 126 ++++++++++++++++++++++++++++- common/video_capture.go | 166 ++++++++++++++++++++++++++++++++++++++ env/env.go | 4 + 4 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 common/video_capture.go diff --git a/common/browser_options.go b/common/browser_options.go index b35c63ea4..8d9a99262 100644 --- a/common/browser_options.go +++ b/common/browser_options.go @@ -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 } @@ -87,6 +88,7 @@ func (bo *BrowserOptions) Parse( //nolint:cyclop env.SelectorEngineEnabled, env.ShowInteractionsEnabled, env.AutoScreenshotEnabled, + env.CaptureVideo, } for _, e := range envOpts { @@ -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: diff --git a/common/page.go b/common/page.go index 2f0d1dc9b..e0baf5d05 100644 --- a/common/page.go +++ b/common/page.go @@ -3,6 +3,7 @@ package common import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -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. @@ -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) { @@ -345,6 +456,7 @@ func (p *Page) initEvents() { events := []string{ cdproto.EventRuntimeConsoleAPICalled, + cdproto.EventPageScreencastFrame, } p.session.on(p.ctx, events, p.eventCh) @@ -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) + } } } } diff --git a/common/video_capture.go b/common/video_capture.go new file mode 100644 index 000000000..854fe7c1a --- /dev/null +++ b/common/video_capture.go @@ -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 +} diff --git a/env/env.go b/env/env.go index c9e076b60..99373bee7 100644 --- a/env/env.go +++ b/env/env.go @@ -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.