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.
@@ -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
- if (options.urls.length === 0) {
11
- throw new Error('At least one URL is required.');
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 (!options.force && (await fileExists(options.outPath))) {
17
- throw new Error(`Output file already exists: ${options.outPath}\nUse --force to overwrite, or specify a different --out path.`);
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(options.outPath),
22
- ensureParentDirectory(options.logFilePath),
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(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);
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: options.fps,
55
- outPath: options.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 [urlIndex, url] of options.urls.entries()) {
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
- url,
80
+ scene,
81
+ sceneIndex,
82
+ totalScenes: job.scenes.length,
72
83
  encoder,
73
- options,
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 isLastPage = urlIndex === options.urls.length - 1;
86
- if (options.pageGapSeconds > 0 && !isLastPage) {
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: options.fps,
91
- gapSeconds: options.pageGapSeconds,
92
- debugFramesDir: options.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 / options.fps;
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: options.outPath,
115
+ outPath: job.outPath,
105
116
  });
106
117
  return {
107
- outPath: options.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(options.outPath);
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, url, encoder, options, logger, progress, signal, urlIndex } = input;
137
- const { frameOffset } = input;
138
- const safeUrl = sanitizeUrl(url);
139
- progress?.onPageStart(urlIndex, options.urls.length, safeUrl);
140
- await logger.info('browser.open', `Opening page ${urlIndex + 1}`, {
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: options.viewport,
181
+ viewport: job.viewport,
143
182
  });
144
- await navigateWithRetry(page, url, options.timeoutMs);
183
+ await navigateWithRetry(page, scene.url, job.timeoutMs);
145
184
  await stabilizePage({
146
185
  page,
147
- waitFor: options.waitFor,
148
- hideSelectors: options.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(options.duration, preflight.maxScroll);
259
+ const plannedDurationSeconds = resolveDurationSeconds(scene.duration, preflight.maxScroll);
152
260
  const frames = buildScrollFrames({
153
- fps: options.fps,
261
+ fps: job.fps,
154
262
  durationSeconds: plannedDurationSeconds,
155
263
  maxScroll: preflight.maxScroll,
156
- motion: options.motion,
264
+ motion: scene.motion,
157
265
  });
158
266
  assertWithinFrameLimit(frameOffset +
159
267
  frames.length +
160
268
  getReservedGapFrames({
161
- fps: options.fps,
162
- pageGapSeconds: options.pageGapSeconds,
163
- isLastPage: urlIndex === options.urls.length - 1,
269
+ fps: job.fps,
270
+ gapSeconds: scene.holdAfterSeconds,
271
+ shouldWriteGap: shouldWriteHoldAfterScene(job, sceneIndex === totalScenes - 1),
164
272
  }));
165
- const durationSeconds = frames.length / options.fps;
166
- await logger.info('render.start', `Rendering frames for page ${urlIndex + 1}`, {
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: options.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
- if (options.debugFramesDir) {
196
- const fileName = `${String(frameOffset + index).padStart(5, '0')}.png`;
197
- await writeFile(`${options.debugFramesDir}/${fileName}`, frame);
198
- }
199
- await encoder.writeFrame(frame);
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 pages.`);
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.isLastPage) {
629
+ if (!options.shouldWriteGap) {
240
630
  return 0;
241
631
  }
242
- return Math.round(options.fps * options.pageGapSeconds);
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;