@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.
@@ -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
- if (startUrl) {
268
- await page.goto(startUrl, { waitUntil: "networkidle" });
269
- }
270
- const result = await runDemo(
271
- definition,
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 (if using HQ video)
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
- await cdpRecorder.assembleVideo(finalVideoPath);
299
- console.log(`[record] High-quality video saved: ${finalVideoPath}`);
300
- if (result.scriptPath) {
301
- await alignScriptToVideo(result.scriptPath, finalVideoPath, videoRecordingStartTime);
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.scriptPath) {
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 = async (params) => {
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
- try {
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 = frameTimestamps.length;
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 and save frame
84
+ // Decode base64 frame data
84
85
  const buffer = Buffer.from(data, "base64");
85
- await fs.writeFile(framePath, buffer);
86
86
 
87
- frameTimestamps.push({
88
- frameNumber,
89
- timestamp,
90
- path: framePath,
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
- // Calculate actual FPS from captured frame timestamps
176
- let fps = fpsOverride;
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 extension = format === "png" ? "png" : "jpg";
190
- const framePattern = path.join(framesDir, `frame_%08d.${extension}`);
191
-
192
- // Use lossless encoding for the assembled video (will be compressed at concat step)
193
- const encodingArgs = getIntermediateEncodingArgs({ includeAudio: false });
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", // Ensure compatibility
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3lnet/sceneforge",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "SceneForge runner and generation utilities for YAML-driven demos",
5
5
  "license": "MIT",
6
6
  "author": "T3LNET",