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 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.5] - 2026-03-18
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
- - Republished under new maintainer account.
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 のインストール後も実行ファイルが見つかりません: ${executablePath}`);
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
  }
@@ -7,16 +7,22 @@ export async function openBrowserSession(options, logger) {
7
7
  const browser = await chromium.launch({
8
8
  headless: true,
9
9
  });
10
- const context = await browser.newContext({
11
- viewport: options.viewport,
12
- ignoreHTTPSErrors: options.urls.some(isLocalUrl),
13
- });
14
- const page = await context.newPage();
15
- page.setDefaultTimeout(options.timeoutMs);
16
- await page.addInitScript(() => {
17
- history.scrollRestoration = 'manual';
18
- });
19
- return { browser, page };
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;
@@ -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 { createVideoEncoder } from './ffmpeg.js';
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('少なくとも1つのURLを指定してください。');
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
- const encoder = await createVideoEncoder({
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
- await browser.close();
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: url.toString(),
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: url.toString(),
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: url.toString(),
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: url.toString(),
206
+ url: safeUrl,
136
207
  });
137
208
  }
138
209
  }
139
210
  if (!lastFrame) {
140
- throw new Error(`ページのフレーム取得に失敗しました: ${url.toString()}`);
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: url.toString(),
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;
@@ -1,5 +1,16 @@
1
- import { spawn } from 'node:child_process';
2
- import { once } from 'node:events';
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 の標準入力が閉じています。'), stderr);
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.end();
67
- const [exitCode] = (await once(ffmpeg, 'close'));
68
- if (exitCode !== 0) {
69
- throw createEncoderError(new Error(`FFmpeg が異常終了しました (exit code: ${exitCode ?? 'null'})`), stderr);
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 が見つかりません。PATH 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 document.fonts.ready;
55
- });
57
+ await Promise.race([
58
+ document.fonts.ready,
59
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
60
+ ]);
61
+ }, FONT_LOADING_TIMEOUT_MS);
56
62
  }
@@ -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(`無効なURLです: ${rawUrl}`);
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(`サポート対象外のURLです: ${rawUrl} (http/https のみ対応)`);
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 { CliError, formatUsage, parseCliArgs } from './options.js';
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 result = await runCaptureCommand(options);
8
- process.stdout.write(`${result.capture.outPath}\n`);
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 : '予期しないエラーが発生しました。'}\n`);
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 { parseCaptureUrl } from './capture/utils.js';
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('サブコマンドが必要です。', true);
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(`未知のサブコマンドです: ${command}`, true);
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 にはURLが必要です。', true);
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 "auto" または数値で指定してください: ${durationOption}`);
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: parseWithCliError(parsed.values.fps ?? String(DEFAULT_FPS), parsePositiveInt),
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: parsed.values['hide-selector'] ?? [],
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 にはURLが必要です。', true);
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
- ' --fps <n> Frames per second (default: 60)',
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 "1440x900" の形式で指定してください: ${rawViewport}`);
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 の値が不正です: ${rawViewport}`);
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(`正の整数を指定してください: ${rawValue}`);
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(`正の数値を指定してください: ${rawValue}`);
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以上の数値を指定してください: ${rawValue}`);
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 ease-in-out-sine または linear です: ${rawMotion}`);
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> 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 load / selector:<css> / ms:<n> のいずれかです: ${rawWaitFor}`);
256
+ throw new Error(`--wait-for must be "load", "selector:<css>", or "ms:<n>": ${rawWaitFor}`);
211
257
  }
@@ -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
- export async function runCaptureCommand(options) {
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: options.urls.map((u) => u.toString()),
11
+ urls: sanitizedUrls,
9
12
  outPath: options.outPath,
10
13
  manifestPath: options.manifestPath,
11
14
  logFilePath: options.logFilePath,
12
15
  });
13
16
  try {
14
- const capture = await captureVideo(options, logger);
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: false,
50
- error,
53
+ warnings,
54
+ videoCreated: capture !== undefined,
55
+ result: capture,
56
+ error: isCancelled ? undefined : error,
51
57
  });
52
- await logger.error('capture.failed', 'Capture failed', {
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.options.urls.map((u) => u.toString()),
88
+ urls: input.sanitizedUrls,
81
89
  viewport: input.options.viewport,
82
90
  fps: input.options.fps,
83
91
  duration: input.options.duration,
@@ -0,0 +1 @@
1
+ export const VERSION = '0.1.9';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollberry",
3
- "version": "0.1.5",
3
+ "version": "0.1.9",
4
4
  "description": "CLI to capture smooth top-to-bottom scroll videos from web pages, including localhost URLs.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.15.0",