@t3lnet/sceneforge 1.0.28 → 1.0.30
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.
|
@@ -182,8 +182,6 @@ 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 hqFpsArg = getFlagValue(args, "--hq-fps");
|
|
186
|
-
const hqFps = hqFpsArg ? parseInt(hqFpsArg, 10) : undefined; // undefined = use actual capture rate
|
|
187
185
|
|
|
188
186
|
if (!baseUrl) {
|
|
189
187
|
console.error("[error] --base-url is required");
|
|
@@ -308,7 +306,7 @@ export async function runRecordDemoCommand(argv) {
|
|
|
308
306
|
const finalVideoPath = path.join(outputPaths.videosDir, `${definition.name}.webm`);
|
|
309
307
|
try {
|
|
310
308
|
if (!demoError && result?.success) {
|
|
311
|
-
await cdpRecorder.assembleVideo(finalVideoPath
|
|
309
|
+
await cdpRecorder.assembleVideo(finalVideoPath);
|
|
312
310
|
console.log(`[record] High-quality video saved: ${finalVideoPath}`);
|
|
313
311
|
if (result.scriptPath) {
|
|
314
312
|
// Use the actual first frame time for alignment, not when we started the recorder
|
|
@@ -206,46 +206,64 @@ export function createCDPRecorder(page, options) {
|
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
208
|
* Assemble captured frames into a video using FFmpeg
|
|
209
|
+
* Uses timestamp-aware assembly to preserve real-time playback timing
|
|
209
210
|
*
|
|
210
211
|
* @param {string} outputPath - Output video path
|
|
211
|
-
* @param {Object} [assembleOptions] - Assembly options
|
|
212
|
-
* @param {number} [assembleOptions.fps] - Output framerate (auto-detected if not specified)
|
|
213
212
|
* @returns {Promise<string>} - Path to assembled video
|
|
214
213
|
*/
|
|
215
|
-
async assembleVideo(outputPath
|
|
216
|
-
const { fps: fpsOverride } = assembleOptions;
|
|
214
|
+
async assembleVideo(outputPath) {
|
|
217
215
|
|
|
218
216
|
if (frameTimestamps.length === 0) {
|
|
219
217
|
throw new Error("No frames captured - cannot assemble video");
|
|
220
218
|
}
|
|
221
219
|
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
220
|
+
// Sort frames by timestamp to ensure correct order
|
|
221
|
+
const sortedFrames = [...frameTimestamps].sort((a, b) => a.timestamp - b.timestamp);
|
|
222
|
+
|
|
223
|
+
// Calculate duration for each frame based on gap to next frame
|
|
224
|
+
// This preserves real-time playback - waits stay as waits, actions stay as actions
|
|
225
|
+
const framesWithDuration = sortedFrames.map((frame, index) => {
|
|
226
|
+
let duration;
|
|
227
|
+
if (index < sortedFrames.length - 1) {
|
|
228
|
+
// Duration is the gap to the next frame
|
|
229
|
+
duration = sortedFrames[index + 1].timestamp - frame.timestamp;
|
|
230
|
+
} else {
|
|
231
|
+
// Last frame - use average duration or minimum display time
|
|
232
|
+
const avgDuration = sortedFrames.length > 1
|
|
233
|
+
? (sortedFrames[sortedFrames.length - 1].timestamp - sortedFrames[0].timestamp) / (sortedFrames.length - 1)
|
|
234
|
+
: 1 / 30;
|
|
235
|
+
duration = Math.max(avgDuration, 1 / 30);
|
|
236
|
+
}
|
|
237
|
+
// Clamp duration to reasonable bounds (min 1ms, max 10 seconds per frame)
|
|
238
|
+
duration = Math.max(0.001, Math.min(duration, 10));
|
|
239
|
+
return { ...frame, duration };
|
|
240
|
+
});
|
|
227
241
|
|
|
228
|
-
//
|
|
229
|
-
const
|
|
242
|
+
// Calculate total video duration from timestamps
|
|
243
|
+
const totalDuration = framesWithDuration.reduce((sum, f) => sum + f.duration, 0);
|
|
244
|
+
console.log(`[cdp-recorder] Assembling ${sortedFrames.length} frames with timestamp-aware timing`);
|
|
245
|
+
console.log(`[cdp-recorder] Expected video duration: ${totalDuration.toFixed(2)}s (real-time preserved)`);
|
|
230
246
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
// Generate FFmpeg concat demuxer file
|
|
248
|
+
// This tells FFmpeg exactly how long to display each frame
|
|
249
|
+
const concatFilePath = path.join(framesDir, "concat.txt");
|
|
250
|
+
const concatContent = framesWithDuration
|
|
251
|
+
.map((frame) => `file '${path.basename(frame.path)}'\nduration ${frame.duration.toFixed(6)}`)
|
|
252
|
+
.join("\n");
|
|
235
253
|
|
|
236
|
-
|
|
254
|
+
// Add the last frame again without duration (FFmpeg concat demuxer requirement)
|
|
255
|
+
const lastFrame = framesWithDuration[framesWithDuration.length - 1];
|
|
256
|
+
const finalConcatContent = concatContent + `\nfile '${path.basename(lastFrame.path)}'`;
|
|
237
257
|
|
|
238
|
-
|
|
239
|
-
const framePattern = path.join(framesDir, `frame_%08d.${frameExtension}`);
|
|
258
|
+
await fs.writeFile(concatFilePath, finalConcatContent);
|
|
240
259
|
|
|
241
260
|
// Determine encoding based on output format
|
|
242
|
-
// WebM requires VP8/VP9/AV1, MP4 can use H.264
|
|
243
261
|
const outputExtension = path.extname(outputPath).toLowerCase();
|
|
244
262
|
const isWebM = outputExtension === ".webm";
|
|
245
263
|
|
|
246
264
|
let encodingArgs;
|
|
247
265
|
if (isWebM) {
|
|
248
|
-
// Use VP9 with
|
|
266
|
+
// Use VP9 with high quality for WebM
|
|
249
267
|
encodingArgs = [
|
|
250
268
|
"-c:v", "libvpx-vp9",
|
|
251
269
|
"-lossless", "1", // Lossless mode
|
|
@@ -257,15 +275,15 @@ export function createCDPRecorder(page, options) {
|
|
|
257
275
|
encodingArgs = getIntermediateEncodingArgs({ includeAudio: false });
|
|
258
276
|
}
|
|
259
277
|
|
|
278
|
+
// Use concat demuxer for timestamp-aware assembly
|
|
260
279
|
await runFFmpeg([
|
|
261
280
|
"-y",
|
|
262
|
-
"-
|
|
263
|
-
|
|
264
|
-
"-i",
|
|
265
|
-
framePattern,
|
|
281
|
+
"-f", "concat",
|
|
282
|
+
"-safe", "0",
|
|
283
|
+
"-i", concatFilePath,
|
|
266
284
|
...encodingArgs,
|
|
267
|
-
"-pix_fmt",
|
|
268
|
-
|
|
285
|
+
"-pix_fmt", "yuv420p",
|
|
286
|
+
"-fps_mode", "vfr", // Variable framerate to preserve timing
|
|
269
287
|
outputPath,
|
|
270
288
|
]);
|
|
271
289
|
|
|
@@ -295,9 +313,7 @@ export function getCDPRecordingHelpText() {
|
|
|
295
313
|
return `
|
|
296
314
|
High-Quality Recording Options:
|
|
297
315
|
--hq-video Use CDP-based high-quality recording instead of Playwright
|
|
298
|
-
Captures frames
|
|
316
|
+
Captures frames with timestamps and assembles with real-time timing
|
|
299
317
|
--hq-format <format> Frame format: jpeg (default) or png (lossless, larger files)
|
|
300
|
-
--hq-quality <1-100> JPEG quality (default: 100, ignored for PNG)
|
|
301
|
-
--hq-fps <fps> Output framerate (default: actual capture rate for real-time)
|
|
302
|
-
Override only if you want to change playback speed`;
|
|
318
|
+
--hq-quality <1-100> JPEG quality (default: 100, ignored for PNG)`;
|
|
303
319
|
}
|
|
@@ -466,7 +466,6 @@ The CDP-based recorder captures frames directly from Chrome DevTools Protocol an
|
|
|
466
466
|
--hq-video # Enable CDP-based high-quality recording
|
|
467
467
|
--hq-format <format> # Frame format: jpeg (default) or png
|
|
468
468
|
--hq-quality <1-100> # JPEG quality (default: 100, ignored for PNG)
|
|
469
|
-
--hq-fps <fps> # Override output framerate (default: actual capture rate for real-time)
|
|
470
469
|
```
|
|
471
470
|
|
|
472
471
|
### Frame Formats
|
|
@@ -478,9 +477,9 @@ The CDP-based recorder captures frames directly from Chrome DevTools Protocol an
|
|
|
478
477
|
|
|
479
478
|
### How It Works
|
|
480
479
|
|
|
481
|
-
1. **Frame Capture**: Uses Chrome's `Page.startScreencast` CDP method
|
|
480
|
+
1. **Frame Capture**: Uses Chrome's `Page.startScreencast` CDP method with timestamps
|
|
482
481
|
2. **Frame Storage**: Saves frames as JPEG (quality 100) or lossless PNG
|
|
483
|
-
3. **
|
|
482
|
+
3. **Timestamp-Aware Assembly**: FFmpeg assembles frames using per-frame durations to preserve real-time playback (waits stay as waits, actions stay as actions)
|
|
484
483
|
4. **Final Output**: Quality settings applied at concat step
|
|
485
484
|
|
|
486
485
|
### Examples
|
|
@@ -466,7 +466,6 @@ The CDP-based recorder captures frames directly from Chrome DevTools Protocol an
|
|
|
466
466
|
--hq-video # Enable CDP-based high-quality recording
|
|
467
467
|
--hq-format <format> # Frame format: jpeg (default) or png
|
|
468
468
|
--hq-quality <1-100> # JPEG quality (default: 100, ignored for PNG)
|
|
469
|
-
--hq-fps <fps> # Override output framerate (default: actual capture rate for real-time)
|
|
470
469
|
```
|
|
471
470
|
|
|
472
471
|
### Frame Formats
|
|
@@ -478,9 +477,9 @@ The CDP-based recorder captures frames directly from Chrome DevTools Protocol an
|
|
|
478
477
|
|
|
479
478
|
### How It Works
|
|
480
479
|
|
|
481
|
-
1. **Frame Capture**: Uses Chrome's `Page.startScreencast` CDP method
|
|
480
|
+
1. **Frame Capture**: Uses Chrome's `Page.startScreencast` CDP method with timestamps
|
|
482
481
|
2. **Frame Storage**: Saves frames as JPEG (quality 100) or lossless PNG
|
|
483
|
-
3. **
|
|
482
|
+
3. **Timestamp-Aware Assembly**: FFmpeg assembles frames using per-frame durations to preserve real-time playback (waits stay as waits, actions stay as actions)
|
|
484
483
|
4. **Final Output**: Quality settings applied at concat step
|
|
485
484
|
|
|
486
485
|
### Examples
|