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.
Files changed (45) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +189 -7
  3. package/dist/capture/actions.d.ts +11 -0
  4. package/dist/capture/actions.js +81 -0
  5. package/dist/capture/browser-install.d.ts +3 -0
  6. package/dist/capture/browser-install.js +1 -1
  7. package/dist/capture/browser.d.ts +13 -0
  8. package/dist/capture/browser.js +16 -10
  9. package/dist/capture/capture.d.ts +8 -0
  10. package/dist/capture/capture.js +566 -55
  11. package/dist/capture/constants.d.ts +15 -0
  12. package/dist/capture/constants.js +7 -0
  13. package/dist/capture/ffmpeg.d.ts +41 -0
  14. package/dist/capture/ffmpeg.js +457 -36
  15. package/dist/capture/logger.d.ts +15 -0
  16. package/dist/capture/preflight.d.ts +4 -0
  17. package/dist/capture/preflight.js +8 -2
  18. package/dist/capture/progress.d.ts +7 -0
  19. package/dist/capture/progress.js +50 -0
  20. package/dist/capture/scroll-plan.d.ts +9 -0
  21. package/dist/capture/scroll-plan.js +7 -1
  22. package/dist/capture/stabilize.d.ts +7 -0
  23. package/dist/capture/stabilize.js +11 -5
  24. package/dist/capture/types.d.ts +216 -0
  25. package/dist/capture/utils.d.ts +14 -0
  26. package/dist/capture/utils.js +30 -3
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +86 -5
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.js +6 -0
  31. package/dist/options.d.ts +42 -0
  32. package/dist/options.js +229 -115
  33. package/dist/project.d.ts +27 -0
  34. package/dist/project.js +722 -0
  35. package/dist/render-plan.d.ts +103 -0
  36. package/dist/render-plan.js +144 -0
  37. package/dist/run-capture.d.ts +3 -0
  38. package/dist/run-capture.js +20 -10
  39. package/dist/run-render.d.ts +17 -0
  40. package/dist/run-render.js +434 -0
  41. package/dist/version.d.ts +1 -0
  42. package/dist/version.js +1 -0
  43. package/package.json +10 -3
  44. package/rollberry.project.sample.json +92 -0
  45. package/rollberry.project.schema.json +474 -0
@@ -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 { createVideoEncoder } from './ffmpeg.js';
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
- if (options.urls.length === 0) {
10
- throw new Error('少なくとも1つのURLを指定してください。');
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(options.outPath),
14
- ensureParentDirectory(options.logFilePath),
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(options, logger);
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
- const encoder = await createVideoEncoder({
23
- fps: options.fps,
24
- outPath: options.outPath,
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 [urlIndex, url] of options.urls.entries()) {
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
- url,
80
+ scene,
81
+ sceneIndex,
82
+ totalScenes: job.scenes.length,
35
83
  encoder,
36
- options,
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 isLastPage = urlIndex === options.urls.length - 1;
47
- if (options.pageGapSeconds > 0 && !isLastPage) {
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: options.fps,
52
- gapSeconds: options.pageGapSeconds,
53
- debugFramesDir: options.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 / options.fps;
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: options.outPath,
115
+ outPath: job.outPath,
64
116
  });
65
117
  return {
66
- outPath: options.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
- await browser.close();
136
+ signal?.removeEventListener('abort', abortHandler);
137
+ await closeBrowser();
75
138
  }
76
139
  }
77
- async function capturePageFrames(input) {
78
- const { page, url, encoder, options, logger, urlIndex } = input;
79
- const { frameOffset } = input;
80
- await logger.info('browser.open', `Opening page ${urlIndex + 1}`, {
81
- url: url.toString(),
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, options.timeoutMs);
183
+ await navigateWithRetry(page, scene.url, job.timeoutMs);
85
184
  await stabilizePage({
86
185
  page,
87
- waitFor: options.waitFor,
88
- hideSelectors: options.hideSelectors,
186
+ waitFor: scene.waitFor,
187
+ hideSelectors: scene.hideSelectors,
89
188
  });
90
- const preflight = await preflightMeasurePage(page);
91
- const plannedDurationSeconds = resolveDurationSeconds(options.duration, preflight.maxScroll);
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: options.fps,
261
+ fps: job.fps,
94
262
  durationSeconds: plannedDurationSeconds,
95
263
  maxScroll: preflight.maxScroll,
96
- motion: options.motion,
264
+ motion: scene.motion,
97
265
  });
98
- const durationSeconds = frames.length / options.fps;
99
- await logger.info('render.start', `Rendering frames for page ${urlIndex + 1}`, {
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: options.fps,
277
+ fps: job.fps,
102
278
  durationSeconds,
103
279
  maxScroll: preflight.maxScroll,
104
- url: url.toString(),
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: url.toString(),
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
- if (options.debugFramesDir) {
126
- const fileName = `${String(frameOffset + index).padStart(5, '0')}.png`;
127
- await writeFile(`${options.debugFramesDir}/${fileName}`, frame);
128
- }
129
- await encoder.writeFrame(frame);
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: url.toString(),
318
+ url: safeUrl,
136
319
  });
137
320
  }
138
321
  }
139
322
  if (!lastFrame) {
140
- throw new Error(`ページのフレーム取得に失敗しました: ${url.toString()}`);
323
+ throw new Error(`Failed to capture frames from page: ${safeUrl}`);
141
324
  }
142
325
  return {
143
326
  pageResult: {
144
- url: url.toString(),
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);