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.
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/SECURITY.md +23 -0
- package/dist/capture/browser-install.js +49 -0
- package/dist/capture/browser.js +54 -0
- package/dist/capture/capture.js +167 -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 +211 -0
- package/dist/run-capture.js +114 -0
- package/package.json +60 -0
- package/regression.sample.json +22 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on Keep a Changelog and the project stays on the `v0.x.x`
|
|
6
|
+
line until the CLI surface and capture behavior settle.
|
|
7
|
+
|
|
8
|
+
## [0.1.5] - 2026-03-18
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Reduced default auto-scroll speed from 1800 px/s to 800 px/s for better
|
|
13
|
+
readability during capture.
|
|
14
|
+
- Republished under new maintainer account.
|
|
15
|
+
|
|
16
|
+
## [0.1.3] - 2026-03-15
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- README now includes a dedicated `Using With npx` section with version-pinned
|
|
21
|
+
examples and common usage patterns.
|
|
22
|
+
|
|
23
|
+
## [0.1.2] - 2026-03-15
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `SECURITY.md`, `CONTRIBUTING.md`, GitHub issue forms, and a pull request
|
|
28
|
+
template.
|
|
29
|
+
- Contact guidance pointing users to https://co-r-e.com/contact where private
|
|
30
|
+
contact is required.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- MIT copyright holder is now `CORe Inc.`.
|
|
35
|
+
- Branch protection policy now also covers `develop` and `release/*`, with
|
|
36
|
+
`mokuwaki` as the only bypass user.
|
|
37
|
+
|
|
38
|
+
## [0.1.1] - 2026-03-15
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- MIT license metadata and `LICENSE` file for open source distribution.
|
|
43
|
+
- GitHub Actions trusted publishing workflow for npm releases on `v*` tags.
|
|
44
|
+
- Maintainer release process documentation for `npx rollberry ...` distribution.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- README rewritten around real `npx` usage, common capture examples, and
|
|
49
|
+
operational troubleshooting.
|
|
50
|
+
- Package metadata now includes repository, homepage, bug tracker, and keywords.
|
|
51
|
+
|
|
52
|
+
## [0.1.0] - 2026-03-15
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- Initial public CLI release for smooth top-to-bottom page capture.
|
|
57
|
+
- `localhost`, `127.0.0.1`, and self-signed `https://localhost` support.
|
|
58
|
+
- Sidecar manifest and JSONL log output.
|
|
59
|
+
- Regression suite runner and fixture-backed integration tests.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Contributing to Rollberry
|
|
2
|
+
|
|
3
|
+
Thank you for contributing to Rollberry.
|
|
4
|
+
|
|
5
|
+
## Before You Start
|
|
6
|
+
|
|
7
|
+
- Open an issue for bugs, feature requests, or larger changes before starting
|
|
8
|
+
implementation.
|
|
9
|
+
- For security issues, do not open a public issue. Use
|
|
10
|
+
https://co-r-e.com/contact instead.
|
|
11
|
+
- By contributing, you agree that your changes will be distributed under the
|
|
12
|
+
MIT license used by this repository.
|
|
13
|
+
|
|
14
|
+
## Development Setup
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
corepack pnpm install
|
|
18
|
+
corepack pnpm exec playwright install chromium
|
|
19
|
+
corepack pnpm check
|
|
20
|
+
corepack pnpm test
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Pull Request Expectations
|
|
24
|
+
|
|
25
|
+
- Keep changes focused and avoid mixing unrelated work.
|
|
26
|
+
- Update docs when CLI behavior or release/operational workflow changes.
|
|
27
|
+
- Add or update tests when behavior changes.
|
|
28
|
+
- Keep `README.md`, `CHANGELOG.md`, and sample config files aligned with the
|
|
29
|
+
code when relevant.
|
|
30
|
+
|
|
31
|
+
## Branch Protection and Reviews
|
|
32
|
+
|
|
33
|
+
The protected branches are:
|
|
34
|
+
|
|
35
|
+
- `main`
|
|
36
|
+
- `develop`
|
|
37
|
+
- `release/*`
|
|
38
|
+
|
|
39
|
+
For contributors other than `mokuwaki`:
|
|
40
|
+
|
|
41
|
+
- direct push is not allowed on protected branches
|
|
42
|
+
- merge requires at least one approval
|
|
43
|
+
- the latest push must still be approved by someone else
|
|
44
|
+
|
|
45
|
+
`mokuwaki` is the only configured bypass user for these protected branches.
|
|
46
|
+
|
|
47
|
+
## Release Notes
|
|
48
|
+
|
|
49
|
+
Rollberry remains on the `v0.x.x` line for now. Release prep should update:
|
|
50
|
+
|
|
51
|
+
- `package.json`
|
|
52
|
+
- `CHANGELOG.md`
|
|
53
|
+
- any user-facing docs affected by the release
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CORe Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Rollberry
|
|
2
|
+
|
|
3
|
+
Rollberry is an MIT-licensed open source CLI for turning web pages into
|
|
4
|
+
smooth top-to-bottom scroll videos. It captures one or more URLs into a single
|
|
5
|
+
MP4. It is built for real browser capture, works with normal URLs and
|
|
6
|
+
`localhost`, and is published for direct `npx` usage.
|
|
7
|
+
|
|
8
|
+
Maintained by CORe Inc.
|
|
9
|
+
|
|
10
|
+
## Quick Start
|
|
11
|
+
|
|
12
|
+
Requirements:
|
|
13
|
+
|
|
14
|
+
- Node.js `24.12.0+`
|
|
15
|
+
- `ffmpeg` and `ffprobe` available on `PATH`
|
|
16
|
+
|
|
17
|
+
Install nothing globally. Run it directly:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx rollberry capture http://localhost:3000 \
|
|
21
|
+
--out ./artifacts/demo.mp4 \
|
|
22
|
+
--viewport 1440x900 \
|
|
23
|
+
--fps 60 \
|
|
24
|
+
--duration auto \
|
|
25
|
+
--wait-for selector:body \
|
|
26
|
+
--hide-selector '#cookie-banner'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
On the first run, if Playwright Chromium is missing, Rollberry installs it
|
|
30
|
+
automatically. `ffmpeg` is not auto-installed.
|
|
31
|
+
|
|
32
|
+
## Using With npx
|
|
33
|
+
|
|
34
|
+
The normal way to run Rollberry is `npx`.
|
|
35
|
+
|
|
36
|
+
Use the latest published version:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx rollberry capture http://localhost:3000 --out ./artifacts/demo.mp4
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pin a specific version:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx rollberry@0.1.2 capture http://localhost:3000 --out ./artifacts/demo.mp4
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Capture a local development server:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx rollberry capture http://localhost:3000 \
|
|
52
|
+
--out ./artifacts/local.mp4 \
|
|
53
|
+
--wait-for selector:body
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Capture a public page:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx rollberry capture https://playwright.dev \
|
|
60
|
+
--out ./artifacts/playwright.mp4 \
|
|
61
|
+
--duration 8
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Notes:
|
|
65
|
+
|
|
66
|
+
- `npx` downloads the published CLI package automatically
|
|
67
|
+
- on the first run, Rollberry installs Playwright Chromium if needed
|
|
68
|
+
- `ffmpeg` and `ffprobe` must already be available on your machine
|
|
69
|
+
- if you want reproducible automation, pin the package version with
|
|
70
|
+
`npx rollberry@<version> ...`
|
|
71
|
+
|
|
72
|
+
## What You Get
|
|
73
|
+
|
|
74
|
+
Each run writes:
|
|
75
|
+
|
|
76
|
+
- `video.mp4`: the rendered capture
|
|
77
|
+
- `video.manifest.json`: environment, options, result, and failure details
|
|
78
|
+
- `video.log.jsonl`: structured operational logs
|
|
79
|
+
|
|
80
|
+
You can override the sidecar paths with `--manifest` and `--log-file`.
|
|
81
|
+
|
|
82
|
+
## Common Examples
|
|
83
|
+
|
|
84
|
+
Capture a development site:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npx rollberry capture http://localhost:3000 --out ./artifacts/local.mp4
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Capture a public site at a fixed duration:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npx rollberry capture https://playwright.dev \
|
|
94
|
+
--out ./artifacts/playwright.mp4 \
|
|
95
|
+
--duration 8 \
|
|
96
|
+
--fps 60
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Wait for a selector and hide overlays:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx rollberry capture https://example.com \
|
|
103
|
+
--wait-for selector:main \
|
|
104
|
+
--hide-selector '#cookie-banner' \
|
|
105
|
+
--hide-selector '.intercom-lightweight-app'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Capture multiple pages into a single video:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx rollberry capture \
|
|
112
|
+
https://example.com \
|
|
113
|
+
https://example.com/about \
|
|
114
|
+
https://example.com/contact \
|
|
115
|
+
--out ./artifacts/multi-page.mp4
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Add a pause between pages (the last frame of each page is held):
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npx rollberry capture \
|
|
122
|
+
https://example.com \
|
|
123
|
+
https://example.com/about \
|
|
124
|
+
--page-gap 1.5 \
|
|
125
|
+
--out ./artifacts/with-gap.mp4
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Dump raw frames for debugging:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npx rollberry capture http://localhost:3000 \
|
|
132
|
+
--out ./artifacts/debug.mp4 \
|
|
133
|
+
--debug-frames-dir ./artifacts/debug-frames
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## CLI Options
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
rollberry capture <url...>
|
|
140
|
+
|
|
141
|
+
--out <file> Output MP4 path
|
|
142
|
+
--viewport <WxH> Viewport size, example: 1440x900
|
|
143
|
+
--fps <n> Frames per second
|
|
144
|
+
--duration <seconds|auto> Explicit seconds or auto
|
|
145
|
+
--motion <curve> ease-in-out-sine | linear
|
|
146
|
+
--timeout <ms> Navigation timeout
|
|
147
|
+
--wait-for <mode> load | selector:<css> | ms:<n>
|
|
148
|
+
--hide-selector <css> Hide CSS selector before capture
|
|
149
|
+
--page-gap <seconds> Pause between pages (default: 0)
|
|
150
|
+
--debug-frames-dir <dir> Save raw PNG frames
|
|
151
|
+
--manifest <file> Manifest JSON output path
|
|
152
|
+
--log-file <file> Log JSONL output path
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Localhost Behavior
|
|
156
|
+
|
|
157
|
+
- Supports `http://localhost:*`, `https://localhost:*`,
|
|
158
|
+
`http://127.0.0.1:*`, and `http://[::1]:*`
|
|
159
|
+
- Retries connection-refused errors until `--timeout`
|
|
160
|
+
- Accepts self-signed certificates for localhost targets only
|
|
161
|
+
- Does not start your dev server for you
|
|
162
|
+
|
|
163
|
+
## Troubleshooting
|
|
164
|
+
|
|
165
|
+
If `ffmpeg` is missing:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
brew install ffmpeg
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
If capture fails, inspect:
|
|
172
|
+
|
|
173
|
+
- `*.manifest.json` for final status and error details
|
|
174
|
+
- `*.log.jsonl` for per-step structured logs
|
|
175
|
+
|
|
176
|
+
If a site keeps shifting during capture:
|
|
177
|
+
|
|
178
|
+
- wait for a stable selector with `--wait-for selector:...`
|
|
179
|
+
- hide chat widgets, cookie banners, and sticky overlays with `--hide-selector`
|
|
180
|
+
- keep dynamic dev-only overlays out of the page when possible
|
|
181
|
+
|
|
182
|
+
## Support and Contact
|
|
183
|
+
|
|
184
|
+
- General contact: https://co-r-e.com/contact
|
|
185
|
+
- Security issues: see [SECURITY.md](./SECURITY.md)
|
|
186
|
+
- Contribution guide: see [CONTRIBUTING.md](./CONTRIBUTING.md)
|
|
187
|
+
|
|
188
|
+
## Local Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
corepack pnpm install
|
|
192
|
+
corepack pnpm exec playwright install chromium
|
|
193
|
+
corepack pnpm check
|
|
194
|
+
corepack pnpm test
|
|
195
|
+
corepack pnpm build
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Run from the repository:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
corepack pnpm dev -- capture http://localhost:3000 --out ./artifacts/demo.mp4
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Run the regression suite:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
cp regression.sample.json regression.sites.json
|
|
208
|
+
corepack pnpm regression -- --config ./regression.sites.json
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Release Flow
|
|
212
|
+
|
|
213
|
+
Rollberry stays on the `v0.x.x` line for now.
|
|
214
|
+
|
|
215
|
+
1. Update `package.json` version and `CHANGELOG.md`
|
|
216
|
+
2. Commit the release prep
|
|
217
|
+
3. Create an annotated tag like `git tag -a v0.1.2 -m "Release v0.1.2"`
|
|
218
|
+
4. Push `main` and the tag to GitHub
|
|
219
|
+
5. GitHub Actions publishes to npm via trusted publishing
|
|
220
|
+
|
|
221
|
+
Trusted publishing setup expected by this repo:
|
|
222
|
+
|
|
223
|
+
- GitHub repository: `co-r-e/rollberry`
|
|
224
|
+
- Workflow filename: `.github/workflows/publish.yml`
|
|
225
|
+
- Trigger: push tag `v*`
|
|
226
|
+
- In npm package settings, add a trusted publisher that matches the repository
|
|
227
|
+
and workflow above
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](./LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Security fixes are provided for the latest published `v0.x.x` release and the
|
|
6
|
+
current `main` branch on a best-effort basis.
|
|
7
|
+
|
|
8
|
+
## Reporting a Vulnerability
|
|
9
|
+
|
|
10
|
+
Do not open a public GitHub issue for suspected security vulnerabilities.
|
|
11
|
+
|
|
12
|
+
Please contact us here instead:
|
|
13
|
+
|
|
14
|
+
- https://co-r-e.com/contact
|
|
15
|
+
|
|
16
|
+
Include as much detail as possible:
|
|
17
|
+
|
|
18
|
+
- affected version
|
|
19
|
+
- reproduction steps
|
|
20
|
+
- impact assessment
|
|
21
|
+
- proof of concept, if available
|
|
22
|
+
|
|
23
|
+
We will review the report privately and respond through the contact channel.
|
|
@@ -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: 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 };
|
|
20
|
+
}
|
|
21
|
+
export async function navigateWithRetry(page, url, timeoutMs) {
|
|
22
|
+
const deadline = Date.now() + timeoutMs;
|
|
23
|
+
const canRetry = isLocalUrl(url);
|
|
24
|
+
while (true) {
|
|
25
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
26
|
+
try {
|
|
27
|
+
await page.goto(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,167 @@
|
|
|
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
|
+
if (options.urls.length === 0) {
|
|
10
|
+
throw new Error('少なくとも1つのURLを指定してください。');
|
|
11
|
+
}
|
|
12
|
+
await Promise.all([
|
|
13
|
+
ensureParentDirectory(options.outPath),
|
|
14
|
+
ensureParentDirectory(options.logFilePath),
|
|
15
|
+
ensureParentDirectory(options.manifestPath),
|
|
16
|
+
options.debugFramesDir
|
|
17
|
+
? ensureDirectory(options.debugFramesDir)
|
|
18
|
+
: undefined,
|
|
19
|
+
]);
|
|
20
|
+
const { browser, page } = await openBrowserSession(options, logger);
|
|
21
|
+
try {
|
|
22
|
+
const encoder = await createVideoEncoder({
|
|
23
|
+
fps: options.fps,
|
|
24
|
+
outPath: options.outPath,
|
|
25
|
+
});
|
|
26
|
+
const pages = [];
|
|
27
|
+
let totalFrameCount = 0;
|
|
28
|
+
let totalDurationSeconds = 0;
|
|
29
|
+
let frameOffset = 0;
|
|
30
|
+
let anyTruncated = false;
|
|
31
|
+
for (const [urlIndex, url] of options.urls.entries()) {
|
|
32
|
+
const { pageResult, lastFrame } = await capturePageFrames({
|
|
33
|
+
page,
|
|
34
|
+
url,
|
|
35
|
+
encoder,
|
|
36
|
+
options,
|
|
37
|
+
logger,
|
|
38
|
+
frameOffset,
|
|
39
|
+
urlIndex,
|
|
40
|
+
});
|
|
41
|
+
pages.push(pageResult);
|
|
42
|
+
totalFrameCount += pageResult.frameCount;
|
|
43
|
+
totalDurationSeconds += pageResult.durationSeconds;
|
|
44
|
+
frameOffset += pageResult.frameCount;
|
|
45
|
+
anyTruncated = anyTruncated || pageResult.truncated;
|
|
46
|
+
const isLastPage = urlIndex === options.urls.length - 1;
|
|
47
|
+
if (options.pageGapSeconds > 0 && !isLastPage) {
|
|
48
|
+
const gapFrameCount = await writeGapFrames({
|
|
49
|
+
encoder,
|
|
50
|
+
frame: lastFrame,
|
|
51
|
+
fps: options.fps,
|
|
52
|
+
gapSeconds: options.pageGapSeconds,
|
|
53
|
+
debugFramesDir: options.debugFramesDir,
|
|
54
|
+
frameOffset,
|
|
55
|
+
});
|
|
56
|
+
totalFrameCount += gapFrameCount;
|
|
57
|
+
totalDurationSeconds += gapFrameCount / options.fps;
|
|
58
|
+
frameOffset += gapFrameCount;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await encoder.finish();
|
|
62
|
+
await logger.info('encode.complete', 'Video encoding finished', {
|
|
63
|
+
outPath: options.outPath,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
outPath: options.outPath,
|
|
67
|
+
frameCount: totalFrameCount,
|
|
68
|
+
durationSeconds: totalDurationSeconds,
|
|
69
|
+
pages,
|
|
70
|
+
truncated: anyTruncated,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await browser.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function capturePageFrames(input) {
|
|
78
|
+
const { page, url, encoder, options, logger, urlIndex } = input;
|
|
79
|
+
const { frameOffset } = input;
|
|
80
|
+
await logger.info('browser.open', `Opening page ${urlIndex + 1}`, {
|
|
81
|
+
url: url.toString(),
|
|
82
|
+
viewport: options.viewport,
|
|
83
|
+
});
|
|
84
|
+
await navigateWithRetry(page, url, options.timeoutMs);
|
|
85
|
+
await stabilizePage({
|
|
86
|
+
page,
|
|
87
|
+
waitFor: options.waitFor,
|
|
88
|
+
hideSelectors: options.hideSelectors,
|
|
89
|
+
});
|
|
90
|
+
const preflight = await preflightMeasurePage(page);
|
|
91
|
+
const plannedDurationSeconds = resolveDurationSeconds(options.duration, preflight.maxScroll);
|
|
92
|
+
const frames = buildScrollFrames({
|
|
93
|
+
fps: options.fps,
|
|
94
|
+
durationSeconds: plannedDurationSeconds,
|
|
95
|
+
maxScroll: preflight.maxScroll,
|
|
96
|
+
motion: options.motion,
|
|
97
|
+
});
|
|
98
|
+
const durationSeconds = frames.length / options.fps;
|
|
99
|
+
await logger.info('render.start', `Rendering frames for page ${urlIndex + 1}`, {
|
|
100
|
+
frameCount: frames.length,
|
|
101
|
+
fps: options.fps,
|
|
102
|
+
durationSeconds,
|
|
103
|
+
maxScroll: preflight.maxScroll,
|
|
104
|
+
url: url.toString(),
|
|
105
|
+
});
|
|
106
|
+
if (preflight.truncated) {
|
|
107
|
+
await logger.warn('preflight.truncated', 'Scroll height exceeded capture limit and was truncated', {
|
|
108
|
+
scrollHeight: preflight.scrollHeight,
|
|
109
|
+
url: url.toString(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let lastFrame;
|
|
113
|
+
for (const [index, scrollTop] of frames.entries()) {
|
|
114
|
+
await page.evaluate((nextScrollTop) => {
|
|
115
|
+
window.scrollTo({ top: nextScrollTop, behavior: 'auto' });
|
|
116
|
+
}, scrollTop);
|
|
117
|
+
await waitForAnimationFrames(page);
|
|
118
|
+
const frame = await page.screenshot({
|
|
119
|
+
type: 'png',
|
|
120
|
+
scale: 'css',
|
|
121
|
+
animations: 'disabled',
|
|
122
|
+
caret: 'hide',
|
|
123
|
+
});
|
|
124
|
+
lastFrame = frame;
|
|
125
|
+
if (options.debugFramesDir) {
|
|
126
|
+
const fileName = `${String(frameOffset + index).padStart(5, '0')}.png`;
|
|
127
|
+
await writeFile(`${options.debugFramesDir}/${fileName}`, frame);
|
|
128
|
+
}
|
|
129
|
+
await encoder.writeFrame(frame);
|
|
130
|
+
if ((index + 1) % Math.max(1, Math.floor(frames.length / 10)) === 0) {
|
|
131
|
+
await logger.info('render.progress', 'Rendered frame batch', {
|
|
132
|
+
renderedFrames: index + 1,
|
|
133
|
+
totalFrames: frames.length,
|
|
134
|
+
scrollTop,
|
|
135
|
+
url: url.toString(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!lastFrame) {
|
|
140
|
+
throw new Error(`ページのフレーム取得に失敗しました: ${url.toString()}`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
pageResult: {
|
|
144
|
+
url: url.toString(),
|
|
145
|
+
frameCount: frames.length,
|
|
146
|
+
durationSeconds,
|
|
147
|
+
scrollHeight: preflight.scrollHeight,
|
|
148
|
+
truncated: preflight.truncated,
|
|
149
|
+
},
|
|
150
|
+
lastFrame,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function writeGapFrames(input) {
|
|
154
|
+
const { encoder, frame, fps, gapSeconds, debugFramesDir, frameOffset } = input;
|
|
155
|
+
const gapFrameCount = Math.round(fps * gapSeconds);
|
|
156
|
+
if (gapFrameCount <= 0) {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
for (let i = 0; i < gapFrameCount; i++) {
|
|
160
|
+
if (debugFramesDir) {
|
|
161
|
+
const fileName = `${String(frameOffset + i).padStart(5, '0')}.png`;
|
|
162
|
+
await writeFile(`${debugFramesDir}/${fileName}`, frame);
|
|
163
|
+
}
|
|
164
|
+
await encoder.writeFrame(frame);
|
|
165
|
+
}
|
|
166
|
+
return gapFrameCount;
|
|
167
|
+
}
|
|
@@ -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 = 800;
|
|
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;
|