Skip to content

Commit 2cd5a20

Browse files
authored
feat: revamped screencast (#571)
* feat: revamped screencast * refactor: use sharp instead of ffmpeg
1 parent 5f89406 commit 2cd5a20

File tree

5 files changed

+125
-179
lines changed

5 files changed

+125
-179
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict'
2+
3+
const timeSpan = require('@kikobeats/time-span')({ format: n => `${n.toFixed(2)}ms` })
4+
const { createCanvas, Image } = require('canvas')
5+
const { GifEncoder } = require('@skyra/gifenc')
6+
const createBrowser = require('browserless')
7+
const sharp = require('sharp')
8+
const http = require('http')
9+
10+
const createScreencast = require('..')
11+
12+
const browser = createBrowser({
13+
timeout: 25000,
14+
lossyDeviceName: true,
15+
ignoreHTTPSErrors: true
16+
})
17+
18+
const CACHE = Object.create(null)
19+
20+
const server = http.createServer(async (req, res) => {
21+
if (req.url === '/favicon.ico') return res.end()
22+
23+
const duration = timeSpan()
24+
let firstFrame = true
25+
26+
const url = req.url.slice(1)
27+
28+
if (CACHE[url]) {
29+
const pngBuffer = CACHE[url].toBuffer('image/png')
30+
res.setHeader('Content-Type', 'image/png')
31+
res.write(pngBuffer)
32+
return res.end()
33+
}
34+
35+
const browserless = await browser.createContext()
36+
const page = await browserless.page()
37+
let lastCanvas = null
38+
39+
res.setHeader('Content-Type', 'image/gif')
40+
41+
const width = 1280
42+
const height = 800
43+
const deviceScaleFactor = 0.5
44+
45+
const outputSize = { width: width * deviceScaleFactor, height: height * deviceScaleFactor }
46+
47+
const canvas = createCanvas(outputSize.width, outputSize.height)
48+
const ctx = canvas.getContext('2d')
49+
50+
const encoder = new GifEncoder(outputSize.width, outputSize.height)
51+
encoder.createReadStream().pipe(res)
52+
53+
const screencast = createScreencast(page, { maxWidth: width, maxHeight: height })
54+
55+
screencast.onFrame(async data => {
56+
const frame = Buffer.from(data, 'base64')
57+
const buffer = await sharp(frame).resize(outputSize).toBuffer()
58+
59+
const img = new Image()
60+
img.src = buffer
61+
ctx.drawImage(img, 0, 0, img.width, img.height)
62+
encoder.addFrame(ctx)
63+
64+
if (firstFrame === true) firstFrame = duration()
65+
66+
lastCanvas = canvas
67+
})
68+
69+
screencast.start()
70+
encoder.start()
71+
await browserless.goto(page, { url })
72+
encoder.finish()
73+
await screencast.stop()
74+
75+
console.log(`\n Resolved ${url}; first frame ${firstFrame}, total ${duration()}`)
76+
77+
CACHE[url] = lastCanvas
78+
})
79+
80+
server.listen(3000, () =>
81+
console.log(`
82+
Listen: http://localhost:3000/{URL}
83+
Example: http://localhost:3000/https://browserless.js.org\n`)
84+
)

packages/screencast/package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@
3232
"screencast",
3333
"video"
3434
],
35-
"dependencies": {
36-
"tinyspawn": "~1.2.6"
37-
},
3835
"devDependencies": {
3936
"@browserless/test": "^10.3.0",
37+
"@kikobeats/time-span": "latest",
38+
"@skyra/gifenc": "latest",
4039
"ava": "5",
41-
"file-type": "16"
40+
"canvas": "latest",
41+
"sharp": "latest",
42+
"tinyspawn": "latest"
4243
},
4344
"engines": {
4445
"node": ">= 12"

packages/screencast/src/index.js

Lines changed: 16 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,20 @@
11
'use strict'
22

3-
const { unlink, readFile } = require('fs/promises')
4-
const { randomUUID } = require('crypto')
5-
const { Readable } = require('stream')
6-
const { tmpdir } = require('os')
7-
const $ = require('tinyspawn')
8-
const path = require('path')
9-
10-
const { startScreencast } = require('./utils')
11-
12-
// Inspired by https://github.com/microsoft/playwright/blob/37b3531a1181c99990899c15000925a98f035eb7/packages/playwright-core/src/server/chromium/videoRecorder.ts#L101
13-
const ffmpegArgs = format => {
14-
// `-an` disables audio
15-
// `-b:v 0` disables video bitrate control
16-
// `-c:v` alias for -vcodec
17-
// `-avioflags direct` reduces buffering
18-
// `-probesize 32` size of the data to analyze to get stream information
19-
// `-analyzeduration 0` specify how many microseconds are analyzed to probe the input
20-
// `-fpsprobesize 0` set number of frames used to probe fps
21-
// `-fflags nobuffer` disables buffering when reading or writing multimedia data
22-
const args =
23-
'-loglevel error -an -b:v 0 -avioflags direct -probesize 32 -analyzeduration 0 -fpsprobesize 0 -fflags nobuffer'
24-
25-
if (format === 'mp4') {
26-
// ffmpeg -h encoder=h264
27-
return `${args} -c:v libx264 -pix_fmt yuv420p -preset ultrafast -realtime true`
28-
}
29-
30-
if (format === 'gif') {
31-
return args
3+
const getCDPClient = page => page._client()
4+
5+
module.exports = (page, opts) => {
6+
const client = getCDPClient(page)
7+
let onFrame
8+
9+
client.on('Page.screencastFrame', ({ data, metadata, sessionId }) => {
10+
client.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
11+
if (metadata.timestamp) onFrame(data, metadata)
12+
})
13+
14+
return {
15+
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-startScreencast
16+
start: () => client.send('Page.startScreencast', opts),
17+
onFrame: fn => (onFrame = fn),
18+
stop: async () => client.send('Page.stopScreencast')
3219
}
33-
34-
if (format === 'webm') {
35-
// ffmpeg -h encoder=vp9
36-
return `${args} -c:v libvpx-vp9 -quality realtime`
37-
}
38-
39-
throw new TypeError(`Format '${format}' not supported`)
40-
}
41-
42-
module.exports = async ({
43-
ffmpegPath,
44-
format = 'webm',
45-
frameRate = 25,
46-
frames: framesOpts = {},
47-
getBrowserless,
48-
gotoOpts,
49-
timeout,
50-
tmpPath = tmpdir(),
51-
withPage
52-
} = {}) => {
53-
const browserless = await getBrowserless()
54-
55-
const fn = (page, goto) => async gotoOpts => {
56-
await goto(page, gotoOpts)
57-
const screencastStop = await startScreencast(page, framesOpts)
58-
await withPage(page)
59-
const frames = await screencastStop()
60-
61-
const interpolatedFrames = frames.reduce((acc, { data, metadata }, index) => {
62-
const previousIndex = index - 1
63-
const previousFrame = index > 0 ? frames[previousIndex] : undefined
64-
const durationSeconds = previousFrame
65-
? metadata.timestamp - previousFrame.metadata.timestamp
66-
: 0
67-
const numFrames = Math.max(1, Math.round(durationSeconds * frameRate))
68-
for (let i = 0; i < numFrames; i++) acc.push(data)
69-
return acc
70-
}, [])
71-
72-
const filepath = path.join(tmpPath, `${randomUUID()}.${format}`)
73-
const subprocess = $(
74-
`${ffmpegPath} -f image2pipe -i pipe:0 -r ${frameRate} ${ffmpegArgs(format)} ${filepath}`
75-
)
76-
Readable.from(interpolatedFrames).pipe(subprocess.stdin)
77-
78-
await subprocess
79-
const buffer = await readFile(filepath)
80-
await unlink(filepath)
81-
82-
return buffer
83-
}
84-
85-
return browserless.withPage(fn, { timeout })(gotoOpts)
8620
}

packages/screencast/src/utils.js

Lines changed: 0 additions & 23 deletions
This file was deleted.

packages/screencast/test/index.js

Lines changed: 20 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,33 @@
11
'use strict'
22

33
const { getBrowserContext } = require('@browserless/test/util')
4-
const { writeFile } = require('fs/promises')
5-
const { randomUUID } = require('crypto')
6-
const FileType = require('file-type')
7-
const { unlinkSync } = require('fs')
8-
const { tmpdir } = require('os')
9-
const $ = require('tinyspawn')
10-
const path = require('path')
114
const test = require('ava')
125

13-
const screencast = require('..')
6+
const createScreencast = require('..')
147

15-
const isCI = !!process.env.CI
8+
test('capture frames', async t => {
9+
const frames = []
1610

17-
test('get a webm video', async t => {
18-
const [browserless, { stdout: ffmpegPath }] = await Promise.all([
19-
getBrowserContext(t),
20-
$('which ffmpeg')
21-
])
11+
const browserless = await getBrowserContext(t)
12+
const page = await browserless.page()
2213

23-
const buffer = await screencast({
24-
getBrowserless: () => browserless,
25-
ffmpegPath,
26-
frames: {
27-
everyNthFrame: 2
28-
},
29-
gotoOpts: {
30-
url: 'https://vercel.com',
31-
animations: true,
32-
abortTypes: [],
33-
waitUntil: 'load'
34-
},
35-
withPage: async page => {
36-
const TOTAL_TIME = 7_000 * isCI ? 0.5 : 1
37-
38-
const timing = {
39-
topToQuarter: (TOTAL_TIME * 1.5) / 7,
40-
quarterToQuarter: (TOTAL_TIME * 0.3) / 7,
41-
quarterToBottom: (TOTAL_TIME * 1) / 7,
42-
bottomToTop: (TOTAL_TIME * 2) / 7
43-
}
44-
45-
const scrollTo = (partial, ms) =>
46-
page.evaluate(
47-
(partial, ms) =>
48-
new Promise(resolve => {
49-
window.requestAnimationFrame(() => {
50-
window.scrollTo({
51-
top: document.scrollingElement.scrollHeight * partial,
52-
behavior: 'smooth'
53-
})
54-
setTimeout(resolve, ms)
55-
})
56-
}),
57-
partial,
58-
ms
59-
)
60-
61-
await scrollTo(1 / 3, timing.topToQuarter)
62-
await scrollTo(2 / 3, timing.quarterToQuarter)
63-
await scrollTo(3 / 3, timing.quarterToBottom)
64-
await scrollTo(0, timing.bottomToTop)
65-
}
14+
const screencast = createScreencast(page, {
15+
quality: 0,
16+
format: 'png',
17+
everyNthFrame: 1
6618
})
6719

68-
const { ext, mime } = await FileType.fromBuffer(buffer)
69-
t.is(ext, 'webm')
70-
t.is(mime, 'video/webm')
71-
72-
const filepath = path.join(tmpdir(), randomUUID())
73-
t.teardown(() => unlinkSync(filepath))
74-
await writeFile(filepath, buffer)
20+
screencast.onFrame((data, metadata) => {
21+
frames.push({ data, metadata })
22+
})
7523

76-
const { stdout: ffprobe } = await $.json(
77-
`ffprobe ${filepath} -print_format json -v quiet -show_format -show_streams -show_error`
78-
)
24+
screencast.start()
25+
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
26+
await screencast.stop()
7927

80-
t.is(ffprobe.streams[0].codec_name, 'vp9')
81-
t.is(ffprobe.streams[0].pix_fmt, 'yuv420p')
82-
t.is(ffprobe.streams[0].avg_frame_rate, '25/1')
28+
frames.forEach(({ data, metadata }) => {
29+
t.truthy(data)
30+
t.is(typeof metadata, 'object')
31+
t.truthy(metadata.timestamp)
32+
})
8333
})

0 commit comments

Comments
 (0)