@browserless/capture 10.11.3 → 10.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/extension/offscreen.js +2 -1
- package/package.json +3 -4
- package/src/capture.js +77 -15
- package/src/constants.js +14 -1
- package/src/index.js +2 -1
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ const capture = createCapture({ goto: browserless.goto })
|
|
|
56
56
|
const video = await capture(page)('https://example.com', {
|
|
57
57
|
duration: 5000,
|
|
58
58
|
type: 'mp4',
|
|
59
|
+
codec: 'avc1.4D401F',
|
|
60
|
+
quality: 'high',
|
|
59
61
|
path: '/tmp/demo.mp4'
|
|
60
62
|
})
|
|
61
63
|
|
|
@@ -84,7 +86,9 @@ Returns a `Buffer` and writes to `opts.path` when provided.
|
|
|
84
86
|
|
|
85
87
|
| Option | Type | Default | Description |
|
|
86
88
|
| --- | --- | --- | --- |
|
|
87
|
-
| `type` | `'webm' \| 'mp4'` | `'
|
|
89
|
+
| `type` | `'webm' \| 'mp4'` | `'mp4'` | Output type selector mapped to MediaRecorder mime type. |
|
|
90
|
+
| `codec` | `string` | Depends on `type` | MediaRecorder codec override. Defaults: `webm -> vp9`, `mp4 -> avc1.4D401F`. |
|
|
91
|
+
| `quality` | `'extra-high' \| 'high' \| 'medium' \| 'low' \| 'extra-low'` | `'high'` | Video quality hint mapped to `MediaRecorder.videoBitsPerSecond`. |
|
|
88
92
|
| `path` | `string` | `undefined` | Write the captured media to disk. |
|
|
89
93
|
| `duration` | `number` | `3000` | Capture duration in milliseconds. |
|
|
90
94
|
| `audio` | `boolean \| object` | `false` | Capture audio. When object, it is used as audio track constraints. |
|
|
@@ -95,15 +99,26 @@ Returns a `Buffer` and writes to `opts.path` when provided.
|
|
|
95
99
|
- `capture.extensionPath`: Absolute path to the bundled extension.
|
|
96
100
|
- `capture.extensionId`: Extension ID used by the package.
|
|
97
101
|
- `capture.types`: Supported values for `type`.
|
|
102
|
+
- `capture.qualities`: Supported values for `quality`.
|
|
98
103
|
|
|
99
104
|
`capture` uses `goto(...).device.viewport` as the capture viewport source.
|
|
100
105
|
When `video` is `true` or omitted, video constraints are inferred from that viewport to keep capture framing aligned with screenshot/pdf rendering.
|
|
101
106
|
When `video` is an object, that object is used as the video constraints.
|
|
102
107
|
When `audio` is an object, that object is used as the audio constraints.
|
|
103
108
|
The inferred constraints also account for `deviceScaleFactor`, so output video pixels match screenshot pixel density.
|
|
104
|
-
|
|
109
|
+
Capture always enforces `videoConstraints.mandatory.maxFrameRate = 120`.
|
|
110
|
+
`quality` maps to bitrate presets (`extra-high`: 20Mbps, `high`: 8Mbps, `medium`: 5Mbps, `low`: 2.5Mbps, `extra-low`: 1Mbps).
|
|
105
111
|
MediaRecorder chunk size is internal and fixed at `250ms`.
|
|
106
|
-
`type` is mapped internally to the MediaRecorder mime type
|
|
112
|
+
`type` is mapped internally to the MediaRecorder mime type, and `codec` is appended as `;codecs=...`.
|
|
113
|
+
Default codecs are `vp9` for `webm` and `avc1.4D401F` for `mp4`.
|
|
114
|
+
You can override codec per request using `opts.codec`.
|
|
115
|
+
For example:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
await capture(page)(url, { type: 'webm', codec: 'vp8' })
|
|
119
|
+
await capture(page)(url, { type: 'mp4', codec: 'avc1.640033' })
|
|
120
|
+
```
|
|
121
|
+
|
|
107
122
|
When `type` is `'mp4'`, the running Chromium build must support MP4 MediaRecorder output.
|
|
108
123
|
For strict screenshot/poster parity in headless mode, launch Chrome with matching `--screen-info`.
|
|
109
124
|
|
package/extension/offscreen.js
CHANGED
|
@@ -78,6 +78,7 @@ const START_RECORDING = async ({
|
|
|
78
78
|
audio,
|
|
79
79
|
frameSize,
|
|
80
80
|
mimeType,
|
|
81
|
+
recorderOptions,
|
|
81
82
|
videoConstraints,
|
|
82
83
|
audioConstraints
|
|
83
84
|
}) => {
|
|
@@ -124,7 +125,7 @@ const START_RECORDING = async ({
|
|
|
124
125
|
throw error
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
const recorder = new MediaRecorder(stream, { mimeType })
|
|
128
|
+
const recorder = new MediaRecorder(stream, { mimeType, ...recorderOptions })
|
|
128
129
|
|
|
129
130
|
const pending = new Set()
|
|
130
131
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@browserless/capture",
|
|
3
3
|
"description": "Record a Puppeteer page using tabCapture API",
|
|
4
4
|
"homepage": "https://browserless.js.org/#/?id=capturepage-options",
|
|
5
|
-
"version": "10.11.
|
|
5
|
+
"version": "10.11.4",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"author": {
|
|
8
8
|
"email": "hello@microlink.io",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"webm"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@browserless/goto": "^10.11.
|
|
33
|
+
"@browserless/goto": "^10.11.4",
|
|
34
34
|
"debug-logfmt": "~1.4.8",
|
|
35
35
|
"ws": "~8.19.0"
|
|
36
36
|
},
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"src"
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
|
-
"bench:runtime": "node benchmarks/runtime-startup.js",
|
|
49
48
|
"coverage": "exit 0",
|
|
50
49
|
"test": "ava"
|
|
51
50
|
},
|
|
@@ -55,5 +54,5 @@
|
|
|
55
54
|
"timeout": "2m",
|
|
56
55
|
"workerThreads": false
|
|
57
56
|
},
|
|
58
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "9ff2a0f0dad20d64a5c47a04a989ee77d1ee5ae8"
|
|
59
58
|
}
|
package/src/capture.js
CHANGED
|
@@ -7,7 +7,15 @@ const debug = require('debug-logfmt')('browserless:capture')
|
|
|
7
7
|
const { closeServer, createWebSocketServer } = require('./util')
|
|
8
8
|
const extension = require('./extension')
|
|
9
9
|
|
|
10
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
DEFAULT,
|
|
12
|
+
DEFAULT_CODEC_BY_TYPE,
|
|
13
|
+
INTERNAL_FRAME_SIZE,
|
|
14
|
+
MAX_FRAME_RATE,
|
|
15
|
+
NOOP,
|
|
16
|
+
QUALITIES,
|
|
17
|
+
VIDEO_BITS_PER_SECOND_BY_QUALITY
|
|
18
|
+
} = require('./constants')
|
|
11
19
|
|
|
12
20
|
let currentIndex = 0
|
|
13
21
|
|
|
@@ -63,19 +71,48 @@ const createRecordingSession = ({ wss, index }) => {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
const MIME_TYPES_BY_TYPE = Object.freeze({
|
|
66
|
-
webm: Object.freeze({
|
|
67
|
-
|
|
68
|
-
audio: 'audio/webm'
|
|
69
|
-
}),
|
|
70
|
-
mp4: Object.freeze({
|
|
71
|
-
video: 'video/mp4',
|
|
72
|
-
audio: 'audio/mp4'
|
|
73
|
-
})
|
|
74
|
+
webm: Object.freeze({ video: 'video/webm', audio: 'audio/webm' }),
|
|
75
|
+
mp4: Object.freeze({ video: 'video/mp4', audio: 'audio/mp4' })
|
|
74
76
|
})
|
|
75
77
|
|
|
76
78
|
const SUPPORTED_TYPES = Object.freeze(Object.keys(MIME_TYPES_BY_TYPE))
|
|
77
79
|
|
|
78
|
-
const
|
|
80
|
+
const getQuality = quality => {
|
|
81
|
+
const normalizedQuality =
|
|
82
|
+
quality === undefined || quality === null
|
|
83
|
+
? DEFAULT.quality
|
|
84
|
+
: String(quality)
|
|
85
|
+
.trim()
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[\s_]+/g, '-')
|
|
88
|
+
|
|
89
|
+
if (!QUALITIES.includes(normalizedQuality)) {
|
|
90
|
+
throw new TypeError(
|
|
91
|
+
`Unsupported \`quality\` "${quality}". Supported qualities: ${QUALITIES.join(', ')}.`
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return normalizedQuality
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const getCodec = ({ codec, type, video }) => {
|
|
99
|
+
if (codec === undefined || codec === null) {
|
|
100
|
+
return video ? DEFAULT_CODEC_BY_TYPE[type] : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof codec !== 'string') {
|
|
104
|
+
throw new TypeError(`Expected \`codec\` to be a string. Received ${typeof codec}.`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const normalizedCodec = codec.trim()
|
|
108
|
+
if (!normalizedCodec) {
|
|
109
|
+
throw new TypeError('Expected `codec` to be a non-empty string.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return normalizedCodec
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const getMimeType = ({ type, audio, video, codec }) => {
|
|
79
116
|
const normalizedType =
|
|
80
117
|
type === undefined || type === null
|
|
81
118
|
? DEFAULT.type
|
|
@@ -89,24 +126,42 @@ const getMimeType = ({ type, audio, video }) => {
|
|
|
89
126
|
)
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
|
|
129
|
+
const streamMimeType = audio && !video ? mimeTypes.audio : mimeTypes.video
|
|
130
|
+
const resolvedCodec = getCodec({ codec, type: normalizedType, video })
|
|
131
|
+
|
|
132
|
+
return resolvedCodec ? `${streamMimeType};codecs=${resolvedCodec}` : streamMimeType
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
const getVideoConstraints = (videoConstraints, viewport) => {
|
|
96
|
-
|
|
136
|
+
const withMaxFrameRate = constraints => {
|
|
137
|
+
const source = constraints && typeof constraints === 'object' ? constraints : {}
|
|
138
|
+
const mandatory =
|
|
139
|
+
source.mandatory && typeof source.mandatory === 'object' ? source.mandatory : {}
|
|
140
|
+
const { mandatory: _mandatory, ...rest } = source
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
...rest,
|
|
144
|
+
mandatory: {
|
|
145
|
+
...mandatory,
|
|
146
|
+
maxFrameRate: MAX_FRAME_RATE
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (videoConstraints) return withMaxFrameRate(videoConstraints)
|
|
97
152
|
|
|
98
153
|
const dpr = Math.max(Number(viewport.deviceScaleFactor) || 1, 1)
|
|
99
154
|
const width = Math.round(viewport.width * dpr)
|
|
100
155
|
const height = Math.round(viewport.height * dpr)
|
|
101
156
|
|
|
102
|
-
return {
|
|
157
|
+
return withMaxFrameRate({
|
|
103
158
|
mandatory: {
|
|
104
159
|
minWidth: width,
|
|
105
160
|
minHeight: height,
|
|
106
161
|
maxWidth: width,
|
|
107
162
|
maxHeight: height
|
|
108
163
|
}
|
|
109
|
-
}
|
|
164
|
+
})
|
|
110
165
|
}
|
|
111
166
|
|
|
112
167
|
const isTrackObject = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -160,7 +215,7 @@ const getTargetId = async page => {
|
|
|
160
215
|
}
|
|
161
216
|
|
|
162
217
|
module.exports = async (page, opts, viewport) => {
|
|
163
|
-
const { path: outputPath, duration = DEFAULT.duration, audio, video, type } = opts
|
|
218
|
+
const { path: outputPath, duration = DEFAULT.duration, audio, video, type, quality, codec } = opts
|
|
164
219
|
|
|
165
220
|
const audioOpts = getOpts(audio, false, 'audio')
|
|
166
221
|
const videoOpts = getOpts(video, true, 'video')
|
|
@@ -174,10 +229,16 @@ module.exports = async (page, opts, viewport) => {
|
|
|
174
229
|
|
|
175
230
|
const streamMimeType = getMimeType({
|
|
176
231
|
type,
|
|
232
|
+
codec,
|
|
177
233
|
audio: audioOpts.enabled,
|
|
178
234
|
video: videoOpts.enabled
|
|
179
235
|
})
|
|
180
236
|
|
|
237
|
+
const resolvedQuality = getQuality(quality)
|
|
238
|
+
const recorderOptions = videoOpts.enabled
|
|
239
|
+
? { videoBitsPerSecond: VIDEO_BITS_PER_SECOND_BY_QUALITY[resolvedQuality] }
|
|
240
|
+
: undefined
|
|
241
|
+
|
|
181
242
|
const resolvedVideoConstraints = getVideoConstraints(videoOpts.constraints, viewport)
|
|
182
243
|
|
|
183
244
|
let worker
|
|
@@ -235,6 +296,7 @@ module.exports = async (page, opts, viewport) => {
|
|
|
235
296
|
audio: audioOpts.enabled,
|
|
236
297
|
frameSize: INTERNAL_FRAME_SIZE,
|
|
237
298
|
mimeType: streamMimeType,
|
|
299
|
+
recorderOptions,
|
|
238
300
|
videoConstraints: resolvedVideoConstraints,
|
|
239
301
|
audioConstraints: audioOpts.constraints
|
|
240
302
|
}
|
package/src/constants.js
CHANGED
|
@@ -3,10 +3,23 @@
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
|
|
5
5
|
module.exports = {
|
|
6
|
-
|
|
6
|
+
MAX_FRAME_RATE: 120,
|
|
7
|
+
DEFAULT: Object.freeze({ duration: 3000, type: 'mp4', quality: 'high' }),
|
|
8
|
+
DEFAULT_CODEC_BY_TYPE: Object.freeze({
|
|
9
|
+
webm: 'vp9',
|
|
10
|
+
mp4: 'avc1.4D401F'
|
|
11
|
+
}),
|
|
7
12
|
EXTENSION_ID: 'jjndjgheafjngoipoacpjgeicjeomjli',
|
|
8
13
|
EXTENSION_PATH: path.join(__dirname, '..', 'extension'),
|
|
9
14
|
TYPES: Object.freeze(['webm', 'mp4']),
|
|
15
|
+
QUALITIES: Object.freeze(['extra-high', 'high', 'medium', 'low', 'extra-low']),
|
|
16
|
+
VIDEO_BITS_PER_SECOND_BY_QUALITY: Object.freeze({
|
|
17
|
+
'extra-high': 20_000_000,
|
|
18
|
+
high: 8_000_000,
|
|
19
|
+
medium: 5_000_000,
|
|
20
|
+
low: 2_500_000,
|
|
21
|
+
'extra-low': 1_000_000
|
|
22
|
+
}),
|
|
10
23
|
INTERNAL_FRAME_SIZE: 250,
|
|
11
24
|
NOOP: () => {}
|
|
12
25
|
}
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const debug = require('debug-logfmt')('browserless:capture')
|
|
4
4
|
const createGoto = require('@browserless/goto')
|
|
5
5
|
|
|
6
|
-
const { DEFAULT, EXTENSION_ID, EXTENSION_PATH, TYPES } = require('./constants')
|
|
6
|
+
const { DEFAULT, EXTENSION_ID, EXTENSION_PATH, QUALITIES, TYPES } = require('./constants')
|
|
7
7
|
const runCapture = require('./capture')
|
|
8
8
|
|
|
9
9
|
module.exports = ({ goto, ...gotoOpts } = {}) => {
|
|
@@ -22,4 +22,5 @@ module.exports = ({ goto, ...gotoOpts } = {}) => {
|
|
|
22
22
|
module.exports.extensionPath = EXTENSION_PATH
|
|
23
23
|
module.exports.extensionId = EXTENSION_ID
|
|
24
24
|
module.exports.types = TYPES
|
|
25
|
+
module.exports.qualities = QUALITIES
|
|
25
26
|
module.exports.DEFAULT = DEFAULT
|