@browserless/capture 10.11.3 → 10.11.5

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 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'` | `'webm'` | Output type selector mapped to MediaRecorder mime type. |
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
- Bitrate hints are not configurable; capture uses Chrome MediaRecorder defaults.
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
 
@@ -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.3",
5
+ "version": "10.11.5",
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.3",
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": "2f86ba8d35c9b92c3c19e310282a3d24d0e0c7e0"
57
+ "gitHead": "26dbfe9e0899c3ee98c61668a4e636a51552dfad"
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 { DEFAULT, INTERNAL_FRAME_SIZE, NOOP } = require('./constants')
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
- video: 'video/webm',
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 getMimeType = ({ type, audio, video }) => {
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
- return audio && !video ? mimeTypes.audio : mimeTypes.video
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
- if (videoConstraints) return videoConstraints
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
- DEFAULT: Object.freeze({ duration: 3000, type: 'webm' }),
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,14 @@
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 {
7
+ DEFAULT,
8
+ DEFAULT_CODEC_BY_TYPE,
9
+ EXTENSION_ID,
10
+ EXTENSION_PATH,
11
+ QUALITIES,
12
+ TYPES
13
+ } = require('./constants')
7
14
  const runCapture = require('./capture')
8
15
 
9
16
  module.exports = ({ goto, ...gotoOpts } = {}) => {
@@ -21,5 +28,7 @@ module.exports = ({ goto, ...gotoOpts } = {}) => {
21
28
 
22
29
  module.exports.extensionPath = EXTENSION_PATH
23
30
  module.exports.extensionId = EXTENSION_ID
24
- module.exports.types = TYPES
31
+ module.exports.TYPES = TYPES
32
+ module.exports.QUALITIES = QUALITIES
25
33
  module.exports.DEFAULT = DEFAULT
34
+ module.exports.DEFAULT_CODEC_BY_TYPE = DEFAULT_CODEC_BY_TYPE