rollberry 0.1.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/README.md +81 -0
- package/dist/capture/browser-install.js +49 -0
- package/dist/capture/browser.js +54 -0
- package/dist/capture/capture.js +89 -0
- package/dist/capture/constants.js +8 -0
- package/dist/capture/ffmpeg.js +80 -0
- package/dist/capture/logger.js +30 -0
- package/dist/capture/preflight.js +40 -0
- package/dist/capture/scroll-plan.js +22 -0
- package/dist/capture/stabilize.js +56 -0
- package/dist/capture/types.js +1 -0
- package/dist/capture/utils.js +55 -0
- package/dist/cli.js +23 -0
- package/dist/options.js +192 -0
- package/dist/run-capture.js +115 -0
- package/package.json +38 -0
- package/regression.sample.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Rollberry
|
|
2
|
+
|
|
3
|
+
Rollberry は、指定した Web ページを上から下まで滑らかにスクロールさせた MP4 を生成する CLI です。`localhost`、`127.0.0.1`、`[::1]` の開発中 URL にも対応します。
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js `24.12.0+`
|
|
8
|
+
- `ffmpeg` / `ffprobe`
|
|
9
|
+
- macOS を優先サポート
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
corepack pnpm install
|
|
15
|
+
corepack pnpm exec playwright install chromium
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx rollberry capture http://localhost:3000 \
|
|
22
|
+
--out ./artifacts/demo.mp4 \
|
|
23
|
+
--viewport 1440x900 \
|
|
24
|
+
--fps 60 \
|
|
25
|
+
--duration auto \
|
|
26
|
+
--wait-for selector:body \
|
|
27
|
+
--hide-selector '#cookie-banner'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
リポジトリ内で開発実行する場合は次です。
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
corepack pnpm dev -- capture http://localhost:3000 \
|
|
34
|
+
--out ./artifacts/demo.mp4 \
|
|
35
|
+
--viewport 1440x900 \
|
|
36
|
+
--fps 60 \
|
|
37
|
+
--duration auto \
|
|
38
|
+
--wait-for selector:body \
|
|
39
|
+
--hide-selector '#cookie-banner'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
ビルド後は次でも実行できます。
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node dist/cli.js capture https://example.com --out ./artifacts/example.mp4
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
初回の `npx rollberry ...` 実行時に Chromium が未導入なら、Rollberry が Playwright Chromium を自動インストールします。`ffmpeg` / `ffprobe` は自動導入しないので、事前に PATH 上で使える必要があります。
|
|
49
|
+
|
|
50
|
+
## Sidecar Outputs
|
|
51
|
+
|
|
52
|
+
各キャプチャのたびに次を出力します。
|
|
53
|
+
|
|
54
|
+
- `video.mp4`: 本体動画
|
|
55
|
+
- `video.manifest.json`: 実行結果、環境、オプション、失敗内容
|
|
56
|
+
- `video.log.jsonl`: 1 行 1 JSON の運用ログ
|
|
57
|
+
|
|
58
|
+
`--manifest` と `--log-file` で出力先を個別に上書きできます。
|
|
59
|
+
|
|
60
|
+
## Localhost Behavior
|
|
61
|
+
|
|
62
|
+
- `http://localhost:*`、`https://localhost:*`、`http://127.0.0.1:*`、`http://[::1]:*` を許可
|
|
63
|
+
- `localhost` 系では接続拒否を `--timeout` まで 500ms 間隔で再試行
|
|
64
|
+
- `https://localhost` 系では自己署名証明書を許可
|
|
65
|
+
- dev server の自動起動はしません。URL は事前に起動済みである前提です
|
|
66
|
+
|
|
67
|
+
## Recommended Operational Flow
|
|
68
|
+
|
|
69
|
+
1. `regression.sample.json` をコピーして自分たちの `regression.sites.json` を作る
|
|
70
|
+
2. 重要な 10〜20 URL を登録する
|
|
71
|
+
3. リリース前に `corepack pnpm regression -- --config ./regression.sites.json` を実行する
|
|
72
|
+
4. `artifacts/regression/summary.json` と各 manifest を確認する
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
corepack pnpm check
|
|
78
|
+
corepack pnpm test
|
|
79
|
+
corepack pnpm build
|
|
80
|
+
corepack pnpm regression -- --config ./regression.sites.json
|
|
81
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
export async function ensureChromiumInstalled(logger) {
|
|
9
|
+
const executablePath = chromium.executablePath();
|
|
10
|
+
if (await hasExecutable(executablePath)) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
await logger.warn('browser.install.start', 'Chromium was not found. Installing Playwright Chromium.', { executablePath });
|
|
14
|
+
await installPlaywrightChromium(resolvePlaywrightCliPath());
|
|
15
|
+
if (!(await hasExecutable(executablePath))) {
|
|
16
|
+
throw new Error(`Chromium のインストール後も実行ファイルが見つかりません: ${executablePath}`);
|
|
17
|
+
}
|
|
18
|
+
await logger.info('browser.install.complete', 'Playwright Chromium installation finished.', { executablePath });
|
|
19
|
+
}
|
|
20
|
+
export function resolvePlaywrightCliPath() {
|
|
21
|
+
return join(dirname(require.resolve('playwright/package.json')), 'cli.js');
|
|
22
|
+
}
|
|
23
|
+
async function hasExecutable(path) {
|
|
24
|
+
try {
|
|
25
|
+
await access(path, constants.X_OK);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function installPlaywrightChromium(cliPath) {
|
|
33
|
+
const child = spawn(process.execPath, [cliPath, 'install', 'chromium'], {
|
|
34
|
+
env: process.env,
|
|
35
|
+
stdio: 'inherit',
|
|
36
|
+
});
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
child.once('error', (error) => {
|
|
39
|
+
reject(error);
|
|
40
|
+
});
|
|
41
|
+
child.once('close', (code) => {
|
|
42
|
+
if (code === 0) {
|
|
43
|
+
resolve();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
reject(new Error(`Playwright Chromium install failed with exit code ${code ?? 'null'}.`));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { ensureChromiumInstalled } from './browser-install.js';
|
|
3
|
+
import { LOCALHOST_RETRY_INTERVAL_MS } from './constants.js';
|
|
4
|
+
import { delay, isLocalUrl } from './utils.js';
|
|
5
|
+
export async function openBrowserSession(options, logger) {
|
|
6
|
+
await ensureChromiumInstalled(logger);
|
|
7
|
+
const browser = await chromium.launch({
|
|
8
|
+
headless: true,
|
|
9
|
+
});
|
|
10
|
+
const context = await browser.newContext({
|
|
11
|
+
viewport: options.viewport,
|
|
12
|
+
ignoreHTTPSErrors: isLocalUrl(options.url),
|
|
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 };
|
|
20
|
+
}
|
|
21
|
+
export async function navigateWithRetry(page, options) {
|
|
22
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
23
|
+
const canRetry = isLocalUrl(options.url);
|
|
24
|
+
while (true) {
|
|
25
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
26
|
+
try {
|
|
27
|
+
await page.goto(options.url.toString(), {
|
|
28
|
+
waitUntil: 'domcontentloaded',
|
|
29
|
+
timeout: remainingMs,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!canRetry ||
|
|
35
|
+
Date.now() + LOCALHOST_RETRY_INTERVAL_MS >= deadline ||
|
|
36
|
+
!isRetryableNavigationError(error)) {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
await delay(LOCALHOST_RETRY_INTERVAL_MS);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isRetryableNavigationError(error) {
|
|
44
|
+
if (!(error instanceof Error)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
'ERR_CONNECTION_REFUSED',
|
|
49
|
+
'ERR_EMPTY_RESPONSE',
|
|
50
|
+
'ERR_CONNECTION_RESET',
|
|
51
|
+
'ECONNREFUSED',
|
|
52
|
+
'ECONNRESET',
|
|
53
|
+
].some((token) => error.message.includes(token));
|
|
54
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { navigateWithRetry, openBrowserSession } from './browser.js';
|
|
3
|
+
import { createVideoEncoder } from './ffmpeg.js';
|
|
4
|
+
import { preflightMeasurePage } from './preflight.js';
|
|
5
|
+
import { buildScrollFrames, resolveDurationSeconds } from './scroll-plan.js';
|
|
6
|
+
import { stabilizePage } from './stabilize.js';
|
|
7
|
+
import { ensureDirectory, ensureParentDirectory, waitForAnimationFrames, } from './utils.js';
|
|
8
|
+
export async function captureVideo(options, logger) {
|
|
9
|
+
await ensureParentDirectory(options.outPath);
|
|
10
|
+
await ensureParentDirectory(options.logFilePath);
|
|
11
|
+
await ensureParentDirectory(options.manifestPath);
|
|
12
|
+
if (options.debugFramesDir) {
|
|
13
|
+
await ensureDirectory(options.debugFramesDir);
|
|
14
|
+
}
|
|
15
|
+
const { browser, page } = await openBrowserSession(options, logger);
|
|
16
|
+
try {
|
|
17
|
+
await logger.info('browser.open', 'Opening page', {
|
|
18
|
+
url: options.url.toString(),
|
|
19
|
+
viewport: options.viewport,
|
|
20
|
+
});
|
|
21
|
+
await navigateWithRetry(page, options);
|
|
22
|
+
await stabilizePage({
|
|
23
|
+
page,
|
|
24
|
+
waitFor: options.waitFor,
|
|
25
|
+
hideSelectors: options.hideSelectors,
|
|
26
|
+
});
|
|
27
|
+
const preflight = await preflightMeasurePage(page);
|
|
28
|
+
const durationSeconds = resolveDurationSeconds(options.duration, preflight.maxScroll);
|
|
29
|
+
const frames = buildScrollFrames({
|
|
30
|
+
fps: options.fps,
|
|
31
|
+
durationSeconds,
|
|
32
|
+
maxScroll: preflight.maxScroll,
|
|
33
|
+
motion: options.motion,
|
|
34
|
+
});
|
|
35
|
+
const encoder = await createVideoEncoder({
|
|
36
|
+
fps: options.fps,
|
|
37
|
+
outPath: options.outPath,
|
|
38
|
+
});
|
|
39
|
+
await logger.info('render.start', 'Rendering frames', {
|
|
40
|
+
frameCount: frames.length,
|
|
41
|
+
fps: options.fps,
|
|
42
|
+
durationSeconds,
|
|
43
|
+
maxScroll: preflight.maxScroll,
|
|
44
|
+
});
|
|
45
|
+
if (preflight.truncated) {
|
|
46
|
+
await logger.warn('preflight.truncated', 'Scroll height exceeded capture limit and was truncated', {
|
|
47
|
+
scrollHeight: preflight.scrollHeight,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
for (const [index, scrollTop] of frames.entries()) {
|
|
51
|
+
await page.evaluate((nextScrollTop) => {
|
|
52
|
+
window.scrollTo({ top: nextScrollTop, behavior: 'auto' });
|
|
53
|
+
}, scrollTop);
|
|
54
|
+
await waitForAnimationFrames(page);
|
|
55
|
+
const frame = await page.screenshot({
|
|
56
|
+
type: 'png',
|
|
57
|
+
scale: 'css',
|
|
58
|
+
animations: 'disabled',
|
|
59
|
+
caret: 'hide',
|
|
60
|
+
});
|
|
61
|
+
if (options.debugFramesDir) {
|
|
62
|
+
const fileName = `${String(index).padStart(5, '0')}.png`;
|
|
63
|
+
await writeFile(`${options.debugFramesDir}/${fileName}`, frame);
|
|
64
|
+
}
|
|
65
|
+
await encoder.writeFrame(frame);
|
|
66
|
+
if ((index + 1) % Math.max(1, Math.floor(frames.length / 10)) === 0) {
|
|
67
|
+
await logger.info('render.progress', 'Rendered frame batch', {
|
|
68
|
+
renderedFrames: index + 1,
|
|
69
|
+
totalFrames: frames.length,
|
|
70
|
+
scrollTop,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await encoder.finish();
|
|
75
|
+
await logger.info('encode.complete', 'Video encoding finished', {
|
|
76
|
+
outPath: options.outPath,
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
outPath: options.outPath,
|
|
80
|
+
frameCount: frames.length,
|
|
81
|
+
durationSeconds,
|
|
82
|
+
finalScrollHeight: preflight.scrollHeight,
|
|
83
|
+
truncated: preflight.truncated,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await browser.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const AUTO_DURATION_MIN_SECONDS = 4;
|
|
2
|
+
export const AUTO_DURATION_MAX_SECONDS = 40;
|
|
3
|
+
export const AUTO_DURATION_PIXELS_PER_SECOND = 1800;
|
|
4
|
+
export const LOCALHOST_RETRY_INTERVAL_MS = 500;
|
|
5
|
+
export const STABILIZE_DELAY_MS = 500;
|
|
6
|
+
export const PREFLIGHT_STEP_DELAY_MS = 120;
|
|
7
|
+
export const PREFLIGHT_STABLE_ROUNDS = 3;
|
|
8
|
+
export const PREFLIGHT_MAX_SCROLL_HEIGHT = 30_000;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
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
|
+
], {
|
|
32
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
let spawnError;
|
|
35
|
+
let stderr = '';
|
|
36
|
+
ffmpeg.on('error', (error) => {
|
|
37
|
+
spawnError = error;
|
|
38
|
+
});
|
|
39
|
+
ffmpeg.stderr.setEncoding('utf8');
|
|
40
|
+
ffmpeg.stderr.on('data', (chunk) => {
|
|
41
|
+
stderr += chunk;
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
async writeFrame(frame) {
|
|
45
|
+
if (spawnError) {
|
|
46
|
+
throw createEncoderError(spawnError, stderr);
|
|
47
|
+
}
|
|
48
|
+
const stdin = ffmpeg.stdin;
|
|
49
|
+
if (stdin.destroyed) {
|
|
50
|
+
throw createEncoderError(new Error('FFmpeg の標準入力が閉じています。'), stderr);
|
|
51
|
+
}
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
stdin.write(frame, (error) => {
|
|
54
|
+
if (error) {
|
|
55
|
+
reject(createEncoderError(error, stderr));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
async finish() {
|
|
63
|
+
if (spawnError) {
|
|
64
|
+
throw createEncoderError(spawnError, stderr);
|
|
65
|
+
}
|
|
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);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function createEncoderError(error, stderr) {
|
|
75
|
+
if ('code' in error && error.code === 'ENOENT') {
|
|
76
|
+
return new Error('FFmpeg が見つかりません。PATH に ffmpeg を追加してください。');
|
|
77
|
+
}
|
|
78
|
+
const detail = stderr.trim();
|
|
79
|
+
return new Error(detail ? `${error.message}\n${detail}` : error.message);
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
|
2
|
+
export function createCaptureLogger(logFilePath) {
|
|
3
|
+
let queue = Promise.resolve();
|
|
4
|
+
const write = async (level, event, message, data) => {
|
|
5
|
+
const logEvent = {
|
|
6
|
+
timestamp: new Date().toISOString(),
|
|
7
|
+
level,
|
|
8
|
+
event,
|
|
9
|
+
message,
|
|
10
|
+
data,
|
|
11
|
+
};
|
|
12
|
+
process.stderr.write(`${logEvent.timestamp} [${level.toUpperCase()}] ${message}\n`);
|
|
13
|
+
queue = queue.then(() => appendFile(logFilePath, `${JSON.stringify(logEvent)}\n`, 'utf8'));
|
|
14
|
+
await queue;
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
info(event, message, data) {
|
|
18
|
+
return write('info', event, message, data);
|
|
19
|
+
},
|
|
20
|
+
warn(event, message, data) {
|
|
21
|
+
return write('warn', event, message, data);
|
|
22
|
+
},
|
|
23
|
+
error(event, message, data) {
|
|
24
|
+
return write('error', event, message, data);
|
|
25
|
+
},
|
|
26
|
+
close() {
|
|
27
|
+
return queue;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { PREFLIGHT_MAX_SCROLL_HEIGHT, PREFLIGHT_STABLE_ROUNDS, PREFLIGHT_STEP_DELAY_MS, } from './constants.js';
|
|
2
|
+
import { delay, measurePage, waitForAnimationFrames } from './utils.js';
|
|
3
|
+
export async function preflightMeasurePage(page) {
|
|
4
|
+
let metrics = await measurePage(page);
|
|
5
|
+
let truncated = metrics.scrollHeight > PREFLIGHT_MAX_SCROLL_HEIGHT;
|
|
6
|
+
let stableRounds = metrics.maxScroll === 0 ? PREFLIGHT_STABLE_ROUNDS : 0;
|
|
7
|
+
while (!truncated && stableRounds < PREFLIGHT_STABLE_ROUNDS) {
|
|
8
|
+
let position = 0;
|
|
9
|
+
while (position < metrics.maxScroll) {
|
|
10
|
+
position = Math.min(position + metrics.viewportHeight, metrics.maxScroll);
|
|
11
|
+
await page.evaluate((scrollTop) => {
|
|
12
|
+
window.scrollTo({ top: scrollTop, behavior: 'auto' });
|
|
13
|
+
}, position);
|
|
14
|
+
await waitForAnimationFrames(page);
|
|
15
|
+
await delay(PREFLIGHT_STEP_DELAY_MS);
|
|
16
|
+
}
|
|
17
|
+
await delay(PREFLIGHT_STEP_DELAY_MS);
|
|
18
|
+
const nextMetrics = await measurePage(page);
|
|
19
|
+
truncated = nextMetrics.scrollHeight > PREFLIGHT_MAX_SCROLL_HEIGHT;
|
|
20
|
+
if (nextMetrics.scrollHeight > metrics.scrollHeight) {
|
|
21
|
+
metrics = nextMetrics;
|
|
22
|
+
stableRounds = nextMetrics.maxScroll === 0 ? PREFLIGHT_STABLE_ROUNDS : 0;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
metrics = nextMetrics;
|
|
26
|
+
stableRounds += 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await page.evaluate(() => {
|
|
30
|
+
window.scrollTo({ top: 0, behavior: 'auto' });
|
|
31
|
+
});
|
|
32
|
+
await waitForAnimationFrames(page);
|
|
33
|
+
const clampedScrollHeight = Math.min(metrics.scrollHeight, PREFLIGHT_MAX_SCROLL_HEIGHT);
|
|
34
|
+
return {
|
|
35
|
+
scrollHeight: clampedScrollHeight,
|
|
36
|
+
viewportHeight: metrics.viewportHeight,
|
|
37
|
+
maxScroll: Math.max(0, clampedScrollHeight - metrics.viewportHeight),
|
|
38
|
+
truncated,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AUTO_DURATION_MAX_SECONDS, AUTO_DURATION_MIN_SECONDS, AUTO_DURATION_PIXELS_PER_SECOND, } from './constants.js';
|
|
2
|
+
import { clamp } from './utils.js';
|
|
3
|
+
export function resolveDurationSeconds(requestedDuration, maxScroll) {
|
|
4
|
+
if (requestedDuration !== 'auto') {
|
|
5
|
+
return requestedDuration;
|
|
6
|
+
}
|
|
7
|
+
return clamp(maxScroll / AUTO_DURATION_PIXELS_PER_SECOND, AUTO_DURATION_MIN_SECONDS, AUTO_DURATION_MAX_SECONDS);
|
|
8
|
+
}
|
|
9
|
+
export function buildScrollFrames(options) {
|
|
10
|
+
const frameCount = Math.max(1, Math.ceil(options.durationSeconds * options.fps));
|
|
11
|
+
if (frameCount === 1) {
|
|
12
|
+
return [0];
|
|
13
|
+
}
|
|
14
|
+
return Array.from({ length: frameCount }, (_, index) => {
|
|
15
|
+
const progress = index / (frameCount - 1);
|
|
16
|
+
const easedProgress = options.motion === 'linear' ? progress : easeInOutSine(progress);
|
|
17
|
+
return Number((options.maxScroll * easedProgress).toFixed(3));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function easeInOutSine(value) {
|
|
21
|
+
return -(Math.cos(Math.PI * value) - 1) / 2;
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { STABILIZE_DELAY_MS } from './constants.js';
|
|
2
|
+
import { delay, waitForAnimationFrames } from './utils.js';
|
|
3
|
+
export async function stabilizePage(options) {
|
|
4
|
+
await options.page.addStyleTag({
|
|
5
|
+
content: buildStabilizingCss(options.hideSelectors),
|
|
6
|
+
});
|
|
7
|
+
await waitForRequestedCondition(options.page, options.waitFor);
|
|
8
|
+
await waitForFonts(options.page);
|
|
9
|
+
await delay(STABILIZE_DELAY_MS);
|
|
10
|
+
await options.page.evaluate(() => {
|
|
11
|
+
window.scrollTo({ top: 0, behavior: 'auto' });
|
|
12
|
+
});
|
|
13
|
+
await waitForAnimationFrames(options.page);
|
|
14
|
+
}
|
|
15
|
+
function buildStabilizingCss(hideSelectors) {
|
|
16
|
+
const hiddenSelectorBlock = hideSelectors.length > 0
|
|
17
|
+
? `${hideSelectors.join(', ')} { display: none !important; visibility: hidden !important; }\n`
|
|
18
|
+
: '';
|
|
19
|
+
return `
|
|
20
|
+
html {
|
|
21
|
+
scroll-behavior: auto !important;
|
|
22
|
+
caret-color: transparent !important;
|
|
23
|
+
scroll-snap-type: none !important;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
*, *::before, *::after {
|
|
27
|
+
animation: none !important;
|
|
28
|
+
transition: none !important;
|
|
29
|
+
scroll-behavior: auto !important;
|
|
30
|
+
caret-color: transparent !important;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
${hiddenSelectorBlock}
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
async function waitForRequestedCondition(page, waitFor) {
|
|
37
|
+
if (waitFor.kind === 'load') {
|
|
38
|
+
await page.waitForLoadState('load');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (waitFor.kind === 'selector') {
|
|
42
|
+
await page.waitForSelector(waitFor.selector, {
|
|
43
|
+
state: 'attached',
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await delay(waitFor.ms);
|
|
48
|
+
}
|
|
49
|
+
async function waitForFonts(page) {
|
|
50
|
+
await page.evaluate(async () => {
|
|
51
|
+
if (!('fonts' in document)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await document.fonts.ready;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
4
|
+
export function clamp(value, min, max) {
|
|
5
|
+
return Math.min(max, Math.max(min, value));
|
|
6
|
+
}
|
|
7
|
+
export function isLocalUrl(url) {
|
|
8
|
+
return LOCALHOST_HOSTS.has(url.hostname);
|
|
9
|
+
}
|
|
10
|
+
export function parseCaptureUrl(rawUrl) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(rawUrl);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`無効なURLです: ${rawUrl}`);
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
19
|
+
throw new Error(`サポート対象外のURLです: ${rawUrl} (http/https のみ対応)`);
|
|
20
|
+
}
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
export async function ensureParentDirectory(filePath) {
|
|
24
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
export async function ensureDirectory(path) {
|
|
27
|
+
await mkdir(path, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
export async function delay(ms) {
|
|
30
|
+
await new Promise((resolve) => {
|
|
31
|
+
setTimeout(resolve, ms);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export async function waitForAnimationFrames(page, frameCount = 2) {
|
|
35
|
+
await page.evaluate(async (count) => {
|
|
36
|
+
for (let index = 0; index < count; index += 1) {
|
|
37
|
+
await new Promise((resolve) => {
|
|
38
|
+
requestAnimationFrame(() => resolve());
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}, frameCount);
|
|
42
|
+
}
|
|
43
|
+
export async function measurePage(page) {
|
|
44
|
+
return page.evaluate(() => {
|
|
45
|
+
const body = document.body;
|
|
46
|
+
const root = document.documentElement;
|
|
47
|
+
const scrollHeight = Math.max(body?.scrollHeight ?? 0, body?.offsetHeight ?? 0, root.scrollHeight, root.offsetHeight, root.clientHeight);
|
|
48
|
+
const viewportHeight = window.innerHeight || root.clientHeight;
|
|
49
|
+
return {
|
|
50
|
+
scrollHeight,
|
|
51
|
+
viewportHeight,
|
|
52
|
+
maxScroll: Math.max(0, scrollHeight - viewportHeight),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { CliError, formatUsage, parseCliArgs } from './options.js';
|
|
3
|
+
import { runCaptureCommand } from './run-capture.js';
|
|
4
|
+
async function main() {
|
|
5
|
+
try {
|
|
6
|
+
const options = parseCliArgs();
|
|
7
|
+
const result = await runCaptureCommand(options);
|
|
8
|
+
process.stdout.write(`${result.capture.outPath}\n`);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error instanceof CliError) {
|
|
12
|
+
process.stderr.write(`${error.message}\n`);
|
|
13
|
+
if (error.showUsage) {
|
|
14
|
+
process.stderr.write(`\n${formatUsage()}\n`);
|
|
15
|
+
}
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
process.stderr.write(`${error instanceof Error ? error.message : '予期しないエラーが発生しました。'}\n`);
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
await main();
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { parseCaptureUrl } from './capture/utils.js';
|
|
4
|
+
const DEFAULT_OUT_FILE = 'rollberry.mp4';
|
|
5
|
+
const DEFAULT_VIEWPORT = '1440x900';
|
|
6
|
+
const DEFAULT_FPS = 60;
|
|
7
|
+
const DEFAULT_DURATION = 'auto';
|
|
8
|
+
const DEFAULT_MOTION = 'ease-in-out-sine';
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
|
+
export class CliError extends Error {
|
|
11
|
+
showUsage;
|
|
12
|
+
constructor(message, showUsage = false) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.showUsage = showUsage;
|
|
15
|
+
this.name = 'CliError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function parseCliArgs(argv = process.argv.slice(2)) {
|
|
19
|
+
const [command, ...rest] = argv;
|
|
20
|
+
if (!command) {
|
|
21
|
+
throw new CliError('サブコマンドが必要です。', true);
|
|
22
|
+
}
|
|
23
|
+
if (command !== 'capture') {
|
|
24
|
+
throw new CliError(`未知のサブコマンドです: ${command}`, true);
|
|
25
|
+
}
|
|
26
|
+
const parsed = parseArgs({
|
|
27
|
+
args: rest,
|
|
28
|
+
allowPositionals: true,
|
|
29
|
+
options: {
|
|
30
|
+
out: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
},
|
|
33
|
+
viewport: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
},
|
|
36
|
+
fps: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
},
|
|
39
|
+
duration: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
},
|
|
42
|
+
motion: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
},
|
|
45
|
+
timeout: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
},
|
|
48
|
+
'wait-for': {
|
|
49
|
+
type: 'string',
|
|
50
|
+
},
|
|
51
|
+
'hide-selector': {
|
|
52
|
+
type: 'string',
|
|
53
|
+
multiple: true,
|
|
54
|
+
},
|
|
55
|
+
'debug-frames-dir': {
|
|
56
|
+
type: 'string',
|
|
57
|
+
},
|
|
58
|
+
manifest: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
},
|
|
61
|
+
'log-file': {
|
|
62
|
+
type: 'string',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
strict: true,
|
|
66
|
+
});
|
|
67
|
+
const rawUrl = parsed.positionals[0];
|
|
68
|
+
if (!rawUrl) {
|
|
69
|
+
throw new CliError('capture にはURLが必要です。', true);
|
|
70
|
+
}
|
|
71
|
+
const durationOption = parsed.values.duration ?? DEFAULT_DURATION;
|
|
72
|
+
if (durationOption !== 'auto' && Number.isNaN(Number(durationOption))) {
|
|
73
|
+
throw new CliError(`--duration は "auto" または数値で指定してください: ${durationOption}`);
|
|
74
|
+
}
|
|
75
|
+
const outPath = resolveOutPath(parsed.values.out ?? DEFAULT_OUT_FILE);
|
|
76
|
+
return {
|
|
77
|
+
url: parseWithCliError(rawUrl, parseCaptureUrl),
|
|
78
|
+
outPath,
|
|
79
|
+
manifestPath: parsed.values.manifest
|
|
80
|
+
? resolveOutPath(parsed.values.manifest)
|
|
81
|
+
: deriveSidecarPath(outPath, '.manifest.json'),
|
|
82
|
+
logFilePath: parsed.values['log-file']
|
|
83
|
+
? resolveOutPath(parsed.values['log-file'])
|
|
84
|
+
: deriveSidecarPath(outPath, '.log.jsonl'),
|
|
85
|
+
viewport: parseWithCliError(parsed.values.viewport ?? DEFAULT_VIEWPORT, parseViewport),
|
|
86
|
+
fps: parseWithCliError(parsed.values.fps ?? String(DEFAULT_FPS), parsePositiveInt),
|
|
87
|
+
duration: durationOption === 'auto'
|
|
88
|
+
? 'auto'
|
|
89
|
+
: parseWithCliError(durationOption, parsePositiveNumber),
|
|
90
|
+
motion: parseWithCliError(parsed.values.motion ?? DEFAULT_MOTION, parseMotion),
|
|
91
|
+
timeoutMs: parseWithCliError(parsed.values.timeout ?? String(DEFAULT_TIMEOUT_MS), parsePositiveInt),
|
|
92
|
+
waitFor: parseWithCliError(parsed.values['wait-for'] ?? 'load', parseWaitFor),
|
|
93
|
+
hideSelectors: parsed.values['hide-selector'] ?? [],
|
|
94
|
+
debugFramesDir: parsed.values['debug-frames-dir']
|
|
95
|
+
? resolveOutPath(parsed.values['debug-frames-dir'])
|
|
96
|
+
: undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function formatUsage() {
|
|
100
|
+
return [
|
|
101
|
+
'Usage:',
|
|
102
|
+
' rollberry capture <url> [options]',
|
|
103
|
+
'',
|
|
104
|
+
'Options:',
|
|
105
|
+
' --out <file> Output MP4 path (default: ./rollberry.mp4)',
|
|
106
|
+
' --viewport <WxH> Viewport size (default: 1440x900)',
|
|
107
|
+
' --fps <n> Frames per second (default: 60)',
|
|
108
|
+
' --duration <seconds|auto> Capture duration (default: auto)',
|
|
109
|
+
' --motion <curve> ease-in-out-sine | linear',
|
|
110
|
+
' --timeout <ms> Navigation timeout (default: 30000)',
|
|
111
|
+
' --wait-for <mode> load | selector:<css> | ms:<n>',
|
|
112
|
+
' --hide-selector <css> Hide CSS selector before capture',
|
|
113
|
+
' --debug-frames-dir <dir> Save raw PNG frames for debugging',
|
|
114
|
+
' --manifest <file> Manifest JSON path (default: <out>.manifest.json)',
|
|
115
|
+
' --log-file <file> Log JSONL path (default: <out>.log.jsonl)',
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
function parseWithCliError(rawValue, parser) {
|
|
119
|
+
try {
|
|
120
|
+
return parser(rawValue);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof CliError) {
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
throw new CliError(error instanceof Error ? error.message : '引数の解析に失敗しました。');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function resolveOutPath(path) {
|
|
130
|
+
return resolve(process.cwd(), path);
|
|
131
|
+
}
|
|
132
|
+
function deriveSidecarPath(path, suffix) {
|
|
133
|
+
const extension = /\.([^.]+)$/u.exec(path);
|
|
134
|
+
if (!extension) {
|
|
135
|
+
return `${path}${suffix}`;
|
|
136
|
+
}
|
|
137
|
+
return path.slice(0, -extension[0].length) + suffix;
|
|
138
|
+
}
|
|
139
|
+
function parseViewport(rawViewport) {
|
|
140
|
+
const match = /^(?<width>\d+)x(?<height>\d+)$/u.exec(rawViewport);
|
|
141
|
+
if (!match?.groups) {
|
|
142
|
+
throw new Error(`--viewport は "1440x900" の形式で指定してください: ${rawViewport}`);
|
|
143
|
+
}
|
|
144
|
+
const width = Number(match.groups.width);
|
|
145
|
+
const height = Number(match.groups.height);
|
|
146
|
+
if (width <= 0 || height <= 0) {
|
|
147
|
+
throw new Error(`--viewport の値が不正です: ${rawViewport}`);
|
|
148
|
+
}
|
|
149
|
+
return { width, height };
|
|
150
|
+
}
|
|
151
|
+
function parsePositiveInt(rawValue) {
|
|
152
|
+
const value = Number(rawValue);
|
|
153
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
154
|
+
throw new Error(`正の整数を指定してください: ${rawValue}`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
function parsePositiveNumber(rawValue) {
|
|
159
|
+
const value = Number(rawValue);
|
|
160
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
161
|
+
throw new Error(`正の数値を指定してください: ${rawValue}`);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
function parseMotion(rawMotion) {
|
|
166
|
+
if (rawMotion === 'ease-in-out-sine' || rawMotion === 'linear') {
|
|
167
|
+
return rawMotion;
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`--motion は ease-in-out-sine または linear です: ${rawMotion}`);
|
|
170
|
+
}
|
|
171
|
+
function parseWaitFor(rawWaitFor) {
|
|
172
|
+
if (rawWaitFor === 'load') {
|
|
173
|
+
return { kind: 'load' };
|
|
174
|
+
}
|
|
175
|
+
if (rawWaitFor.startsWith('selector:')) {
|
|
176
|
+
const selector = rawWaitFor.slice('selector:'.length).trim();
|
|
177
|
+
if (!selector) {
|
|
178
|
+
throw new Error('--wait-for selector:<css> の CSS セレクタが空です。');
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind: 'selector',
|
|
182
|
+
selector,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (rawWaitFor.startsWith('ms:')) {
|
|
186
|
+
return {
|
|
187
|
+
kind: 'delay',
|
|
188
|
+
ms: parsePositiveInt(rawWaitFor.slice('ms:'.length)),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
throw new Error(`--wait-for は load / selector:<css> / ms:<n> のいずれかです: ${rawWaitFor}`);
|
|
192
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { captureVideo } from './capture/capture.js';
|
|
3
|
+
import { createCaptureLogger } from './capture/logger.js';
|
|
4
|
+
import { ensureParentDirectory } from './capture/utils.js';
|
|
5
|
+
export async function runCaptureCommand(options) {
|
|
6
|
+
await ensureParentDirectory(options.outPath);
|
|
7
|
+
await ensureParentDirectory(options.manifestPath);
|
|
8
|
+
await ensureParentDirectory(options.logFilePath);
|
|
9
|
+
const logger = createCaptureLogger(options.logFilePath);
|
|
10
|
+
const startedAt = new Date();
|
|
11
|
+
await logger.info('capture.start', 'Capture started', {
|
|
12
|
+
url: options.url.toString(),
|
|
13
|
+
outPath: options.outPath,
|
|
14
|
+
manifestPath: options.manifestPath,
|
|
15
|
+
logFilePath: options.logFilePath,
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
const capture = await captureVideo(options, logger);
|
|
19
|
+
const finishedAt = new Date();
|
|
20
|
+
const warnings = capture.truncated ? ['scroll_height_truncated'] : [];
|
|
21
|
+
const manifest = buildManifest({
|
|
22
|
+
status: 'succeeded',
|
|
23
|
+
options,
|
|
24
|
+
startedAt,
|
|
25
|
+
finishedAt,
|
|
26
|
+
warnings,
|
|
27
|
+
videoCreated: true,
|
|
28
|
+
result: capture,
|
|
29
|
+
});
|
|
30
|
+
await writeManifest(options.manifestPath, manifest);
|
|
31
|
+
await logger.info('capture.complete', 'Capture finished', {
|
|
32
|
+
outPath: capture.outPath,
|
|
33
|
+
frameCount: capture.frameCount,
|
|
34
|
+
durationSeconds: capture.durationSeconds,
|
|
35
|
+
manifestPath: options.manifestPath,
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
capture,
|
|
39
|
+
manifestPath: options.manifestPath,
|
|
40
|
+
logFilePath: options.logFilePath,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const finishedAt = new Date();
|
|
45
|
+
const manifest = buildManifest({
|
|
46
|
+
status: 'failed',
|
|
47
|
+
options,
|
|
48
|
+
startedAt,
|
|
49
|
+
finishedAt,
|
|
50
|
+
warnings: [],
|
|
51
|
+
videoCreated: false,
|
|
52
|
+
error,
|
|
53
|
+
});
|
|
54
|
+
await logger.error('capture.failed', 'Capture failed', {
|
|
55
|
+
name: manifest.error?.name,
|
|
56
|
+
message: manifest.error?.message,
|
|
57
|
+
manifestPath: options.manifestPath,
|
|
58
|
+
});
|
|
59
|
+
await writeManifest(options.manifestPath, manifest);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await logger.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function writeManifest(manifestPath, manifest) {
|
|
67
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
function buildManifest(input) {
|
|
70
|
+
return {
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
status: input.status,
|
|
73
|
+
startedAt: input.startedAt.toISOString(),
|
|
74
|
+
finishedAt: input.finishedAt.toISOString(),
|
|
75
|
+
durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
|
|
76
|
+
environment: {
|
|
77
|
+
nodeVersion: process.version,
|
|
78
|
+
platform: process.platform,
|
|
79
|
+
arch: process.arch,
|
|
80
|
+
},
|
|
81
|
+
options: {
|
|
82
|
+
url: input.options.url.toString(),
|
|
83
|
+
viewport: input.options.viewport,
|
|
84
|
+
fps: input.options.fps,
|
|
85
|
+
duration: input.options.duration,
|
|
86
|
+
motion: input.options.motion,
|
|
87
|
+
timeoutMs: input.options.timeoutMs,
|
|
88
|
+
waitFor: input.options.waitFor,
|
|
89
|
+
hideSelectors: input.options.hideSelectors,
|
|
90
|
+
},
|
|
91
|
+
artifacts: {
|
|
92
|
+
videoPath: input.options.outPath,
|
|
93
|
+
manifestPath: input.options.manifestPath,
|
|
94
|
+
logFilePath: input.options.logFilePath,
|
|
95
|
+
debugFramesDir: input.options.debugFramesDir,
|
|
96
|
+
videoCreated: input.videoCreated,
|
|
97
|
+
},
|
|
98
|
+
result: input.result,
|
|
99
|
+
warnings: input.warnings,
|
|
100
|
+
error: input.error ? serializeError(input.error) : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function serializeError(error) {
|
|
104
|
+
if (error instanceof Error) {
|
|
105
|
+
return {
|
|
106
|
+
name: error.name,
|
|
107
|
+
message: error.message,
|
|
108
|
+
stack: error.stack,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name: 'Error',
|
|
113
|
+
message: typeof error === 'string' ? error : 'Unknown error',
|
|
114
|
+
};
|
|
115
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rollberry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to capture smooth top-to-bottom scroll videos from web pages, including localhost URLs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@10.15.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"regression.sample.json"
|
|
11
|
+
],
|
|
12
|
+
"bin": {
|
|
13
|
+
"rollberry": "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24.12.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"check": "biome check . && tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"dev": "tsx src/cli.ts",
|
|
22
|
+
"browsers:install": "playwright install chromium",
|
|
23
|
+
"prepack": "npm run build",
|
|
24
|
+
"regression": "tsx scripts/run-regression-suite.ts",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"playwright": "1.58.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@biomejs/biome": "2.4.7",
|
|
32
|
+
"@types/node": "25.5.0",
|
|
33
|
+
"selfsigned": "5.5.0",
|
|
34
|
+
"tsx": "4.21.0",
|
|
35
|
+
"typescript": "5.9.3",
|
|
36
|
+
"vitest": "4.1.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"outputDir": "./artifacts/regression",
|
|
3
|
+
"cases": [
|
|
4
|
+
{
|
|
5
|
+
"name": "local-homepage",
|
|
6
|
+
"url": "http://localhost:3000",
|
|
7
|
+
"viewport": "1440x900",
|
|
8
|
+
"fps": 60,
|
|
9
|
+
"duration": "auto",
|
|
10
|
+
"waitFor": "selector:body",
|
|
11
|
+
"hideSelectors": ["#cookie-banner", ".intercom-lightweight-app"]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "playwright-docs",
|
|
15
|
+
"url": "https://playwright.dev/",
|
|
16
|
+
"viewport": "1440x900",
|
|
17
|
+
"fps": 60,
|
|
18
|
+
"duration": 8,
|
|
19
|
+
"waitFor": "selector:main"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|