rollberry 0.1.8 → 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 +61 -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-install.js +1 -1
- package/dist/capture/browser.d.ts +13 -0
- package/dist/capture/browser.js +16 -10
- package/dist/capture/capture.d.ts +8 -0
- package/dist/capture/capture.js +566 -55
- package/dist/capture/constants.d.ts +15 -0
- package/dist/capture/constants.js +7 -0
- package/dist/capture/ffmpeg.d.ts +41 -0
- package/dist/capture/ffmpeg.js +457 -36
- package/dist/capture/logger.d.ts +15 -0
- package/dist/capture/preflight.d.ts +4 -0
- package/dist/capture/preflight.js +8 -2
- package/dist/capture/progress.d.ts +7 -0
- package/dist/capture/progress.js +50 -0
- package/dist/capture/scroll-plan.d.ts +9 -0
- package/dist/capture/scroll-plan.js +7 -1
- package/dist/capture/stabilize.d.ts +7 -0
- package/dist/capture/stabilize.js +11 -5
- package/dist/capture/types.d.ts +216 -0
- package/dist/capture/utils.d.ts +14 -0
- package/dist/capture/utils.js +30 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +86 -5
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/options.d.ts +42 -0
- package/dist/options.js +229 -115
- 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-capture.js +20 -10
- package/dist/run-render.d.ts +17 -0
- package/dist/run-render.js +434 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +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,116 +1,296 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
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 {
|
|
4
|
+
import { MAX_TOTAL_FRAMES, PREFLIGHT_MAX_SCROLL_HEIGHT } from './constants.js';
|
|
5
|
+
import { checkFfmpegAvailable, createVideoEncoder, } from './ffmpeg.js';
|
|
4
6
|
import { preflightMeasurePage } from './preflight.js';
|
|
5
|
-
import { buildScrollFrames, resolveDurationSeconds } from './scroll-plan.js';
|
|
7
|
+
import { buildScrollFrames, resolveDurationSeconds, resolveTimelineDurationSeconds, } from './scroll-plan.js';
|
|
6
8
|
import { stabilizePage } from './stabilize.js';
|
|
7
|
-
import { ensureDirectory, ensureParentDirectory, waitForAnimationFrames, } from './utils.js';
|
|
8
|
-
export async function captureVideo(options, logger) {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { clamp, ensureDirectory, ensureParentDirectory, fileExists, measurePage, sanitizeUrl, waitForAnimationFrames, } from './utils.js';
|
|
10
|
+
export async function captureVideo(options, logger, progress, signal) {
|
|
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.');
|
|
16
|
+
}
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
throw new AbortError();
|
|
19
|
+
}
|
|
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.`);
|
|
11
22
|
}
|
|
23
|
+
await checkFfmpegAvailable();
|
|
12
24
|
await Promise.all([
|
|
13
|
-
ensureParentDirectory(
|
|
14
|
-
|
|
15
|
-
ensureParentDirectory(options.manifestPath),
|
|
16
|
-
options.debugFramesDir
|
|
17
|
-
? ensureDirectory(options.debugFramesDir)
|
|
18
|
-
: undefined,
|
|
25
|
+
ensureParentDirectory(job.outPath),
|
|
26
|
+
job.debugFramesDir ? ensureDirectory(job.debugFramesDir) : undefined,
|
|
19
27
|
]);
|
|
20
|
-
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);
|
|
33
|
+
let encoder;
|
|
34
|
+
let encodingFinished = false;
|
|
35
|
+
let browserClosed = false;
|
|
36
|
+
const closeBrowser = async () => {
|
|
37
|
+
if (browserClosed) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
browserClosed = true;
|
|
41
|
+
try {
|
|
42
|
+
await browser.close();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Browser may already be closed during cancellation
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const abortHandler = () => {
|
|
49
|
+
void encoder?.abort();
|
|
50
|
+
void closeBrowser();
|
|
51
|
+
};
|
|
52
|
+
signal?.addEventListener('abort', abortHandler);
|
|
21
53
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
54
|
+
if (signal?.aborted) {
|
|
55
|
+
throw new AbortError();
|
|
56
|
+
}
|
|
57
|
+
encoder = await createVideoEncoder({
|
|
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,
|
|
25
65
|
});
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
throw new AbortError();
|
|
68
|
+
}
|
|
26
69
|
const pages = [];
|
|
27
70
|
let totalFrameCount = 0;
|
|
28
71
|
let totalDurationSeconds = 0;
|
|
29
72
|
let frameOffset = 0;
|
|
30
73
|
let anyTruncated = false;
|
|
31
|
-
for (const [
|
|
74
|
+
for (const [sceneIndex, scene] of job.scenes.entries()) {
|
|
75
|
+
if (signal?.aborted) {
|
|
76
|
+
throw new AbortError();
|
|
77
|
+
}
|
|
32
78
|
const { pageResult, lastFrame } = await capturePageFrames({
|
|
33
79
|
page,
|
|
34
|
-
|
|
80
|
+
scene,
|
|
81
|
+
sceneIndex,
|
|
82
|
+
totalScenes: job.scenes.length,
|
|
35
83
|
encoder,
|
|
36
|
-
|
|
84
|
+
job,
|
|
37
85
|
logger,
|
|
86
|
+
progress,
|
|
87
|
+
signal,
|
|
38
88
|
frameOffset,
|
|
39
|
-
urlIndex,
|
|
40
89
|
});
|
|
41
90
|
pages.push(pageResult);
|
|
42
91
|
totalFrameCount += pageResult.frameCount;
|
|
43
92
|
totalDurationSeconds += pageResult.durationSeconds;
|
|
44
93
|
frameOffset += pageResult.frameCount;
|
|
45
94
|
anyTruncated = anyTruncated || pageResult.truncated;
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
95
|
+
const isLastScene = sceneIndex === job.scenes.length - 1;
|
|
96
|
+
if (scene.holdAfterSeconds > 0 &&
|
|
97
|
+
shouldWriteHoldAfterScene(job, isLastScene)) {
|
|
48
98
|
const gapFrameCount = await writeGapFrames({
|
|
49
99
|
encoder,
|
|
50
100
|
frame: lastFrame,
|
|
51
|
-
fps:
|
|
52
|
-
gapSeconds:
|
|
53
|
-
debugFramesDir:
|
|
101
|
+
fps: job.fps,
|
|
102
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
103
|
+
debugFramesDir: job.debugFramesDir,
|
|
54
104
|
frameOffset,
|
|
55
105
|
});
|
|
56
106
|
totalFrameCount += gapFrameCount;
|
|
57
|
-
totalDurationSeconds += gapFrameCount /
|
|
107
|
+
totalDurationSeconds += gapFrameCount / job.fps;
|
|
58
108
|
frameOffset += gapFrameCount;
|
|
59
109
|
}
|
|
60
110
|
}
|
|
61
111
|
await encoder.finish();
|
|
112
|
+
encodingFinished = true;
|
|
113
|
+
progress?.onEncodeComplete();
|
|
62
114
|
await logger.info('encode.complete', 'Video encoding finished', {
|
|
63
|
-
outPath:
|
|
115
|
+
outPath: job.outPath,
|
|
64
116
|
});
|
|
65
117
|
return {
|
|
66
|
-
outPath:
|
|
118
|
+
outPath: job.outPath,
|
|
67
119
|
frameCount: totalFrameCount,
|
|
68
120
|
durationSeconds: totalDurationSeconds,
|
|
69
121
|
pages,
|
|
70
122
|
truncated: anyTruncated,
|
|
71
123
|
};
|
|
72
124
|
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const failure = signal?.aborted && !(error instanceof AbortError)
|
|
127
|
+
? new AbortError()
|
|
128
|
+
: error;
|
|
129
|
+
if (encoder && !encodingFinished) {
|
|
130
|
+
await encoder.abort();
|
|
131
|
+
await cleanupPartialOutput(job.outPath);
|
|
132
|
+
}
|
|
133
|
+
throw failure;
|
|
134
|
+
}
|
|
73
135
|
finally {
|
|
74
|
-
|
|
136
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
137
|
+
await closeBrowser();
|
|
75
138
|
}
|
|
76
139
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
140
|
+
export class AbortError extends Error {
|
|
141
|
+
constructor() {
|
|
142
|
+
super('Capture was cancelled.');
|
|
143
|
+
this.name = 'AbortError';
|
|
144
|
+
}
|
|
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',
|
|
82
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
|
+
}
|
|
173
|
+
async function capturePageFrames(input) {
|
|
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,
|
|
180
|
+
url: safeUrl,
|
|
181
|
+
viewport: job.viewport,
|
|
83
182
|
});
|
|
84
|
-
await navigateWithRetry(page, url,
|
|
183
|
+
await navigateWithRetry(page, scene.url, job.timeoutMs);
|
|
85
184
|
await stabilizePage({
|
|
86
185
|
page,
|
|
87
|
-
waitFor:
|
|
88
|
-
hideSelectors:
|
|
186
|
+
waitFor: scene.waitFor,
|
|
187
|
+
hideSelectors: scene.hideSelectors,
|
|
89
188
|
});
|
|
90
|
-
|
|
91
|
-
|
|
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;
|
|
258
|
+
const preflight = await preflightMeasurePage(page, logger);
|
|
259
|
+
const plannedDurationSeconds = resolveDurationSeconds(scene.duration, preflight.maxScroll);
|
|
92
260
|
const frames = buildScrollFrames({
|
|
93
|
-
fps:
|
|
261
|
+
fps: job.fps,
|
|
94
262
|
durationSeconds: plannedDurationSeconds,
|
|
95
263
|
maxScroll: preflight.maxScroll,
|
|
96
|
-
motion:
|
|
264
|
+
motion: scene.motion,
|
|
97
265
|
});
|
|
98
|
-
|
|
99
|
-
|
|
266
|
+
assertWithinFrameLimit(frameOffset +
|
|
267
|
+
frames.length +
|
|
268
|
+
getReservedGapFrames({
|
|
269
|
+
fps: job.fps,
|
|
270
|
+
gapSeconds: scene.holdAfterSeconds,
|
|
271
|
+
shouldWriteGap: shouldWriteHoldAfterScene(job, sceneIndex === totalScenes - 1),
|
|
272
|
+
}));
|
|
273
|
+
const durationSeconds = frames.length / job.fps;
|
|
274
|
+
await logger.info('render.start', `Rendering frames for scene ${sceneIndex + 1}`, {
|
|
275
|
+
name: scene.name,
|
|
100
276
|
frameCount: frames.length,
|
|
101
|
-
fps:
|
|
277
|
+
fps: job.fps,
|
|
102
278
|
durationSeconds,
|
|
103
279
|
maxScroll: preflight.maxScroll,
|
|
104
|
-
url:
|
|
280
|
+
url: safeUrl,
|
|
105
281
|
});
|
|
106
282
|
if (preflight.truncated) {
|
|
107
283
|
await logger.warn('preflight.truncated', 'Scroll height exceeded capture limit and was truncated', {
|
|
284
|
+
name: scene.name,
|
|
108
285
|
scrollHeight: preflight.scrollHeight,
|
|
109
|
-
url:
|
|
286
|
+
url: safeUrl,
|
|
110
287
|
});
|
|
111
288
|
}
|
|
112
289
|
let lastFrame;
|
|
113
290
|
for (const [index, scrollTop] of frames.entries()) {
|
|
291
|
+
if (signal?.aborted) {
|
|
292
|
+
throw new AbortError();
|
|
293
|
+
}
|
|
114
294
|
await page.evaluate((nextScrollTop) => {
|
|
115
295
|
window.scrollTo({ top: nextScrollTop, behavior: 'auto' });
|
|
116
296
|
}, scrollTop);
|
|
@@ -122,26 +302,30 @@ async function capturePageFrames(input) {
|
|
|
122
302
|
caret: 'hide',
|
|
123
303
|
});
|
|
124
304
|
lastFrame = frame;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
305
|
+
await writeFrameToOutput({
|
|
306
|
+
encoder,
|
|
307
|
+
frame,
|
|
308
|
+
debugFramesDir: job.debugFramesDir,
|
|
309
|
+
frameNumber: frameOffset + index,
|
|
310
|
+
});
|
|
311
|
+
progress?.onFrameRendered(index, frames.length);
|
|
130
312
|
if ((index + 1) % Math.max(1, Math.floor(frames.length / 10)) === 0) {
|
|
131
313
|
await logger.info('render.progress', 'Rendered frame batch', {
|
|
314
|
+
name: scene.name,
|
|
132
315
|
renderedFrames: index + 1,
|
|
133
316
|
totalFrames: frames.length,
|
|
134
317
|
scrollTop,
|
|
135
|
-
url:
|
|
318
|
+
url: safeUrl,
|
|
136
319
|
});
|
|
137
320
|
}
|
|
138
321
|
}
|
|
139
322
|
if (!lastFrame) {
|
|
140
|
-
throw new Error(
|
|
323
|
+
throw new Error(`Failed to capture frames from page: ${safeUrl}`);
|
|
141
324
|
}
|
|
142
325
|
return {
|
|
143
326
|
pageResult: {
|
|
144
|
-
|
|
327
|
+
name: scene.name,
|
|
328
|
+
url: safeUrl,
|
|
145
329
|
frameCount: frames.length,
|
|
146
330
|
durationSeconds,
|
|
147
331
|
scrollHeight: preflight.scrollHeight,
|
|
@@ -150,6 +334,333 @@ async function capturePageFrames(input) {
|
|
|
150
334
|
lastFrame,
|
|
151
335
|
};
|
|
152
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
|
+
}
|
|
615
|
+
async function cleanupPartialOutput(outPath) {
|
|
616
|
+
try {
|
|
617
|
+
await unlink(outPath);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
// Partial output may not exist
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function assertWithinFrameLimit(frameCount) {
|
|
624
|
+
if (frameCount > MAX_TOTAL_FRAMES) {
|
|
625
|
+
throw new Error(`Total frame count ${frameCount} exceeds maximum ${MAX_TOTAL_FRAMES}. Reduce --fps, --duration, --page-gap, or the number of scenes.`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function getReservedGapFrames(options) {
|
|
629
|
+
if (!options.shouldWriteGap) {
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
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
|
+
}
|
|
663
|
+
}
|
|
153
664
|
async function writeGapFrames(input) {
|
|
154
665
|
const { encoder, frame, fps, gapSeconds, debugFramesDir, frameOffset } = input;
|
|
155
666
|
const gapFrameCount = Math.round(fps * gapSeconds);
|