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
|
@@ -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;
|
|
@@ -1,8 +1,15 @@
|
|
|
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;
|
|
7
10
|
export const PREFLIGHT_STABLE_ROUNDS = 3;
|
|
8
11
|
export const PREFLIGHT_MAX_SCROLL_HEIGHT = 30_000;
|
|
12
|
+
export const MAX_TOTAL_FRAMES = 36_000;
|
|
13
|
+
export const MAX_FPS = 120;
|
|
14
|
+
export const FONT_LOADING_TIMEOUT_MS = 10_000;
|
|
15
|
+
export const MAX_PREFLIGHT_ITERATIONS = 20;
|
|
@@ -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>;
|
package/dist/capture/ffmpeg.js
CHANGED
|
@@ -1,38 +1,31 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const FFMPEG_ABORT_TIMEOUT_MS = 1_000;
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export async function checkFfmpegAvailable() {
|
|
6
|
+
await new Promise((resolve, reject) => {
|
|
7
|
+
execFile('ffmpeg', ['-version'], (error) => {
|
|
8
|
+
if (error) {
|
|
9
|
+
reject(createFfmpegPreflightError(error));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
resolve();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
3
16
|
export async function createVideoEncoder(options) {
|
|
4
|
-
const ffmpeg = spawn('ffmpeg',
|
|
5
|
-
'-hide_banner',
|
|
6
|
-
'-loglevel',
|
|
7
|
-
'error',
|
|
8
|
-
'-y',
|
|
9
|
-
'-f',
|
|
10
|
-
'image2pipe',
|
|
11
|
-
'-framerate',
|
|
12
|
-
String(options.fps),
|
|
13
|
-
'-c:v',
|
|
14
|
-
'png',
|
|
15
|
-
'-i',
|
|
16
|
-
'pipe:0',
|
|
17
|
-
'-an',
|
|
18
|
-
'-c:v',
|
|
19
|
-
'libx264',
|
|
20
|
-
'-pix_fmt',
|
|
21
|
-
'yuv420p',
|
|
22
|
-
'-preset',
|
|
23
|
-
'slow',
|
|
24
|
-
'-crf',
|
|
25
|
-
'18',
|
|
26
|
-
'-movflags',
|
|
27
|
-
'+faststart',
|
|
28
|
-
'-vf',
|
|
29
|
-
'pad=ceil(iw/2)*2:ceil(ih/2)*2',
|
|
30
|
-
options.outPath,
|
|
31
|
-
], {
|
|
17
|
+
const ffmpeg = spawn('ffmpeg', buildStreamingFfmpegArgs(options), {
|
|
32
18
|
stdio: ['pipe', 'ignore', 'pipe'],
|
|
33
19
|
});
|
|
34
20
|
let spawnError;
|
|
35
21
|
let stderr = '';
|
|
22
|
+
let closeResult;
|
|
23
|
+
const closePromise = new Promise((resolve) => {
|
|
24
|
+
ffmpeg.once('close', (exitCode, signal) => {
|
|
25
|
+
closeResult = { exitCode, signal };
|
|
26
|
+
resolve(closeResult);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
36
29
|
ffmpeg.on('error', (error) => {
|
|
37
30
|
spawnError = error;
|
|
38
31
|
});
|
|
@@ -45,9 +38,12 @@ export async function createVideoEncoder(options) {
|
|
|
45
38
|
if (spawnError) {
|
|
46
39
|
throw createEncoderError(spawnError, stderr);
|
|
47
40
|
}
|
|
41
|
+
if (closeResult) {
|
|
42
|
+
throw createEncoderError(new Error(`FFmpeg exited before encoding completed (${formatCloseResult(closeResult)})`), stderr);
|
|
43
|
+
}
|
|
48
44
|
const stdin = ffmpeg.stdin;
|
|
49
45
|
if (stdin.destroyed) {
|
|
50
|
-
throw createEncoderError(new Error('FFmpeg
|
|
46
|
+
throw createEncoderError(new Error('FFmpeg stdin is closed.'), stderr);
|
|
51
47
|
}
|
|
52
48
|
await new Promise((resolve, reject) => {
|
|
53
49
|
stdin.write(frame, (error) => {
|
|
@@ -63,18 +59,443 @@ export async function createVideoEncoder(options) {
|
|
|
63
59
|
if (spawnError) {
|
|
64
60
|
throw createEncoderError(spawnError, stderr);
|
|
65
61
|
}
|
|
66
|
-
ffmpeg.stdin.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
if (!ffmpeg.stdin.destroyed && !ffmpeg.stdin.writableEnded) {
|
|
63
|
+
ffmpeg.stdin.end();
|
|
64
|
+
}
|
|
65
|
+
const result = closeResult ?? (await closePromise);
|
|
66
|
+
if (result.exitCode !== 0) {
|
|
67
|
+
throw createEncoderError(new Error(`FFmpeg exited with error (${formatCloseResult(result)})`), stderr);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
async abort() {
|
|
71
|
+
try {
|
|
72
|
+
if (!ffmpeg.stdin.destroyed && !ffmpeg.stdin.writableEnded) {
|
|
73
|
+
ffmpeg.stdin.destroy();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// stdin may already be closed
|
|
78
|
+
}
|
|
79
|
+
if (closeResult) {
|
|
80
|
+
await closePromise;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
ffmpeg.kill('SIGTERM');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Process may already be exiting
|
|
88
|
+
}
|
|
89
|
+
const exited = await Promise.race([
|
|
90
|
+
closePromise.then(() => true),
|
|
91
|
+
new Promise((resolve) => {
|
|
92
|
+
setTimeout(resolve, FFMPEG_ABORT_TIMEOUT_MS, false);
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
if (!exited && !closeResult) {
|
|
96
|
+
try {
|
|
97
|
+
ffmpeg.kill('SIGKILL');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Process may already be exiting
|
|
101
|
+
}
|
|
102
|
+
await closePromise;
|
|
70
103
|
}
|
|
71
104
|
},
|
|
72
105
|
};
|
|
73
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
|
+
}
|
|
478
|
+
function createFfmpegPreflightError(error) {
|
|
479
|
+
if (error.code === 'ENOENT') {
|
|
480
|
+
return new Error([
|
|
481
|
+
'FFmpeg is required but was not found in PATH.',
|
|
482
|
+
' macOS: brew install ffmpeg',
|
|
483
|
+
' Ubuntu: sudo apt install ffmpeg',
|
|
484
|
+
' Windows: winget install ffmpeg',
|
|
485
|
+
].join('\n'));
|
|
486
|
+
}
|
|
487
|
+
return new Error(`Failed to execute FFmpeg: ${error.message}`);
|
|
488
|
+
}
|
|
74
489
|
function createEncoderError(error, stderr) {
|
|
75
490
|
if ('code' in error && error.code === 'ENOENT') {
|
|
76
|
-
return new Error('FFmpeg
|
|
491
|
+
return new Error('FFmpeg not found. Please add ffmpeg to your PATH.');
|
|
77
492
|
}
|
|
78
493
|
const detail = stderr.trim();
|
|
79
494
|
return new Error(detail ? `${error.message}\n${detail}` : error.message);
|
|
80
495
|
}
|
|
496
|
+
function formatCloseResult(result) {
|
|
497
|
+
if (result.signal) {
|
|
498
|
+
return `signal: ${result.signal}`;
|
|
499
|
+
}
|
|
500
|
+
return `exit code: ${result.exitCode ?? 'null'}`;
|
|
501
|
+
}
|
|
@@ -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;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { PREFLIGHT_MAX_SCROLL_HEIGHT, PREFLIGHT_STABLE_ROUNDS, PREFLIGHT_STEP_DELAY_MS, } from './constants.js';
|
|
1
|
+
import { MAX_PREFLIGHT_ITERATIONS, PREFLIGHT_MAX_SCROLL_HEIGHT, PREFLIGHT_STABLE_ROUNDS, PREFLIGHT_STEP_DELAY_MS, } from './constants.js';
|
|
2
2
|
import { delay, measurePage, waitForAnimationFrames } from './utils.js';
|
|
3
|
-
export async function preflightMeasurePage(page) {
|
|
3
|
+
export async function preflightMeasurePage(page, logger) {
|
|
4
4
|
let metrics = await measurePage(page);
|
|
5
5
|
let truncated = metrics.scrollHeight > PREFLIGHT_MAX_SCROLL_HEIGHT;
|
|
6
6
|
let stableRounds = metrics.maxScroll === 0 ? PREFLIGHT_STABLE_ROUNDS : 0;
|
|
7
|
+
let iterations = 0;
|
|
7
8
|
while (!truncated && stableRounds < PREFLIGHT_STABLE_ROUNDS) {
|
|
9
|
+
iterations += 1;
|
|
10
|
+
if (iterations > MAX_PREFLIGHT_ITERATIONS) {
|
|
11
|
+
await logger?.warn('preflight.max_iterations', `Preflight measurement stopped after ${MAX_PREFLIGHT_ITERATIONS} iterations (page content may still be loading)`);
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
8
14
|
let position = 0;
|
|
9
15
|
while (position < metrics.maxScroll) {
|
|
10
16
|
position = Math.min(position + metrics.viewportHeight, metrics.maxScroll);
|
|
@@ -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;
|