@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, hqFps ? { fps: hqFps } : {});
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, assembleOptions = {}) {
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
- // Calculate actual capture FPS for real-time playback
223
- // This ensures video duration matches the actual recording duration
224
- const calculatedFps = frameTimestamps.length > 0 && recordingDuration > 0
225
- ? frameTimestamps.length / (recordingDuration / 1000)
226
- : 30;
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
- // Use provided fps override or actual capture fps for real-time playback
229
- const fps = fpsOverride || calculatedFps;
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
- if (fpsOverride && Math.abs(fpsOverride - calculatedFps) > 1) {
232
- console.log(`[cdp-recorder] Note: Using ${fps} fps (actual capture rate was ${calculatedFps.toFixed(1)} fps)`);
233
- console.log(`[cdp-recorder] This will ${fpsOverride > calculatedFps ? 'speed up' : 'slow down'} playback`);
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
- console.log(`[cdp-recorder] Assembling video at ${fps.toFixed(1)} fps`);
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
- const frameExtension = format === "png" ? "png" : "jpg";
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 lossless mode for WebM
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
- "-framerate",
263
- String(fps),
264
- "-i",
265
- framePattern,
281
+ "-f", "concat",
282
+ "-safe", "0",
283
+ "-i", concatFilePath,
266
284
  ...encodingArgs,
267
- "-pix_fmt",
268
- isWebM ? "yuv420p" : "yuv420p",
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 at maximum quality and assembles with FFmpeg
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. **Video Assembly**: FFmpeg assembles frames with lossless intermediate encoding
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. **Video Assembly**: FFmpeg assembles frames with lossless intermediate encoding
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3lnet/sceneforge",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "description": "SceneForge runner and generation utilities for YAML-driven demos",
5
5
  "license": "MIT",
6
6
  "author": "T3LNET",