rollberry 0.1.9 → 0.2.0
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/CHANGELOG.md +30 -0
- package/README.md +189 -7
- package/dist/capture/actions.d.ts +11 -0
- package/dist/capture/actions.js +81 -0
- package/dist/capture/browser-install.d.ts +3 -0
- package/dist/capture/browser.d.ts +13 -0
- package/dist/capture/capture.d.ts +8 -0
- package/dist/capture/capture.js +476 -56
- package/dist/capture/constants.d.ts +15 -0
- package/dist/capture/constants.js +3 -0
- package/dist/capture/ffmpeg.d.ts +41 -0
- package/dist/capture/ffmpeg.js +374 -28
- package/dist/capture/logger.d.ts +15 -0
- package/dist/capture/preflight.d.ts +4 -0
- package/dist/capture/progress.d.ts +7 -0
- package/dist/capture/scroll-plan.d.ts +9 -0
- package/dist/capture/scroll-plan.js +4 -1
- package/dist/capture/stabilize.d.ts +7 -0
- package/dist/capture/types.d.ts +216 -0
- package/dist/capture/utils.d.ts +14 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +52 -24
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/options.d.ts +42 -0
- package/dist/options.js +187 -119
- package/dist/project.d.ts +27 -0
- package/dist/project.js +722 -0
- package/dist/render-plan.d.ts +103 -0
- package/dist/render-plan.js +144 -0
- package/dist/run-capture.d.ts +3 -0
- package/dist/run-render.d.ts +17 -0
- package/dist/run-render.js +434 -0
- package/dist/version.d.ts +1 -0
- package/package.json +10 -3
- package/rollberry.project.sample.json +92 -0
- package/rollberry.project.schema.json +474 -0
package/dist/capture/capture.js
CHANGED
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
import { unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { executeCaptureAction, executeSceneActions, resetScrollPosition, } from './actions.js';
|
|
2
3
|
import { navigateWithRetry, openBrowserSession } from './browser.js';
|
|
3
|
-
import { MAX_TOTAL_FRAMES } from './constants.js';
|
|
4
|
+
import { MAX_TOTAL_FRAMES, PREFLIGHT_MAX_SCROLL_HEIGHT } from './constants.js';
|
|
4
5
|
import { checkFfmpegAvailable, createVideoEncoder, } from './ffmpeg.js';
|
|
5
6
|
import { preflightMeasurePage } from './preflight.js';
|
|
6
|
-
import { buildScrollFrames, resolveDurationSeconds } from './scroll-plan.js';
|
|
7
|
+
import { buildScrollFrames, resolveDurationSeconds, resolveTimelineDurationSeconds, } from './scroll-plan.js';
|
|
7
8
|
import { stabilizePage } from './stabilize.js';
|
|
8
|
-
import { ensureDirectory, ensureParentDirectory, fileExists, sanitizeUrl, waitForAnimationFrames, } from './utils.js';
|
|
9
|
+
import { clamp, ensureDirectory, ensureParentDirectory, fileExists, measurePage, sanitizeUrl, waitForAnimationFrames, } from './utils.js';
|
|
9
10
|
export async function captureVideo(options, logger, progress, signal) {
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
return captureSceneVideo(buildCaptureJob(options), logger, progress, signal);
|
|
12
|
+
}
|
|
13
|
+
export async function captureSceneVideo(job, logger, progress, signal) {
|
|
14
|
+
if (job.scenes.length === 0) {
|
|
15
|
+
throw new Error('At least one scene is required.');
|
|
12
16
|
}
|
|
13
17
|
if (signal?.aborted) {
|
|
14
18
|
throw new AbortError();
|
|
15
19
|
}
|
|
16
|
-
if (!
|
|
17
|
-
throw new Error(`Output file already exists: ${
|
|
20
|
+
if (!job.force && (await fileExists(job.outPath))) {
|
|
21
|
+
throw new Error(`Output file already exists: ${job.outPath}\nUse --force to overwrite, or specify a different --out path.`);
|
|
18
22
|
}
|
|
19
23
|
await checkFfmpegAvailable();
|
|
20
24
|
await Promise.all([
|
|
21
|
-
ensureParentDirectory(
|
|
22
|
-
|
|
23
|
-
ensureParentDirectory(options.manifestPath),
|
|
24
|
-
options.debugFramesDir
|
|
25
|
-
? ensureDirectory(options.debugFramesDir)
|
|
26
|
-
: undefined,
|
|
25
|
+
ensureParentDirectory(job.outPath),
|
|
26
|
+
job.debugFramesDir ? ensureDirectory(job.debugFramesDir) : undefined,
|
|
27
27
|
]);
|
|
28
|
-
const { browser, page } = await openBrowserSession(
|
|
28
|
+
const { browser, page } = await openBrowserSession({
|
|
29
|
+
viewport: job.viewport,
|
|
30
|
+
timeoutMs: job.timeoutMs,
|
|
31
|
+
urls: job.scenes.map((scene) => scene.url),
|
|
32
|
+
}, logger);
|
|
29
33
|
let encoder;
|
|
30
34
|
let encodingFinished = false;
|
|
31
35
|
let browserClosed = false;
|
|
@@ -51,8 +55,13 @@ export async function captureVideo(options, logger, progress, signal) {
|
|
|
51
55
|
throw new AbortError();
|
|
52
56
|
}
|
|
53
57
|
encoder = await createVideoEncoder({
|
|
54
|
-
fps:
|
|
55
|
-
outPath:
|
|
58
|
+
fps: job.fps,
|
|
59
|
+
outPath: job.outPath,
|
|
60
|
+
format: job.format,
|
|
61
|
+
audio: job.audio,
|
|
62
|
+
subtitles: job.subtitles,
|
|
63
|
+
transition: job.transition,
|
|
64
|
+
videoEncoding: job.videoEncoding,
|
|
56
65
|
});
|
|
57
66
|
if (signal?.aborted) {
|
|
58
67
|
throw new AbortError();
|
|
@@ -62,38 +71,40 @@ export async function captureVideo(options, logger, progress, signal) {
|
|
|
62
71
|
let totalDurationSeconds = 0;
|
|
63
72
|
let frameOffset = 0;
|
|
64
73
|
let anyTruncated = false;
|
|
65
|
-
for (const [
|
|
74
|
+
for (const [sceneIndex, scene] of job.scenes.entries()) {
|
|
66
75
|
if (signal?.aborted) {
|
|
67
76
|
throw new AbortError();
|
|
68
77
|
}
|
|
69
78
|
const { pageResult, lastFrame } = await capturePageFrames({
|
|
70
79
|
page,
|
|
71
|
-
|
|
80
|
+
scene,
|
|
81
|
+
sceneIndex,
|
|
82
|
+
totalScenes: job.scenes.length,
|
|
72
83
|
encoder,
|
|
73
|
-
|
|
84
|
+
job,
|
|
74
85
|
logger,
|
|
75
86
|
progress,
|
|
76
87
|
signal,
|
|
77
88
|
frameOffset,
|
|
78
|
-
urlIndex,
|
|
79
89
|
});
|
|
80
90
|
pages.push(pageResult);
|
|
81
91
|
totalFrameCount += pageResult.frameCount;
|
|
82
92
|
totalDurationSeconds += pageResult.durationSeconds;
|
|
83
93
|
frameOffset += pageResult.frameCount;
|
|
84
94
|
anyTruncated = anyTruncated || pageResult.truncated;
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
95
|
+
const isLastScene = sceneIndex === job.scenes.length - 1;
|
|
96
|
+
if (scene.holdAfterSeconds > 0 &&
|
|
97
|
+
shouldWriteHoldAfterScene(job, isLastScene)) {
|
|
87
98
|
const gapFrameCount = await writeGapFrames({
|
|
88
99
|
encoder,
|
|
89
100
|
frame: lastFrame,
|
|
90
|
-
fps:
|
|
91
|
-
gapSeconds:
|
|
92
|
-
debugFramesDir:
|
|
101
|
+
fps: job.fps,
|
|
102
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
103
|
+
debugFramesDir: job.debugFramesDir,
|
|
93
104
|
frameOffset,
|
|
94
105
|
});
|
|
95
106
|
totalFrameCount += gapFrameCount;
|
|
96
|
-
totalDurationSeconds += gapFrameCount /
|
|
107
|
+
totalDurationSeconds += gapFrameCount / job.fps;
|
|
97
108
|
frameOffset += gapFrameCount;
|
|
98
109
|
}
|
|
99
110
|
}
|
|
@@ -101,10 +112,10 @@ export async function captureVideo(options, logger, progress, signal) {
|
|
|
101
112
|
encodingFinished = true;
|
|
102
113
|
progress?.onEncodeComplete();
|
|
103
114
|
await logger.info('encode.complete', 'Video encoding finished', {
|
|
104
|
-
outPath:
|
|
115
|
+
outPath: job.outPath,
|
|
105
116
|
});
|
|
106
117
|
return {
|
|
107
|
-
outPath:
|
|
118
|
+
outPath: job.outPath,
|
|
108
119
|
frameCount: totalFrameCount,
|
|
109
120
|
durationSeconds: totalDurationSeconds,
|
|
110
121
|
pages,
|
|
@@ -117,7 +128,7 @@ export async function captureVideo(options, logger, progress, signal) {
|
|
|
117
128
|
: error;
|
|
118
129
|
if (encoder && !encodingFinished) {
|
|
119
130
|
await encoder.abort();
|
|
120
|
-
await cleanupPartialOutput(
|
|
131
|
+
await cleanupPartialOutput(job.outPath);
|
|
121
132
|
}
|
|
122
133
|
throw failure;
|
|
123
134
|
}
|
|
@@ -132,46 +143,145 @@ export class AbortError extends Error {
|
|
|
132
143
|
this.name = 'AbortError';
|
|
133
144
|
}
|
|
134
145
|
}
|
|
146
|
+
function buildCaptureJob(options) {
|
|
147
|
+
if (options.urls.length === 0) {
|
|
148
|
+
throw new Error('At least one URL is required.');
|
|
149
|
+
}
|
|
150
|
+
const scenes = options.urls.map((url, index) => ({
|
|
151
|
+
url,
|
|
152
|
+
duration: options.duration,
|
|
153
|
+
motion: options.motion,
|
|
154
|
+
waitFor: options.waitFor,
|
|
155
|
+
hideSelectors: options.hideSelectors,
|
|
156
|
+
holdAfterSeconds: index === options.urls.length - 1 ? 0 : options.pageGapSeconds,
|
|
157
|
+
actions: [],
|
|
158
|
+
timeline: [],
|
|
159
|
+
}));
|
|
160
|
+
const [firstScene, ...remainingScenes] = scenes;
|
|
161
|
+
return {
|
|
162
|
+
scenes: [firstScene, ...remainingScenes],
|
|
163
|
+
outPath: options.outPath,
|
|
164
|
+
format: 'mp4',
|
|
165
|
+
viewport: options.viewport,
|
|
166
|
+
fps: options.fps,
|
|
167
|
+
timeoutMs: options.timeoutMs,
|
|
168
|
+
debugFramesDir: options.debugFramesDir,
|
|
169
|
+
includeHoldAfterFinalScene: false,
|
|
170
|
+
force: options.force,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
135
173
|
async function capturePageFrames(input) {
|
|
136
|
-
const { page,
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
progress?.onPageStart(
|
|
140
|
-
await logger.info('browser.open', `Opening
|
|
174
|
+
const { page, scene, sceneIndex, totalScenes, encoder, job, logger, progress, signal, frameOffset, } = input;
|
|
175
|
+
const safeUrl = sanitizeUrl(scene.url);
|
|
176
|
+
const progressTarget = scene.name ? `${scene.name} (${safeUrl})` : safeUrl;
|
|
177
|
+
progress?.onPageStart(sceneIndex, totalScenes, progressTarget);
|
|
178
|
+
await logger.info('browser.open', `Opening scene ${sceneIndex + 1}`, {
|
|
179
|
+
name: scene.name,
|
|
141
180
|
url: safeUrl,
|
|
142
|
-
viewport:
|
|
181
|
+
viewport: job.viewport,
|
|
143
182
|
});
|
|
144
|
-
await navigateWithRetry(page, url,
|
|
183
|
+
await navigateWithRetry(page, scene.url, job.timeoutMs);
|
|
145
184
|
await stabilizePage({
|
|
146
185
|
page,
|
|
147
|
-
waitFor:
|
|
148
|
-
hideSelectors:
|
|
186
|
+
waitFor: scene.waitFor,
|
|
187
|
+
hideSelectors: scene.hideSelectors,
|
|
149
188
|
});
|
|
189
|
+
if (scene.actions.length > 0) {
|
|
190
|
+
await executeSceneActions({
|
|
191
|
+
page,
|
|
192
|
+
actions: scene.actions,
|
|
193
|
+
timeoutMs: job.timeoutMs,
|
|
194
|
+
onActionStart(action, index) {
|
|
195
|
+
return logger.info('scene.action.start', 'Executing scene action', {
|
|
196
|
+
action: serializeAction(action),
|
|
197
|
+
actionIndex: index,
|
|
198
|
+
name: scene.name,
|
|
199
|
+
url: safeUrl,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
onActionComplete(action, index) {
|
|
203
|
+
return logger.info('scene.action.complete', 'Scene action completed', {
|
|
204
|
+
action: serializeAction(action),
|
|
205
|
+
actionIndex: index,
|
|
206
|
+
name: scene.name,
|
|
207
|
+
url: safeUrl,
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
await resetScrollPosition(page);
|
|
212
|
+
}
|
|
213
|
+
if (scene.timeline.length > 0) {
|
|
214
|
+
const timelineResult = await captureTimelineFrames({
|
|
215
|
+
page,
|
|
216
|
+
scene,
|
|
217
|
+
sceneIndex,
|
|
218
|
+
totalScenes,
|
|
219
|
+
encoder,
|
|
220
|
+
job,
|
|
221
|
+
logger,
|
|
222
|
+
progress,
|
|
223
|
+
signal,
|
|
224
|
+
frameOffset,
|
|
225
|
+
safeUrl,
|
|
226
|
+
});
|
|
227
|
+
progress?.onPageComplete(sceneIndex);
|
|
228
|
+
return {
|
|
229
|
+
pageResult: {
|
|
230
|
+
name: scene.name,
|
|
231
|
+
url: safeUrl,
|
|
232
|
+
frameCount: timelineResult.frameCount,
|
|
233
|
+
durationSeconds: timelineResult.frameCount / job.fps,
|
|
234
|
+
scrollHeight: timelineResult.scrollHeight,
|
|
235
|
+
truncated: timelineResult.truncated,
|
|
236
|
+
},
|
|
237
|
+
lastFrame: timelineResult.lastFrame,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const legacyResult = await captureLegacyScrollFrames({
|
|
241
|
+
page,
|
|
242
|
+
scene,
|
|
243
|
+
sceneIndex,
|
|
244
|
+
totalScenes,
|
|
245
|
+
encoder,
|
|
246
|
+
job,
|
|
247
|
+
logger,
|
|
248
|
+
progress,
|
|
249
|
+
signal,
|
|
250
|
+
frameOffset,
|
|
251
|
+
safeUrl,
|
|
252
|
+
});
|
|
253
|
+
progress?.onPageComplete(sceneIndex);
|
|
254
|
+
return legacyResult;
|
|
255
|
+
}
|
|
256
|
+
async function captureLegacyScrollFrames(input) {
|
|
257
|
+
const { page, scene, sceneIndex, totalScenes, encoder, job, logger, progress, signal, frameOffset, safeUrl, } = input;
|
|
150
258
|
const preflight = await preflightMeasurePage(page, logger);
|
|
151
|
-
const plannedDurationSeconds = resolveDurationSeconds(
|
|
259
|
+
const plannedDurationSeconds = resolveDurationSeconds(scene.duration, preflight.maxScroll);
|
|
152
260
|
const frames = buildScrollFrames({
|
|
153
|
-
fps:
|
|
261
|
+
fps: job.fps,
|
|
154
262
|
durationSeconds: plannedDurationSeconds,
|
|
155
263
|
maxScroll: preflight.maxScroll,
|
|
156
|
-
motion:
|
|
264
|
+
motion: scene.motion,
|
|
157
265
|
});
|
|
158
266
|
assertWithinFrameLimit(frameOffset +
|
|
159
267
|
frames.length +
|
|
160
268
|
getReservedGapFrames({
|
|
161
|
-
fps:
|
|
162
|
-
|
|
163
|
-
|
|
269
|
+
fps: job.fps,
|
|
270
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
271
|
+
shouldWriteGap: shouldWriteHoldAfterScene(job, sceneIndex === totalScenes - 1),
|
|
164
272
|
}));
|
|
165
|
-
const durationSeconds = frames.length /
|
|
166
|
-
await logger.info('render.start', `Rendering frames for
|
|
273
|
+
const durationSeconds = frames.length / job.fps;
|
|
274
|
+
await logger.info('render.start', `Rendering frames for scene ${sceneIndex + 1}`, {
|
|
275
|
+
name: scene.name,
|
|
167
276
|
frameCount: frames.length,
|
|
168
|
-
fps:
|
|
277
|
+
fps: job.fps,
|
|
169
278
|
durationSeconds,
|
|
170
279
|
maxScroll: preflight.maxScroll,
|
|
171
280
|
url: safeUrl,
|
|
172
281
|
});
|
|
173
282
|
if (preflight.truncated) {
|
|
174
283
|
await logger.warn('preflight.truncated', 'Scroll height exceeded capture limit and was truncated', {
|
|
284
|
+
name: scene.name,
|
|
175
285
|
scrollHeight: preflight.scrollHeight,
|
|
176
286
|
url: safeUrl,
|
|
177
287
|
});
|
|
@@ -192,14 +302,16 @@ async function capturePageFrames(input) {
|
|
|
192
302
|
caret: 'hide',
|
|
193
303
|
});
|
|
194
304
|
lastFrame = frame;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
305
|
+
await writeFrameToOutput({
|
|
306
|
+
encoder,
|
|
307
|
+
frame,
|
|
308
|
+
debugFramesDir: job.debugFramesDir,
|
|
309
|
+
frameNumber: frameOffset + index,
|
|
310
|
+
});
|
|
200
311
|
progress?.onFrameRendered(index, frames.length);
|
|
201
312
|
if ((index + 1) % Math.max(1, Math.floor(frames.length / 10)) === 0) {
|
|
202
313
|
await logger.info('render.progress', 'Rendered frame batch', {
|
|
314
|
+
name: scene.name,
|
|
203
315
|
renderedFrames: index + 1,
|
|
204
316
|
totalFrames: frames.length,
|
|
205
317
|
scrollTop,
|
|
@@ -210,9 +322,9 @@ async function capturePageFrames(input) {
|
|
|
210
322
|
if (!lastFrame) {
|
|
211
323
|
throw new Error(`Failed to capture frames from page: ${safeUrl}`);
|
|
212
324
|
}
|
|
213
|
-
progress?.onPageComplete(urlIndex);
|
|
214
325
|
return {
|
|
215
326
|
pageResult: {
|
|
327
|
+
name: scene.name,
|
|
216
328
|
url: safeUrl,
|
|
217
329
|
frameCount: frames.length,
|
|
218
330
|
durationSeconds,
|
|
@@ -222,6 +334,284 @@ async function capturePageFrames(input) {
|
|
|
222
334
|
lastFrame,
|
|
223
335
|
};
|
|
224
336
|
}
|
|
337
|
+
async function captureTimelineFrames(input) {
|
|
338
|
+
const { page, scene, sceneIndex, totalScenes, encoder, job, logger, progress, signal, frameOffset, safeUrl, } = input;
|
|
339
|
+
let sceneFrameCount = 0;
|
|
340
|
+
let lastFrame;
|
|
341
|
+
let maxObservedScrollHeight = 0;
|
|
342
|
+
let anyTruncated = false;
|
|
343
|
+
const initialMetrics = await measureTimelineMetrics(page);
|
|
344
|
+
maxObservedScrollHeight = initialMetrics.scrollHeight;
|
|
345
|
+
anyTruncated = initialMetrics.truncated;
|
|
346
|
+
await logger.info('timeline.start', `Running timeline for scene ${sceneIndex + 1}`, {
|
|
347
|
+
name: scene.name,
|
|
348
|
+
segmentCount: scene.timeline.length,
|
|
349
|
+
url: safeUrl,
|
|
350
|
+
});
|
|
351
|
+
for (const [segmentIndex, segment] of scene.timeline.entries()) {
|
|
352
|
+
if (signal?.aborted) {
|
|
353
|
+
throw new AbortError();
|
|
354
|
+
}
|
|
355
|
+
await logger.info('timeline.segment.start', 'Executing timeline segment', {
|
|
356
|
+
name: scene.name,
|
|
357
|
+
sceneIndex,
|
|
358
|
+
segmentIndex,
|
|
359
|
+
segment: serializeTimelineSegment(segment),
|
|
360
|
+
url: safeUrl,
|
|
361
|
+
});
|
|
362
|
+
switch (segment.kind) {
|
|
363
|
+
case 'scroll': {
|
|
364
|
+
const metrics = await measureTimelineMetrics(page);
|
|
365
|
+
maxObservedScrollHeight = Math.max(maxObservedScrollHeight, metrics.scrollHeight);
|
|
366
|
+
anyTruncated = anyTruncated || metrics.truncated;
|
|
367
|
+
const startScrollTop = metrics.scrollTop;
|
|
368
|
+
const targetScrollTop = await resolveTimelineTargetScrollTop(page, metrics.viewportHeight, metrics.maxScroll, startScrollTop, segment.target);
|
|
369
|
+
const scrollDistance = targetScrollTop - startScrollTop;
|
|
370
|
+
const durationSeconds = segment.duration === 'auto'
|
|
371
|
+
? resolveTimelineDurationSeconds(Math.abs(scrollDistance))
|
|
372
|
+
: segment.duration;
|
|
373
|
+
const relativeFrames = buildScrollFrames({
|
|
374
|
+
fps: job.fps,
|
|
375
|
+
durationSeconds,
|
|
376
|
+
maxScroll: Math.abs(scrollDistance),
|
|
377
|
+
motion: segment.motion,
|
|
378
|
+
});
|
|
379
|
+
assertWithinFrameLimit(frameOffset +
|
|
380
|
+
sceneFrameCount +
|
|
381
|
+
relativeFrames.length +
|
|
382
|
+
getReservedGapFrames({
|
|
383
|
+
fps: job.fps,
|
|
384
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
385
|
+
shouldWriteGap: shouldWriteHoldAfterScene(job, sceneIndex === totalScenes - 1),
|
|
386
|
+
}));
|
|
387
|
+
for (const [index, relativeScrollTop] of relativeFrames.entries()) {
|
|
388
|
+
if (signal?.aborted) {
|
|
389
|
+
throw new AbortError();
|
|
390
|
+
}
|
|
391
|
+
const nextScrollTop = scrollDistance >= 0
|
|
392
|
+
? startScrollTop + relativeScrollTop
|
|
393
|
+
: startScrollTop - relativeScrollTop;
|
|
394
|
+
await page.evaluate((scrollTop) => {
|
|
395
|
+
window.scrollTo({ top: scrollTop, behavior: 'auto' });
|
|
396
|
+
}, nextScrollTop);
|
|
397
|
+
await waitForAnimationFrames(page);
|
|
398
|
+
const frame = await captureFrame(page);
|
|
399
|
+
lastFrame = frame;
|
|
400
|
+
await writeFrameToOutput({
|
|
401
|
+
encoder,
|
|
402
|
+
frame,
|
|
403
|
+
debugFramesDir: job.debugFramesDir,
|
|
404
|
+
frameNumber: frameOffset + sceneFrameCount,
|
|
405
|
+
});
|
|
406
|
+
sceneFrameCount += 1;
|
|
407
|
+
progress?.onFrameRendered(index, relativeFrames.length);
|
|
408
|
+
}
|
|
409
|
+
const postScrollMetrics = await measureTimelineMetrics(page);
|
|
410
|
+
maxObservedScrollHeight = Math.max(maxObservedScrollHeight, postScrollMetrics.scrollHeight);
|
|
411
|
+
anyTruncated = anyTruncated || postScrollMetrics.truncated;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case 'pause': {
|
|
415
|
+
const pauseFrame = lastFrame ?? (await captureFrame(page));
|
|
416
|
+
lastFrame = pauseFrame;
|
|
417
|
+
const pauseFrameCount = Math.max(1, Math.round(segment.durationSeconds * job.fps));
|
|
418
|
+
assertWithinFrameLimit(frameOffset +
|
|
419
|
+
sceneFrameCount +
|
|
420
|
+
pauseFrameCount +
|
|
421
|
+
getReservedGapFrames({
|
|
422
|
+
fps: job.fps,
|
|
423
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
424
|
+
shouldWriteGap: shouldWriteHoldAfterScene(job, sceneIndex === totalScenes - 1),
|
|
425
|
+
}));
|
|
426
|
+
await writeRepeatedFrames({
|
|
427
|
+
encoder,
|
|
428
|
+
frame: pauseFrame,
|
|
429
|
+
frameCount: pauseFrameCount,
|
|
430
|
+
debugFramesDir: job.debugFramesDir,
|
|
431
|
+
frameOffset: frameOffset + sceneFrameCount,
|
|
432
|
+
progress,
|
|
433
|
+
});
|
|
434
|
+
sceneFrameCount += pauseFrameCount;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case 'action': {
|
|
438
|
+
await executeCaptureAction(page, segment.action, job.timeoutMs);
|
|
439
|
+
const actionFrame = await captureFrame(page);
|
|
440
|
+
lastFrame = actionFrame;
|
|
441
|
+
const holdFrameCount = Math.round(segment.holdAfterSeconds * job.fps);
|
|
442
|
+
const totalActionFrames = 1 + holdFrameCount;
|
|
443
|
+
assertWithinFrameLimit(frameOffset +
|
|
444
|
+
sceneFrameCount +
|
|
445
|
+
totalActionFrames +
|
|
446
|
+
getReservedGapFrames({
|
|
447
|
+
fps: job.fps,
|
|
448
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
449
|
+
shouldWriteGap: shouldWriteHoldAfterScene(job, sceneIndex === totalScenes - 1),
|
|
450
|
+
}));
|
|
451
|
+
await writeFrameToOutput({
|
|
452
|
+
encoder,
|
|
453
|
+
frame: actionFrame,
|
|
454
|
+
debugFramesDir: job.debugFramesDir,
|
|
455
|
+
frameNumber: frameOffset + sceneFrameCount,
|
|
456
|
+
});
|
|
457
|
+
sceneFrameCount += 1;
|
|
458
|
+
progress?.onFrameRendered(0, totalActionFrames);
|
|
459
|
+
if (holdFrameCount > 0) {
|
|
460
|
+
await writeRepeatedFrames({
|
|
461
|
+
encoder,
|
|
462
|
+
frame: actionFrame,
|
|
463
|
+
frameCount: holdFrameCount,
|
|
464
|
+
debugFramesDir: job.debugFramesDir,
|
|
465
|
+
frameOffset: frameOffset + sceneFrameCount,
|
|
466
|
+
progress,
|
|
467
|
+
progressStartIndex: 1,
|
|
468
|
+
progressTotal: totalActionFrames,
|
|
469
|
+
});
|
|
470
|
+
sceneFrameCount += holdFrameCount;
|
|
471
|
+
}
|
|
472
|
+
const postActionMetrics = await measureTimelineMetrics(page);
|
|
473
|
+
maxObservedScrollHeight = Math.max(maxObservedScrollHeight, postActionMetrics.scrollHeight);
|
|
474
|
+
anyTruncated = anyTruncated || postActionMetrics.truncated;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
await logger.info('timeline.segment.complete', 'Timeline segment completed', {
|
|
479
|
+
name: scene.name,
|
|
480
|
+
sceneIndex,
|
|
481
|
+
segmentIndex,
|
|
482
|
+
sceneFrameCount,
|
|
483
|
+
segment: serializeTimelineSegment(segment),
|
|
484
|
+
url: safeUrl,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
await logger.info('timeline.complete', `Timeline finished for scene ${sceneIndex + 1}`, {
|
|
488
|
+
name: scene.name,
|
|
489
|
+
frameCount: sceneFrameCount,
|
|
490
|
+
durationSeconds: sceneFrameCount / job.fps,
|
|
491
|
+
url: safeUrl,
|
|
492
|
+
});
|
|
493
|
+
if (!lastFrame) {
|
|
494
|
+
throw new Error(`Failed to capture timeline frames from page: ${safeUrl}`);
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
frameCount: sceneFrameCount,
|
|
498
|
+
lastFrame,
|
|
499
|
+
scrollHeight: maxObservedScrollHeight,
|
|
500
|
+
truncated: anyTruncated,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
async function captureFrame(page) {
|
|
504
|
+
return page.screenshot({
|
|
505
|
+
type: 'png',
|
|
506
|
+
scale: 'css',
|
|
507
|
+
animations: 'disabled',
|
|
508
|
+
caret: 'hide',
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
async function writeFrameToOutput(input) {
|
|
512
|
+
const { encoder, frame, debugFramesDir, frameNumber } = input;
|
|
513
|
+
if (debugFramesDir) {
|
|
514
|
+
const fileName = `${String(frameNumber).padStart(5, '0')}.png`;
|
|
515
|
+
await writeFile(`${debugFramesDir}/${fileName}`, frame);
|
|
516
|
+
}
|
|
517
|
+
await encoder.writeFrame(frame);
|
|
518
|
+
}
|
|
519
|
+
async function writeRepeatedFrames(input) {
|
|
520
|
+
const { encoder, frame, frameCount, debugFramesDir, frameOffset, progress, progressStartIndex = 0, progressTotal = frameCount, } = input;
|
|
521
|
+
for (let index = 0; index < frameCount; index += 1) {
|
|
522
|
+
await writeFrameToOutput({
|
|
523
|
+
encoder,
|
|
524
|
+
frame,
|
|
525
|
+
debugFramesDir,
|
|
526
|
+
frameNumber: frameOffset + index,
|
|
527
|
+
});
|
|
528
|
+
progress?.onFrameRendered(progressStartIndex + index, progressTotal);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async function measureTimelineMetrics(page) {
|
|
532
|
+
const metrics = await measurePage(page);
|
|
533
|
+
const scrollTop = await page.evaluate(() => window.scrollY || window.pageYOffset || 0);
|
|
534
|
+
const truncated = metrics.scrollHeight > PREFLIGHT_MAX_SCROLL_HEIGHT;
|
|
535
|
+
const scrollHeight = Math.min(metrics.scrollHeight, PREFLIGHT_MAX_SCROLL_HEIGHT);
|
|
536
|
+
return {
|
|
537
|
+
scrollHeight,
|
|
538
|
+
viewportHeight: metrics.viewportHeight,
|
|
539
|
+
maxScroll: Math.max(0, scrollHeight - metrics.viewportHeight),
|
|
540
|
+
scrollTop: clamp(scrollTop, 0, Math.max(0, scrollHeight - metrics.viewportHeight)),
|
|
541
|
+
truncated,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
async function resolveTimelineTargetScrollTop(page, viewportHeight, maxScroll, currentScrollTop, target) {
|
|
545
|
+
switch (target.kind) {
|
|
546
|
+
case 'bottom':
|
|
547
|
+
return maxScroll;
|
|
548
|
+
case 'absolute':
|
|
549
|
+
return clamp(target.top, 0, maxScroll);
|
|
550
|
+
case 'relative':
|
|
551
|
+
return clamp(currentScrollTop + target.delta, 0, maxScroll);
|
|
552
|
+
case 'selector': {
|
|
553
|
+
const targetScrollTop = await page.evaluate(({ selector, block, viewportHeight: height }) => {
|
|
554
|
+
const element = document.querySelector(selector);
|
|
555
|
+
if (!element) {
|
|
556
|
+
throw new Error(`Selector not found for timeline scroll target: ${selector}`);
|
|
557
|
+
}
|
|
558
|
+
const rect = element.getBoundingClientRect();
|
|
559
|
+
const absoluteTop = rect.top + window.scrollY;
|
|
560
|
+
switch (block) {
|
|
561
|
+
case 'start':
|
|
562
|
+
return absoluteTop;
|
|
563
|
+
case 'center':
|
|
564
|
+
return absoluteTop - (height - rect.height) / 2;
|
|
565
|
+
case 'end':
|
|
566
|
+
return absoluteTop - (height - rect.height);
|
|
567
|
+
}
|
|
568
|
+
}, {
|
|
569
|
+
selector: target.selector,
|
|
570
|
+
block: target.block,
|
|
571
|
+
viewportHeight,
|
|
572
|
+
});
|
|
573
|
+
return clamp(targetScrollTop, 0, maxScroll);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function serializeTimelineSegment(segment) {
|
|
578
|
+
switch (segment.kind) {
|
|
579
|
+
case 'pause':
|
|
580
|
+
return {
|
|
581
|
+
kind: segment.kind,
|
|
582
|
+
durationSeconds: segment.durationSeconds,
|
|
583
|
+
};
|
|
584
|
+
case 'scroll':
|
|
585
|
+
return {
|
|
586
|
+
kind: segment.kind,
|
|
587
|
+
duration: segment.duration,
|
|
588
|
+
motion: segment.motion,
|
|
589
|
+
target: serializeTimelineTarget(segment.target),
|
|
590
|
+
};
|
|
591
|
+
case 'action':
|
|
592
|
+
return {
|
|
593
|
+
kind: segment.kind,
|
|
594
|
+
holdAfterSeconds: segment.holdAfterSeconds,
|
|
595
|
+
action: serializeAction(segment.action),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function serializeTimelineTarget(target) {
|
|
600
|
+
switch (target.kind) {
|
|
601
|
+
case 'bottom':
|
|
602
|
+
return { kind: target.kind };
|
|
603
|
+
case 'absolute':
|
|
604
|
+
return { kind: target.kind, top: target.top };
|
|
605
|
+
case 'relative':
|
|
606
|
+
return { kind: target.kind, delta: target.delta };
|
|
607
|
+
case 'selector':
|
|
608
|
+
return {
|
|
609
|
+
kind: target.kind,
|
|
610
|
+
selector: target.selector,
|
|
611
|
+
block: target.block,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
225
615
|
async function cleanupPartialOutput(outPath) {
|
|
226
616
|
try {
|
|
227
617
|
await unlink(outPath);
|
|
@@ -232,14 +622,44 @@ async function cleanupPartialOutput(outPath) {
|
|
|
232
622
|
}
|
|
233
623
|
function assertWithinFrameLimit(frameCount) {
|
|
234
624
|
if (frameCount > MAX_TOTAL_FRAMES) {
|
|
235
|
-
throw new Error(`Total frame count ${frameCount} exceeds maximum ${MAX_TOTAL_FRAMES}. Reduce --fps, --duration, --page-gap, or the number of
|
|
625
|
+
throw new Error(`Total frame count ${frameCount} exceeds maximum ${MAX_TOTAL_FRAMES}. Reduce --fps, --duration, --page-gap, or the number of scenes.`);
|
|
236
626
|
}
|
|
237
627
|
}
|
|
238
628
|
function getReservedGapFrames(options) {
|
|
239
|
-
if (options.
|
|
629
|
+
if (!options.shouldWriteGap) {
|
|
240
630
|
return 0;
|
|
241
631
|
}
|
|
242
|
-
return Math.round(options.fps * options.
|
|
632
|
+
return Math.round(options.fps * options.gapSeconds);
|
|
633
|
+
}
|
|
634
|
+
function shouldWriteHoldAfterScene(job, isLastScene) {
|
|
635
|
+
if (!isLastScene) {
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
return job.includeHoldAfterFinalScene === true;
|
|
639
|
+
}
|
|
640
|
+
function serializeAction(action) {
|
|
641
|
+
switch (action.kind) {
|
|
642
|
+
case 'wait':
|
|
643
|
+
return { kind: action.kind, ms: action.ms };
|
|
644
|
+
case 'press':
|
|
645
|
+
return { kind: action.kind, key: action.key };
|
|
646
|
+
case 'click':
|
|
647
|
+
case 'hover':
|
|
648
|
+
return { kind: action.kind, selector: action.selector };
|
|
649
|
+
case 'type':
|
|
650
|
+
return {
|
|
651
|
+
kind: action.kind,
|
|
652
|
+
selector: action.selector,
|
|
653
|
+
textLength: action.text.length,
|
|
654
|
+
clear: action.clear,
|
|
655
|
+
};
|
|
656
|
+
case 'scroll-to':
|
|
657
|
+
return {
|
|
658
|
+
kind: action.kind,
|
|
659
|
+
selector: action.selector,
|
|
660
|
+
block: action.block,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
243
663
|
}
|
|
244
664
|
async function writeGapFrames(input) {
|
|
245
665
|
const { encoder, frame, fps, gapSeconds, debugFramesDir, frameOffset } = input;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const AUTO_DURATION_MIN_SECONDS = 4;
|
|
2
|
+
export declare const AUTO_DURATION_MAX_SECONDS = 40;
|
|
3
|
+
export declare const AUTO_DURATION_PIXELS_PER_SECOND = 800;
|
|
4
|
+
export declare const TIMELINE_AUTO_DURATION_MIN_SECONDS = 0.6;
|
|
5
|
+
export declare const TIMELINE_AUTO_DURATION_MAX_SECONDS = 12;
|
|
6
|
+
export declare const TIMELINE_AUTO_DURATION_PIXELS_PER_SECOND = 1400;
|
|
7
|
+
export declare const LOCALHOST_RETRY_INTERVAL_MS = 500;
|
|
8
|
+
export declare const STABILIZE_DELAY_MS = 500;
|
|
9
|
+
export declare const PREFLIGHT_STEP_DELAY_MS = 120;
|
|
10
|
+
export declare const PREFLIGHT_STABLE_ROUNDS = 3;
|
|
11
|
+
export declare const PREFLIGHT_MAX_SCROLL_HEIGHT = 30000;
|
|
12
|
+
export declare const MAX_TOTAL_FRAMES = 36000;
|
|
13
|
+
export declare const MAX_FPS = 120;
|
|
14
|
+
export declare const FONT_LOADING_TIMEOUT_MS = 10000;
|
|
15
|
+
export declare const MAX_PREFLIGHT_ITERATIONS = 20;
|