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,6 +1,9 @@
1
1
  export const AUTO_DURATION_MIN_SECONDS = 4;
2
2
  export const AUTO_DURATION_MAX_SECONDS = 40;
3
3
  export const AUTO_DURATION_PIXELS_PER_SECOND = 800;
4
+ export const TIMELINE_AUTO_DURATION_MIN_SECONDS = 0.6;
5
+ export const TIMELINE_AUTO_DURATION_MAX_SECONDS = 12;
6
+ export const TIMELINE_AUTO_DURATION_PIXELS_PER_SECOND = 1_400;
4
7
  export const LOCALHOST_RETRY_INTERVAL_MS = 500;
5
8
  export const STABILIZE_DELAY_MS = 500;
6
9
  export const PREFLIGHT_STEP_DELAY_MS = 120;
@@ -0,0 +1,41 @@
1
+ import type { CaptureAudioTrack, CaptureSubtitleTrack, CaptureTransition, CaptureVideoEncodingSettings, FinalVideoEncodingSettings, OutputFormat } from './types.js';
2
+ export interface VideoEncoder {
3
+ writeFrame(frame: Buffer): Promise<void>;
4
+ finish(): Promise<void>;
5
+ abort(): Promise<void>;
6
+ }
7
+ export interface ComposedVideoClip {
8
+ path: string;
9
+ durationSeconds: number;
10
+ }
11
+ export interface VideoProbeResult {
12
+ durationSeconds: number;
13
+ frameCount?: number;
14
+ }
15
+ export interface VideoProbeOutcome {
16
+ status: 'probed' | 'unavailable' | 'failed' | 'invalid';
17
+ result?: VideoProbeResult;
18
+ warning?: string;
19
+ }
20
+ export declare function checkFfmpegAvailable(): Promise<void>;
21
+ export declare function createVideoEncoder(options: {
22
+ fps: number;
23
+ outPath: string;
24
+ format: OutputFormat;
25
+ audio?: CaptureAudioTrack;
26
+ subtitles?: CaptureSubtitleTrack;
27
+ transition?: CaptureTransition;
28
+ videoEncoding?: CaptureVideoEncodingSettings;
29
+ }): Promise<VideoEncoder>;
30
+ export declare function composeVideoClips(options: {
31
+ clips: [ComposedVideoClip, ...ComposedVideoClip[]];
32
+ outPath: string;
33
+ format: OutputFormat;
34
+ fps: number;
35
+ audio?: CaptureAudioTrack;
36
+ subtitles?: CaptureSubtitleTrack;
37
+ transition?: CaptureTransition;
38
+ finalVideo: FinalVideoEncodingSettings;
39
+ force: boolean;
40
+ }): Promise<void>;
41
+ export declare function probeVideoFile(filePath: string): Promise<VideoProbeOutcome>;
@@ -1,5 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
+ import { promisify } from 'node:util';
2
3
  const FFMPEG_ABORT_TIMEOUT_MS = 1_000;
