@t3lnet/sceneforge 1.0.24 → 1.0.26
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/cli/commands/record-demo.js +40 -23
- package/cli/utils/cdp-recorder.js +50 -31
- package/package.json +1 -1
|
@@ -182,6 +182,7 @@ export async function runRecordDemoCommand(argv) {
|
|
|
182
182
|
const hqVideo = hasFlag(args, "--hq-video");
|
|
183
183
|
const hqFormat = getFlagValue(args, "--hq-format") ?? "jpeg";
|
|
184
184
|
const hqQuality = parseInt(getFlagValue(args, "--hq-quality") ?? "100", 10);
|
|
185
|
+
const hqFps = parseInt(getFlagValue(args, "--hq-fps") ?? "30", 10);
|
|
185
186
|
|
|
186
187
|
if (!baseUrl) {
|
|
187
188
|
console.error("[error] --base-url is required");
|
|
@@ -262,27 +263,35 @@ export async function runRecordDemoCommand(argv) {
|
|
|
262
263
|
});
|
|
263
264
|
await cdpRecorder.start();
|
|
264
265
|
}
|
|
266
|
+
|
|
265
267
|
const startUrl = resolveStartUrl(startPath, baseUrl);
|
|
266
268
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
page,
|
|
274
|
-
baseURL: baseUrl,
|
|
275
|
-
outputDir: outputPaths.outputDir,
|
|
276
|
-
assetBaseDir: assetRoot ? toAbsolute(rootDir, assetRoot) : undefined,
|
|
277
|
-
videoRecordingStartTime,
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
generateScripts: true,
|
|
281
|
-
scriptOutputDir: path.join(outputPaths.outputDir, "scripts"),
|
|
269
|
+
let result;
|
|
270
|
+
let demoError = null;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
if (startUrl) {
|
|
274
|
+
await page.goto(startUrl, { waitUntil: "networkidle" });
|
|
282
275
|
}
|
|
283
|
-
|
|
276
|
+
result = await runDemo(
|
|
277
|
+
definition,
|
|
278
|
+
{
|
|
279
|
+
page,
|
|
280
|
+
baseURL: baseUrl,
|
|
281
|
+
outputDir: outputPaths.outputDir,
|
|
282
|
+
assetBaseDir: assetRoot ? toAbsolute(rootDir, assetRoot) : undefined,
|
|
283
|
+
videoRecordingStartTime,
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
generateScripts: true,
|
|
287
|
+
scriptOutputDir: path.join(outputPaths.outputDir, "scripts"),
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
demoError = err;
|
|
292
|
+
}
|
|
284
293
|
|
|
285
|
-
// Stop CDP recorder before closing context
|
|
294
|
+
// Stop CDP recorder before closing context
|
|
286
295
|
if (cdpRecorder) {
|
|
287
296
|
await cdpRecorder.stop();
|
|
288
297
|
}
|
|
@@ -292,27 +301,35 @@ export async function runRecordDemoCommand(argv) {
|
|
|
292
301
|
|
|
293
302
|
// Handle video based on recording method
|
|
294
303
|
if (cdpRecorder) {
|
|
295
|
-
// Assemble CDP frames into video
|
|
296
304
|
const finalVideoPath = path.join(outputPaths.videosDir, `${definition.name}.webm`);
|
|
297
305
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
306
|
+
if (!demoError && result?.success) {
|
|
307
|
+
await cdpRecorder.assembleVideo(finalVideoPath, { fps: hqFps });
|
|
308
|
+
console.log(`[record] High-quality video saved: ${finalVideoPath}`);
|
|
309
|
+
if (result.scriptPath) {
|
|
310
|
+
await alignScriptToVideo(result.scriptPath, finalVideoPath, videoRecordingStartTime);
|
|
311
|
+
}
|
|
302
312
|
}
|
|
303
313
|
} finally {
|
|
314
|
+
// Always cleanup frames
|
|
304
315
|
await cdpRecorder.cleanup();
|
|
316
|
+
console.log(`[record] Cleaned up temporary frames from: ${framesDir}`);
|
|
305
317
|
}
|
|
306
318
|
} else if (video) {
|
|
307
319
|
const recordedPath = await video.path();
|
|
308
320
|
const finalVideoPath = path.join(outputPaths.videosDir, `${definition.name}.webm`);
|
|
309
321
|
await moveVideo(recordedPath, finalVideoPath);
|
|
310
322
|
console.log(`[record] Video saved: ${finalVideoPath}`);
|
|
311
|
-
if (result
|
|
323
|
+
if (result?.scriptPath) {
|
|
312
324
|
await alignScriptToVideo(result.scriptPath, finalVideoPath, videoRecordingStartTime);
|
|
313
325
|
}
|
|
314
326
|
}
|
|
315
327
|
|
|
328
|
+
// Re-throw demo error after cleanup
|
|
329
|
+
if (demoError) {
|
|
330
|
+
throw demoError;
|
|
331
|
+
}
|
|
332
|
+
|
|
316
333
|
if (result.success) {
|
|
317
334
|
console.log(`[record] ✓ Completed ${definition.name}`);
|
|
318
335
|
if (result.scriptPath) {
|
|
@@ -55,19 +55,19 @@ export function createCDPRecorder(page, options) {
|
|
|
55
55
|
let frameTimestamps = [];
|
|
56
56
|
let isRecording = false;
|
|
57
57
|
let startTime = null;
|
|
58
|
+
let writeQueue = Promise.resolve();
|
|
59
|
+
let nextFrameNumber = 0;
|
|
58
60
|
|
|
59
|
-
const frameHandler =
|
|
61
|
+
const frameHandler = (params) => {
|
|
60
62
|
if (!isRecording) return;
|
|
61
63
|
|
|
62
64
|
const { data, metadata, sessionId } = params;
|
|
63
65
|
frameCount += 1;
|
|
64
66
|
|
|
65
|
-
// Acknowledge the frame to continue receiving
|
|
66
|
-
|
|
67
|
-
await cdpSession.send("Page.screencastFrameAck", { sessionId });
|
|
68
|
-
} catch {
|
|
67
|
+
// Acknowledge the frame immediately to continue receiving
|
|
68
|
+
cdpSession.send("Page.screencastFrameAck", { sessionId }).catch(() => {
|
|
69
69
|
// Session may be closed
|
|
70
|
-
}
|
|
70
|
+
});
|
|
71
71
|
|
|
72
72
|
// Skip frames based on everyNthFrame setting
|
|
73
73
|
if (everyNthFrame > 1 && frameCount % everyNthFrame !== 0) {
|
|
@@ -75,19 +75,27 @@ export function createCDPRecorder(page, options) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
const timestamp = metadata.timestamp;
|
|
78
|
-
const frameNumber =
|
|
78
|
+
const frameNumber = nextFrameNumber;
|
|
79
|
+
nextFrameNumber += 1;
|
|
79
80
|
const paddedNumber = String(frameNumber).padStart(8, "0");
|
|
80
81
|
const extension = format === "png" ? "png" : "jpg";
|
|
81
82
|
const framePath = path.join(framesDir, `frame_${paddedNumber}.${extension}`);
|
|
82
83
|
|
|
83
|
-
// Decode base64
|
|
84
|
+
// Decode base64 frame data
|
|
84
85
|
const buffer = Buffer.from(data, "base64");
|
|
85
|
-
await fs.writeFile(framePath, buffer);
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
// Queue frame writes to ensure sequential completion
|
|
88
|
+
writeQueue = writeQueue.then(async () => {
|
|
89
|
+
try {
|
|
90
|
+
await fs.writeFile(framePath, buffer);
|
|
91
|
+
frameTimestamps.push({
|
|
92
|
+
frameNumber,
|
|
93
|
+
timestamp,
|
|
94
|
+
path: framePath,
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(`[cdp-recorder] Failed to write frame ${frameNumber}:`, err.message);
|
|
98
|
+
}
|
|
91
99
|
});
|
|
92
100
|
};
|
|
93
101
|
|
|
@@ -145,6 +153,9 @@ export function createCDPRecorder(page, options) {
|
|
|
145
153
|
// Session may already be closed
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
// Wait for all pending frame writes to complete
|
|
157
|
+
await writeQueue;
|
|
158
|
+
|
|
148
159
|
console.log(
|
|
149
160
|
`[cdp-recorder] Captured ${frameTimestamps.length} frames in ${(duration / 1000).toFixed(2)}s`
|
|
150
161
|
);
|
|
@@ -172,25 +183,32 @@ export function createCDPRecorder(page, options) {
|
|
|
172
183
|
throw new Error("No frames captured - cannot assemble video");
|
|
173
184
|
}
|
|
174
185
|
|
|
175
|
-
//
|
|
176
|
-
|
|
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
|
+
// Use provided fps or default to 30
|
|
187
|
+
const fps = fpsOverride || 30;
|
|
186
188
|
|
|
187
189
|
console.log(`[cdp-recorder] Assembling video at ${fps} fps`);
|
|
188
190
|
|
|
189
|
-
const
|
|
190
|
-
const framePattern = path.join(framesDir, `frame_%08d.${
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
|
|
191
|
+
const frameExtension = format === "png" ? "png" : "jpg";
|
|
192
|
+
const framePattern = path.join(framesDir, `frame_%08d.${frameExtension}`);
|
|
193
|
+
|
|
194
|
+
// Determine encoding based on output format
|
|
195
|
+
// WebM requires VP8/VP9/AV1, MP4 can use H.264
|
|
196
|
+
const outputExtension = path.extname(outputPath).toLowerCase();
|
|
197
|
+
const isWebM = outputExtension === ".webm";
|
|
198
|
+
|
|
199
|
+
let encodingArgs;
|
|
200
|
+
if (isWebM) {
|
|
201
|
+
// Use VP9 with lossless mode for WebM
|
|
202
|
+
encodingArgs = [
|
|
203
|
+
"-c:v", "libvpx-vp9",
|
|
204
|
+
"-lossless", "1", // Lossless mode
|
|
205
|
+
"-row-mt", "1", // Enable row-based multithreading
|
|
206
|
+
"-an", // No audio for now
|
|
207
|
+
];
|
|
208
|
+
} else {
|
|
209
|
+
// Use H.264 lossless for MP4
|
|
210
|
+
encodingArgs = getIntermediateEncodingArgs({ includeAudio: false });
|
|
211
|
+
}
|
|
194
212
|
|
|
195
213
|
await runFFmpeg([
|
|
196
214
|
"-y",
|
|
@@ -200,7 +218,7 @@ export function createCDPRecorder(page, options) {
|
|
|
200
218
|
framePattern,
|
|
201
219
|
...encodingArgs,
|
|
202
220
|
"-pix_fmt",
|
|
203
|
-
"yuv420p"
|
|
221
|
+
isWebM ? "yuv420p" : "yuv420p",
|
|
204
222
|
outputPath,
|
|
205
223
|
]);
|
|
206
224
|
|
|
@@ -232,5 +250,6 @@ High-Quality Recording Options:
|
|
|
232
250
|
--hq-video Use CDP-based high-quality recording instead of Playwright
|
|
233
251
|
Captures frames at maximum quality and assembles with FFmpeg
|
|
234
252
|
--hq-format <format> Frame format: jpeg (default) or png (lossless, larger files)
|
|
235
|
-
--hq-quality <1-100> JPEG quality (default: 100, ignored for PNG)
|
|
253
|
+
--hq-quality <1-100> JPEG quality (default: 100, ignored for PNG)
|
|
254
|
+
--hq-fps <fps> Output framerate (default: 30)`;
|
|
236
255
|
}
|