@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.
- package/cli/commands/record-demo.js +18 -7
- package/cli/commands/split-video.js +77 -27
- package/cli/utils/cdp-recorder.js +134 -2
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
const
|
|
122
|
-
const
|
|
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}. ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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 ${
|
|
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
|
}
|