@t3lnet/sceneforge 1.0.22 → 1.0.24

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
@@ -119,15 +119,13 @@ npx sceneforge split --demo my-demo --crf 15
119
119
 
120
120
  ## Viewport Settings (Recording)
121
121
 
122
- Control output video resolution and content zoom:
122
+ Control output video resolution:
123
123
 
124
124
  ```bash
125
125
  --viewport <WxH|preset> # Video resolution (default: 1440x900)
126
126
  # Presets: 720p, 1080p, 1440p, 4k
127
127
  --width <px> # Video width
128
128
  --height <px> # Video height
129
- --zoom <percent> # Content zoom: 100, 150, 200 (default: 100)
130
- # Makes UI elements appear larger
131
129
  ```
132
130
 
133
131
  **Examples:**
@@ -136,13 +134,10 @@ Control output video resolution and content zoom:
136
134
  # Record at 1080p
137
135
  npx sceneforge record --definition demo.yaml --base-url http://localhost:5173 --viewport 1080p
138
136
 
139
- # Record at 1080p with zoomed content (UI appears 1.5x larger)
140
- npx sceneforge record --definition demo.yaml --base-url http://localhost:5173 \
141
- --viewport 1080p --zoom 150
137
+ # Record at 4K
138
+ npx sceneforge record --definition demo.yaml --base-url http://localhost:5173 --viewport 4k
142
139
  ```
143
140
 
144
- **How zoom works:** `--viewport 1080p --zoom 150` records a 1920x1080 video where content appears 1.5x larger (browser viewport is actually 1280x720, scaled up to 1080p).
145
-
146
141
  ## Output Dimensions (Video Processing)
147
142
 
148
143
  Control final video resolution with support for different platforms and aspect ratios:
@@ -179,7 +174,7 @@ npx sceneforge concat --demo my-demo --output-size square
179
174
 
180
175
  # Full pipeline with all video options
181
176
  npx sceneforge pipeline --demo my-demo --base-url http://localhost:5173 \
182
- --viewport 1080p --zoom 150 \
177
+ --viewport 1080p \
183
178
  --output-size 1080p \
184
179
  --quality high
185
180
  ```
@@ -5,10 +5,7 @@ import { getFlagValue, hasFlag } from "../utils/args.js";
5
5
  import { getOutputPaths, resolveRoot, readJson } from "../utils/paths.js";
6
6
  import { sanitizeFileSegment } from "../utils/sanitize.js";
7
7
  import {
8
- getVideoEncodingArgs,
9
- parseQualityArgs,
10
- getQualityHelpText,
11
- logQualitySettings,
8
+ getIntermediateEncodingArgs,
12
9
  } from "../utils/quality.js";
13
10
  import {
14
11
  parseOutputDimensions,
@@ -21,6 +18,9 @@ function printHelp() {
21
18
  console.log(`
22
19
  Add audio to individual video step clips
23
20
 
21
+ Uses lossless encoding for intermediate files to preserve quality.
22
+ Final compression is applied only at the concat step.
23
+
24
24
  Usage:
25
25
  sceneforge add-audio [options]
26
26
 
@@ -31,7 +31,6 @@ Options:
31
31
  --root <path> Project root (defaults to cwd)
32
32
  --output-dir <path> Output directory (defaults to e2e/output or output)
33
33
  --help, -h Show this help message
34
- ${getQualityHelpText()}
35
34
  ${getOutputDimensionsHelpText()}
36
35
 
37
36
  Output:
@@ -39,9 +38,8 @@ Output:
39
38
 
40
39
  Examples:
41
40
  sceneforge add-audio --demo create-quote
42
- sceneforge add-audio --demo create-quote --quality high
43
41
  sceneforge add-audio --demo create-quote --output-size 1080p
44
- sceneforge add-audio --all --codec libx265
42
+ sceneforge add-audio --all
45
43
  `);
46
44
  }
47
45
 
@@ -55,11 +53,12 @@ function buildScaleFilter(outputDimensions) {
55
53
  return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
56
54
  }
57
55
 
58
- async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVideoPath, qualityOptions = {}, outputDimensions = null) {
56
+ async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVideoPath, outputDimensions = null) {
59
57
  const videoDuration = await getMediaDuration(videoPath);
60
58
  const audioDuration = await getMediaDuration(audioPath);
61
59
  const targetDuration = audioDuration + padding;
62
- const encodingArgs = getVideoEncodingArgs(qualityOptions);
60
+ // Use lossless encoding for intermediate files to prevent generation loss
61
+ const encodingArgs = getIntermediateEncodingArgs({ includeAudio: true });
63
62
  const scaleFilter = buildScaleFilter(outputDimensions);
64
63
 
65
64
  if (targetDuration <= videoDuration) {
@@ -142,9 +141,9 @@ async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVid
142
141
  ]);
143
142
  }
144
143
 
