@t3lnet/sceneforge 1.0.33 → 1.0.34

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.
@@ -274,15 +274,26 @@ export async function runRecordDemoCommand(argv) {
274
274
  if (startUrl) {
275
275
  await page.goto(startUrl, { waitUntil: "networkidle" });
276
276
  }
277
+
278
+ // Build context with optional step callback for CDP recording
279
+ const demoContext = {
280
+ page,
281
+ baseURL: baseUrl,
282
+ outputDir: outputPaths.outputDir,
283
+ assetBaseDir: assetRoot ? toAbsolute(rootDir, assetRoot) : undefined,
284
+ videoRecordingStartTime,
285
+ };
286
+
287
+ // Add step callback for frame-accurate step markers when using CDP recording
288
+ if (cdpRecorder) {
289
+ demoContext.onStepStart = (stepId, stepIndex) => {
290
+ cdpRecorder.markStep(stepId, stepIndex);
291
+ };
292
+ }
293
+
277
294
  result = await runDemo(
278
295
  definition,
279
- {
280
- page,
281
- baseURL: baseUrl,
282
- outputDir: outputPaths.outputDir,
283
- assetBaseDir: assetRoot ? toAbsolute(rootDir, assetRoot) : undefined,
284
- videoRecordingStartTime,
285
- },
296
+ demoContext,
286
297
  {
287
298
  generateScripts: true,
288
299
  scriptOutputDir: path.join(outputPaths.outputDir, "scripts"),
@@ -97,7 +97,6 @@ async function splitDemo(demoName, paths, outputDimensions = null) {
97
97
  }
98
98
 
99
99
  console.log(`[split] Video: ${videoPath}`);
100
- console.log(`[split] Steps: ${script.stepBoundaries.length}`);
101
100
 
102
101
  const videoDurationSec = await getMediaDuration(videoPath);
103
102
  const videoDurationMs = Math.round(videoDurationSec * 1000);
@@ -105,30 +104,71 @@ async function splitDemo(demoName, paths, outputDimensions = null) {
105
104
  const stepClipsDir = path.join(paths.videosDir, demoName);
106
105
  await fs.mkdir(stepClipsDir, { recursive: true });
107
106
 
108
- for (const boundary of script.stepBoundaries) {
109
- const isFirstStep = boundary.stepIndex === 0;
110
- const startMs = isFirstStep ? 0 : boundary.videoStartMs;
111
- if (startMs >= videoDurationMs) {
112
- console.warn(`[split] Skipping ${boundary.stepId}: start beyond video duration`);
113
- continue;
107
+ // Check for CDP-generated step manifest (frame-accurate boundaries)
108
+ const cdpManifestPath = videoPath.replace(/\.[^.]+$/, '-steps.json');
109
+ let useCdpManifest = false;
110
+ let cdpStepBoundaries = [];
111
+
112
+ try {
113
+ const cdpManifestContent = await fs.readFile(cdpManifestPath, 'utf-8');
114
+ const cdpManifest = JSON.parse(cdpManifestContent);
115
+ if (cdpManifest.steps && cdpManifest.steps.length > 0) {
116
+ cdpStepBoundaries = cdpManifest.steps;
117
+ useCdpManifest = true;
118
+ console.log(`[split] Using CDP step manifest: ${cdpManifestPath}`);
119
+ console.log(`[split] Steps: ${cdpStepBoundaries.length} (frame-accurate)`);
120
+ }
121
+ } catch {
122
+ // CDP manifest not found or invalid, fall back to script boundaries
123
+ }
124
+
125
+ // Use CDP manifest boundaries if available, otherwise fall back to script
126
+ const stepBoundaries = useCdpManifest ? cdpStepBoundaries : script.stepBoundaries;
127
+
128
+ if (!useCdpManifest) {
129
+ console.log(`[split] Steps: ${stepBoundaries.length} (from script)`);
130
+ }
131
+
132
+ for (const boundary of stepBoundaries) {
133
+ // Handle both CDP manifest format and script format
134
+ const stepIndex = boundary.stepIndex;
135
+ const stepId = boundary.stepId;
136
+
137
+ let startSec, endSec;
138
+ if (useCdpManifest) {
139
+ // CDP manifest has video times in seconds
140
+ startSec = boundary.videoStartSec;
141
+ endSec = boundary.videoEndSec;
142
+ } else {
143
+ // Script has times in milliseconds, need conversion and clamping
144
+ const isFirstStep = stepIndex === 0;
145
+ const startMs = isFirstStep ? 0 : boundary.videoStartMs;
146
+ if (startMs >= videoDurationMs) {
147
+ console.warn(`[split] Skipping ${stepId}: start beyond video duration`);
148
+ continue;
149
+ }
150
+ const clampedEndMs = Math.min(boundary.videoEndMs, videoDurationMs);
151
+ if (clampedEndMs <= startMs) {
152
+ console.warn(`[split] Skipping ${stepId}: invalid duration after clamp`);
153
+ continue;
154
+ }
155
+ startSec = startMs / 1000;
156
+ endSec = clampedEndMs / 1000;
114
157
  }
115
- const clampedEndMs = Math.min(boundary.videoEndMs, videoDurationMs);
116
- if (clampedEndMs <= startMs) {
117
- console.warn(`[split] Skipping ${boundary.stepId}: invalid duration after clamp`);
158
+
159
+ const duration = endSec - startSec;
160
+ if (duration <= 0) {
161
+ console.warn(`[split] Skipping ${stepId}: invalid duration ${duration.toFixed(2)}s`);
118
162
  continue;
119
163
  }
120
- const startSec = startMs / 1000;
121
- const duration = (clampedEndMs - startMs) / 1000;
122
- const paddedIndex = String(boundary.stepIndex + 1).padStart(2, "0");
123
- const safeStepId = sanitizeFileSegment(
124
- boundary.stepId,
125
- `step-${boundary.stepIndex + 1}`
126
- );
164
+
165
+ const paddedIndex = String(stepIndex + 1).padStart(2, "0");
166
+ const safeStepId = sanitizeFileSegment(stepId, `step-${stepIndex + 1}`);
127
167
  const outputFileName = `step_${paddedIndex}_${safeStepId}.mp4`;
128
168
  const outputPath = path.join(stepClipsDir, outputFileName);
129
169
 
130
170
  console.log(
131
- `[split] ${paddedIndex}. ${boundary.stepId}: ${startSec.toFixed(2)}s - ${(startSec + duration).toFixed(2)}s (${duration.toFixed(2)}s)`
171
+ `[split] ${paddedIndex}. ${stepId}: ${startSec.toFixed(2)}s - ${endSec.toFixed(2)}s (${duration.toFixed(2)}s)`
132
172
  );
133
173
 
134
174
  try {
@@ -149,7 +189,7 @@ async function splitDemo(demoName, paths, outputDimensions = null) {
149
189
  outputPath,
150
190
  ]);
151
191
  } catch (error) {
152
- console.error(`[split] ✗ Failed to extract step ${boundary.stepId}:`, error);
192
+ console.error(`[split] ✗ Failed to extract step ${stepId}:`, error);
153
193
  throw error;
154
194
  }
155
195
  }
@@ -161,31 +201,41 @@ async function splitDemo(demoName, paths, outputDimensions = null) {
161
201
  generatedAt: new Date().toISOString(),
162
202
  sourceVideo: videoPath,
163
203
  sourceVideoDurationMs: videoDurationMs,
164
- steps: script.stepBoundaries.map((boundary) => {
204
+ usedCdpManifest: useCdpManifest,
205
+ steps: stepBoundaries.map((boundary) => {
165
206
  const paddedIndex = String(boundary.stepIndex + 1).padStart(2, "0");
166
- const isFirstStep = boundary.stepIndex === 0;
167
- const splitStartMs = isFirstStep ? 0 : boundary.videoStartMs;
168
- const clampedEndMs = Math.min(boundary.videoEndMs, videoDurationMs);
169
207
  const safeStepId = sanitizeFileSegment(
170
208
  boundary.stepId,
171
209
  `step-${boundary.stepIndex + 1}`
172
210
  );
211
+
212
+ let splitStartMs, splitEndMs;
213
+ if (useCdpManifest) {
214
+ // CDP manifest has times in seconds
215
+ splitStartMs = Math.round(boundary.videoStartSec * 1000);
216
+ splitEndMs = Math.round(boundary.videoEndSec * 1000);
217
+ } else {
218
+ // Script has times in milliseconds
219
+ const isFirstStep = boundary.stepIndex === 0;
220
+ splitStartMs = isFirstStep ? 0 : boundary.videoStartMs;
221
+ splitEndMs = Math.min(boundary.videoEndMs, videoDurationMs);
222
+ }
223
+
173
224
  return {
174
225
  stepId: boundary.stepId,
175
226
  safeStepId,
176
227
  stepIndex: boundary.stepIndex,
177
228
  videoFile: path.join(stepClipsDir, `step_${paddedIndex}_${safeStepId}.mp4`),
178
229
  splitStartMs,
179
- originalStartMs: boundary.videoStartMs,
180
- originalEndMs: boundary.videoEndMs,
181
- durationMs: Math.max(0, clampedEndMs - splitStartMs),
230
+ splitEndMs,
231
+ durationMs: Math.max(0, splitEndMs - splitStartMs),
182
232
  };
183
233
  }),
184
234
  };
185
235
 
186
236
  await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
187
237
 
188
- console.log(`\n[split] ✓ Split into ${script.stepBoundaries.length} clips`);
238
+ console.log(`\n[split] ✓ Split into ${stepBoundaries.length} clips`);
189
239
  console.log(`[split] Output: ${stepClipsDir}`);
190
240
  console.log(`[split] Manifest: ${manifestPath}`);
191
241
  }
@@ -60,6 +60,11 @@ export function createCDPRecorder(page, options) {
60
60
  let nextFrameNumber = 0;
61
61
  let recordingDuration = 0; // Duration in ms from start to stop
62
62
 
63
+ // Step tracking - maps frames to steps for accurate splitting
64
+ let currentStepId = null;
65
+ let currentStepIndex = -1;
66
+ let stepMarkers = []; // Array of { stepId, stepIndex, startFrame, endFrame }
67
+
63
68
  const frameHandler = (params) => {
64
69
  if (!isRecording) return;
65
70
 
@@ -95,6 +100,10 @@ export function createCDPRecorder(page, options) {
95
100
  const buffer = Buffer.from(data, "base64");
96
101
 
97
102
  // Queue frame writes to ensure sequential completion
103
+ // Capture current step info at time of frame capture (before async write)
104
+ const frameStepId = currentStepId;
105
+ const frameStepIndex = currentStepIndex;
106
+
98
107
  writeQueue = writeQueue.then(async () => {
99
108
  try {
100
109
  await fs.writeFile(framePath, buffer);
@@ -102,6 +111,8 @@ export function createCDPRecorder(page, options) {
102
111
  frameNumber,
103
112
  timestamp,
104
113
  path: framePath,
114
+ stepId: frameStepId,
115
+ stepIndex: frameStepIndex,
105
116
  });
106
117
  } catch (err) {
107
118
  console.warn(`[cdp-recorder] Failed to write frame ${frameNumber}:`, err.message);
@@ -161,13 +172,46 @@ export function createCDPRecorder(page, options) {
161
172
  console.log(`[cdp-recorder] First frame received after ${Date.now() - startWait}ms`);
162
173
  },
163
174
 
175
+ /**
176
+ * Mark the start of a new step. Call this when each step begins.
177
+ * This creates frame-accurate step boundaries for splitting.
178
+ * @param {string} stepId - The step identifier
179
+ * @param {number} stepIndex - The step index (0-based)
180
+ */
181
+ markStep(stepId, stepIndex) {
182
+ // Close out previous step marker if one exists
183
+ if (currentStepId !== null && stepMarkers.length > 0) {
184
+ const lastMarker = stepMarkers[stepMarkers.length - 1];
185
+ // End frame will be set to the frame before this one (nextFrameNumber - 1)
186
+ // But we'll finalize this when we have actual frames
187
+ lastMarker.endFrameNumber = nextFrameNumber - 1;
188
+ }
189
+
190
+ // Start new step marker
191
+ currentStepId = stepId;
192
+ currentStepIndex = stepIndex;
193
+ stepMarkers.push({
194
+ stepId,
195
+ stepIndex,
196
+ startFrameNumber: nextFrameNumber, // Will be the next frame captured
197
+ endFrameNumber: null, // Will be set when next step starts or recording ends
198
+ });
199
+ },
200
+
201
+ /**
202
+ * Get current step markers (for debugging/logging)
203
+ */
204
+ getStepMarkers() {
205
+ return [...stepMarkers];
206
+ },
207
+
164
208
  /**
165
209
  * Stop recording and return frame information
166
- * @returns {Promise<{frameCount: number, duration: number, framesDir: string}>}
210
+ * @returns {Promise<{frameCount: number, duration: number, framesDir: string, stepBoundaries: Array}>}
167
211
  */
168
212
  async stop() {
169
213
  if (!isRecording || !cdpSession) {
170
- return { frameCount: 0, duration: 0, framesDir };
214
+ return { frameCount: 0, duration: 0, framesDir, stepBoundaries: [] };
171
215
  }
172
216
 
173
217
  isRecording = false;
@@ -184,6 +228,38 @@ export function createCDPRecorder(page, options) {
184
228
  // Wait for all pending frame writes to complete
185
229
  await writeQueue;
186
230
 
231
+ // Finalize the last step marker
232
+ if (stepMarkers.length > 0) {
233
+ const lastMarker = stepMarkers[stepMarkers.length - 1];
234
+ if (lastMarker.endFrameNumber === null) {
235
+ lastMarker.endFrameNumber = frameTimestamps.length - 1;
236
+ }
237
+ }
238
+
239
+ // Build frame-accurate step boundaries from the actual captured frames
240
+ // This uses the step info attached to each frame for precise boundaries
241
+ const stepBoundaries = stepMarkers.map((marker) => {
242
+ // Find actual frames that belong to this step
243
+ const stepFrames = frameTimestamps.filter(f => f.stepIndex === marker.stepIndex);
244
+ if (stepFrames.length === 0) {
245
+ return null;
246
+ }
247
+
248
+ const firstFrame = stepFrames[0];
249
+ const lastFrame = stepFrames[stepFrames.length - 1];
250
+
251
+ return {
252
+ stepId: marker.stepId,
253
+ stepIndex: marker.stepIndex,
254
+ startFrameNumber: firstFrame.frameNumber,
255
+ endFrameNumber: lastFrame.frameNumber,
256
+ frameCount: stepFrames.length,
257
+ // Time-based boundaries derived from frame timestamps
258
+ startTimeSec: firstFrame.timestamp,
259
+ endTimeSec: lastFrame.timestamp,
260
+ };
261
+ }).filter(Boolean);
262
+
187
263
  const delayToFirstFrame = firstFrameTime ? firstFrameTime - startTime : 0;
188
264
  const actualFps = frameTimestamps.length > 0 && recordingDuration > 0
189
265
  ? frameTimestamps.length / (recordingDuration / 1000)
@@ -195,6 +271,9 @@ export function createCDPRecorder(page, options) {
195
271
  if (delayToFirstFrame > 100) {
196
272
  console.log(`[cdp-recorder] First frame delay: ${delayToFirstFrame}ms`);
197
273
  }
274
+ if (stepBoundaries.length > 0) {
275
+ console.log(`[cdp-recorder] Tracked ${stepBoundaries.length} steps with frame-accurate boundaries`);
276
+ }
198
277
 
199
278
  return {
200
279
  frameCount: frameTimestamps.length,
@@ -204,6 +283,7 @@ export function createCDPRecorder(page, options) {
204
283
  firstFrameTime, // Wall clock time when first frame was captured
205
284
  startTime, // Wall clock time when recording was started
206
285
  actualFps, // Actual capture framerate for real-time playback
286
+ stepBoundaries, // Frame-accurate step boundaries
207
287
  };
208
288
  },
209
289
 
@@ -307,6 +387,58 @@ export function createCDPRecorder(page, options) {
307
387
 
308
388
  console.log(`[cdp-recorder] Video assembled: ${outputPath}`);
309
389
 
390
+ // Build frame-to-video-time mapping
391
+ // Each frame's video start time = sum of all previous frame durations
392
+ let cumulativeTime = 0;
393
+ const frameVideoTimes = framesWithDuration.map((frame) => {
394
+ const videoStartTime = cumulativeTime;
395
+ cumulativeTime += frame.duration;
396
+ return {
397
+ frameNumber: frame.frameNumber,
398
+ stepId: frame.stepId,
399
+ stepIndex: frame.stepIndex,
400
+ videoStartTimeSec: videoStartTime,
401
+ videoEndTimeSec: cumulativeTime,
402
+ duration: frame.duration,
403
+ };
404
+ });
405
+
406
+ // Calculate step boundaries in video time
407
+ const videoStepBoundaries = [];
408
+ if (stepMarkers.length > 0) {
409
+ for (const marker of stepMarkers) {
410
+ const stepFrames = frameVideoTimes.filter(f => f.stepIndex === marker.stepIndex);
411
+ if (stepFrames.length === 0) continue;
412
+
413
+ const firstFrame = stepFrames[0];
414
+ const lastFrame = stepFrames[stepFrames.length - 1];
415
+
416
+ videoStepBoundaries.push({
417
+ stepId: marker.stepId,
418
+ stepIndex: marker.stepIndex,
419
+ startFrameNumber: firstFrame.frameNumber,
420
+ endFrameNumber: lastFrame.frameNumber,
421
+ frameCount: stepFrames.length,
422
+ videoStartSec: firstFrame.videoStartTimeSec,
423
+ videoEndSec: lastFrame.videoEndTimeSec,
424
+ durationSec: lastFrame.videoEndTimeSec - firstFrame.videoStartTimeSec,
425
+ });
426
+ }
427
+
428
+ // Write step manifest for the split command
429
+ const manifestPath = outputPath.replace(/\.[^.]+$/, '-steps.json');
430
+ await fs.writeFile(manifestPath, JSON.stringify({
431
+ videoPath: outputPath,
432
+ totalDurationSec: totalDuration,
433
+ outputFps,
434
+ frameCount: framesWithDuration.length,
435
+ steps: videoStepBoundaries,
436
+ generatedAt: new Date().toISOString(),
437
+ }, null, 2));
438
+
439
+ console.log(`[cdp-recorder] Step manifest written: ${manifestPath}`);
440
+ }
441
+
310
442
  return outputPath;
311
443
  },
312
444
 
package/dist/index.cjs CHANGED
@@ -2410,6 +2410,9 @@ async function runDemo(definition, context, options = {}) {
2410
2410
  if (scriptGenerator && stepIndex > 0) {
2411
2411
  await waitForStepReady(context.page, step);
2412
2412
  }
2413
+ if (context.onStepStart) {
2414
+ context.onStepStart(step.id, stepIndex);
2415
+ }
2413
2416
  if (scriptGenerator) {
2414
2417
  scriptGenerator.startStep(step.id);
2415
2418
  }