Skip to content

Commit

Permalink
feat: add screencast package
Browse files Browse the repository at this point in the history
  • Loading branch information
Kikobeats committed Jun 25, 2023
1 parent 233be5b commit b3666b8
Show file tree
Hide file tree
Showing 5 changed files with 304 additions and 37 deletions.
77 changes: 40 additions & 37 deletions packages/browserless/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,46 +107,49 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
}
}

const withPage = (fn, { timeout: evaluateTimeout } = {}) => async (...args) => {
let isRejected = false

async function run () {
let page

try {
page = await createPage(args)
setTimeout(() => closePage(page), timeout).unref()
const value = await fn(page)(...args)
await closePage(page)
return value
} catch (error) {
await closePage(page)
if (!isRejected) throw ensureError(error)
}
}

const task = () =>
pRetry(run, {
retries: retry,
onFailedAttempt: async error => {
debug('onFailedAttempt', { name: error.name, code: error.code, isRejected })
if (error.name === 'AbortError') throw error
if (isRejected) throw new AbortError()
if (error.code === 'EBRWSRCONTEXTCONNRESET') {
_contextPromise = createBrowserContext(contextOpts)
const withPage =
(fn, { timeout: evaluateTimeout } = {}) =>
async (...args) => {
let isRejected = false

async function run () {
let page

try {
page = await createPage(args)
setTimeout(() => closePage(page), timeout).unref()

const value = await fn(page, goto)(...args)
await closePage(page)
return value
} catch (error) {
await closePage(page)
if (!isRejected) throw ensureError(error)
}
const { message, attemptNumber, retriesLeft } = error
debug('retry', { attemptNumber, retriesLeft, message })
}
})

const timeout = evaluateTimeout || contextTimeout || globalTimeout

return pTimeout(task(), timeout, () => {
isRejected = true
throw browserTimeout({ timeout })
})
}
const task = () =>
pRetry(run, {
retries: retry,
onFailedAttempt: async error => {
debug('onFailedAttempt', { name: error.name, code: error.code, isRejected })
if (error.name === 'AbortError') throw error
if (isRejected) throw new AbortError()
if (error.code === 'EBRWSRCONTEXTCONNRESET') {
_contextPromise = createBrowserContext(contextOpts)
}
const { message, attemptNumber, retriesLeft } = error
debug('retry', { attemptNumber, retriesLeft, message })
}
})

const timeout = evaluateTimeout || contextTimeout || globalTimeout

return pTimeout(task(), timeout, () => {
isRejected = true
throw browserTimeout({ timeout })
})
}

const evaluate = (fn, gotoOpts) =>
withPage(
Expand Down
53 changes: 53 additions & 0 deletions packages/screencast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@browserless/screencast",
"description": "Take a video recording screencast of any website",
"homepage": "https://browserless.js.org/#/?id=screencasturl-options",
"version": "9.11.0",
"main": "src/index.js",
"author": {
"email": "hello@microlink.io",
"name": "microlink.io",
"url": "https://microlink.io"
},
"repository": {
"directory": "packages/screencast",
"type": "git",
"url": "git+https://github.com/microlinkhq/browserless.git#master"
},
"bugs": {
"url": "https://github.com/microlinkhq/browserless/issues"
},
"keywords": [
"browser",
"browserless",
"chrome",
"chromeless",
"core",
"headless",
"puppeteer",
"screencast"
],
"dependencies": {
"data-uri-to-buffer": "~5.0.1"
},
"devDependencies": {
"@browserless/test": "^9.11.0",
"ava": "latest"
},
"engines": {
"node": ">= 12"
},
"files": [
"scripts",
"src"
],
"scripts": {
"coverage": "exit 0",
"test": "ava"
},
"license": "MIT",
"ava": {
"timeout": "30s",
"workerThreads": false
}
}
47 changes: 47 additions & 0 deletions packages/screencast/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict'

const { createScreenRecorder, startScreencast } = require('./utils')
const { dataUriToBuffer } = require('data-uri-to-buffer')

module.exports = async ({
everyNthFrame = 1,
format = 'video/webm;codecs=vp9',
getBrowserless,
gotoOpts,
imageFormat = 'png',
quality = 100,
timeout,
withPage
} = {}) => {
const browserless = await getBrowserless()

const fn = (page, goto) => async gotoOpts => {
await goto(page, gotoOpts)

const renderer = await browserless.page()
const draws = []

const [screenRecorder, screencast] = await Promise.all([
createScreenRecorder(renderer, { format }),
startScreencast(page, {
format: imageFormat,
quality,
everyNthFrame,
onFrame: data => draws.push(screenRecorder.draw(data, `image/${imageFormat}`))
})
])

screenRecorder.start()

await withPage(page)
await screencast.stop()
await Promise.all(draws)

const dataUri = await screenRecorder.stop()
await renderer.close()

return dataUriToBuffer(dataUri)
}

return browserless.withPage(fn, { timeout })(gotoOpts)
}
104 changes: 104 additions & 0 deletions packages/screencast/src/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/* global createImageBitmap, Blob, MediaRecorder, FileReader */

'use strict'

const getCDPClient = page => page._client()

const startScreencast = async (page, { onFrame, ...opts }) => {
const client = getCDPClient(page)
const listeners = []
const acks = []

client.on('Page.screencastFrame', async ({ data, metadata, sessionId }) => {
onFrame(data, metadata)
acks.push(client.send('Page.screencastFrameAck', { sessionId }).catch(() => {}))
})

const windowSize = await page.evaluate(() => ({
maxWidth: window.innerWidth,
maxHeight: window.innerHeight
}))

// https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-startScreencast
await client.send('Page.startScreencast', { ...opts, ...windowSize })

return {
on: (_, listener) => listeners.push(listener),
stop: async () => {
await Promise.all(acks)
return client.send('Page.stopScreencast')
}
}
}

const createScreenRecorder = async (page, { format }) => {
const screenRecorder = await page.evaluateHandle(format => {
class ScreenRecorder {
constructor ({ format }) {
this.videoMimeType = format.startsWith('video') ? format : `video/${format}`
if (!MediaRecorder.isTypeSupported(this.videoMimeType)) {
throw new TypeError(
`The MediaRecorder type provided (${this.videoMimeType}) is not supported`
)
}

this.canvas = document.createElement('canvas')
document.body.appendChild(this.canvas)
this.ctx = this.canvas.getContext('2d')
this.chunks = []
}

async beginRecording (stream) {
return new Promise((resolve, reject) => {
this.recorder = new MediaRecorder(stream, {
mimeType: this.videoMimeType
})
this.recorder.ondataavailable = ({ data }) => this.chunks.push(data)
this.recorder.onerror = reject
this.recorder.onstop = resolve
this.recorder.start()
})
}

async serialize () {
await this.recordingFinish
return new Promise((resolve, reject) => {
const blob = new Blob(this.chunks, { type: this.videoMimeType })
const reader = new FileReader()
reader.onload = event => resolve(event.target.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}

start () {
this.canvas.width = window.innerWidth
this.canvas.height = window.innerHeight
this.recordingFinish = this.beginRecording(this.canvas.captureStream())
}

async draw (base64, mimeType) {
const data = await fetch(`data:${mimeType};base64,${base64}`)
.then(res => res.blob())
.then(blob => createImageBitmap(blob))
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.drawImage(data, 0, 0)
}

stop () {
this.recorder.stop()
return this.serialize()
}
}

return new ScreenRecorder({ format })
}, format)

return {
start: () => page.evaluate(sr => sr.start(), screenRecorder),
draw: data => page.evaluate((sr, data) => sr.draw(data), screenRecorder, data),
stop: () => page.evaluate(sr => sr.stop(), screenRecorder)
}
}

module.exports = { getCDPClient, createScreenRecorder, startScreencast }
60 changes: 60 additions & 0 deletions packages/screencast/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict'

const { getBrowserContext } = require('@browserless/test/util')
const FileType = require('file-type')
const test = require('ava')

const screencast = require('..')

test('get a webm video', async t => {
const browserless = await getBrowserContext(t)

const buffer = await screencast({
getBrowserless: () => browserless,
videoFormat: 'webm',
gotoOpts: {
url: 'https://vercel.com',
animations: true,
abortTypes: [],
waitUntil: 'load'
},
withPage: async page => {
const TOTAL_TIME = 7_000

const timing = {
topToQuarter: (TOTAL_TIME * 1.5) / 7,
quarterToQuarter: (TOTAL_TIME * 0.3) / 7,
quarterToBottom: (TOTAL_TIME * 1) / 7,
bottomToTop: (TOTAL_TIME * 2) / 7
}

const scrollTo = (partial, ms) =>
page.evaluate(
(partial, ms) =>
new Promise(resolve => {
window.requestAnimationFrame(() => {
window.scrollTo({
top: document.scrollingElement.scrollHeight * partial,
behavior: 'smooth'
})
setTimeout(resolve, ms)
})
}),
partial,
ms
)

await scrollTo(1 / 3, timing.topToQuarter)
await scrollTo(2 / 3, timing.quarterToQuarter)
await scrollTo(3 / 3, timing.quarterToBottom)
await scrollTo(0, timing.bottomToTop)
}
})

const { ext, mime } = await FileType.fromBuffer(buffer)

require('fs').writeFileSync('video.webm', buffer)

t.is(ext, 'webm')
t.is(mime, 'video/webm')
})

0 comments on commit b3666b8

Please sign in to comment.