rollberry 0.1.5 → 0.1.9
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 +37 -2
- package/dist/capture/browser-install.js +1 -1
- package/dist/capture/browser.js +16 -10
- package/dist/capture/capture.js +106 -15
- package/dist/capture/constants.js +4 -0
- package/dist/capture/ffmpeg.js +83 -8
- package/dist/capture/preflight.js +8 -2
- package/dist/capture/progress.js +50 -0
- package/dist/capture/scroll-plan.js +4 -1
- package/dist/capture/stabilize.js +11 -5
- package/dist/capture/utils.js +30 -3
- package/dist/cli.js +57 -4
- package/dist/options.js +67 -21
- package/dist/run-capture.js +21 -13
- package/dist/version.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,13 +5,48 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on Keep a Changelog and the project stays on the `v0.x.x`
|
|
6
6
|
line until the CLI surface and capture behavior settle.
|
|
7
7
|
|
|
8
|
-
## [0.1.
|
|
8
|
+
## [0.1.9] - 2026-03-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- TTY-aware progress bar during frame rendering; falls back to milestone
|
|
13
|
+
percentages in non-TTY environments.
|
|
14
|
+
- `--force` flag to overwrite an existing output file.
|
|
15
|
+
- `--version` / `-V` flag to print the current version.
|
|
16
|
+
- `--help` / `-h` at both top-level and subcommand level.
|
|
17
|
+
- Graceful cancellation via SIGINT / SIGTERM with partial-output cleanup.
|
|
18
|
+
- Capture summary printed to stderr on completion (file size, duration, frame
|
|
19
|
+
count, pages, manifest path).
|
|
20
|
+
- FFmpeg availability pre-check with platform-specific install instructions.
|
|
21
|
+
- Frame-count safety limit (36 000 frames) and FPS cap (120) to prevent
|
|
22
|
+
runaway captures.
|
|
23
|
+
- `--hide-selector` input validation (rejects selectors containing `{` or `}`).
|
|
24
|
+
- URL credential sanitization — userinfo is stripped from all logs and
|
|
25
|
+
manifests.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Font loading now times out after 10 s instead of waiting indefinitely.
|
|
30
|
+
- Preflight page measurement stops after 20 iterations to avoid infinite loops
|
|
31
|
+
on dynamically-loading pages.
|
|
32
|
+
- Browser session properly closes when context creation fails.
|
|
33
|
+
- FFmpeg encoder supports abort with SIGTERM → SIGKILL escalation.
|
|
34
|
+
- Manifest `status` field now includes a `cancelled` value.
|
|
35
|
+
- Error messages throughout the CLI include actionable hints and expected
|
|
36
|
+
formats.
|
|
37
|
+
- Help text now shows default values, max constraints, and usage examples.
|
|
38
|
+
|
|
39
|
+
## [0.1.8] - 2026-03-19
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- Multi-page capture support.
|
|
9
44
|
|
|
10
45
|
### Changed
|
|
11
46
|
|
|
12
47
|
- Reduced default auto-scroll speed from 1800 px/s to 800 px/s for better
|
|
13
48
|
readability during capture.
|
|
14
|
-
-
|
|
49
|
+
- GitHub Actions publish workflow now uses token-based authentication.
|
|
15
50
|
|
|
16
51
|
## [0.1.3] - 2026-03-15
|
|
17
52
|
|
|
@@ -13,7 +13,7 @@ export async function ensureChromiumInstalled(logger) {
|
|
|
13
13
|
await logger.warn('browser.install.start', 'Chromium was not found. Installing Playwright Chromium.', { executablePath });
|
|
14
14
|
await installPlaywrightChromium(resolvePlaywrightCliPath());
|
|
15
15
|
if (!(await hasExecutable(executablePath))) {
|
|
16
|
-
throw new Error(`Chromium
|
|
16
|
+
throw new Error(`Chromium executable not found after installation: ${executablePath}\n Try running: npx playwright install chromium`);
|
|
17
17
|
}
|
|
18
18
|
await logger.info('browser.install.complete', 'Playwright Chromium installation finished.', { executablePath });
|
|
19
19
|
}
|
package/dist/capture/browser.js
CHANGED
|
@@ -7,16 +7,22 @@ export async function openBrowserSession(options, logger) {
|
|
|
7
7
|
const browser = await chromium.launch({
|
|
8
8
|
headless: true,
|
|
9
9
|
});
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
try {
|
|
11
|
+
const context = await browser.newContext({
|
|
12
|
+
viewport: options.viewport,
|
|
13
|
+
ignoreHTTPSErrors: options.urls.some(isLocalUrl),
|
|
14
|
+
});
|
|
15
|
+
const page = await context.newPage();
|
|
16
|
+
page.setDefaultTimeout(options.timeoutMs);
|
|
17
|
+
await page.addInitScript(() => {
|
|
18
|
+
history.scrollRestoration = 'manual';
|
|
19
|
+
});
|
|
20
|
+
return { browser, page };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
await browser.close();
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
20
26
|
}
|
|
21
27
|
export async function navigateWithRetry(page, url, timeoutMs) {
|
|
22
28
|
const deadline = Date.now() + timeoutMs;
|
package/dist/capture/capture.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { navigateWithRetry, openBrowserSession } from './browser.js';
|
|
3
|
-
import {
|
|
3
|
+
import { MAX_TOTAL_FRAMES } from './constants.js';
|
|
4
|
+
import { checkFfmpegAvailable, createVideoEncoder, } from './ffmpeg.js';
|
|
4
5
|
import { preflightMeasurePage } from './preflight.js';
|
|
5
6
|
import { buildScrollFrames, resolveDurationSeconds } from './scroll-plan.js';
|
|
6
7
|
import { stabilizePage } from './stabilize.js';
|
|
7
|
-
import { ensureDirectory, ensureParentDirectory, waitForAnimationFrames, } from './utils.js';
|
|
8
|
-
export async function captureVideo(options, logger) {
|
|
8
|
+
import { ensureDirectory, ensureParentDirectory, fileExists, sanitizeUrl, waitForAnimationFrames, } from './utils.js';
|
|
9
|
+
export async function captureVideo(options, logger, progress, signal) {
|
|
9
10
|
if (options.urls.length === 0) {
|
|
10
|
-
throw new Error('
|
|
11
|
+
throw new Error('At least one URL is required.');
|
|
11
12
|
}
|
|
13
|
+
if (signal?.aborted) {
|
|
14
|
+
throw new AbortError();
|
|
15
|
+
}
|
|
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.`);
|
|
18
|
+
}
|
|
19
|
+
await checkFfmpegAvailable();
|
|
12
20
|
await Promise.all([
|
|
13
21
|
ensureParentDirectory(options.outPath),
|
|
14
22
|
ensureParentDirectory(options.logFilePath),
|
|
@@ -18,23 +26,54 @@ export async function captureVideo(options, logger) {
|
|
|
18
26
|
: undefined,
|
|
19
27
|
]);
|
|
20
28
|
const { browser, page } = await openBrowserSession(options, logger);
|
|
29
|
+
let encoder;
|
|
30
|
+
let encodingFinished = false;
|
|
31
|
+
let browserClosed = false;
|
|
32
|
+
const closeBrowser = async () => {
|
|
33
|
+
if (browserClosed) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
browserClosed = true;
|
|
37
|
+
try {
|
|
38
|
+
await browser.close();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Browser may already be closed during cancellation
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const abortHandler = () => {
|
|
45
|
+
void encoder?.abort();
|
|
46
|
+
void closeBrowser();
|
|
47
|
+
};
|
|
48
|
+
signal?.addEventListener('abort', abortHandler);
|
|
21
49
|
try {
|
|
22
|
-
|
|
50
|
+
if (signal?.aborted) {
|
|
51
|
+
throw new AbortError();
|
|
52
|
+
}
|
|
53
|
+
encoder = await createVideoEncoder({
|
|
23
54
|
fps: options.fps,
|
|
24
55
|
outPath: options.outPath,
|
|
25
56
|
});
|
|
57
|
+
if (signal?.aborted) {
|
|
58
|
+
throw new AbortError();
|
|
59
|
+
}
|
|
26
60
|
const pages = [];
|
|
27
61
|
let totalFrameCount = 0;
|
|
28
62
|
let totalDurationSeconds = 0;
|
|
29
63
|
let frameOffset = 0;
|
|
30
64
|
let anyTruncated = false;
|
|
31
65
|
for (const [urlIndex, url] of options.urls.entries()) {
|
|
66
|
+
if (signal?.aborted) {
|
|
67
|
+
throw new AbortError();
|
|
68
|
+
}
|
|
32
69
|
const { pageResult, lastFrame } = await capturePageFrames({
|
|
33
70
|
page,
|
|
34
71
|
url,
|
|
35
72
|
encoder,
|
|
36
73
|
options,
|
|
37
74
|
logger,
|
|
75
|
+
progress,
|
|
76
|
+
signal,
|
|
38
77
|
frameOffset,
|
|
39
78
|
urlIndex,
|
|
40
79
|
});
|
|
@@ -59,6 +98,8 @@ export async function captureVideo(options, logger) {
|
|
|
59
98
|
}
|
|
60
99
|
}
|
|
61
100
|
await encoder.finish();
|
|
101
|
+
encodingFinished = true;
|
|
102
|
+
progress?.onEncodeComplete();
|
|
62
103
|
await logger.info('encode.complete', 'Video encoding finished', {
|
|
63
104
|
outPath: options.outPath,
|
|
64
105
|
});
|
|
@@ -70,15 +111,34 @@ export async function captureVideo(options, logger) {
|
|
|
70
111
|
truncated: anyTruncated,
|
|
71
112
|
};
|
|
72
113
|
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const failure = signal?.aborted && !(error instanceof AbortError)
|
|
116
|
+
? new AbortError()
|
|
117
|
+
: error;
|
|
118
|
+
if (encoder && !encodingFinished) {
|
|
119
|
+
await encoder.abort();
|
|
120
|
+
await cleanupPartialOutput(options.outPath);
|
|
121
|
+
}
|
|
122
|
+
throw failure;
|
|
123
|
+
}
|
|
73
124
|
finally {
|
|
74
|
-
|
|
125
|
+
signal?.removeEventListener('abort', abortHandler);
|
|
126
|
+
await closeBrowser();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export class AbortError extends Error {
|
|
130
|
+
constructor() {
|
|
131
|
+
super('Capture was cancelled.');
|
|
132
|
+
this.name = 'AbortError';
|
|
75
133
|
}
|
|
76
134
|
}
|
|
77
135
|
async function capturePageFrames(input) {
|
|
78
|
-
const { page, url, encoder, options, logger, urlIndex } = input;
|
|
136
|
+
const { page, url, encoder, options, logger, progress, signal, urlIndex } = input;
|
|
79
137
|
const { frameOffset } = input;
|
|
138
|
+
const safeUrl = sanitizeUrl(url);
|
|
139
|
+
progress?.onPageStart(urlIndex, options.urls.length, safeUrl);
|
|
80
140
|
await logger.info('browser.open', `Opening page ${urlIndex + 1}`, {
|
|
81
|
-
url:
|
|
141
|
+
url: safeUrl,
|
|
82
142
|
viewport: options.viewport,
|
|
83
143
|
});
|
|
84
144
|
await navigateWithRetry(page, url, options.timeoutMs);
|
|
@@ -87,7 +147,7 @@ async function capturePageFrames(input) {
|
|
|
87
147
|
waitFor: options.waitFor,
|
|
88
148
|
hideSelectors: options.hideSelectors,
|
|
89
149
|
});
|
|
90
|
-
const preflight = await preflightMeasurePage(page);
|
|
150
|
+
const preflight = await preflightMeasurePage(page, logger);
|
|
91
151
|
const plannedDurationSeconds = resolveDurationSeconds(options.duration, preflight.maxScroll);
|
|
92
152
|
const frames = buildScrollFrames({
|
|
93
153
|
fps: options.fps,
|
|
@@ -95,22 +155,32 @@ async function capturePageFrames(input) {
|
|
|
95
155
|
maxScroll: preflight.maxScroll,
|
|
96
156
|
motion: options.motion,
|
|
97
157
|
});
|
|
158
|
+
assertWithinFrameLimit(frameOffset +
|
|
159
|
+
frames.length +
|
|
160
|
+
getReservedGapFrames({
|
|
161
|
+
fps: options.fps,
|
|
162
|
+
pageGapSeconds: options.pageGapSeconds,
|
|
163
|
+
isLastPage: urlIndex === options.urls.length - 1,
|
|
164
|
+
}));
|
|
98
165
|
const durationSeconds = frames.length / options.fps;
|
|
99
166
|
await logger.info('render.start', `Rendering frames for page ${urlIndex + 1}`, {
|
|
100
167
|
frameCount: frames.length,
|
|
101
168
|
fps: options.fps,
|
|
102
169
|
durationSeconds,
|
|
103
170
|
maxScroll: preflight.maxScroll,
|
|
104
|
-
url:
|
|
171
|
+
url: safeUrl,
|
|
105
172
|
});
|
|
106
173
|
if (preflight.truncated) {
|
|
107
174
|
await logger.warn('preflight.truncated', 'Scroll height exceeded capture limit and was truncated', {
|
|
108
175
|
scrollHeight: preflight.scrollHeight,
|
|
109
|
-
url:
|
|
176
|
+
url: safeUrl,
|
|
110
177
|
});
|
|
111
178
|
}
|
|
112
179
|
let lastFrame;
|
|
113
180
|
for (const [index, scrollTop] of frames.entries()) {
|
|
181
|
+
if (signal?.aborted) {
|
|
182
|
+
throw new AbortError();
|
|
183
|
+
}
|
|
114
184
|
await page.evaluate((nextScrollTop) => {
|
|
115
185
|
window.scrollTo({ top: nextScrollTop, behavior: 'auto' });
|
|
116
186
|
}, scrollTop);
|
|
@@ -127,21 +197,23 @@ async function capturePageFrames(input) {
|
|
|
127
197
|
await writeFile(`${options.debugFramesDir}/${fileName}`, frame);
|
|
128
198
|
}
|
|
129
199
|
await encoder.writeFrame(frame);
|
|
200
|
+
progress?.onFrameRendered(index, frames.length);
|
|
130
201
|
if ((index + 1) % Math.max(1, Math.floor(frames.length / 10)) === 0) {
|
|
131
202
|
await logger.info('render.progress', 'Rendered frame batch', {
|
|
132
203
|
renderedFrames: index + 1,
|
|
133
204
|
totalFrames: frames.length,
|
|
134
205
|
scrollTop,
|
|
135
|
-
url:
|
|
206
|
+
url: safeUrl,
|
|
136
207
|
});
|
|
137
208
|
}
|
|
138
209
|
}
|
|
139
210
|
if (!lastFrame) {
|
|
140
|
-
throw new Error(
|
|
211
|
+
throw new Error(`Failed to capture frames from page: ${safeUrl}`);
|
|
141
212
|
}
|
|
213
|
+
progress?.onPageComplete(urlIndex);
|
|
142
214
|
return {
|
|
143
215
|
pageResult: {
|
|
144
|
-
url:
|
|
216
|
+
url: safeUrl,
|
|
145
217
|
frameCount: frames.length,
|
|
146
218
|
durationSeconds,
|
|
147
219
|
scrollHeight: preflight.scrollHeight,
|
|
@@ -150,6 +222,25 @@ async function capturePageFrames(input) {
|
|
|
150
222
|
lastFrame,
|
|
151
223
|
};
|
|
152
224
|
}
|
|
225
|
+
async function cleanupPartialOutput(outPath) {
|
|
226
|
+
try {
|
|
227
|
+
await unlink(outPath);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Partial output may not exist
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function assertWithinFrameLimit(frameCount) {
|
|
234
|
+
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.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function getReservedGapFrames(options) {
|
|
239
|
+
if (options.isLastPage) {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
return Math.round(options.fps * options.pageGapSeconds);
|
|
243
|
+
}
|
|
153
244
|
async function writeGapFrames(input) {
|
|
154
245
|
const { encoder, frame, fps, gapSeconds, debugFramesDir, frameOffset } = input;
|
|
155
246
|
const gapFrameCount = Math.round(fps * gapSeconds);
|
|
@@ -6,3 +6,7 @@ export const STABILIZE_DELAY_MS = 500;
|
|
|
6
6
|
export const PREFLIGHT_STEP_DELAY_MS = 120;
|
|
7
7
|
export const PREFLIGHT_STABLE_ROUNDS = 3;
|
|
8
8
|
export const PREFLIGHT_MAX_SCROLL_HEIGHT = 30_000;
|
|
9
|
+
export const MAX_TOTAL_FRAMES = 36_000;
|
|
10
|
+
export const MAX_FPS = 120;
|
|
11
|
+
export const FONT_LOADING_TIMEOUT_MS = 10_000;
|
|
12
|
+
export const MAX_PREFLIGHT_ITERATIONS = 20;
|
package/dist/capture/ffmpeg.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
const FFMPEG_ABORT_TIMEOUT_MS = 1_000;
|
|
3
|
+
export async function checkFfmpegAvailable() {
|
|
4
|
+
await new Promise((resolve, reject) => {
|
|
5
|
+
execFile('ffmpeg', ['-version'], (error) => {
|
|
6
|
+
if (error) {
|
|
7
|
+
reject(createFfmpegPreflightError(error));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
resolve();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
3
14
|
export async function createVideoEncoder(options) {
|
|
4
15
|
const ffmpeg = spawn('ffmpeg', [
|
|
5
16
|
'-hide_banner',
|
|
@@ -33,6 +44,13 @@ export async function createVideoEncoder(options) {
|
|
|
33
44
|
});
|
|
34
45
|
let spawnError;
|
|
35
46
|
let stderr = '';
|
|
47
|
+
let closeResult;
|
|
48
|
+
const closePromise = new Promise((resolve) => {
|
|
49
|
+
ffmpeg.once('close', (exitCode, signal) => {
|
|
50
|
+
closeResult = { exitCode, signal };
|
|
51
|
+
resolve(closeResult);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
36
54
|
ffmpeg.on('error', (error) => {
|
|
37
55
|
spawnError = error;
|
|
38
56
|
});
|
|
@@ -45,9 +63,12 @@ export async function createVideoEncoder(options) {
|
|
|
45
63
|
if (spawnError) {
|
|
46
64
|
throw createEncoderError(spawnError, stderr);
|
|
47
65
|
}
|
|
66
|
+
if (closeResult) {
|
|
67
|
+
throw createEncoderError(new Error(`FFmpeg exited before encoding completed (${formatCloseResult(closeResult)})`), stderr);
|
|
68
|
+
}
|
|
48
69
|
const stdin = ffmpeg.stdin;
|
|
49
70
|
if (stdin.destroyed) {
|
|
50
|
-
throw createEncoderError(new Error('FFmpeg
|
|
71
|
+
throw createEncoderError(new Error('FFmpeg stdin is closed.'), stderr);
|
|
51
72
|
}
|
|
52
73
|
await new Promise((resolve, reject) => {
|
|
53
74
|
stdin.write(frame, (error) => {
|
|
@@ -63,18 +84,72 @@ export async function createVideoEncoder(options) {
|
|
|
63
84
|
if (spawnError) {
|
|
64
85
|
throw createEncoderError(spawnError, stderr);
|
|
65
86
|
}
|
|
66
|
-
ffmpeg.stdin.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
if (!ffmpeg.stdin.destroyed && !ffmpeg.stdin.writableEnded) {
|
|
88
|
+
ffmpeg.stdin.end();
|
|
89
|
+
}
|
|
90
|
+
const result = closeResult ?? (await closePromise);
|
|
91
|
+
if (result.exitCode !== 0) {
|
|
92
|
+
throw createEncoderError(new Error(`FFmpeg exited with error (${formatCloseResult(result)})`), stderr);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async abort() {
|
|
96
|
+
try {
|
|
97
|
+
if (!ffmpeg.stdin.destroyed && !ffmpeg.stdin.writableEnded) {
|
|
98
|
+
ffmpeg.stdin.destroy();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// stdin may already be closed
|
|
103
|
+
}
|
|
104
|
+
if (closeResult) {
|
|
105
|
+
await closePromise;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
ffmpeg.kill('SIGTERM');
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Process may already be exiting
|
|
113
|
+
}
|
|
114
|
+
const exited = await Promise.race([
|
|
115
|
+
closePromise.then(() => true),
|
|
116
|
+
new Promise((resolve) => {
|
|
117
|
+
setTimeout(resolve, FFMPEG_ABORT_TIMEOUT_MS, false);
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
if (!exited && !closeResult) {
|
|
121
|
+
try {
|
|
122
|
+
ffmpeg.kill('SIGKILL');
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Process may already be exiting
|
|
126
|
+
}
|
|
127
|
+
await closePromise;
|
|
70
128
|
}
|
|
71
129
|
},
|
|
72
130
|
};
|
|
73
131
|
}
|
|
132
|
+
function createFfmpegPreflightError(error) {
|
|
133
|
+
if (error.code === 'ENOENT') {
|
|
134
|
+
return new Error([
|
|
135
|
+
'FFmpeg is required but was not found in PATH.',
|
|
136
|
+
' macOS: brew install ffmpeg',
|
|
137
|
+
' Ubuntu: sudo apt install ffmpeg',
|
|
138
|
+
' Windows: winget install ffmpeg',
|
|
139
|
+
].join('\n'));
|
|
140
|
+
}
|
|
141
|
+
return new Error(`Failed to execute FFmpeg: ${error.message}`);
|
|
142
|
+
}
|
|
74
143
|
function createEncoderError(error, stderr) {
|
|
75
144
|
if ('code' in error && error.code === 'ENOENT') {
|
|
76
|
-
return new Error('FFmpeg
|
|
145
|
+
return new Error('FFmpeg not found. Please add ffmpeg to your PATH.');
|
|
77
146
|
}
|
|
78
147
|
const detail = stderr.trim();
|
|
79
148
|
return new Error(detail ? `${error.message}\n${detail}` : error.message);
|
|
80
149
|
}
|
|
150
|
+
function formatCloseResult(result) {
|
|
151
|
+
if (result.signal) {
|
|
152
|
+
return `signal: ${result.signal}`;
|
|
153
|
+
}
|
|
154
|
+
return `exit code: ${result.exitCode ?? 'null'}`;
|
|
155
|
+
}
|
|
@@ -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,50 @@
|
|
|
1
|
+
export function createProgressReporter() {
|
|
2
|
+
const isTTY = process.stderr.isTTY === true;
|
|
3
|
+
let lastUpdateTime = 0;
|
|
4
|
+
let lastReportedMilestone = -1;
|
|
5
|
+
function clearLine() {
|
|
6
|
+
if (isTTY) {
|
|
7
|
+
process.stderr.write('\r\x1b[K');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
onPageStart(pageIndex, totalPages, url) {
|
|
12
|
+
clearLine();
|
|
13
|
+
lastReportedMilestone = -1;
|
|
14
|
+
const displayUrl = url.length > 60 ? `${url.slice(0, 57)}...` : url;
|
|
15
|
+
process.stderr.write(`Capturing page ${pageIndex + 1}/${totalPages}: ${displayUrl}\n`);
|
|
16
|
+
},
|
|
17
|
+
onFrameRendered(frameIndex, totalFrames) {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const isLast = frameIndex + 1 === totalFrames;
|
|
20
|
+
if (!isLast && now - lastUpdateTime < 100) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
lastUpdateTime = now;
|
|
24
|
+
const progress = (frameIndex + 1) / totalFrames;
|
|
25
|
+
const percent = Math.round(progress * 100);
|
|
26
|
+
if (isTTY) {
|
|
27
|
+
const barWidth = 20;
|
|
28
|
+
const filled = Math.round(barWidth * progress);
|
|
29
|
+
const bar = '#'.repeat(filled) + '-'.repeat(barWidth - filled);
|
|
30
|
+
process.stderr.write(`\r [${bar}] ${percent}% (${frameIndex + 1}/${totalFrames} frames)`);
|
|
31
|
+
if (isLast) {
|
|
32
|
+
process.stderr.write('\n');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const milestone = Math.floor(percent / 25) * 25;
|
|
37
|
+
if (isLast || (milestone > lastReportedMilestone && milestone > 0)) {
|
|
38
|
+
lastReportedMilestone = milestone;
|
|
39
|
+
process.stderr.write(` Progress: ${percent}% (${frameIndex + 1}/${totalFrames} frames)\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
onPageComplete(_pageIndex) {
|
|
44
|
+
// Line already advanced after last frame render
|
|
45
|
+
},
|
|
46
|
+
onEncodeComplete() {
|
|
47
|
+
// Summary is printed by cli.ts
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AUTO_DURATION_MAX_SECONDS, AUTO_DURATION_MIN_SECONDS, AUTO_DURATION_PIXELS_PER_SECOND, } from './constants.js';
|
|
1
|
+
import { AUTO_DURATION_MAX_SECONDS, AUTO_DURATION_MIN_SECONDS, AUTO_DURATION_PIXELS_PER_SECOND, MAX_TOTAL_FRAMES, } from './constants.js';
|
|
2
2
|
import { clamp } from './utils.js';
|
|
3
3
|
export function resolveDurationSeconds(requestedDuration, maxScroll) {
|
|
4
4
|
if (requestedDuration !== 'auto') {
|
|
@@ -8,6 +8,9 @@ export function resolveDurationSeconds(requestedDuration, maxScroll) {
|
|
|
8
8
|
}
|
|
9
9
|
export function buildScrollFrames(options) {
|
|
10
10
|
const frameCount = Math.max(1, Math.ceil(options.durationSeconds * options.fps));
|
|
11
|
+
if (frameCount > MAX_TOTAL_FRAMES) {
|
|
12
|
+
throw new Error(`Frame count ${frameCount} exceeds maximum ${MAX_TOTAL_FRAMES}. Reduce --fps or --duration.`);
|
|
13
|
+
}
|
|
11
14
|
if (frameCount === 1) {
|
|
12
15
|
return [0];
|
|
13
16
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { STABILIZE_DELAY_MS } from './constants.js';
|
|
2
|
-
import { delay, waitForAnimationFrames } from './utils.js';
|
|
1
|
+
import { FONT_LOADING_TIMEOUT_MS, STABILIZE_DELAY_MS } from './constants.js';
|
|
2
|
+
import { delay, validateHideSelector, waitForAnimationFrames, } from './utils.js';
|
|
3
3
|
export async function stabilizePage(options) {
|
|
4
4
|
await options.page.addStyleTag({
|
|
5
5
|
content: buildStabilizingCss(options.hideSelectors),
|
|
@@ -13,6 +13,9 @@ export async function stabilizePage(options) {
|
|
|
13
13
|
await waitForAnimationFrames(options.page);
|
|
14
14
|
}
|
|
15
15
|
function buildStabilizingCss(hideSelectors) {
|
|
16
|
+
for (const selector of hideSelectors) {
|
|
17
|
+
validateHideSelector(selector);
|
|
18
|
+
}
|
|
16
19
|
const hiddenSelectorBlock = hideSelectors.length > 0
|
|
17
20
|
? `${hideSelectors.join(', ')} { display: none !important; visibility: hidden !important; }\n`
|
|
18
21
|
: '';
|
|
@@ -47,10 +50,13 @@ async function waitForRequestedCondition(page, waitFor) {
|
|
|
47
50
|
await delay(waitFor.ms);
|
|
48
51
|
}
|
|
49
52
|
async function waitForFonts(page) {
|
|
50
|
-
await page.evaluate(async () => {
|
|
53
|
+
await page.evaluate(async (timeoutMs) => {
|
|
51
54
|
if (!('fonts' in document)) {
|
|
52
55
|
return;
|
|
53
56
|
}
|
|
54
|
-
await
|
|
55
|
-
|
|
57
|
+
await Promise.race([
|
|
58
|
+
document.fonts.ready,
|
|
59
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
60
|
+
]);
|
|
61
|
+
}, FONT_LOADING_TIMEOUT_MS);
|
|
56
62
|
}
|
package/dist/capture/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises';
|
|
1
|
+
import { access, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
4
4
|
export function clamp(value, min, max) {
|
|
@@ -13,13 +13,40 @@ export function parseCaptureUrl(rawUrl) {
|
|
|
13
13
|
url = new URL(rawUrl);
|
|
14
14
|
}
|
|
15
15
|
catch {
|
|
16
|
-
throw new Error(
|
|
16
|
+
throw new Error(`Invalid URL: ${rawUrl}\n Expected format: http://example.com or https://localhost:3000`);
|
|
17
17
|
}
|
|
18
18
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
19
|
-
throw new Error(
|
|
19
|
+
throw new Error(`Unsupported URL: ${rawUrl} (only http:// and https:// are supported)`);
|
|
20
20
|
}
|
|
21
21
|
return url;
|
|
22
22
|
}
|
|
23
|
+
export function sanitizeUrl(url) {
|
|
24
|
+
const sanitized = new URL(url.toString());
|
|
25
|
+
sanitized.username = '';
|
|
26
|
+
sanitized.password = '';
|
|
27
|
+
return sanitized.toString();
|
|
28
|
+
}
|
|
29
|
+
export function formatFileSize(bytes) {
|
|
30
|
+
if (bytes < 1024)
|
|
31
|
+
return `${bytes} B`;
|
|
32
|
+
if (bytes < 1024 * 1024)
|
|
33
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
34
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
35
|
+
}
|
|
36
|
+
export function validateHideSelector(selector) {
|
|
37
|
+
if (selector.includes('{') || selector.includes('}')) {
|
|
38
|
+
throw new Error(`Invalid CSS selector for --hide-selector: "${selector}". Selectors must not contain { or } characters.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function fileExists(path) {
|
|
42
|
+
try {
|
|
43
|
+
await access(path);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
23
50
|
export async function ensureParentDirectory(filePath) {
|
|
24
51
|
await mkdir(dirname(filePath), { recursive: true });
|
|
25
52
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { AbortError } from './capture/capture.js';
|
|
5
|
+
import { createProgressReporter } from './capture/progress.js';
|
|
6
|
+
import { formatFileSize } from './capture/utils.js';
|
|
7
|
+
import { CliError, formatUsage, HelpRequest, parseCliArgs, VersionRequest, } from './options.js';
|
|
3
8
|
import { runCaptureCommand } from './run-capture.js';
|
|
4
9
|
async function main() {
|
|
5
10
|
try {
|
|
6
11
|
const options = parseCliArgs();
|
|
7
|
-
const
|
|
8
|
-
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const onSignal = () => {
|
|
14
|
+
controller.abort();
|
|
15
|
+
};
|
|
16
|
+
process.on('SIGINT', onSignal);
|
|
17
|
+
process.on('SIGTERM', onSignal);
|
|
18
|
+
const progress = createProgressReporter();
|
|
19
|
+
try {
|
|
20
|
+
const result = await runCaptureCommand(options, progress, controller.signal);
|
|
21
|
+
process.stdout.write(`${result.capture.outPath}\n`);
|
|
22
|
+
const fileName = basename(result.capture.outPath);
|
|
23
|
+
let fileSizeStr = '';
|
|
24
|
+
try {
|
|
25
|
+
const stat = statSync(result.capture.outPath);
|
|
26
|
+
fileSizeStr = formatFileSize(stat.size);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// File size unavailable
|
|
30
|
+
}
|
|
31
|
+
const lines = [
|
|
32
|
+
'',
|
|
33
|
+
`Capture complete: ${fileName}`,
|
|
34
|
+
` Duration: ${result.capture.durationSeconds.toFixed(1)}s (${result.capture.frameCount} frames at ${options.fps}fps)`,
|
|
35
|
+
];
|
|
36
|
+
if (fileSizeStr) {
|
|
37
|
+
lines.push(` File size: ${fileSizeStr}`);
|
|
38
|
+
}
|
|
39
|
+
if (result.capture.pages.length > 1) {
|
|
40
|
+
lines.push(` Pages: ${result.capture.pages.length}`);
|
|
41
|
+
}
|
|
42
|
+
lines.push(` Manifest: ${basename(result.manifestPath)}`);
|
|
43
|
+
process.stderr.write(`${lines.join('\n')}\n`);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
process.off('SIGINT', onSignal);
|
|
47
|
+
process.off('SIGTERM', onSignal);
|
|
48
|
+
}
|
|
9
49
|
}
|
|
10
50
|
catch (error) {
|
|
51
|
+
if (error instanceof HelpRequest) {
|
|
52
|
+
process.stdout.write(`${error.message}\n`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (error instanceof VersionRequest) {
|
|
56
|
+
process.stdout.write(`${error.version}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof AbortError) {
|
|
60
|
+
process.stderr.write('\nCapture cancelled.\n');
|
|
61
|
+
process.exitCode = 130;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
11
64
|
if (error instanceof CliError) {
|
|
12
65
|
process.stderr.write(`${error.message}\n`);
|
|
13
66
|
if (error.showUsage) {
|
|
@@ -16,7 +69,7 @@ async function main() {
|
|
|
16
69
|
process.exitCode = 1;
|
|
17
70
|
return;
|
|
18
71
|
}
|
|
19
|
-
process.stderr.write(`${error instanceof Error ? error.message : '
|
|
72
|
+
process.stderr.write(`${error instanceof Error ? error.message : 'An unexpected error occurred.'}\n`);
|
|
20
73
|
process.exitCode = 1;
|
|
21
74
|
}
|
|
22
75
|
}
|
package/dist/options.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { MAX_FPS } from './capture/constants.js';
|
|
4
|
+
import { parseCaptureUrl, validateHideSelector } from './capture/utils.js';
|
|
5
|
+
import { VERSION } from './version.js';
|
|
4
6
|
const DEFAULT_OUT_FILE = 'rollberry.mp4';
|
|
5
7
|
const DEFAULT_VIEWPORT = '1440x900';
|
|
6
8
|
const DEFAULT_FPS = 60;
|
|
@@ -15,13 +17,35 @@ export class CliError extends Error {
|
|
|
15
17
|
this.name = 'CliError';
|
|
16
18
|
}
|
|
17
19
|
}
|
|
20
|
+
export class HelpRequest extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'HelpRequest';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class VersionRequest extends Error {
|
|
27
|
+
version = VERSION;
|
|
28
|
+
constructor() {
|
|
29
|
+
super(VERSION);
|
|
30
|
+
this.name = 'VersionRequest';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
18
33
|
export function parseCliArgs(argv = process.argv.slice(2)) {
|
|
19
34
|
const [command, ...rest] = argv;
|
|
35
|
+
if (command === '--help' || command === '-h') {
|
|
36
|
+
throw new HelpRequest(formatUsage());
|
|
37
|
+
}
|
|
38
|
+
if (command === '--version' || command === '-V') {
|
|
39
|
+
throw new VersionRequest();
|
|
40
|
+
}
|
|
20
41
|
if (!command) {
|
|
21
|
-
throw new CliError('
|
|
42
|
+
throw new CliError('A subcommand is required.\n\nAvailable commands:\n capture Capture a scroll video from one or more URLs\n\nRun rollberry --help for more information.');
|
|
22
43
|
}
|
|
23
44
|
if (command !== 'capture') {
|
|
24
|
-
throw new CliError(
|
|
45
|
+
throw new CliError(`Unknown subcommand: ${command}\n\nAvailable commands:\n capture Capture a scroll video from one or more URLs\n\nRun rollberry --help for more information.`);
|
|
46
|
+
}
|
|
47
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
48
|
+
throw new HelpRequest(formatUsage());
|
|
25
49
|
}
|
|
26
50
|
const parsed = parseArgs({
|
|
27
51
|
args: rest,
|
|
@@ -64,19 +88,30 @@ export function parseCliArgs(argv = process.argv.slice(2)) {
|
|
|
64
88
|
'log-file': {
|
|
65
89
|
type: 'string',
|
|
66
90
|
},
|
|
91
|
+
force: {
|
|
92
|
+
type: 'boolean',
|
|
93
|
+
},
|
|
67
94
|
},
|
|
68
95
|
strict: true,
|
|
69
96
|
});
|
|
70
97
|
if (parsed.positionals.length === 0) {
|
|
71
|
-
throw new CliError('capture
|
|
98
|
+
throw new CliError('capture requires at least one URL.', true);
|
|
72
99
|
}
|
|
73
100
|
const urls = parsed.positionals.map((raw) => parseWithCliError(raw, parseCaptureUrl));
|
|
74
101
|
const durationOption = parsed.values.duration ?? DEFAULT_DURATION;
|
|
75
102
|
if (durationOption !== 'auto' && Number.isNaN(Number(durationOption))) {
|
|
76
|
-
throw new CliError(`--duration
|
|
103
|
+
throw new CliError(`--duration must be "auto" or a positive number (e.g. --duration 5): ${durationOption}`);
|
|
77
104
|
}
|
|
78
105
|
const outPath = resolveOutPath(parsed.values.out ?? DEFAULT_OUT_FILE);
|
|
79
106
|
const pageGapSeconds = parseWithCliError(parsed.values['page-gap'] ?? '0', parseNonNegativeNumber);
|
|
107
|
+
const hideSelectors = parsed.values['hide-selector'] ?? [];
|
|
108
|
+
for (const selector of hideSelectors) {
|
|
109
|
+
parseWithCliError(selector, validateHideSelector);
|
|
110
|
+
}
|
|
111
|
+
const fps = parseWithCliError(parsed.values.fps ?? String(DEFAULT_FPS), parsePositiveInt);
|
|
112
|
+
if (fps > MAX_FPS) {
|
|
113
|
+
throw new CliError(`--fps must be at most ${MAX_FPS}: ${fps}`);
|
|
114
|
+
}
|
|
80
115
|
return {
|
|
81
116
|
urls: toNonEmptyArray(urls),
|
|
82
117
|
outPath,
|
|
@@ -87,44 +122,55 @@ export function parseCliArgs(argv = process.argv.slice(2)) {
|
|
|
87
122
|
? resolveOutPath(parsed.values['log-file'])
|
|
88
123
|
: deriveSidecarPath(outPath, '.log.jsonl'),
|
|
89
124
|
viewport: parseWithCliError(parsed.values.viewport ?? DEFAULT_VIEWPORT, parseViewport),
|
|
90
|
-
fps
|
|
125
|
+
fps,
|
|
91
126
|
duration: durationOption === 'auto'
|
|
92
127
|
? 'auto'
|
|
93
128
|
: parseWithCliError(durationOption, parsePositiveNumber),
|
|
94
129
|
motion: parseWithCliError(parsed.values.motion ?? DEFAULT_MOTION, parseMotion),
|
|
95
130
|
timeoutMs: parseWithCliError(parsed.values.timeout ?? String(DEFAULT_TIMEOUT_MS), parsePositiveInt),
|
|
96
131
|
waitFor: parseWithCliError(parsed.values['wait-for'] ?? 'load', parseWaitFor),
|
|
97
|
-
hideSelectors
|
|
132
|
+
hideSelectors,
|
|
98
133
|
pageGapSeconds,
|
|
99
134
|
debugFramesDir: parsed.values['debug-frames-dir']
|
|
100
135
|
? resolveOutPath(parsed.values['debug-frames-dir'])
|
|
101
136
|
: undefined,
|
|
137
|
+
force: parsed.values.force === true,
|
|
102
138
|
};
|
|
103
139
|
}
|
|
104
140
|
function toNonEmptyArray(items) {
|
|
105
141
|
if (items.length === 0) {
|
|
106
|
-
throw new CliError('capture
|
|
142
|
+
throw new CliError('capture requires at least one URL.', true);
|
|
107
143
|
}
|
|
108
144
|
return items;
|
|
109
145
|
}
|
|
110
146
|
export function formatUsage() {
|
|
111
147
|
return [
|
|
148
|
+
`rollberry v${VERSION} — Capture smooth scroll videos from web pages`,
|
|
149
|
+
'',
|
|
112
150
|
'Usage:',
|
|
113
151
|
' rollberry capture <url...> [options]',
|
|
152
|
+
' rollberry --help | -h',
|
|
153
|
+
' rollberry --version | -V',
|
|
114
154
|
'',
|
|
115
155
|
'Options:',
|
|
116
156
|
' --out <file> Output MP4 path (default: ./rollberry.mp4)',
|
|
117
157
|
' --viewport <WxH> Viewport size (default: 1440x900)',
|
|
118
|
-
|
|
158
|
+
` --fps <n> Frames per second (default: 60, max: ${MAX_FPS})`,
|
|
119
159
|
' --duration <seconds|auto> Capture duration (default: auto)',
|
|
120
|
-
' --motion <curve> ease-in-out-sine | linear',
|
|
160
|
+
' --motion <curve> ease-in-out-sine | linear (default: ease-in-out-sine)',
|
|
121
161
|
' --timeout <ms> Navigation timeout (default: 30000)',
|
|
122
|
-
' --wait-for <mode> load | selector:<css> | ms:<n>',
|
|
123
|
-
' --hide-selector <css> Hide CSS selector before capture',
|
|
162
|
+
' --wait-for <mode> load | selector:<css> | ms:<n> (default: load)',
|
|
163
|
+
' --hide-selector <css> Hide CSS selector before capture (repeatable)',
|
|
164
|
+
' --force Overwrite output file if it already exists',
|
|
124
165
|
' --debug-frames-dir <dir> Save raw PNG frames for debugging',
|
|
125
166
|
' --page-gap <seconds> Pause between pages (default: 0)',
|
|
126
167
|
' --manifest <file> Manifest JSON path (default: <out>.manifest.json)',
|
|
127
168
|
' --log-file <file> Log JSONL path (default: <out>.log.jsonl)',
|
|
169
|
+
'',
|
|
170
|
+
'Examples:',
|
|
171
|
+
' rollberry capture http://localhost:3000',
|
|
172
|
+
' rollberry capture https://example.com --out demo.mp4 --viewport 1920x1080',
|
|
173
|
+
' rollberry capture https://example.com --duration 10 --fps 30 --force',
|
|
128
174
|
].join('\n');
|
|
129
175
|
}
|
|
130
176
|
function parseWithCliError(rawValue, parser) {
|
|
@@ -135,7 +181,7 @@ function parseWithCliError(rawValue, parser) {
|
|
|
135
181
|
if (error instanceof CliError) {
|
|
136
182
|
throw error;
|
|
137
183
|
}
|
|
138
|
-
throw new CliError(error instanceof Error ? error.message : '
|
|
184
|
+
throw new CliError(error instanceof Error ? error.message : 'Failed to parse arguments.');
|
|
139
185
|
}
|
|
140
186
|
}
|
|
141
187
|
function resolveOutPath(path) {
|
|
@@ -151,33 +197,33 @@ function deriveSidecarPath(path, suffix) {
|
|
|
151
197
|
function parseViewport(rawViewport) {
|
|
152
198
|
const match = /^(?<width>\d+)x(?<height>\d+)$/u.exec(rawViewport);
|
|
153
199
|
if (!match?.groups) {
|
|
154
|
-
throw new Error(`--viewport
|
|
200
|
+
throw new Error(`--viewport must be in "WxH" format (e.g. "1440x900"): ${rawViewport}`);
|
|
155
201
|
}
|
|
156
202
|
const width = Number(match.groups.width);
|
|
157
203
|
const height = Number(match.groups.height);
|
|
158
204
|
if (width <= 0 || height <= 0) {
|
|
159
|
-
throw new Error(`--viewport
|
|
205
|
+
throw new Error(`--viewport dimensions must be positive (e.g. "1440x900"): ${rawViewport}`);
|
|
160
206
|
}
|
|
161
207
|
return { width, height };
|
|
162
208
|
}
|
|
163
209
|
function parsePositiveInt(rawValue) {
|
|
164
210
|
const value = Number(rawValue);
|
|
165
211
|
if (!Number.isInteger(value) || value <= 0) {
|
|
166
|
-
throw new Error(
|
|
212
|
+
throw new Error(`Expected a positive integer (e.g. "60"): ${rawValue}`);
|
|
167
213
|
}
|
|
168
214
|
return value;
|
|
169
215
|
}
|
|
170
216
|
function parsePositiveNumber(rawValue) {
|
|
171
217
|
const value = Number(rawValue);
|
|
172
218
|
if (!Number.isFinite(value) || value <= 0) {
|
|
173
|
-
throw new Error(
|
|
219
|
+
throw new Error(`Expected a positive number (e.g. "5.0"): ${rawValue}`);
|
|
174
220
|
}
|
|
175
221
|
return value;
|
|
176
222
|
}
|
|
177
223
|
function parseNonNegativeNumber(rawValue) {
|
|
178
224
|
const value = Number(rawValue);
|
|
179
225
|
if (!Number.isFinite(value) || value < 0) {
|
|
180
|
-
throw new Error(`0
|
|
226
|
+
throw new Error(`Expected a non-negative number (e.g. "0" or "1.5"): ${rawValue}`);
|
|
181
227
|
}
|
|
182
228
|
return value;
|
|
183
229
|
}
|
|
@@ -185,7 +231,7 @@ function parseMotion(rawMotion) {
|
|
|
185
231
|
if (rawMotion === 'ease-in-out-sine' || rawMotion === 'linear') {
|
|
186
232
|
return rawMotion;
|
|
187
233
|
}
|
|
188
|
-
throw new Error(`--motion
|
|
234
|
+
throw new Error(`--motion must be "ease-in-out-sine" or "linear": ${rawMotion}`);
|
|
189
235
|
}
|
|
190
236
|
function parseWaitFor(rawWaitFor) {
|
|
191
237
|
if (rawWaitFor === 'load') {
|
|
@@ -194,7 +240,7 @@ function parseWaitFor(rawWaitFor) {
|
|
|
194
240
|
if (rawWaitFor.startsWith('selector:')) {
|
|
195
241
|
const selector = rawWaitFor.slice('selector:'.length).trim();
|
|
196
242
|
if (!selector) {
|
|
197
|
-
throw new Error('--wait-for selector:<css>
|
|
243
|
+
throw new Error('--wait-for selector:<css> requires a non-empty CSS selector.');
|
|
198
244
|
}
|
|
199
245
|
return {
|
|
200
246
|
kind: 'selector',
|
|
@@ -207,5 +253,5 @@ function parseWaitFor(rawWaitFor) {
|
|
|
207
253
|
ms: parsePositiveInt(rawWaitFor.slice('ms:'.length)),
|
|
208
254
|
};
|
|
209
255
|
}
|
|
210
|
-
throw new Error(`--wait-for
|
|
256
|
+
throw new Error(`--wait-for must be "load", "selector:<css>", or "ms:<n>": ${rawWaitFor}`);
|
|
211
257
|
}
|
package/dist/run-capture.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
|
-
import { captureVideo } from './capture/capture.js';
|
|
2
|
+
import { AbortError, captureVideo } from './capture/capture.js';
|
|
3
3
|
import { createCaptureLogger } from './capture/logger.js';
|
|
4
|
-
|
|
4
|
+
import { sanitizeUrl } from './capture/utils.js';
|
|
5
|
+
export async function runCaptureCommand(options, progress, signal) {
|
|
5
6
|
const logger = createCaptureLogger(options.logFilePath);
|
|
6
7
|
const startedAt = new Date();
|
|
8
|
+
let capture;
|
|
9
|
+
const sanitizedUrls = options.urls.map((u) => sanitizeUrl(u));
|
|
7
10
|
await logger.info('capture.start', 'Capture started', {
|
|
8
|
-
urls:
|
|
11
|
+
urls: sanitizedUrls,
|
|
9
12
|
outPath: options.outPath,
|
|
10
13
|
manifestPath: options.manifestPath,
|
|
11
14
|
logFilePath: options.logFilePath,
|
|
12
15
|
});
|
|
13
16
|
try {
|
|
14
|
-
|
|
17
|
+
capture = await captureVideo(options, logger, progress, signal);
|
|
15
18
|
const finishedAt = new Date();
|
|
16
|
-
const warnings = capture.truncated
|
|
17
|
-
? ['scroll_height_truncated']
|
|
18
|
-
: [];
|
|
19
|
+
const warnings = capture.truncated ? ['scroll_height_truncated'] : [];
|
|
19
20
|
const manifest = buildManifest({
|
|
20
21
|
status: 'succeeded',
|
|
21
22
|
options,
|
|
23
|
+
sanitizedUrls,
|
|
22
24
|
startedAt,
|
|
23
25
|
finishedAt,
|
|
24
26
|
warnings,
|
|
@@ -40,16 +42,22 @@ export async function runCaptureCommand(options) {
|
|
|
40
42
|
}
|
|
41
43
|
catch (error) {
|
|
42
44
|
const finishedAt = new Date();
|
|
45
|
+
const isCancelled = error instanceof AbortError;
|
|
46
|
+
const warnings = capture?.truncated ? ['scroll_height_truncated'] : [];
|
|
43
47
|
const manifest = buildManifest({
|
|
44
|
-
status: 'failed',
|
|
48
|
+
status: isCancelled ? 'cancelled' : 'failed',
|
|
45
49
|
options,
|
|
50
|
+
sanitizedUrls,
|
|
46
51
|
startedAt,
|
|
47
52
|
finishedAt,
|
|
48
|
-
warnings
|
|
49
|
-
videoCreated:
|
|
50
|
-
|
|
53
|
+
warnings,
|
|
54
|
+
videoCreated: capture !== undefined,
|
|
55
|
+
result: capture,
|
|
56
|
+
error: isCancelled ? undefined : error,
|
|
51
57
|
});
|
|
52
|
-
|
|
58
|
+
const logEvent = isCancelled ? 'capture.cancelled' : 'capture.failed';
|
|
59
|
+
const logMessage = isCancelled ? 'Capture cancelled' : 'Capture failed';
|
|
60
|
+
await logger.error(logEvent, logMessage, {
|
|
53
61
|
name: manifest.error?.name,
|
|
54
62
|
message: manifest.error?.message,
|
|
55
63
|
manifestPath: options.manifestPath,
|
|
@@ -77,7 +85,7 @@ function buildManifest(input) {
|
|
|
77
85
|
arch: process.arch,
|
|
78
86
|
},
|
|
79
87
|
options: {
|
|
80
|
-
urls: input.
|
|
88
|
+
urls: input.sanitizedUrls,
|
|
81
89
|
viewport: input.options.viewport,
|
|
82
90
|
fps: input.options.fps,
|
|
83
91
|
duration: input.options.duration,
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = '0.1.9';
|