145
- async function processDemo(demoName, paths, padding, qualityOptions = {}, outputDimensions = null) {
146
- console.log(`\n[audio] Processing: ${demoName}\n`);
147
- logQualitySettings(qualityOptions, "[audio]");
144
+ async function processDemo(demoName, paths, padding, outputDimensions = null) {
145
+ console.log(`\n[audio] Processing: ${demoName}`);
146
+ console.log("[audio] Using lossless encoding for intermediate files");
148
147
  logOutputDimensions(outputDimensions, "[audio]");
149
148
 
150
149
  const stepsManifestPath = path.join(paths.videosDir, demoName, "steps-manifest.json");
@@ -231,7 +230,7 @@ async function processDemo(demoName, paths, padding, qualityOptions = {}, output
231
230
  );
232
231
 
233
232
  try {
234
- await addAudioToStep(step.videoFile, audioSegment.audioFile, outputPath, padding, nextVideoPath, qualityOptions, outputDimensions);
233
+ await addAudioToStep(step.videoFile, audioSegment.audioFile, outputPath, padding, nextVideoPath, outputDimensions);
235
234
  outputFiles.push(outputPath);
236
235
  } catch (error) {
237
236
  console.error(`[audio] ${paddedIndex}. ${step.stepId}: ✗ Failed to process`);
@@ -264,7 +263,7 @@ async function processDemo(demoName, paths, padding, qualityOptions = {}, output
264
263
  console.log(`[audio] Output: ${path.join(paths.videosDir, demoName)}`);
265
264
  }
266
265
 
267
- async function processAll(paths, padding, qualityOptions = {}, outputDimensions = null) {
266
+ async function processAll(paths, padding, outputDimensions = null) {
268
267
  console.log("\n[audio] Processing all demos...\n");
269
268
 
270
269
  try {
@@ -295,7 +294,7 @@ async function processAll(paths, padding, qualityOptions = {}, outputDimensions
295
294
  console.log(`[audio] Found ${demosToProcess.length} demo(s) to process\n`);
296
295
 
297
296
  for (const demo of demosToProcess) {
298
- await processDemo(demo, paths, padding, qualityOptions, outputDimensions);
297
+ await processDemo(demo, paths, padding, outputDimensions);
299
298
  }
300
299
 
301
300
  await fs.rm(paths.tempDir, { recursive: true, force: true });
@@ -328,17 +327,16 @@ export async function runAddAudioCommand(argv) {
328
327
 
329
328
  const rootDir = resolveRoot(root);
330
329
  const paths = await getOutputPaths(rootDir, outputDir);
331
- const qualityOptions = parseQualityArgs(args, getFlagValue, hasFlag);
332
330
  const outputDimensions = parseOutputDimensions(args, getFlagValue);
333
331
 
334
332
  if (demo) {
335
- await processDemo(demo, paths, padding, qualityOptions, outputDimensions);
333
+ await processDemo(demo, paths, padding, outputDimensions);
336
334
  await fs.rm(paths.tempDir, { recursive: true, force: true });
337
335
  return;
338
336
  }
339
337
 
340
338
  if (all) {
341
- await processAll(paths, padding, qualityOptions, outputDimensions);
339
+ await processAll(paths, padding, outputDimensions);
342
340
  return;
343
341
  }
344
342
 
@@ -5,6 +5,7 @@ import { getFlagValue, hasFlag } from "../utils/args.js";
5
5
  import { ensureDir, getOutputPaths, resolveRoot, toAbsolute } from "../utils/paths.js";
6
6
  import { getQualityHelpText } from "../utils/quality.js";
7
7
  import { getViewportHelpText, getOutputDimensionsHelpText } from "../utils/dimensions.js";
8
+ import { getCDPRecordingHelpText } from "../utils/cdp-recorder.js";
8
9
  import { runRecordDemoCommand } from "./record-demo.js";
9
10
  import { runSplitVideoCommand } from "./split-video.js";
10
11
  import { runGenerateVoiceoverCommand } from "./generate-voiceover.js";
@@ -45,6 +46,7 @@ Media options (for final video):
45
46
  ${getQualityHelpText()}
46
47
  ${getViewportHelpText()}
47
48
  ${getOutputDimensionsHelpText()}
49
+ ${getCDPRecordingHelpText()}
48
50
 
49
51
  --help, -h Show this help message
50
52
 
@@ -207,8 +209,6 @@ export async function runPipelineCommand(argv) {
207
209
  const viewport = getFlagValue(args, "--viewport");
208
210
  const viewportWidth = getFlagValue(args, "--width");
209
211
  const viewportHeight = getFlagValue(args, "--height");
210
- const zoom = getFlagValue(args, "--zoom");
211
- const deviceScaleFactor = getFlagValue(args, "--device-scale-factor");
212
212
 
213
213
  // Output dimension options (for video processing)
214
214
  const outputSize = getFlagValue(args, "--output-size");
@@ -261,13 +261,11 @@ export async function runPipelineCommand(argv) {
261
261
  if (crf) console.log(` - CRF: ${crf}`);
262
262
  if (codec) console.log(` - Codec: ${codec}`);
263
263
  }
264
- if (viewport || viewportWidth || viewportHeight || zoom || deviceScaleFactor) {
264
+ if (viewport || viewportWidth || viewportHeight) {
265
265
  console.log("\nViewport options:");
266
266
  if (viewport) console.log(` - Viewport: ${viewport}`);
267
267
  if (viewportWidth) console.log(` - Width: ${viewportWidth}`);
268
268
  if (viewportHeight) console.log(` - Height: ${viewportHeight}`);
269
- if (zoom) console.log(` - Zoom: ${zoom}%`);
270
- if (deviceScaleFactor) console.log(` - Device scale factor: ${deviceScaleFactor}`);
271
269
  }
272
270
  if (outputSize || outputWidth || outputHeight) {
273
271
  console.log("\nOutput dimension options:");
@@ -334,8 +332,9 @@ export async function runPipelineCommand(argv) {
334
332
  outputDimensionArgs.push("--output-height", outputHeight);
335
333
  }
336
334
 
335
+ // Split and add-audio use lossless encoding for intermediates (no quality args needed)
337
336
  await runStep("split", plan.split, () =>
338
- runSplitVideoCommand(["--demo", demoName, ...sharedArgs, ...qualityArgs, ...outputDimensionArgs])
337
+ runSplitVideoCommand(["--demo", demoName, ...sharedArgs, ...outputDimensionArgs])
339
338
  );
340
339
 
341
340
  const voiceArgs = ["--demo", demoName, ...sharedArgs];
@@ -347,13 +346,14 @@ export async function runPipelineCommand(argv) {
347
346
  }
348
347
  await runStep("voiceover", plan.voiceover, () => runGenerateVoiceoverCommand(voiceArgs));
349
348
 
350
- const audioArgs = ["--demo", demoName, ...sharedArgs, ...qualityArgs, ...outputDimensionArgs];
349
+ // Add-audio uses lossless encoding for intermediates (no quality args needed)
350
+ const audioArgs = ["--demo", demoName, ...sharedArgs, ...outputDimensionArgs];
351
351
  if (padding) {
352
352
  audioArgs.push("--padding", padding);
353
353
  }
354
354
  await runStep("add-audio", plan.addAudio, () => runAddAudioCommand(audioArgs));
355
355
 
356
- // Build concat args with media options and quality settings
356
+ // Concat applies final compression - quality args are used here
357
357
  const concatArgs = ["--demo", demoName, ...sharedArgs, ...qualityArgs, ...outputDimensionArgs];
358
358
  if (intro) {
359
359
  concatArgs.push("--intro", intro);
@@ -17,9 +17,9 @@ import {
17
17
  import { getMediaDuration } from "../utils/media.js";
18
18
  import {
19
19
  parseViewportArgs,
20
- parseDeviceScaleFactor,
21
20
  getViewportHelpText,
22
21
  } from "../utils/dimensions.js";
22
+ import { createCDPRecorder, getCDPRecordingHelpText } from "../utils/cdp-recorder.js";
23
23
 
24
24
  function printHelp() {
25
25
  console.log(`
@@ -45,11 +45,12 @@ Options:
45
45
  --no-video Skip video recording
46
46
  --help, -h Show this help message
47
47
  ${getViewportHelpText()}
48
+ ${getCDPRecordingHelpText()}
48
49
 
49
50
  Examples:
50
51
  sceneforge record --definition demo-definitions/create-quote.yaml --base-url http://localhost:5173
51
52
  sceneforge record --demo create-quote --definitions-dir examples --base-url http://localhost:5173
52
- sceneforge record --demo create-quote --base-url http://localhost:5173 --viewport 1920x1080 --zoom 150
53
+ sceneforge record --demo create-quote --base-url http://localhost:5173 --viewport 1920x1080
53
54
  `);
54
55
  }
55
56
 
@@ -178,6 +179,9 @@ export async function runRecordDemoCommand(argv) {
178
179
  const headed = hasFlag(args, "--headed");
179
180
  const slowMo = getFlagValue(args, "--slowmo");
180
181
  const noVideo = hasFlag(args, "--no-video");
182
+ const hqVideo = hasFlag(args, "--hq-video");
183
+ const hqFormat = getFlagValue(args, "--hq-format") ?? "jpeg";
184
+ const hqQuality = parseInt(getFlagValue(args, "--hq-quality") ?? "100", 10);
181
185
 
182
186
  if (!baseUrl) {
183
187
  console.error("[error] --base-url is required");
@@ -213,24 +217,12 @@ export async function runRecordDemoCommand(argv) {
213
217
  await ensureDir(outputPaths.videosDir);
214
218
 
215
219
  const viewport = parseViewportArgs(args, getFlagValue);
216
- const zoomFactor = parseDeviceScaleFactor(args, getFlagValue);
217
-
218
- // To achieve "zoom", we use CSS zoom to make content appear larger.
219
- // The viewport stays at the requested size, and we inject CSS zoom after page load.
220
- // Example: --viewport 4k --zoom 200 means:
221
- // - Viewport is 3840x2160 (4K)
222
- // - CSS zoom: 2 makes content appear 2x larger
223
- // - User sees the equivalent of 1920x1080 content filling the 4K frame
224
-
225
- // Log what's happening
226
- if (zoomFactor !== 1) {
227
- console.log(`[record] Viewport: ${viewport.width}x${viewport.height} with ${Math.round(zoomFactor * 100)}% CSS zoom`);
228
- } else {
229
- console.log(`[record] Viewport: ${viewport.width}x${viewport.height}`);
230
- }
220
+
221
+ console.log(`[record] Viewport: ${viewport.width}x${viewport.height}`);
231
222
 
232
223
  const recordDir = path.join(outputPaths.videosDir, ".recordings", definition.name);
233
- if (!noVideo) {
224
+ const framesDir = path.join(outputPaths.videosDir, ".frames", definition.name);
225
+ if (!noVideo && !hqVideo) {
234
226
  await ensureDir(recordDir);
235
227
  }
236
228
 
@@ -239,9 +231,12 @@ export async function runRecordDemoCommand(argv) {
239
231
  slowMo: slowMo ? Number(slowMo) : undefined,
240
232
  });
241
233
 
234
+ // When using HQ video, don't use Playwright's built-in recording
235
+ const usePlaywrightRecording = !noVideo && !hqVideo;
236
+
242
237
  const context = await browser.newContext({
243
238
  viewport,
244
- recordVideo: noVideo ? undefined : { dir: recordDir, size: viewport },
239
+ recordVideo: usePlaywrightRecording ? { dir: recordDir, size: viewport } : undefined,
245
240
  storageState: storageState ? toAbsolute(rootDir, storageState) : undefined,
246
241
  locale: locale || undefined,
247
242
  extraHTTPHeaders: locale
@@ -252,32 +247,25 @@ export async function runRecordDemoCommand(argv) {
252
247
  });
253
248
 
254
249
  const page = await context.newPage();
250
+ const video = usePlaywrightRecording ? page.video() : null;
251
+ const videoRecordingStartTime = Date.now();
255
252
 
256
- // Apply CSS zoom if requested (must be done before navigation to affect all content)
257
- if (zoomFactor !== 1) {
258
- await page.addInitScript((zoom) => {
259
- document.addEventListener('DOMContentLoaded', () => {
260
- document.documentElement.style.zoom = String(zoom);
261
- });
262
- // Also set immediately in case DOMContentLoaded already fired
263
- if (document.documentElement) {
264
- document.documentElement.style.zoom = String(zoom);
265
- }
266
- }, zoomFactor);
253
+ // Set up CDP recorder for high-quality video
254
+ let cdpRecorder = null;
255
+ if (hqVideo && !noVideo) {
256
+ cdpRecorder = createCDPRecorder(page, {
257
+ framesDir,
258
+ format: hqFormat,
259
+ quality: hqQuality,
260
+ maxWidth: viewport.width,
261
+ maxHeight: viewport.height,
262
+ });
263
+ await cdpRecorder.start();
267
264
  }
268
-
269
- const video = page.video();
270
- const videoRecordingStartTime = Date.now();
271
265
  const startUrl = resolveStartUrl(startPath, baseUrl);
272
266
 
273
267
  if (startUrl) {
274
268
  await page.goto(startUrl, { waitUntil: "networkidle" });
275
- // Ensure zoom is applied after navigation
276
- if (zoomFactor !== 1) {
277
- await page.evaluate((zoom) => {
278
- document.documentElement.style.zoom = String(zoom);
279
- }, zoomFactor);
280
- }
281
269
  }
282
270
  const result = await runDemo(
283
271
  definition,
@@ -294,10 +282,28 @@ export async function runRecordDemoCommand(argv) {
294
282
  }
295
283
  );
296
284
 
285
+ // Stop CDP recorder before closing context (if using HQ video)
286
+ if (cdpRecorder) {
287
+ await cdpRecorder.stop();
288
+ }
289
+
297
290
  await context.close();
298
291
  await browser.close();
299
292
 
300
- if (video) {
293
+ // Handle video based on recording method
294
+ if (cdpRecorder) {
295
+ // Assemble CDP frames into video
296
+ const finalVideoPath = path.join(outputPaths.videosDir, `${definition.name}.webm`);
297
+ try {
298
+ await cdpRecorder.assembleVideo(finalVideoPath);
299
+ console.log(`[record] High-quality video saved: ${finalVideoPath}`);
300
+ if (result.scriptPath) {
301
+ await alignScriptToVideo(result.scriptPath, finalVideoPath, videoRecordingStartTime);
302
+ }
303
+ } finally {
304
+ await cdpRecorder.cleanup();
305
+ }
306
+ } else if (video) {
301
307
  const recordedPath = await video.path();
302
308
  const finalVideoPath = path.join(outputPaths.videosDir, `${definition.name}.webm`);
303
309
  await moveVideo(recordedPath, finalVideoPath);
@@ -5,10 +5,7 @@ import { getOutputPaths, readJson, resolveRoot } from "../utils/paths.js";
5
5
  import { getFlagValue, hasFlag } from "../utils/args.js";
6
6
  import { sanitizeFileSegment } from "../utils/sanitize.js";
7
7
  import {
8
- getVideoEncodingArgs,
9
- parseQualityArgs,
10
- getQualityHelpText,
11
- logQualitySettings,
8
+ getIntermediateEncodingArgs,
12
9
  } from "../utils/quality.js";
13
10
  import {
14
11
  parseOutputDimensions,
@@ -21,6 +18,9 @@ function printHelp() {
21
18
  console.log(`
22
19
  Split recorded demo video into per-step clips
23
20
 
21
+ Uses lossless encoding for intermediate files to preserve quality.
22
+ Final compression is applied only at the concat step.
23
+
24
24
  Usage:
25
25
  sceneforge split [options]
26
26
 
@@ -30,13 +30,10 @@ Options:
30
30
  --root <path> Project root (defaults to cwd)
31
31
  --output-dir <path> Output directory (defaults to e2e/output or output)
32
32
  --help, -h Show this help message
33
- ${getQualityHelpText()}
34
33
  ${getOutputDimensionsHelpText()}
35
34
 
36
35
  Examples:
37
36
  sceneforge split --demo create-quote
38
- sceneforge split --demo create-quote --quality high
39
- sceneforge split --demo create-quote --crf 15 --codec libx265
40
37
  sceneforge split --demo create-quote --output-size 1080p
41
38
  sceneforge split --all
42
39
  `);
@@ -71,9 +68,9 @@ async function findVideoFile(demoName, videosDir, testResultsDir) {
71
68
  return null;
72
69
  }
73
70
 
74
- async function splitDemo(demoName, paths, qualityOptions = {}, outputDimensions = null) {
75
- console.log(`\n[split] Processing: ${demoName}\n`);
76
- logQualitySettings(qualityOptions, "[split]");
71
+ async function splitDemo(demoName, paths, outputDimensions = null) {
72
+ console.log(`\n[split] Processing: ${demoName}`);
73
+ console.log("[split] Using lossless encoding for intermediate files");
77
74
  logOutputDimensions(outputDimensions, "[split]");
78
75
 
79
76
  const scriptPath = path.join(paths.scriptsDir, `${demoName}.json`);
@@ -135,7 +132,8 @@ async function splitDemo(demoName, paths, qualityOptions = {}, outputDimensions
135
132
  );
136
133
 
137
134
  try {
138
- const encodingArgs = getVideoEncodingArgs({ ...qualityOptions, includeAudio: false });
135
+ // Use lossless encoding for intermediate files to prevent generation loss
136
+ const encodingArgs = getIntermediateEncodingArgs({ includeAudio: false });
139
137
  const scaleArgs = getScaleFilterArgs(outputDimensions);
140
138
  await runFFmpeg([
141
139
  "-y",
@@ -192,7 +190,7 @@ async function splitDemo(demoName, paths, qualityOptions = {}, outputDimensions
192
190
  console.log(`[split] Manifest: ${manifestPath}`);
193
191
  }
194
192
 
195
- async function splitAll(paths, qualityOptions = {}, outputDimensions = null) {
193
+ async function splitAll(paths, outputDimensions = null) {
196
194
  console.log("\n[split] Processing all demos...\n");
197
195
 
198
196
  try {
@@ -210,7 +208,7 @@ async function splitAll(paths, qualityOptions = {}, outputDimensions = null) {
210
208
 
211
209
  for (const file of scriptFiles) {
212
210
  const demoName = path.basename(file, ".json");
213
- await splitDemo(demoName, paths, qualityOptions, outputDimensions);
211
+ await splitDemo(demoName, paths, outputDimensions);
214
212
  }
215
213
 
216
214
  console.log("\n[split] All demos processed!");
@@ -240,16 +238,15 @@ export async function runSplitVideoCommand(argv) {
240
238
 
241
239
  const rootDir = resolveRoot(root);
242
240
  const paths = await getOutputPaths(rootDir, outputDir);
243
- const qualityOptions = parseQualityArgs(args, getFlagValue, hasFlag);
244
241
  const outputDimensions = parseOutputDimensions(args, getFlagValue);
245
242
 
246
243
  if (demo) {
247
- await splitDemo(demo, paths, qualityOptions, outputDimensions);
244
+ await splitDemo(demo, paths, outputDimensions);
248
245
  return;
249
246
  }
250
247
 
251
248
  if (all) {
252
- await splitAll(paths, qualityOptions, outputDimensions);
249
+ await splitAll(paths, outputDimensions);
253
250
  return;
254
251
  }
255
252
 
@@ -0,0 +1,236 @@
1
+ /**
2
+ * CDP-based high-quality screen recorder
3
+ *
4
+ * Uses Chrome DevTools Protocol screencast API to capture high-quality frames,
5
+ * then assembles them into a video using FFmpeg. This bypasses Playwright's
6
+ * hardcoded low-quality video settings.
7
+ *
8
+ * Benefits over Playwright's built-in recording:
9
+ * - Configurable quality (PNG lossless or high-quality JPEG)
10
+ * - No 1Mbps bitrate cap
11
+ * - Better compression settings via FFmpeg
12
+ */
13
+
14
+ import * as fs from "fs/promises";
15
+ import * as path from "path";
16
+ import { runFFmpeg } from "./media.js";
17
+ import { getIntermediateEncodingArgs } from "./quality.js";
18
+
19
+ /**
20
+ * @typedef {Object} CDPRecorderOptions
21
+ * @property {string} framesDir - Directory to store captured frames
22
+ * @property {number} [quality=100] - JPEG quality (1-100), 100 = best quality
23
+ * @property {string} [format='jpeg'] - Frame format: 'jpeg' or 'png'
24
+ * @property {number} [maxWidth] - Max frame width (undefined = viewport width)
25
+ * @property {number} [maxHeight] - Max frame height (undefined = viewport height)
26
+ * @property {number} [everyNthFrame=1] - Capture every Nth frame (1 = all frames)
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} CDPRecorder
31
+ * @property {Function} start - Start recording
32
+ * @property {Function} stop - Stop recording and return frame info
33
+ * @property {Function} assembleVideo - Assemble frames into video
34
+ */
35
+
36
+ /**
37
+ * Create a CDP-based screen recorder for a Playwright page
38
+ *
39
+ * @param {import('@playwright/test').Page} page - Playwright page
40
+ * @param {CDPRecorderOptions} options - Recorder options
41
+ * @returns {CDPRecorder}
42
+ */
43
+ export function createCDPRecorder(page, options) {
44
+ const {
45
+ framesDir,
46
+ quality = 100,
47
+ format = "jpeg",
48
+ maxWidth,
49
+ maxHeight,
50
+ everyNthFrame = 1,
51
+ } = options;
52
+
53
+ let cdpSession = null;
54
+ let frameCount = 0;
55
+ let frameTimestamps = [];
56
+ let isRecording = false;
57
+ let startTime = null;
58
+
59
+ const frameHandler = async (params) => {
60
+ if (!isRecording) return;
61
+
62
+ const { data, metadata, sessionId } = params;
63
+ frameCount += 1;
64
+
65
+ // Acknowledge the frame to continue receiving
66
+ try {
67
+ await cdpSession.send("Page.screencastFrameAck", { sessionId });
68
+ } catch {
69
+ // Session may be closed
70
+ }
71
+
72
+ // Skip frames based on everyNthFrame setting
73
+ if (everyNthFrame > 1 && frameCount % everyNthFrame !== 0) {
74
+ return;
75
+ }
76
+
77
+ const timestamp = metadata.timestamp;
78
+ const frameNumber = frameTimestamps.length;
79
+ const paddedNumber = String(frameNumber).padStart(8, "0");
80
+ const extension = format === "png" ? "png" : "jpg";
81
+ const framePath = path.join(framesDir, `frame_${paddedNumber}.${extension}`);
82
+
83
+ // Decode base64 and save frame
84
+ const buffer = Buffer.from(data, "base64");
85
+ await fs.writeFile(framePath, buffer);
86
+
87
+ frameTimestamps.push({
88
+ frameNumber,
89
+ timestamp,
90
+ path: framePath,
91
+ });
92
+ };
93
+
94
+ return {
95
+ /**
96
+ * Start recording frames via CDP screencast
97
+ */
98
+ async start() {
99
+ await fs.mkdir(framesDir, { recursive: true });
100
+
101
+ // Create CDP session for the page
102
+ cdpSession = await page.context().newCDPSession(page);
103
+
104
+ // Set up frame handler
105
+ cdpSession.on("Page.screencastFrame", frameHandler);
106
+
107
+ // Start screencast with high quality settings
108
+ // format: 'jpeg' or 'png'
109
+ // quality: 0-100 for jpeg (100 = best), ignored for png
110
+ // maxWidth/maxHeight: optional frame dimensions
111
+ // everyNthFrame: capture frequency (1 = every frame)
112
+ await cdpSession.send("Page.startScreencast", {
113
+ format,
114
+ quality,
115
+ maxWidth,
116
+ maxHeight,
117
+ everyNthFrame,
118
+ });
119
+
120
+ isRecording = true;
121
+ startTime = Date.now();
122
+ frameCount = 0;
123
+ frameTimestamps = [];
124
+
125
+ console.log("[cdp-recorder] Started high-quality screen capture");
126
+ },
127
+
128
+ /**
129
+ * Stop recording and return frame information
130
+ * @returns {Promise<{frameCount: number, duration: number, framesDir: string}>}
131
+ */
132
+ async stop() {
133
+ if (!isRecording || !cdpSession) {
134
+ return { frameCount: 0, duration: 0, framesDir };
135
+ }
136
+
137
+ isRecording = false;
138
+ const duration = Date.now() - startTime;
139
+
140
+ try {
141
+ await cdpSession.send("Page.stopScreencast");
142
+ cdpSession.off("Page.screencastFrame", frameHandler);
143
+ await cdpSession.detach();
144
+ } catch {
145
+ // Session may already be closed
146
+ }
147
+
148
+ console.log(
149
+ `[cdp-recorder] Captured ${frameTimestamps.length} frames in ${(duration / 1000).toFixed(2)}s`
150
+ );
151
+
152
+ return {
153
+ frameCount: frameTimestamps.length,
154
+ duration,
155
+ framesDir,
156
+ frameTimestamps,
157
+ };
158
+ },
159
+
160
+ /**
161
+ * Assemble captured frames into a video using FFmpeg
162
+ *
163
+ * @param {string} outputPath - Output video path
164
+ * @param {Object} [assembleOptions] - Assembly options
165
+ * @param {number} [assembleOptions.fps] - Output framerate (auto-detected if not specified)
166
+ * @returns {Promise<string>} - Path to assembled video
167
+ */
168
+ async assembleVideo(outputPath, assembleOptions = {}) {
169
+ const { fps: fpsOverride } = assembleOptions;
170
+
171
+ if (frameTimestamps.length === 0) {
172
+ throw new Error("No frames captured - cannot assemble video");
173
+ }
174
+
175
+ // Calculate actual FPS from captured frame timestamps
176
+ let fps = fpsOverride;
177
+ if (!fps && frameTimestamps.length > 1) {
178
+ const firstTimestamp = frameTimestamps[0].timestamp;
179
+ const lastTimestamp = frameTimestamps[frameTimestamps.length - 1].timestamp;
180
+ const durationSec = lastTimestamp - firstTimestamp;
181
+ if (durationSec > 0) {
182
+ fps = Math.round(frameTimestamps.length / durationSec);
183
+ }
184
+ }
185
+ fps = fps || 30; // Default to 30fps
186
+
187
+ console.log(`[cdp-recorder] Assembling video at ${fps} fps`);
188
+
189
+ const extension = format === "png" ? "png" : "jpg";
190
+ const framePattern = path.join(framesDir, `frame_%08d.${extension}`);
191
+
192
+ // Use lossless encoding for the assembled video (will be compressed at concat step)
193
+ const encodingArgs = getIntermediateEncodingArgs({ includeAudio: false });
194
+
195
+ await runFFmpeg([
196
+ "-y",
197
+ "-framerate",
198
+ String(fps),
199
+ "-i",
200
+ framePattern,
201
+ ...encodingArgs,
202
+ "-pix_fmt",
203
+ "yuv420p", // Ensure compatibility
204
+ outputPath,
205
+ ]);
206
+
207
+ console.log(`[cdp-recorder] Video assembled: ${outputPath}`);
208
+
209
+ return outputPath;
210
+ },
211
+
212
+ /**
213
+ * Clean up frame files
214
+ */
215
+ async cleanup() {
216
+ try {
217
+ await fs.rm(framesDir, { recursive: true, force: true });
218
+ } catch {
219
+ // Ignore cleanup errors
220
+ }
221
+ },
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Get help text for CDP recording options
227
+ * @returns {string}
228
+ */
229
+ export function getCDPRecordingHelpText() {
230
+ return `
231
+ High-Quality Recording Options:
232
+ --hq-video Use CDP-based high-quality recording instead of Playwright
233
+ Captures frames at maximum quality and assembles with FFmpeg
234
+ --hq-format <format> Frame format: jpeg (default) or png (lossless, larger files)
235
+ --hq-quality <1-100> JPEG quality (default: 100, ignored for PNG)`;
236
+ }
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Browser Viewport:
5
5
  * - Controls the browser window size during recording
6
- * - Supports device scale factor (zoom) for high-DPI capture
7
6
  *
8
7
  * Output Dimensions:
9
8
  * - Controls the final video resolution after processing
@@ -35,7 +34,6 @@ export const OUTPUT_PRESETS = {
35
34
  };
36
35
 
37
36
  export const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
38
- export const DEFAULT_DEVICE_SCALE_FACTOR = 1;
39
37
 
40
38
  /**
41
39
  * Parse a dimension string like "1920x1080" or a preset name like "1080p"
@@ -85,35 +83,6 @@ export function parseViewportArgs(args, getFlagValue) {
85
83
  return parseDimensions(viewportValue, VIEWPORT_PRESETS, DEFAULT_VIEWPORT);
86
84
  }
87
85
 
88
- /**
89
- * Parse device scale factor (zoom) from CLI arguments
90
- * @param {string[]} args - CLI arguments
91
- * @param {Function} getFlagValue - Function to get flag values
92
- * @returns {number} Device scale factor (1 = 100%, 1.5 = 150%, 2 = 200%)
93
- */
94
- export function parseDeviceScaleFactor(args, getFlagValue) {
95
- const zoomValue = getFlagValue(args, "--zoom");
96
- const scaleValue = getFlagValue(args, "--device-scale-factor");
97
-
98
- // --zoom takes a percentage (100, 150, 200)
99
- if (zoomValue !== null && zoomValue !== undefined) {
100
- const zoom = Number(zoomValue);
101
- if (Number.isFinite(zoom) && zoom > 0) {
102
- return zoom / 100; // Convert percentage to scale factor
103
- }
104
- }
105
-
106
- // --device-scale-factor takes the actual scale (1, 1.5, 2)
107
- if (scaleValue !== null && scaleValue !== undefined) {
108
- const scale = Number(scaleValue);
109
- if (Number.isFinite(scale) && scale > 0) {
110
- return scale;
111
- }
112
- }
113
-
114
- return DEFAULT_DEVICE_SCALE_FACTOR;
115
- }
116
-
117
86
  /**
118
87
  * Parse output dimensions from CLI arguments
119
88
  * @param {string[]} args - CLI arguments
@@ -192,11 +161,7 @@ Viewport Options (Recording):
192
161
  Presets: 720p, 1080p, 1440p, 4k
193
162
  Example: --viewport 1920x1080 or --viewport 1080p
194
163
  --width <px> Video width (overrides --viewport)
195
- --height <px> Video height (overrides --viewport)
196
- --zoom <percent> Content zoom level: 100, 150, 200 (default: 100)
197
- Makes UI elements appear larger in the video
198
- Example: --viewport 1080p --zoom 150 records at
199
- 1920x1080 with content appearing 1.5x larger`;
164
+ --height <px> Video height (overrides --viewport)`;
200
165
  }
201
166
 
202
167
  /**
@@ -217,28 +182,6 @@ Output Dimensions:
217
182
  If not specified, keeps original recording dimensions`;
218
183
  }
219
184
 
220
- /**
221
- * Log viewport settings being used
222
- * @param {Object} viewport - { width, height }
223
- * @param {number} deviceScaleFactor - Scale factor
224
- * @param {string} prefix - Log prefix
225
- */
226
- export function logViewportSettings(viewport, deviceScaleFactor, prefix = "") {
227
- const zoomPercent = Math.round(deviceScaleFactor * 100);
228
- console.log(
229
- `${prefix} Viewport: ${viewport.width}x${viewport.height} @ ${zoomPercent}% zoom`
230
- );
231
- if (deviceScaleFactor !== 1) {
232
- const effectiveRes = {
233
- width: Math.round(viewport.width * deviceScaleFactor),
234
- height: Math.round(viewport.height * deviceScaleFactor),
235
- };
236
- console.log(
237
- `${prefix} Effective capture resolution: ${effectiveRes.width}x${effectiveRes.height}`
238
- );
239
- }
240
- }
241
-
242
185
  /**
243
186
  * Log output dimension settings
244
187
  * @param {Object|null} dimensions - { width, height } or null
@@ -47,6 +47,12 @@ export const DEFAULT_QUALITY = "medium";
47
47
  export const DEFAULT_AUDIO_CODEC = "aac";
48
48
  export const DEFAULT_AUDIO_BITRATE = "192k";
49
49
 
50
+ // Lossless encoding for intermediate files to prevent generation loss
51
+ // CRF 0 = mathematically lossless for x264/x265
52
+ // Using "ultrafast" preset since these are temporary files and encoding speed matters
53
+ export const INTERMEDIATE_CRF = 0;
54
+ export const INTERMEDIATE_PRESET = "ultrafast";
55
+
50
56
  /**
51
57
  * Get video encoding arguments for FFmpeg
52
58
  * @param {Object} options
@@ -78,6 +84,36 @@ export function getVideoEncodingArgs(options = {}) {
78
84
  return args;
79
85
  }
80
86
 
87
+ /**
88
+ * Get lossless encoding arguments for intermediate files.
89
+ * Uses CRF 0 (lossless) to prevent generation loss during multi-step processing.
90
+ * Final compression should be applied only at the last step (concat).
91
+ *
92
+ * @param {Object} options
93
+ * @param {string} [options.codec] - Video codec: libx264, libx265 (default: libx264)
94
+ * @param {boolean} [options.includeAudio] - Include audio encoding args
95
+ * @returns {string[]} FFmpeg arguments for lossless intermediate encoding
96
+ */
97
+ export function getIntermediateEncodingArgs(options = {}) {
98
+ const {
99
+ codec = DEFAULT_CODEC,
100
+ includeAudio = true,
101
+ } = options;
102
+
103
+ const args = [
104
+ "-c:v", codec,
105
+ "-preset", INTERMEDIATE_PRESET,
106
+ "-crf", String(INTERMEDIATE_CRF),
107
+ ];
108
+
109
+ if (includeAudio) {
110
+ // Use high-quality audio for intermediates too
111
+ args.push("-c:a", DEFAULT_AUDIO_CODEC, "-b:a", DEFAULT_AUDIO_BITRATE);
112
+ }
113
+
114
+ return args;
115
+ }
116
+
81
117
  /**
82
118
  * Parse quality-related CLI arguments
83
119
  * @param {string[]} args - CLI arguments
@@ -28,7 +28,9 @@ sceneforge record --definition <path> [options]
28
28
  | `--viewport` | Target video resolution (preset or WxH, default: 1440x900) |
29
29
  | `--width` | Video width (overrides --viewport) |
30
30
  | `--height` | Video height (overrides --viewport) |
31
- | `--zoom` | Content zoom level: 100, 150, 200 (default: 100) - makes UI larger |
31
+ | `--hq-video` | Use CDP-based high-quality recording |
32
+ | `--hq-format` | Frame format: jpeg (default) or png (lossless) |
33
+ | `--hq-quality` | JPEG quality 1-100 (default: 100) |
32
34
 
33
35
  **Viewport Presets:** 720p (1280x720), 1080p (1920x1080), 1440p (2560x1440), 4k (3840x2160)
34
36
 
@@ -46,8 +48,14 @@ sceneforge record -d demo.yaml -b http://localhost:3000 --storage ./auth.json
46
48
  # Record at 1080p resolution
47
49
  sceneforge record -d demo.yaml -b http://localhost:3000 --viewport 1080p
48
50
 
49
- # Record at 1080p with zoomed content (UI appears 1.5x larger)
50
- sceneforge record -d demo.yaml -b http://localhost:3000 --viewport 1080p --zoom 150
51
+ # Record at 4K resolution
52
+ sceneforge record -d demo.yaml -b http://localhost:3000 --viewport 4k
53
+
54
+ # High-quality CDP recording (bypasses Playwright's bitrate limits)
55
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video
56
+
57
+ # HQ recording with lossless PNG frames
58
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video --hq-format png
51
59
  ```
52
60
 
53
61
  ### setup
@@ -94,10 +102,12 @@ sceneforge pipeline --definition <path> [options]
94
102
  | `--crf` | Override CRF value |
95
103
  | `--codec` | Video codec: libx264, libx265 |
96
104
  | `--viewport` | Target video resolution (preset or WxH) |
97
- | `--zoom` | Content zoom level: 100, 150, 200 (makes UI larger) |
98
105
  | `--output-size` | Final output dimensions (preset or WxH) |
99
106
  | `--output-width` | Output width (-1 for auto) |
100
107
  | `--output-height` | Output height (-1 for auto) |
108
+ | `--hq-video` | Use CDP-based high-quality recording |
109
+ | `--hq-format` | Frame format: jpeg (default) or png |
110
+ | `--hq-quality` | JPEG quality 1-100 (default: 100) |
101
111
 
102
112
  **Examples:**
103
113
  ```bash
@@ -116,9 +126,9 @@ sceneforge pipeline -d demo.yaml -b http://localhost:3000 --quality high --outpu
116
126
  # TikTok/YouTube Shorts format
117
127
  sceneforge pipeline -d demo.yaml -b http://localhost:3000 --output-size tiktok --quality high
118
128
 
119
- # Full options: viewport + zoom + output scaling
129
+ # Full options: viewport + output scaling
120
130
  sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
121
- --viewport 1080p --zoom 150 \
131
+ --viewport 1080p \
122
132
  --output-size 1080p \
123
133
  --quality high
124
134
  ```
@@ -364,7 +374,7 @@ sceneforge split --demo my-demo --crf 15
364
374
 
365
375
  ## Viewport Settings
366
376
 
367
- Control output video resolution and content zoom during recording.
377
+ Control output video resolution during recording.
368
378
 
369
379
  ### CLI Flags (record, pipeline)
370
380
 
@@ -372,7 +382,6 @@ Control output video resolution and content zoom during recording.
372
382
  --viewport <WxH|preset> # Target video resolution (default: 1440x900)
373
383
  --width <px> # Video width (overrides --viewport)
374
384
  --height <px> # Video height (overrides --viewport)
375
- --zoom <percent> # Content zoom: 100, 150, 200 (default: 100)
376
385
  ```
377
386
 
378
387
  ### Viewport Presets
@@ -384,16 +393,6 @@ Control output video resolution and content zoom during recording.
384
393
  | `1440p` | 2560x1440 |
385
394
  | `4k` | 3840x2160 |
386
395
 
387
- ### How Zoom Works
388
-
389
- - `--viewport` sets the output video resolution (e.g., 1920x1080 for 1080p)
390
- - `--zoom` makes UI elements appear larger by using a smaller browser viewport
391
- - Example: `--viewport 1080p --zoom 150` records a 1920x1080 video
392
- - Actual browser viewport: 1280x720 (1920/1.5 x 1080/1.5)
393
- - Output video: 1920x1080
394
- - Result: Content appears 1.5x larger/zoomed
395
- - Useful for demos where UI elements need to be more visible and readable
396
-
397
396
  ## Output Dimensions
398
397
 
399
398
  Control final video resolution after processing. Supports landscape, portrait, and square formats.
@@ -443,7 +442,76 @@ sceneforge concat --demo my-demo --output-size square
443
442
 
444
443
  # Full pipeline with all options
445
444
  sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
446
- --viewport 1080p --zoom 150 \
445
+ --viewport 1080p \
447
446
  --output-size 1080p \
448
447
  --quality high
449
448
  ```
449
+
450
+ ## High-Quality Recording (CDP)
451
+
452
+ SceneForge offers an alternative high-quality recording mode that bypasses Playwright's built-in video recording limitations.
453
+
454
+ ### Why Use HQ Recording?
455
+
456
+ Playwright's built-in video recording uses hardcoded FFmpeg settings optimized for debugging rather than production quality:
457
+ - **1Mbps bitrate cap** - Severely limits quality at higher resolutions
458
+ - **Speed 8 encoding** - Prioritizes speed over quality
459
+ - **Realtime deadline** - No time for quality optimization
460
+
461
+ The CDP-based recorder captures frames directly from Chrome DevTools Protocol and assembles them with FFmpeg using our quality settings.
462
+
463
+ ### CLI Flags (record, pipeline)
464
+
465
+ ```bash
466
+ --hq-video # Enable CDP-based high-quality recording
467
+ --hq-format <format> # Frame format: jpeg (default) or png
468
+ --hq-quality <1-100> # JPEG quality (default: 100, ignored for PNG)
469
+ ```
470
+
471
+ ### Frame Formats
472
+
473
+ | Format | Quality | File Size | Use Case |
474
+ |--------|---------|-----------|----------|
475
+ | `jpeg` | Very high (at quality 100) | Smaller | Default, good for most cases |
476
+ | `png` | Lossless | Larger | Maximum quality, longer recordings |
477
+
478
+ ### How It Works
479
+
480
+ 1. **Frame Capture**: Uses Chrome's `Page.startScreencast` CDP method
481
+ 2. **Frame Storage**: Saves frames as JPEG (quality 100) or lossless PNG
482
+ 3. **Video Assembly**: FFmpeg assembles frames with lossless intermediate encoding
483
+ 4. **Final Output**: Quality settings applied at concat step
484
+
485
+ ### Examples
486
+
487
+ ```bash
488
+ # High-quality recording with default settings
489
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video
490
+
491
+ # Lossless PNG frames for maximum quality
492
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video --hq-format png
493
+
494
+ # Full pipeline with HQ recording
495
+ sceneforge pipeline -d demo.yaml -b http://localhost:3000 --hq-video --quality high
496
+
497
+ # HQ recording at 4K
498
+ sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
499
+ --hq-video \
500
+ --viewport 4k \
501
+ --output-size 1080p \
502
+ --quality high
503
+ ```
504
+
505
+ ### Trade-offs
506
+
507
+ | Aspect | Standard Recording | HQ Recording |
508
+ |--------|-------------------|--------------|
509
+ | Quality | Limited by 1Mbps cap | Full quality frames |
510
+ | Speed | Faster | Slower (frame-by-frame) |
511
+ | Disk Usage | Moderate | Higher (temporary frames) |
512
+ | Compatibility | Always works | Requires FFmpeg |
513
+
514
+ ### When to Use
515
+
516
+ - **Use HQ Recording** when final video quality is critical
517
+ - **Use Standard Recording** for quick drafts, debugging, or when disk space is limited
@@ -28,7 +28,9 @@ sceneforge record --definition <path> [options]
28
28
  | `--viewport` | Target video resolution (preset or WxH, default: 1440x900) |
29
29
  | `--width` | Video width (overrides --viewport) |
30
30
  | `--height` | Video height (overrides --viewport) |
31
- | `--zoom` | Content zoom level: 100, 150, 200 (default: 100) - makes UI larger |
31
+ | `--hq-video` | Use CDP-based high-quality recording |
32
+ | `--hq-format` | Frame format: jpeg (default) or png (lossless) |
33
+ | `--hq-quality` | JPEG quality 1-100 (default: 100) |
32
34
 
33
35
  **Viewport Presets:** 720p (1280x720), 1080p (1920x1080), 1440p (2560x1440), 4k (3840x2160)
34
36
 
@@ -46,8 +48,14 @@ sceneforge record -d demo.yaml -b http://localhost:3000 --storage ./auth.json
46
48
  # Record at 1080p resolution
47
49
  sceneforge record -d demo.yaml -b http://localhost:3000 --viewport 1080p
48
50
 
49
- # Record at 1080p with zoomed content (UI appears 1.5x larger)
50
- sceneforge record -d demo.yaml -b http://localhost:3000 --viewport 1080p --zoom 150
51
+ # Record at 4K resolution
52
+ sceneforge record -d demo.yaml -b http://localhost:3000 --viewport 4k
53
+
54
+ # High-quality CDP recording (bypasses Playwright's bitrate limits)
55
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video
56
+
57
+ # HQ recording with lossless PNG frames
58
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video --hq-format png
51
59
  ```
52
60
 
53
61
  ### setup
@@ -94,10 +102,12 @@ sceneforge pipeline --definition <path> [options]
94
102
  | `--crf` | Override CRF value |
95
103
  | `--codec` | Video codec: libx264, libx265 |
96
104
  | `--viewport` | Target video resolution (preset or WxH) |
97
- | `--zoom` | Content zoom level: 100, 150, 200 (makes UI larger) |
98
105
  | `--output-size` | Final output dimensions (preset or WxH) |
99
106
  | `--output-width` | Output width (-1 for auto) |
100
107
  | `--output-height` | Output height (-1 for auto) |
108
+ | `--hq-video` | Use CDP-based high-quality recording |
109
+ | `--hq-format` | Frame format: jpeg (default) or png |
110
+ | `--hq-quality` | JPEG quality 1-100 (default: 100) |
101
111
 
102
112
  **Examples:**
103
113
  ```bash
@@ -116,9 +126,9 @@ sceneforge pipeline -d demo.yaml -b http://localhost:3000 --quality high --outpu
116
126
  # TikTok/YouTube Shorts format
117
127
  sceneforge pipeline -d demo.yaml -b http://localhost:3000 --output-size tiktok --quality high
118
128
 
119
- # Full options: viewport + zoom + output scaling
129
+ # Full options: viewport + output scaling
120
130
  sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
121
- --viewport 1080p --zoom 150 \
131
+ --viewport 1080p \
122
132
  --output-size 1080p \
123
133
  --quality high
124
134
  ```
@@ -364,7 +374,7 @@ sceneforge split --demo my-demo --crf 15
364
374
 
365
375
  ## Viewport Settings
366
376
 
367
- Control output video resolution and content zoom during recording.
377
+ Control output video resolution during recording.
368
378
 
369
379
  ### CLI Flags (record, pipeline)
370
380
 
@@ -372,7 +382,6 @@ Control output video resolution and content zoom during recording.
372
382
  --viewport <WxH|preset> # Target video resolution (default: 1440x900)
373
383
  --width <px> # Video width (overrides --viewport)
374
384
  --height <px> # Video height (overrides --viewport)
375
- --zoom <percent> # Content zoom: 100, 150, 200 (default: 100)
376
385
  ```
377
386
 
378
387
  ### Viewport Presets
@@ -384,16 +393,6 @@ Control output video resolution and content zoom during recording.
384
393
  | `1440p` | 2560x1440 |
385
394
  | `4k` | 3840x2160 |
386
395
 
387
- ### How Zoom Works
388
-
389
- - `--viewport` sets the output video resolution (e.g., 1920x1080 for 1080p)
390
- - `--zoom` makes UI elements appear larger by using a smaller browser viewport
391
- - Example: `--viewport 1080p --zoom 150` records a 1920x1080 video
392
- - Actual browser viewport: 1280x720 (1920/1.5 x 1080/1.5)
393
- - Output video: 1920x1080
394
- - Result: Content appears 1.5x larger/zoomed
395
- - Useful for demos where UI elements need to be more visible and readable
396
-
397
396
  ## Output Dimensions
398
397
 
399
398
  Control final video resolution after processing. Supports landscape, portrait, and square formats.
@@ -443,7 +442,76 @@ sceneforge concat --demo my-demo --output-size square
443
442
 
444
443
  # Full pipeline with all options
445
444
  sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
446
- --viewport 1080p --zoom 150 \
445
+ --viewport 1080p \
447
446
  --output-size 1080p \
448
447
  --quality high
449
448
  ```
449
+
450
+ ## High-Quality Recording (CDP)
451
+
452
+ SceneForge offers an alternative high-quality recording mode that bypasses Playwright's built-in video recording limitations.
453
+
454
+ ### Why Use HQ Recording?
455
+
456
+ Playwright's built-in video recording uses hardcoded FFmpeg settings optimized for debugging rather than production quality:
457
+ - **1Mbps bitrate cap** - Severely limits quality at higher resolutions
458
+ - **Speed 8 encoding** - Prioritizes speed over quality
459
+ - **Realtime deadline** - No time for quality optimization
460
+
461
+ The CDP-based recorder captures frames directly from Chrome DevTools Protocol and assembles them with FFmpeg using our quality settings.
462
+
463
+ ### CLI Flags (record, pipeline)
464
+
465
+ ```bash
466
+ --hq-video # Enable CDP-based high-quality recording
467
+ --hq-format <format> # Frame format: jpeg (default) or png
468
+ --hq-quality <1-100> # JPEG quality (default: 100, ignored for PNG)
469
+ ```
470
+
471
+ ### Frame Formats
472
+
473
+ | Format | Quality | File Size | Use Case |
474
+ |--------|---------|-----------|----------|
475
+ | `jpeg` | Very high (at quality 100) | Smaller | Default, good for most cases |
476
+ | `png` | Lossless | Larger | Maximum quality, longer recordings |
477
+
478
+ ### How It Works
479
+
480
+ 1. **Frame Capture**: Uses Chrome's `Page.startScreencast` CDP method
481
+ 2. **Frame Storage**: Saves frames as JPEG (quality 100) or lossless PNG
482
+ 3. **Video Assembly**: FFmpeg assembles frames with lossless intermediate encoding
483
+ 4. **Final Output**: Quality settings applied at concat step
484
+
485
+ ### Examples
486
+
487
+ ```bash
488
+ # High-quality recording with default settings
489
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video
490
+
491
+ # Lossless PNG frames for maximum quality
492
+ sceneforge record -d demo.yaml -b http://localhost:3000 --hq-video --hq-format png
493
+
494
+ # Full pipeline with HQ recording
495
+ sceneforge pipeline -d demo.yaml -b http://localhost:3000 --hq-video --quality high
496
+
497
+ # HQ recording at 4K
498
+ sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
499
+ --hq-video \
500
+ --viewport 4k \
501
+ --output-size 1080p \
502
+ --quality high
503
+ ```
504
+
505
+ ### Trade-offs
506
+
507
+ | Aspect | Standard Recording | HQ Recording |
508
+ |--------|-------------------|--------------|
509
+ | Quality | Limited by 1Mbps cap | Full quality frames |
510
+ | Speed | Faster | Slower (frame-by-frame) |
511
+ | Disk Usage | Moderate | Higher (temporary frames) |
512
+ | Compatibility | Always works | Requires FFmpeg |
513
+
514
+ ### When to Use
515
+
516
+ - **Use HQ Recording** when final video quality is critical
517
+ - **Use Standard Recording** for quick drafts, debugging, or when disk space is limited
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3lnet/sceneforge",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "SceneForge runner and generation utilities for YAML-driven demos",
5
5
  "license": "MIT",
6
6
  "author": "T3LNET",