4
+ const execFileAsync = promisify(execFile);
3
5
  export async function checkFfmpegAvailable() {
4
6
  await new Promise((resolve, reject) => {
5
7
  execFile('ffmpeg', ['-version'], (error) => {
@@ -12,34 +14,7 @@ export async function checkFfmpegAvailable() {
12
14
  });
13
15
  }
14
16
  export async function createVideoEncoder(options) {
15
- const ffmpeg = spawn('ffmpeg', [
16
- '-hide_banner',
17
- '-loglevel',
18
- 'error',
19
- '-y',
20
- '-f',
21
- 'image2pipe',
22
- '-framerate',
23
- String(options.fps),
24
- '-c:v',
25
- 'png',
26
- '-i',
27
- 'pipe:0',
28
- '-an',
29
- '-c:v',
30
- 'libx264',
31
- '-pix_fmt',
32
- 'yuv420p',
33
- '-preset',
34
- 'slow',
35
- '-crf',
36
- '18',
37
- '-movflags',
38
- '+faststart',
39
- '-vf',
40
- 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
41
- options.outPath,
42
- ], {
17
+ const ffmpeg = spawn('ffmpeg', buildStreamingFfmpegArgs(options), {
43
18
  stdio: ['pipe', 'ignore', 'pipe'],
44
19
  });
45
20
  let spawnError;
@@ -129,6 +104,377 @@ export async function createVideoEncoder(options) {
129
104
  },
130
105
  };
131
106
  }
107
+ export async function composeVideoClips(options) {
108
+ await runFfmpeg(buildComposeFfmpegArgs(options));
109
+ }
110
+ export async function probeVideoFile(filePath) {
111
+ try {
112
+ const { stdout } = await execFileAsync('ffprobe', [
113
+ '-v',
114
+ 'error',
115
+ '-count_frames',
116
+ '-print_format',
117
+ 'json',
118
+ '-show_format',
119
+ '-show_streams',
120
+ filePath,
121
+ ]);
122
+ const parsed = JSON.parse(formatExecBuffer(stdout));
123
+ const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video');
124
+ const durationSeconds = parseFiniteNumber(parsed.format?.duration ?? videoStream?.duration);
125
+ if (durationSeconds === undefined) {
126
+ return {
127
+ status: 'invalid',
128
+ warning: `ffprobe did not return a usable duration for: ${filePath}`,
129
+ };
130
+ }
131
+ return {
132
+ status: 'probed',
133
+ result: {
134
+ durationSeconds,
135
+ frameCount: parseFrameCount(videoStream?.nb_read_frames ?? videoStream?.nb_frames),
136
+ },
137
+ };
138
+ }
139
+ catch (error) {
140
+ if (error &&
141
+ typeof error === 'object' &&
142
+ 'code' in error &&
143
+ error.code === 'ENOENT') {
144
+ return {
145
+ status: 'unavailable',
146
+ warning: 'ffprobe is not available on PATH; falling back to estimated metrics.',
147
+ };
148
+ }
149
+ return {
150
+ status: 'failed',
151
+ warning: error instanceof Error
152
+ ? `ffprobe failed for ${filePath}: ${error.message}`
153
+ : `ffprobe failed for ${filePath}.`,
154
+ };
155
+ }
156
+ }
157
+ function buildStreamingFfmpegArgs(options) {
158
+ const args = [
159
+ '-hide_banner',
160
+ '-loglevel',
161
+ 'error',
162
+ '-y',
163
+ '-f',
164
+ 'image2pipe',
165
+ '-framerate',
166
+ String(options.fps),
167
+ '-c:v',
168
+ 'png',
169
+ '-i',
170
+ 'pipe:0',
171
+ ];
172
+ let nextInputIndex = 1;
173
+ let audioInputIndex;
174
+ let subtitleInputIndex;
175
+ if (options.audio) {
176
+ if (options.audio.loop) {
177
+ args.push('-stream_loop', '-1');
178
+ }
179
+ args.push('-i', options.audio.sourcePath);
180
+ audioInputIndex = nextInputIndex;
181
+ nextInputIndex += 1;
182
+ }
183
+ if (options.subtitles?.mode === 'soft') {
184
+ args.push('-i', options.subtitles.sourcePath);
185
+ subtitleInputIndex = nextInputIndex;
186
+ nextInputIndex += 1;
187
+ }
188
+ args.push('-map', '0:v:0');
189
+ if (audioInputIndex !== undefined) {
190
+ args.push('-map', `${audioInputIndex}:a:0`);
191
+ }
192
+ if (subtitleInputIndex !== undefined) {
193
+ args.push('-map', `${subtitleInputIndex}:s:0`);
194
+ }
195
+ if (options.audio) {
196
+ args.push('-af', buildAudioFilter(options.audio));
197
+ args.push('-shortest');
198
+ }
199
+ else {
200
+ args.push('-an');
201
+ }
202
+ args.push('-vf', buildVideoFilter(options.subtitles, options.transition));
203
+ args.push(...buildIntermediateFormatArgs(options.format, options.audio, options.subtitles, options.videoEncoding));
204
+ args.push(options.outPath);
205
+ return args;
206
+ }
207
+ function buildComposeFfmpegArgs(options) {
208
+ const args = [
209
+ '-hide_banner',
210
+ '-loglevel',
211
+ 'error',
212
+ options.force ? '-y' : '-n',
213
+ ];
214
+ for (const clip of options.clips) {
215
+ args.push('-i', clip.path);
216
+ }
217
+ let nextInputIndex = options.clips.length;
218
+ let audioInputIndex;
219
+ let subtitleInputIndex;
220
+ if (options.audio) {
221
+ if (options.audio.loop) {
222
+ args.push('-stream_loop', '-1');
223
+ }
224
+ args.push('-i', options.audio.sourcePath);
225
+ audioInputIndex = nextInputIndex;
226
+ nextInputIndex += 1;
227
+ }
228
+ if (options.subtitles?.mode === 'soft') {
229
+ args.push('-i', options.subtitles.sourcePath);
230
+ subtitleInputIndex = nextInputIndex;
231
+ }
232
+ const filterComplex = buildComposeFilterComplex({
233
+ clips: options.clips,
234
+ subtitles: options.subtitles,
235
+ transition: options.transition,
236
+ });
237
+ if (filterComplex) {
238
+ args.push('-filter_complex', filterComplex, '-map', '[video_out]');
239
+ }
240
+ else {
241
+ args.push('-map', '0:v:0');
242
+ }
243
+ if (audioInputIndex !== undefined) {
244
+ const audio = options.audio;
245
+ if (!audio) {
246
+ throw new Error('Audio input index was set without an audio track.');
247
+ }
248
+ args.push('-map', `${audioInputIndex}:a:0`);
249
+ args.push('-af', buildAudioFilter(audio));
250
+ args.push('-shortest');
251
+ }
252
+ else {
253
+ args.push('-an');
254
+ }
255
+ if (subtitleInputIndex !== undefined) {
256
+ args.push('-map', `${subtitleInputIndex}:s:0`);
257
+ }
258
+ args.push('-r', String(options.fps));
259
+ args.push(...buildFinalFormatArgs(options.format, options.audio, options.subtitles, options.finalVideo));
260
+ args.push(options.outPath);
261
+ return args;
262
+ }
263
+ function buildComposeFilterComplex(options) {
264
+ const segments = [];
265
+ const crossfade = options.transition?.kind === 'crossfade' && options.clips.length > 1
266
+ ? options.transition
267
+ : undefined;
268
+ let currentLabel;
269
+ if (crossfade) {
270
+ validateCrossfadeDuration(options.clips, crossfade.durationSeconds);
271
+ for (const [index] of options.clips.entries()) {
272
+ segments.push(`[${index}:v:0]format=yuv420p,setsar=1[v${index}]`);
273
+ }
274
+ currentLabel = '[v0]';
275
+ let accumulatedDuration = options.clips[0].durationSeconds;
276
+ for (let index = 1; index < options.clips.length; index += 1) {
277
+ const outputLabel = `[xfade${index}]`;
278
+ const offset = accumulatedDuration - crossfade.durationSeconds * index;
279
+ segments.push(`${currentLabel}[v${index}]xfade=transition=fade:duration=${formatFilterNumber(crossfade.durationSeconds)}:offset=${formatFilterNumber(offset)}${outputLabel}`);
280
+ currentLabel = outputLabel;
281
+ accumulatedDuration += options.clips[index].durationSeconds;
282
+ }
283
+ }
284
+ else if (options.clips.length > 1) {
285
+ currentLabel = '[concat_video]';
286
+ segments.push(`${options.clips
287
+ .map((_, index) => `[${index}:v:0]`)
288
+ .join('')}concat=n=${options.clips.length}:v=1:a=0${currentLabel}`);
289
+ }
290
+ else {
291
+ currentLabel = '[0:v:0]';
292
+ }
293
+ const postFilters = ['pad=ceil(iw/2)*2:ceil(ih/2)*2'];
294
+ if (options.subtitles?.mode === 'burn-in') {
295
+ postFilters.push(buildBurnInSubtitleFilter(options.subtitles));
296
+ }
297
+ if (options.transition?.kind === 'fade-in') {
298
+ postFilters.push(`fade=t=in:st=0:d=${formatFilterNumber(options.transition.durationSeconds)}`);
299
+ }
300
+ if (postFilters.length === 0) {
301
+ return segments.length > 0 ? segments.join(';') : undefined;
302
+ }
303
+ segments.push(`${currentLabel}${postFilters.join(',')}[video_out]`);
304
+ return segments.join(';');
305
+ }
306
+ function buildVideoFilter(subtitles, transition) {
307
+ const filters = ['pad=ceil(iw/2)*2:ceil(ih/2)*2'];
308
+ if (subtitles?.mode === 'burn-in') {
309
+ filters.push(buildBurnInSubtitleFilter(subtitles));
310
+ }
311
+ if (transition?.kind === 'fade-in') {
312
+ filters.push(`fade=t=in:st=0:d=${formatFilterNumber(transition.durationSeconds)}`);
313
+ }
314
+ return filters.join(',');
315
+ }
316
+ function buildBurnInSubtitleFilter(subtitles) {
317
+ return `subtitles=filename='${escapeFfmpegFilterString(subtitles.sourcePath)}'`;
318
+ }
319
+ function buildAudioFilter(audio) {
320
+ return `volume=${audio.volume.toFixed(3)},apad`;
321
+ }
322
+ function buildIntermediateFormatArgs(format, audio, subtitles, videoEncoding) {
323
+ switch (format) {
324
+ case 'mp4': {
325
+ const args = [
326
+ '-c:v',
327
+ 'libx264',
328
+ '-pix_fmt',
329
+ 'yuv420p',
330
+ '-preset',
331
+ videoEncoding?.preset ?? 'slow',
332
+ '-crf',
333
+ String(videoEncoding?.crf ?? 18),
334
+ '-movflags',
335
+ '+faststart',
336
+ ];
337
+ if (audio) {
338
+ args.push('-c:a', 'aac', '-b:a', '192k');
339
+ }
340
+ if (subtitles?.mode === 'soft') {
341
+ args.push('-c:s', 'mov_text');
342
+ }
343
+ return args;
344
+ }
345
+ case 'webm': {
346
+ const args = [
347
+ '-c:v',
348
+ 'libvpx-vp9',
349
+ '-pix_fmt',
350
+ 'yuv420p',
351
+ '-row-mt',
352
+ '1',
353
+ '-deadline',
354
+ 'good',
355
+ '-crf',
356
+ '32',
357
+ '-b:v',
358
+ '0',
359
+ ];
360
+ if (audio) {
361
+ args.push('-c:a', 'libopus', '-b:a', '128k');
362
+ }
363
+ if (subtitles?.mode === 'soft') {
364
+ args.push('-c:s', 'webvtt');
365
+ }
366
+ return args;
367
+ }
368
+ }
369
+ }
370
+ function buildFinalFormatArgs(format, audio, subtitles, videoEncoding) {
371
+ switch (format) {
372
+ case 'mp4': {
373
+ if (videoEncoding.format !== 'mp4') {
374
+ throw new Error('Final video settings do not match mp4 output format.');
375
+ }
376
+ const args = [
377
+ '-c:v',
378
+ 'libx264',
379
+ '-pix_fmt',
380
+ 'yuv420p',
381
+ '-preset',
382
+ videoEncoding.preset,
383
+ '-crf',
384
+ String(videoEncoding.crf),
385
+ '-movflags',
386
+ '+faststart',
387
+ ];
388
+ if (audio) {
389
+ args.push('-c:a', 'aac', '-b:a', '192k');
390
+ }
391
+ if (subtitles?.mode === 'soft') {
392
+ args.push('-c:s', 'mov_text');
393
+ }
394
+ return args;
395
+ }
396
+ case 'webm': {
397
+ if (videoEncoding.format !== 'webm') {
398
+ throw new Error('Final video settings do not match webm output format.');
399
+ }
400
+ const args = [
401
+ '-c:v',
402
+ 'libvpx-vp9',
403
+ '-pix_fmt',
404
+ 'yuv420p',
405
+ '-row-mt',
406
+ '1',
407
+ '-deadline',
408
+ videoEncoding.deadline,
409
+ '-crf',
410
+ String(videoEncoding.crf),
411
+ '-b:v',
412
+ '0',
413
+ ];
414
+ if (audio) {
415
+ args.push('-c:a', 'libopus', '-b:a', '128k');
416
+ }
417
+ if (subtitles?.mode === 'soft') {
418
+ args.push('-c:s', 'webvtt');
419
+ }
420
+ return args;
421
+ }
422
+ }
423
+ }
424
+ async function runFfmpeg(args) {
425
+ try {
426
+ await execFileAsync('ffmpeg', args);
427
+ }
428
+ catch (error) {
429
+ if (error instanceof Error) {
430
+ const stderr = 'stderr' in error ? formatExecBuffer(error.stderr) : '';
431
+ throw createEncoderError(error, stderr);
432
+ }
433
+ throw error;
434
+ }
435
+ }
436
+ function validateCrossfadeDuration(clips, durationSeconds) {
437
+ for (let index = 0; index < clips.length - 1; index += 1) {
438
+ const left = clips[index];
439
+ const right = clips[index + 1];
440
+ const maxDuration = Math.min(left.durationSeconds, right.durationSeconds);
441
+ if (durationSeconds >= maxDuration) {
442
+ throw new Error(`Crossfade duration ${durationSeconds.toFixed(3)}s is too long for scene pair ${index + 1}-${index + 2}. Each adjacent scene must be longer than the crossfade.`);
443
+ }
444
+ }
445
+ }
446
+ function escapeFfmpegFilterString(value) {
447
+ return value
448
+ .replaceAll('\\', '\\\\')
449
+ .replaceAll(':', '\\:')
450
+ .replaceAll("'", "\\'");
451
+ }
452
+ function formatFilterNumber(value) {
453
+ return value.toFixed(3);
454
+ }
455
+ function formatExecBuffer(value) {
456
+ if (typeof value === 'string') {
457
+ return value.trim();
458
+ }
459
+ if (Buffer.isBuffer(value)) {
460
+ return value.toString('utf8').trim();
461
+ }
462
+ return '';
463
+ }
464
+ function parseFiniteNumber(value) {
465
+ if (!value) {
466
+ return undefined;
467
+ }
468
+ const parsed = Number(value);
469
+ return Number.isFinite(parsed) ? parsed : undefined;
470
+ }
471
+ function parseFrameCount(value) {
472
+ if (!value) {
473
+ return undefined;
474
+ }
475
+ const parsed = Number(value);
476
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
477
+ }
132
478
  function createFfmpegPreflightError(error) {
133
479
  if (error.code === 'ENOENT') {
134
480
  return new Error([
@@ -0,0 +1,15 @@
1
+ export type CaptureLogLevel = 'info' | 'warn' | 'error';
2
+ export interface CaptureLogEvent {
3
+ timestamp: string;
4
+ level: CaptureLogLevel;
5
+ event: string;
6
+ message: string;
7
+ data?: Record<string, unknown>;
8
+ }
9
+ export interface CaptureLogger {
10
+ info(event: string, message: string, data?: Record<string, unknown>): Promise<void>;
11
+ warn(event: string, message: string, data?: Record<string, unknown>): Promise<void>;
12
+ error(event: string, message: string, data?: Record<string, unknown>): Promise<void>;
13
+ close(): Promise<void>;
14
+ }
15
+ export declare function createCaptureLogger(logFilePath: string): CaptureLogger;
@@ -0,0 +1,4 @@
1
+ import type { Page } from 'playwright';
2
+ import type { CaptureLogger } from './logger.js';
3
+ import type { PreflightResult } from './types.js';
4
+ export declare function preflightMeasurePage(page: Page, logger?: CaptureLogger): Promise<PreflightResult>;
@@ -0,0 +1,7 @@
1
+ export interface ProgressReporter {
2
+ onPageStart(pageIndex: number, totalPages: number, url: string): void;
3
+ onFrameRendered(frameIndex: number, totalFrames: number): void;
4
+ onPageComplete(pageIndex: number): void;
5
+ onEncodeComplete(): void;
6
+ }
7
+ export declare function createProgressReporter(): ProgressReporter;
@@ -0,0 +1,9 @@
1
+ import type { MotionCurve } from './types.js';
2
+ export declare function resolveDurationSeconds(requestedDuration: number | 'auto', maxScroll: number): number;
3
+ export declare function buildScrollFrames(options: {
4
+ fps: number;
5
+ durationSeconds: number;
6
+ maxScroll: number;
7
+ motion: MotionCurve;
8
+ }): number[];
9
+ export declare function resolveTimelineDurationSeconds(distance: number): number;
@@ -1,4 +1,4 @@
1
- import { AUTO_DURATION_MAX_SECONDS, AUTO_DURATION_MIN_SECONDS, AUTO_DURATION_PIXELS_PER_SECOND, MAX_TOTAL_FRAMES, } from './constants.js';
1
+ import { AUTO_DURATION_MAX_SECONDS, AUTO_DURATION_MIN_SECONDS, AUTO_DURATION_PIXELS_PER_SECOND, MAX_TOTAL_FRAMES, TIMELINE_AUTO_DURATION_MAX_SECONDS, TIMELINE_AUTO_DURATION_MIN_SECONDS, TIMELINE_AUTO_DURATION_PIXELS_PER_SECOND, } from './constants.js';
2
2
  import { clamp } from './utils.js';
3
3
  export function resolveDurationSeconds(requestedDuration, maxScroll) {
4
4
  if (requestedDuration !== 'auto') {
@@ -20,6 +20,9 @@ export function buildScrollFrames(options) {
20
20
  return Number((options.maxScroll * easedProgress).toFixed(3));
21
21
  });
22
22
  }
23
+ export function resolveTimelineDurationSeconds(distance) {
24
+ return clamp(distance / TIMELINE_AUTO_DURATION_PIXELS_PER_SECOND, TIMELINE_AUTO_DURATION_MIN_SECONDS, TIMELINE_AUTO_DURATION_MAX_SECONDS);
25
+ }
23
26
  function easeInOutSine(value) {
24
27
  return -(Math.cos(Math.PI * value) - 1) / 2;
25
28
  }
@@ -0,0 +1,7 @@
1
+ import type { Page } from 'playwright';
2
+ import type { WaitForCondition } from './types.js';
3
+ export declare function stabilizePage(options: {
4
+ page: Page;
5
+ waitFor: WaitForCondition;
6
+ hideSelectors: string[];
7
+ }): Promise<void>;