@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.
- package/cli/utils/cdp-recorder.js +46 -24
- package/package.json +1 -1
|
@@ -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
|
|
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:
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
//
|
|
229
|
-
const
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"-
|
|
263
|
-
|
|
264
|
-
"-i",
|
|
265
|
-
framePattern,
|
|
284
|
+
"-f", "concat",
|
|
285
|
+
"-safe", "0",
|
|
286
|
+
"-i", concatFilePath,
|
|
266
287
|
...encodingArgs,
|
|
267
|
-
"-pix_fmt",
|
|
268
|
-
|
|
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
|
|