@t3lnet/sceneforge 1.0.28 → 1.0.29

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.
@@ -206,46 +206,67 @@ 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
212
  * @param {Object} [assembleOptions] - Assembly options
212
- * @param {number} [assembleOptions.fps] - Output framerate (auto-detected if not specified)
213
+ * @param {number} [assembleOptions.fps] - Output framerate for encoding (default: 30)
213
214
  * @returns {Promise<string>} - Path to assembled video
214
215
  */
215
216
  async assembleVideo(outputPath, assembleOptions = {}) {
216
- const { fps: fpsOverride } = assembleOptions;
217
+ const { fps: outputFps = 30 } = assembleOptions;
217
218
 
218
219
  if (frameTimestamps.length === 0) {
219
220
  throw new Error("No frames captured - cannot assemble video");
220
221
  }
221
222
 
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;
223
+ // Sort frames by timestamp to ensure correct order
224
+ const sortedFrames = [...frameTimestamps].sort((a, b) => a.timestamp - b.timestamp);
225
+
226
+ // Calculate duration for each frame based on gap to next frame
227
+ // This preserves real-time playback - waits stay as waits, actions stay as actions
228
+ const framesWithDuration = sortedFrames.map((frame, index) => {
229
+ let duration;
230
+ if (index < sortedFrames.length - 1) {
231
+ // Duration is the gap to the next frame
232
+ duration = sortedFrames[index + 1].timestamp - frame.timestamp;
233
+ } else {
234
+ // Last frame - use average duration or minimum display time
235
+ const avgDuration = sortedFrames.length > 1
236
+ ? (sortedFrames[sortedFrames.length - 1].timestamp - sortedFrames[0].timestamp) / (sortedFrames.length - 1)
237
+ : 1 / outputFps;
238
+ duration = Math.max(avgDuration, 1 / outputFps);
239
+ }
240
+ // Clamp duration to reasonable bounds (min 1ms, max 10 seconds per frame)
241
+ duration = Math.max(0.001, Math.min(duration, 10));
242
+ return { ...frame, duration };
243
+ });
227
244
 
228
- // Use provided fps override or actual capture fps for real-time playback
229
- const fps = fpsOverride || calculatedFps;
245
+ // Calculate total video duration from timestamps
246
+ const totalDuration = framesWithDuration.reduce((sum, f) => sum + f.duration, 0);
247
+ console.log(`[cdp-recorder] Assembling ${sortedFrames.length} frames with timestamp-aware timing`);
248
+ console.log(`[cdp-recorder] Expected video duration: ${totalDuration.toFixed(2)}s (real-time preserved)`);
230
249
 
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
- }
250
+ // Generate FFmpeg concat demuxer file
251
+ // This tells FFmpeg exactly how long to display each frame
252
+ const concatFilePath = path.join(framesDir, "concat.txt");
253
+ const concatContent = framesWithDuration
254
+ .map((frame) => `file '${path.basename(frame.path)}'\nduration ${frame.duration.toFixed(6)}`)
255
+ .join("\n");
235
256
 
236
- console.log(`[cdp-recorder] Assembling video at ${fps.toFixed(1)} fps`);
257
+ // Add the last frame again without duration (FFmpeg concat demuxer requirement)
258
+ const lastFrame = framesWithDuration[framesWithDuration.length - 1];
259
+ const finalConcatContent = concatContent + `\nfile '${path.basename(lastFrame.path)}'`;
237
260
 
238
- const frameExtension = format === "png" ? "png" : "jpg";
239
- const framePattern = path.join(framesDir, `frame_%08d.${frameExtension}`);
261
+ await fs.writeFile(concatFilePath, finalConcatContent);
240
262
 
241
263
  // Determine encoding based on output format
242
- // WebM requires VP8/VP9/AV1, MP4 can use H.264
243
264
  const outputExtension = path.extname(outputPath).toLowerCase();
244
265
  const isWebM = outputExtension === ".webm";
245
266
 
246
267
  let encodingArgs;
247
268
  if (isWebM) {
248
- // Use VP9 with lossless mode for WebM
269
+ // Use VP9 with high quality for WebM
249
270
  encodingArgs = [
250
271
  "-c:v", "libvpx-vp9",
251
272
  "-lossless", "1", // Lossless mode
@@ -257,15 +278,16 @@ export function createCDPRecorder(page, options) {
257
278
  encodingArgs = getIntermediateEncodingArgs({ includeAudio: false });
258
279
  }
259
280
 
281
+ // Use concat demuxer for timestamp-aware assembly
260
282
  await runFFmpeg([
261
283
  "-y",
262
- "-framerate",
263
- String(fps),
264
- "-i",
265
- framePattern,
284
+ "-f", "concat",
285
+ "-safe", "0",
286
+ "-i", concatFilePath,
266
287
  ...encodingArgs,
267
- "-pix_fmt",
268
- isWebM ? "yuv420p" : "yuv420p",
288
+ "-pix_fmt", "yuv420p",
289
+ "-r", String(outputFps), // Output framerate (for smooth playback)
290
+ "-vsync", "vfr", // Variable framerate to preserve timing
269
291
  outputPath,
270
292
  ]);
271
293
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3lnet/sceneforge",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "SceneForge runner and generation utilities for YAML-driven demos",
5
5
  "license": "MIT",
6
6
  "author": "T3LNET",