Skip to content
This repository was archived by the owner on Jan 30, 2025. It is now read-only.

Commit 04b1b19

Browse files
committed
Implement video capture
Signed-off-by: Pablo Chacin <[email protected]>
1 parent c5c9435 commit 04b1b19

File tree

4 files changed

+334
-4
lines changed

4 files changed

+334
-4
lines changed

browser/page_mapping.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ func mapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop
1818
rt := vu.Runtime()
1919
maps := mapping{
2020
"bringToFront": p.BringToFront,
21-
"check": p.Check,
21+
"captureVideo": func(opts goja.Value) error {
22+
ctx := vu.Context()
23+
24+
popts := common.NewVidepCaptureOptions()
25+
if err := popts.Parse(ctx, opts); err != nil {
26+
return fmt.Errorf("parsing page screencast options: %w", err)
27+
}
28+
29+
return p.CaptureVideo(popts, vu.filePersister)
30+
},
31+
"check": p.Check,
2232
"click": func(selector string, opts goja.Value) (*goja.Promise, error) {
2333
popts, err := parseFrameClickOptions(vu.Context(), opts, p.Timeout())
2434
if err != nil {
@@ -163,6 +173,7 @@ func mapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop
163173
"setExtraHTTPHeaders": p.SetExtraHTTPHeaders,
164174
"setInputFiles": p.SetInputFiles,
165175
"setViewportSize": p.SetViewportSize,
176+
"stopVideoCapture": p.StopVideCapture,
166177
"tap": func(selector string, opts goja.Value) (*goja.Promise, error) {
167178
popts := common.NewFrameTapOptions(p.Timeout())
168179
if err := popts.Parse(vu.Context(), opts); err != nil {

common/page.go

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package common
33
import (
44
"bytes"
55
"context"
6+
"encoding/base64"
67
"encoding/json"
78
"errors"
89
"fmt"
@@ -229,6 +230,9 @@ type Page struct {
229230
closedMu sync.RWMutex
230231
closed bool
231232

233+
videoCaptureMu sync.RWMutex
234+
videoCapture *videocapture
235+
232236
// TODO: setter change these fields (mutex?)
233237
emulatedSize *EmulatedSize
234238
mediaType MediaType
@@ -334,6 +338,7 @@ func (p *Page) initEvents() {
334338

335339
events := []string{
336340
cdproto.EventRuntimeConsoleAPICalled,
341+
cdproto.EventPageScreencastFrame,
337342
}
338343
p.session.on(p.ctx, events, p.eventCh)
339344

@@ -356,8 +361,17 @@ func (p *Page) initEvents() {
356361
"sid:%v tid:%v", p.session.ID(), p.targetID)
357362
return
358363
case event := <-p.eventCh:
359-
if ev, ok := event.data.(*cdpruntime.EventConsoleAPICalled); ok {
360-
p.onConsoleAPICalled(ev)
364+
p.logger.Debugf("Page:initEvents:event",
365+
"sid:%v tid:%v event:%s eventDataType:%T", p.session.ID(), p.targetID, event.typ, event.data)
366+
switch event.typ {
367+
case cdproto.EventPageScreencastFrame:
368+
if ev, ok := event.data.(*page.EventScreencastFrame); ok {
369+
p.onScreencastFrame(ev)
370+
}
371+
case cdproto.EventRuntimeConsoleAPICalled:
372+
if ev, ok := event.data.(*cdpruntime.EventConsoleAPICalled); ok {
373+
p.onConsoleAPICalled(ev)
374+
}
361375
}
362376
}
363377
}
@@ -1091,6 +1105,67 @@ func (p *Page) Screenshot(opts *PageScreenshotOptions, sp ScreenshotPersister) (
10911105
return buf, err
10921106
}
10931107

1108+
// CaptureVideo will start a screen cast of the current page and save it to specified file.
1109+
func (p *Page) CaptureVideo(opts *VideoCaptureOptions, scp VideoCapturePersister) error {
1110+
p.videoCaptureMu.RLock()
1111+
defer p.videoCaptureMu.RUnlock()
1112+
1113+
if p.videoCapture != nil {
1114+
return fmt.Errorf("ongoing video capture")
1115+
}
1116+
1117+
vc, err := newVideoCapture(p.ctx, p.logger, *opts, scp)
1118+
if err != nil {
1119+
return fmt.Errorf("creating video capture: %w", err)
1120+
}
1121+
p.videoCapture = vc
1122+
1123+
err = p.session.ExecuteWithoutExpectationOnReply(
1124+
p.ctx,
1125+
cdppage.CommandStartScreencast,
1126+
cdppage.StartScreencastParams{
1127+
Format: "png",
1128+
Quality: opts.Quality,
1129+
MaxWidth: opts.MaxWidth,
1130+
MaxHeight: opts.MaxHeight,
1131+
EveryNthFrame: opts.EveryNthFrame,
1132+
},
1133+
nil,
1134+
)
1135+
if err != nil {
1136+
return fmt.Errorf("starting screen cast %w", err)
1137+
}
1138+
1139+
return nil
1140+
}
1141+
1142+
// StopVideCapture stops any ongoing screen capture. In none is ongoing, is nop
1143+
func (p *Page) StopVideCapture() error {
1144+
p.videoCaptureMu.RLock()
1145+
defer p.videoCaptureMu.RUnlock()
1146+
1147+
if p.videoCapture == nil {
1148+
return nil
1149+
}
1150+
1151+
err := p.session.ExecuteWithoutExpectationOnReply(
1152+
p.ctx,
1153+
cdppage.CommandStopScreencast,
1154+
nil,
1155+
nil,
1156+
)
1157+
// don't return error to allow video to be recorded
1158+
if err != nil {
1159+
p.logger.Errorf("Page:StopVideoCapture", "sid:%v error:%v", p.sessionID(), err)
1160+
}
1161+
1162+
// prevent any pending frame to be sent to video capture while closing it
1163+
vc := p.videoCapture
1164+
p.videoCapture = nil
1165+
1166+
return vc.Close(p.ctx)
1167+
}
1168+
10941169
func (p *Page) SelectOption(selector string, values goja.Value, opts goja.Value) []string {
10951170
p.logger.Debugf("Page:SelectOption", "sid:%v selector:%s", p.sessionID(), selector)
10961171

@@ -1294,6 +1369,41 @@ func (p *Page) TargetID() string {
12941369
return p.targetID.String()
12951370
}
12961371

1372+
func (p *Page) onScreencastFrame(event *page.EventScreencastFrame) {
1373+
p.videoCaptureMu.RLock()
1374+
defer p.videoCaptureMu.RUnlock()
1375+
1376+
if p.videoCapture != nil {
1377+
err := p.session.ExecuteWithoutExpectationOnReply(
1378+
p.ctx,
1379+
cdppage.CommandScreencastFrameAck,
1380+
cdppage.ScreencastFrameAckParams{SessionID: event.SessionID},
1381+
nil,
1382+
)
1383+
if err != nil {
1384+
p.logger.Debugf("Page:onScreenCastFrame", "frame ack:%v", err)
1385+
return
1386+
}
1387+
1388+
frameData := make([]byte, base64.StdEncoding.DecodedLen(len(event.Data)))
1389+
_, err = base64.StdEncoding.Decode(frameData, []byte(event.Data))
1390+
if err != nil {
1391+
p.logger.Debugf("Page:onScreenCastFrame", "decoding frame :%v", err)
1392+
}
1393+
//content := base64.NewDecoder(base64.StdEncoding, bytes.NewBuffer([]byte(event.Data)))
1394+
err = p.videoCapture.handleFrame(
1395+
p.ctx,
1396+
&VideoFrame{
1397+
Content: frameData,
1398+
Timestamp: event.Metadata.Timestamp.Time().UnixMilli(),
1399+
},
1400+
)
1401+
if err != nil {
1402+
p.logger.Debugf("Page:onScreenCastFrame", "handling frame :%v", err)
1403+
}
1404+
}
1405+
}
1406+
12971407
func (p *Page) onConsoleAPICalled(event *cdpruntime.EventConsoleAPICalled) {
12981408
// If there are no handlers for EventConsoleAPICalled, return
12991409
p.eventHandlersMu.RLock()

common/page_options.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ type PageScreenshotOptions struct {
3232
Quality int64 `json:"quality"`
3333
}
3434

35+
type VideoCaptureOptions struct {
36+
Path string `json:"path"`
37+
Format VideoFormat `json:"format"`
38+
FrameRate int64 `json:"frameRate"`
39+
Quality int64 `json:"quality"`
40+
EveryNthFrame int64 `json:"everyNthFrame"`
41+
MaxWidth int64 `json:"maxWidth"`
42+
MaxHeight int64 `json:"maxHeight"`
43+
}
44+
3545
func NewPageEmulateMediaOptions(defaultMedia MediaType, defaultColorScheme ColorScheme, defaultReducedMotion ReducedMotion) *PageEmulateMediaOptions {
3646
return &PageEmulateMediaOptions{
3747
ColorScheme: defaultColorScheme,
@@ -131,7 +141,7 @@ func (o *PageScreenshotOptions) Parse(ctx context.Context, opts goja.Value) erro
131141
}
132142
}
133143

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

142152
return nil
143153
}
154+
155+
func (o *VideoCaptureOptions) Parse(ctx context.Context, opts goja.Value) error {
156+
rt := k6ext.Runtime(ctx)
157+
if opts != nil && !goja.IsUndefined(opts) && !goja.IsNull(opts) {
158+
formatSpecified := false
159+
opts := opts.ToObject(rt)
160+
for _, k := range opts.Keys() {
161+
switch k {
162+
case "everyNthFrame":
163+
o.EveryNthFrame = opts.Get(k).ToInteger()
164+
case "frameRate":
165+
o.FrameRate = opts.Get(k).ToInteger()
166+
case "maxHeigth":
167+
o.MaxHeight = opts.Get(k).ToInteger()
168+
case "maxWidth":
169+
o.MaxWidth = opts.Get(k).ToInteger()
170+
case "path":
171+
o.Path = opts.Get(k).String()
172+
case "quality":
173+
o.Quality = opts.Get(k).ToInteger()
174+
case "format":
175+
if f, ok := videoFormatToID[opts.Get(k).String()]; ok {
176+
o.Format = f
177+
formatSpecified = true
178+
}
179+
}
180+
}
181+
182+
// Infer file format by path if format not explicitly specified (default is webm)
183+
// TODO: throw error if format is not defined
184+
if o.Path != "" && !formatSpecified {
185+
if strings.HasSuffix(o.Path, ".webm") {
186+
o.Format = VideoFormatWebM
187+
}
188+
}
189+
}
190+
191+
return nil
192+
}
193+
194+
func NewVidepCaptureOptions() *VideoCaptureOptions {
195+
return &VideoCaptureOptions{
196+
Path: "",
197+
Format: VideoFormatWebM,
198+
Quality: 100,
199+
FrameRate: 25,
200+
EveryNthFrame: 1,
201+
}
202+
}

0 commit comments

Comments
 (0)