rollberry 0.1.5

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.
@@ -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();
@@ -0,0 +1,211 @@
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
+ 'page-gap': {
59
+ type: 'string',
60
+ },
61
+ manifest: {
62
+ type: 'string',
63
+ },
64
+ 'log-file': {
65
+ type: 'string',
66
+ },
67
+ },
68
+ strict: true,
69
+ });
70
+ if (parsed.positionals.length === 0) {
71
+ throw new CliError('capture にはURLが必要です。', true);
72
+ }
73
+ const urls = parsed.positionals.map((raw) => parseWithCliError(raw, parseCaptureUrl));
74
+ const durationOption = parsed.values.duration ?? DEFAULT_DURATION;
75
+ if (durationOption !== 'auto' && Number.isNaN(Number(durationOption))) {
76
+ throw new CliError(`--duration は "auto" または数値で指定してください: ${durationOption}`);
77
+ }
78
+ const outPath = resolveOutPath(parsed.values.out ?? DEFAULT_OUT_FILE);
79
+ const pageGapSeconds = parseWithCliError(parsed.values['page-gap'] ?? '0', parseNonNegativeNumber);
80
+ return {
81
+ urls: toNonEmptyArray(urls),
82
+ outPath,
83
+ manifestPath: parsed.values.manifest
84
+ ? resolveOutPath(parsed.values.manifest)
85
+ : deriveSidecarPath(outPath, '.manifest.json'),
86
+ logFilePath: parsed.values['log-file']
87
+ ? resolveOutPath(parsed.values['log-file'])
88
+ : deriveSidecarPath(outPath, '.log.jsonl'),
89
+ viewport: parseWithCliError(parsed.values.viewport ?? DEFAULT_VIEWPORT, parseViewport),
90
+ fps: parseWithCliError(parsed.values.fps ?? String(DEFAULT_FPS), parsePositiveInt),
91
+ duration: durationOption === 'auto'
92
+ ? 'auto'
93
+ : parseWithCliError(durationOption, parsePositiveNumber),
94
+ motion: parseWithCliError(parsed.values.motion ?? DEFAULT_MOTION, parseMotion),
95
+ timeoutMs: parseWithCliError(parsed.values.timeout ?? String(DEFAULT_TIMEOUT_MS), parsePositiveInt),
96
+ waitFor: parseWithCliError(parsed.values['wait-for'] ?? 'load', parseWaitFor),
97
+ hideSelectors: parsed.values['hide-selector'] ?? [],
98
+ pageGapSeconds,
99
+ debugFramesDir: parsed.values['debug-frames-dir']
100
+ ? resolveOutPath(parsed.values['debug-frames-dir'])
101
+ : undefined,
102
+ };
103
+ }
104
+ function toNonEmptyArray(items) {
105
+ if (items.length === 0) {
106
+ throw new CliError('capture にはURLが必要です。', true);
107
+ }
108
+ return items;
109
+ }
110
+ export function formatUsage() {
111
+ return [
112
+ 'Usage:',
113
+ ' rollberry capture <url...> [options]',
114
+ '',
115
+ 'Options:',
116
+ ' --out <file> Output MP4 path (default: ./rollberry.mp4)',
117
+ ' --viewport <WxH> Viewport size (default: 1440x900)',
118
+ ' --fps <n> Frames per second (default: 60)',
119
+ ' --duration <seconds|auto> Capture duration (default: auto)',
120
+ ' --motion <curve> ease-in-out-sine | linear',
121
+ ' --timeout <ms> Navigation timeout (default: 30000)',
122
+ ' --wait-for <mode> load | selector:<css> | ms:<n>',
123
+ ' --hide-selector <css> Hide CSS selector before capture',
124
+ ' --debug-frames-dir <dir> Save raw PNG frames for debugging',
125
+ ' --page-gap <seconds> Pause between pages (default: 0)',
126
+ ' --manifest <file> Manifest JSON path (default: <out>.manifest.json)',
127
+ ' --log-file <file> Log JSONL path (default: <out>.log.jsonl)',
128
+ ].join('\n');
129
+ }
130
+ function parseWithCliError(rawValue, parser) {
131
+ try {
132
+ return parser(rawValue);
133
+ }
134
+ catch (error) {
135
+ if (error instanceof CliError) {
136
+ throw error;
137
+ }
138
+ throw new CliError(error instanceof Error ? error.message : '引数の解析に失敗しました。');
139
+ }
140
+ }
141
+ function resolveOutPath(path) {
142
+ return resolve(process.cwd(), path);
143
+ }
144
+ function deriveSidecarPath(path, suffix) {
145
+ const extension = /\.([^.]+)$/u.exec(path);
146
+ if (!extension) {
147
+ return `${path}${suffix}`;
148
+ }
149
+ return path.slice(0, -extension[0].length) + suffix;
150
+ }
151
+ function parseViewport(rawViewport) {
152
+ const match = /^(?<width>\d+)x(?<height>\d+)$/u.exec(rawViewport);
153
+ if (!match?.groups) {
154
+ throw new Error(`--viewport は "1440x900" の形式で指定してください: ${rawViewport}`);
155
+ }
156
+ const width = Number(match.groups.width);
157
+ const height = Number(match.groups.height);
158
+ if (width <= 0 || height <= 0) {
159
+ throw new Error(`--viewport の値が不正です: ${rawViewport}`);
160
+ }
161
+ return { width, height };
162
+ }
163
+ function parsePositiveInt(rawValue) {
164
+ const value = Number(rawValue);
165
+ if (!Number.isInteger(value) || value <= 0) {
166
+ throw new Error(`正の整数を指定してください: ${rawValue}`);
167
+ }
168
+ return value;
169
+ }
170
+ function parsePositiveNumber(rawValue) {
171
+ const value = Number(rawValue);
172
+ if (!Number.isFinite(value) || value <= 0) {
173
+ throw new Error(`正の数値を指定してください: ${rawValue}`);
174
+ }
175
+ return value;
176
+ }
177
+ function parseNonNegativeNumber(rawValue) {
178
+ const value = Number(rawValue);
179
+ if (!Number.isFinite(value) || value < 0) {
180
+ throw new Error(`0以上の数値を指定してください: ${rawValue}`);
181
+ }
182
+ return value;
183
+ }
184
+ function parseMotion(rawMotion) {
185
+ if (rawMotion === 'ease-in-out-sine' || rawMotion === 'linear') {
186
+ return rawMotion;
187
+ }
188
+ throw new Error(`--motion は ease-in-out-sine または linear です: ${rawMotion}`);
189
+ }
190
+ function parseWaitFor(rawWaitFor) {
191
+ if (rawWaitFor === 'load') {
192
+ return { kind: 'load' };
193
+ }
194
+ if (rawWaitFor.startsWith('selector:')) {
195
+ const selector = rawWaitFor.slice('selector:'.length).trim();
196
+ if (!selector) {
197
+ throw new Error('--wait-for selector:<css> の CSS セレクタが空です。');
198
+ }
199
+ return {
200
+ kind: 'selector',
201
+ selector,
202
+ };
203
+ }
204
+ if (rawWaitFor.startsWith('ms:')) {
205
+ return {
206
+ kind: 'delay',
207
+ ms: parsePositiveInt(rawWaitFor.slice('ms:'.length)),
208
+ };
209
+ }
210
+ throw new Error(`--wait-for は load / selector:<css> / ms:<n> のいずれかです: ${rawWaitFor}`);
211
+ }
@@ -0,0 +1,114 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { captureVideo } from './capture/capture.js';
3
+ import { createCaptureLogger } from './capture/logger.js';
4
+ export async function runCaptureCommand(options) {
5
+ const logger = createCaptureLogger(options.logFilePath);
6
+ const startedAt = new Date();
7
+ await logger.info('capture.start', 'Capture started', {
8
+ urls: options.urls.map((u) => u.toString()),
9
+ outPath: options.outPath,
10
+ manifestPath: options.manifestPath,
11
+ logFilePath: options.logFilePath,
12
+ });
13
+ try {
14
+ const capture = await captureVideo(options, logger);
15
+ const finishedAt = new Date();
16
+ const warnings = capture.truncated
17
+ ? ['scroll_height_truncated']
18
+ : [];
19
+ const manifest = buildManifest({
20
+ status: 'succeeded',
21
+ options,
22
+ startedAt,
23
+ finishedAt,
24
+ warnings,
25
+ videoCreated: true,
26
+ result: capture,
27
+ });
28
+ await writeManifest(options.manifestPath, manifest);
29
+ await logger.info('capture.complete', 'Capture finished', {
30
+ outPath: capture.outPath,
31
+ frameCount: capture.frameCount,
32
+ durationSeconds: capture.durationSeconds,
33
+ manifestPath: options.manifestPath,
34
+ });
35
+ return {
36
+ capture,
37
+ manifestPath: options.manifestPath,
38
+ logFilePath: options.logFilePath,
39
+ };
40
+ }
41
+ catch (error) {
42
+ const finishedAt = new Date();
43
+ const manifest = buildManifest({
44
+ status: 'failed',
45
+ options,
46
+ startedAt,
47
+ finishedAt,
48
+ warnings: [],
49
+ videoCreated: false,
50
+ error,
51
+ });
52
+ await logger.error('capture.failed', 'Capture failed', {
53
+ name: manifest.error?.name,
54
+ message: manifest.error?.message,
55
+ manifestPath: options.manifestPath,
56
+ });
57
+ await writeManifest(options.manifestPath, manifest);
58
+ throw error;
59
+ }
60
+ finally {
61
+ await logger.close();
62
+ }
63
+ }
64
+ async function writeManifest(manifestPath, manifest) {
65
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
66
+ }
67
+ function buildManifest(input) {
68
+ return {
69
+ schemaVersion: 2,
70
+ status: input.status,
71
+ startedAt: input.startedAt.toISOString(),
72
+ finishedAt: input.finishedAt.toISOString(),
73
+ durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
74
+ environment: {
75
+ nodeVersion: process.version,
76
+ platform: process.platform,
77
+ arch: process.arch,
78
+ },
79
+ options: {
80
+ urls: input.options.urls.map((u) => u.toString()),
81
+ viewport: input.options.viewport,
82
+ fps: input.options.fps,
83
+ duration: input.options.duration,
84
+ motion: input.options.motion,
85
+ timeoutMs: input.options.timeoutMs,
86
+ waitFor: input.options.waitFor,
87
+ hideSelectors: input.options.hideSelectors,
88
+ pageGapSeconds: input.options.pageGapSeconds,
89
+ },
90
+ artifacts: {
91
+ videoPath: input.options.outPath,
92
+ manifestPath: input.options.manifestPath,
93
+ logFilePath: input.options.logFilePath,
94
+ debugFramesDir: input.options.debugFramesDir,
95
+ videoCreated: input.videoCreated,
96
+ },
97
+ result: input.result,
98
+ warnings: input.warnings,
99
+ error: input.error ? serializeError(input.error) : undefined,
100
+ };
101
+ }
102
+ function serializeError(error) {
103
+ if (error instanceof Error) {
104
+ return {
105
+ name: error.name,
106
+ message: error.message,
107
+ stack: error.stack,
108
+ };
109
+ }
110
+ return {
111
+ name: 'Error',
112
+ message: typeof error === 'string' ? error : 'Unknown error',
113
+ };
114
+ }