@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 +4 -9
- package/cli/commands/add-audio-to-steps.js +16 -18
- package/cli/commands/pipeline.js +8 -8
- package/cli/commands/record-demo.js +46 -40
- package/cli/commands/split-video.js +13 -16
- package/cli/utils/cdp-recorder.js +236 -0
- package/cli/utils/dimensions.js +1 -58
- package/cli/utils/quality.js +36 -0
- package/context/templates/base/cli-reference.md +87 -19
- package/dist/templates/base/cli-reference.md +87 -19
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
146
|
-
console.log(`\n[audio] Processing: ${demoName}
|
|
147
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
339
|
+
await processAll(paths, padding, outputDimensions);
|
|
342
340
|
return;
|
|
343
341
|
}
|
|
344
342
|
|
package/cli/commands/pipeline.js
CHANGED
|
@@ -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
|
|
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, ...
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
75
|
-
console.log(`\n[split] Processing: ${demoName}
|
|
76
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
244
|
+
await splitDemo(demo, paths, outputDimensions);
|
|
248
245
|
return;
|
|
249
246
|
}
|
|
250
247
|
|
|
251
248
|
if (all) {
|
|
252
|
-
await splitAll(paths,
|
|
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
|
+
}
|
package/cli/utils/dimensions.js
CHANGED
|
@@ -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
|
package/cli/utils/quality.js
CHANGED
|
@@ -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
|
-
| `--
|
|
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
|
|
50
|
-
sceneforge record -d demo.yaml -b http://localhost:3000 --viewport
|
|
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 +
|
|
129
|
+
# Full options: viewport + output scaling
|
|
120
130
|
sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
|
|
121
|
-
--viewport 1080p
|
|
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
|
|
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
|
|
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
|
-
| `--
|
|
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
|
|
50
|
-
sceneforge record -d demo.yaml -b http://localhost:3000 --viewport
|
|
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 +
|
|
129
|
+
# Full options: viewport + output scaling
|
|
120
130
|
sceneforge pipeline -d demo.yaml -b http://localhost:3000 \
|
|
121
|
-
--viewport 1080p
|
|
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
|
|
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
|
|
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
|