shapy-render 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +205 -2
- package/bin/shapy-render.js +203 -0
- package/index.d.ts +65 -0
- package/package.json +62 -4
- package/src/ffmpeg.js +214 -0
- package/src/index.js +389 -0
- package/src/options.js +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hemant Rao
|
|
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
CHANGED
|
@@ -1,3 +1,206 @@
|
|
|
1
|
-
#
|
|
1
|
+
# shapy-render
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Export [Shapy](https://github.com/hemant-rao/shapy-bg) generative backgrounds to
|
|
4
|
+
MP4, WebM, or a PNG sequence. Rendering is deterministic: Shapy captures each
|
|
5
|
+
frame with `seek()` at a fixed timeline position, so the same seed and options
|
|
6
|
+
produce the same clip.
|
|
7
|
+
|
|
8
|
+
> **Sponsored by [Odio Book](https://odiobook.com)** — the all-in-one AI video &
|
|
9
|
+
> audio studio: AI video editing, auto subtitles, scene detection, voice
|
|
10
|
+
> generation and text-to-speech, all in the browser. Odio Book funds the time
|
|
11
|
+
> that goes into this project. **[Try it free →](https://odiobook.com)**
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install shapy-bg shapy-render
|
|
17
|
+
npx playwright install chromium
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`shapy-render` uses Playwright for frame capture. MP4 output also needs a
|
|
21
|
+
system `ffmpeg` with `libx264`. Non-alpha WebM works with the ffmpeg that
|
|
22
|
+
Playwright installs; PNG sequences need no ffmpeg. Alpha WebM needs a full
|
|
23
|
+
ffmpeg with PNG decoding; the Playwright build does not include a PNG decoder.
|
|
24
|
+
|
|
25
|
+
## CLI
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx shapy-render --seed reel-01 --duration 8 --output reel.mp4
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A 9:16 clip with platform safe zones and a poster:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx shapy-render \
|
|
35
|
+
--seed reel-01 \
|
|
36
|
+
--palette ocean \
|
|
37
|
+
--width 1080 --height 1920 \
|
|
38
|
+
--duration 8 --fps 30 \
|
|
39
|
+
--loop 8 \
|
|
40
|
+
--safe-zones tiktok \
|
|
41
|
+
--output reel.mp4 \
|
|
42
|
+
--poster reel-poster.png
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Common options:
|
|
46
|
+
|
|
47
|
+
| Option | Default | Description |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `--output` | `shapy-render.mp4` | Output file, or directory for PNG frames. |
|
|
50
|
+
| `--format` | inferred | `mp4`, `webm`, or `png`. |
|
|
51
|
+
| `--width` / `--height` | `1080` / `1920` | Capture size in CSS pixels. |
|
|
52
|
+
| `--fps` | `30` | Frames per second. |
|
|
53
|
+
| `--duration` | `8` | Clip length in seconds. |
|
|
54
|
+
| `--dpr` | `1` | Device pixel ratio. |
|
|
55
|
+
| `--alpha` | off | Transparent background. WebM/PNG only. |
|
|
56
|
+
| `--poster` | none | Also write a `.png`, `.jpg`, or `.jpeg` still. |
|
|
57
|
+
| `--poster-time` | `0` | Timeline position for the poster. |
|
|
58
|
+
| `--crf` | `20` | Quality, 0–51. Lower is better. |
|
|
59
|
+
| `--loop` | none | Seamless loop period passed to Shapy. |
|
|
60
|
+
| `--ffmpeg` | auto | Use a specific ffmpeg executable. |
|
|
61
|
+
| `--keep-frames` | off | Keep captured image frames. |
|
|
62
|
+
|
|
63
|
+
For a clean loop, keep `--duration` an exact multiple of `--loop` (for example
|
|
64
|
+
`--loop 8 --duration 8`). `seek()` wraps at the loop period, so a duration that
|
|
65
|
+
is not a multiple can jump mid-clip.
|
|
66
|
+
|
|
67
|
+
Shapy options are passed straight through:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx shapy-render \
|
|
71
|
+
--seed deck-01 \
|
|
72
|
+
--palette plum \
|
|
73
|
+
--shape-set custom \
|
|
74
|
+
--shapes circle,ring,emoji-4 \
|
|
75
|
+
--layout rows \
|
|
76
|
+
--align center \
|
|
77
|
+
--motion calm \
|
|
78
|
+
--safe-zones reels \
|
|
79
|
+
--output deck.webm
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use `--option key=value` for anything not listed, or `--options` with a JSON
|
|
83
|
+
object:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx shapy-render \
|
|
87
|
+
--options '{"seed":"json-seed","palette":"forest","text":"Shape your story."}' \
|
|
88
|
+
--output hero.mp4
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Run `npx shapy-render --help` for the complete flag list.
|
|
92
|
+
|
|
93
|
+
## Library
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { render } from 'shapy-render';
|
|
97
|
+
|
|
98
|
+
const result = await render({
|
|
99
|
+
output: 'reel.webm',
|
|
100
|
+
width: 1080,
|
|
101
|
+
height: 1920,
|
|
102
|
+
duration: 8,
|
|
103
|
+
fps: 30,
|
|
104
|
+
loop: 8,
|
|
105
|
+
options: {
|
|
106
|
+
seed: 'reel-01',
|
|
107
|
+
palette: 'ocean',
|
|
108
|
+
safeZones: 'tiktok'
|
|
109
|
+
},
|
|
110
|
+
onProgress({ frame, total }) {
|
|
111
|
+
console.log(`${frame}/${total}`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
console.log(result.seed, result.output);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`render()` returns:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
{
|
|
122
|
+
output, // absolute output path
|
|
123
|
+
format, // 'mp4' | 'webm' | 'png'
|
|
124
|
+
seed, // the seed used, including an auto-generated one
|
|
125
|
+
engineVersion, // SHAPY_ENGINE_VERSION used for the capture
|
|
126
|
+
width, // output pixels (width × dpr)
|
|
127
|
+
height, // output pixels (height × dpr)
|
|
128
|
+
fps,
|
|
129
|
+
duration, // actual duration in seconds
|
|
130
|
+
frames, // captured frame count
|
|
131
|
+
poster,
|
|
132
|
+
ffmpeg,
|
|
133
|
+
framesDir // when keepFrames is true
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Alpha
|
|
138
|
+
|
|
139
|
+
- **WebM VP9** supports alpha with `--alpha`. Alpha encoding needs a full ffmpeg
|
|
140
|
+
with a PNG decoder; Playwright's bundled ffmpeg does not include one.
|
|
141
|
+
- **PNG sequences** support alpha with `--alpha` and `--format png`.
|
|
142
|
+
- **MP4 does not support alpha.** `shapy-render` rejects the combination instead
|
|
143
|
+
of silently producing black frames.
|
|
144
|
+
|
|
145
|
+
When `--alpha` is set, the background is forced to `transparent`. Poster images
|
|
146
|
+
must be PNG in alpha mode.
|
|
147
|
+
|
|
148
|
+
## PNG sequences
|
|
149
|
+
|
|
150
|
+
`--format png` treats `--output` as a directory:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npx shapy-render --format png --output frames --duration 4
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Frames are written as `frames/frame-000001.png`, `frame-000002.png`, and so on.
|
|
157
|
+
Encode them separately if you need a different codec or container.
|
|
158
|
+
|
|
159
|
+
## MP4 and ffmpeg
|
|
160
|
+
|
|
161
|
+
MP4 encoding requires an ffmpeg binary with `libx264`:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
ffmpeg -version
|
|
165
|
+
ffmpeg -hide_banner -encoders | grep libx264
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If ffmpeg is not on `PATH`, pass `--ffmpeg C:\path\to\ffmpeg.exe` or set
|
|
169
|
+
`SHAPY_FFMPEG`. WebM needs `libvpx-vp9`; the ffmpeg bundled with Playwright
|
|
170
|
+
includes it, so non-alpha WebM usually works without a system install. Alpha
|
|
171
|
+
WebM additionally needs PNG decoding, which the bundled build does not provide.
|
|
172
|
+
|
|
173
|
+
## Determinism
|
|
174
|
+
|
|
175
|
+
The renderer:
|
|
176
|
+
|
|
177
|
+
1. loads Shapy with `live="off"`,
|
|
178
|
+
2. fixes the viewport, device pixel ratio, and color profile,
|
|
179
|
+
3. waits for fonts and images,
|
|
180
|
+
4. calls `seek(t)` for each frame, and
|
|
181
|
+
5. captures a screenshot after the frame has painted (PNG, or JPEG when the
|
|
182
|
+
available ffmpeg build has no PNG decoder).
|
|
183
|
+
|
|
184
|
+
Use a fixed seed, viewport, DPR, and engine version for reproducible output.
|
|
185
|
+
Rasterization, emoji artwork, and font availability can still differ between
|
|
186
|
+
operating systems. See
|
|
187
|
+
[Seed stability](https://github.com/hemant-rao/shapy-bg/blob/main/docs/SEED_STABILITY.md).
|
|
188
|
+
|
|
189
|
+
## Requirements
|
|
190
|
+
|
|
191
|
+
- Node.js 20.19 or newer
|
|
192
|
+
- `shapy-bg` 1.0 or newer
|
|
193
|
+
- Playwright Chromium
|
|
194
|
+
- ffmpeg with `libx264` for MP4
|
|
195
|
+
|
|
196
|
+
## Security
|
|
197
|
+
|
|
198
|
+
Report vulnerabilities privately through the
|
|
199
|
+
[security policy](https://github.com/hemant-rao/shapy-bg/blob/main/SECURITY.md).
|
|
200
|
+
Do not open a public issue for a security problem.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT © [Hemant Rao](https://github.com/hemant-rao). Sponsored by
|
|
205
|
+
[Odio Book](https://odiobook.com). Support the project through
|
|
206
|
+
[GitHub Sponsors](https://github.com/sponsors/hemant-rao).
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { render } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
|
|
7
|
+
const HELP = `shapy-render ${packageJson.version}
|
|
8
|
+
|
|
9
|
+
Export a Shapy background to MP4, WebM, or a PNG sequence.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
shapy-render --output reel.mp4 [options]
|
|
13
|
+
shapy-render --format png --output frames [options]
|
|
14
|
+
|
|
15
|
+
Output:
|
|
16
|
+
--output <path> File for video, directory for PNG frames.
|
|
17
|
+
--format <format> mp4, webm, or png. Inferred from --output.
|
|
18
|
+
--width <px> Capture width. Default: 1080.
|
|
19
|
+
--height <px> Capture height. Default: 1920.
|
|
20
|
+
--fps <number> Frames per second. Default: 30.
|
|
21
|
+
--duration <seconds> Clip length. Default: 8.
|
|
22
|
+
--dpr <number> Device pixel ratio. Default: 1.
|
|
23
|
+
--alpha Transparent background. WebM/PNG only.
|
|
24
|
+
--poster <path> Also write a still (.png, .jpg, .jpeg).
|
|
25
|
+
--poster-time <sec> Poster timeline position. Default: 0.
|
|
26
|
+
--crf <number> Quality (0-51, lower is better). Default: 20.
|
|
27
|
+
--loop <seconds> Seamless loop period passed to Shapy.
|
|
28
|
+
--ffmpeg <path> Use a specific ffmpeg executable.
|
|
29
|
+
--keep-frames Keep captured image frames after encoding.
|
|
30
|
+
|
|
31
|
+
Shapy:
|
|
32
|
+
--seed <string> Seed. A fresh seed is generated when omitted.
|
|
33
|
+
--palette <name> midnight, sandstone, bauhaus, forest, plum,
|
|
34
|
+
ocean, clay, or mono.
|
|
35
|
+
--shape-set <name> all, geometry, lines, organic, emoji, stickers,
|
|
36
|
+
or custom.
|
|
37
|
+
--shapes <list> Comma-separated keys for shape-set="custom".
|
|
38
|
+
--layout <name> auto, scatter, grid, rows, columns, center, edges.
|
|
39
|
+
--align <name> random, left, center, right, top, middle, bottom.
|
|
40
|
+
--size-mode <name> mixed, same, subtle, dramatic, small, medium, large.
|
|
41
|
+
--motion <name> mixed, chaos, flow, bounce, drift, spin, pulse,
|
|
42
|
+
wobble, orbit, calm.
|
|
43
|
+
--direction <name> random, lr, rl, tb, bt, dr, dl, ur, ul, alternate.
|
|
44
|
+
--density <number> 20-100.
|
|
45
|
+
--scale <number> 60-140.
|
|
46
|
+
--speed <number> 50-200.
|
|
47
|
+
--move <number> 30-200.
|
|
48
|
+
--background <color> Background color.
|
|
49
|
+
--custom-colors <list> Comma-separated palette overrides.
|
|
50
|
+
--safe-zones <list> tiktok, reels, shorts, story, or a JSON array.
|
|
51
|
+
--safe-area Keep shapes behind the text overlay clear.
|
|
52
|
+
--option key=value Any other Shapy option. Repeatable.
|
|
53
|
+
--options <json> JSON object of Shapy options. Merged last.
|
|
54
|
+
--help Show this help.
|
|
55
|
+
--version Print the version.
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
function fail(message) {
|
|
59
|
+
console.error(`shapy-render: ${message}`);
|
|
60
|
+
console.error('Run shapy-render --help for usage.');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseValue(value) {
|
|
65
|
+
const text = String(value).trim();
|
|
66
|
+
if (text === 'true') return true;
|
|
67
|
+
if (text === 'false') return false;
|
|
68
|
+
if (text === 'null') return null;
|
|
69
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
|
|
70
|
+
if ((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']'))) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(text);
|
|
73
|
+
} catch {
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const VALUE_FLAGS = new Set([
|
|
81
|
+
'output', 'format', 'width', 'height', 'fps', 'duration', 'dpr',
|
|
82
|
+
'poster', 'poster-time', 'crf', 'loop', 'ffmpeg',
|
|
83
|
+
'seed', 'palette', 'shape-set', 'shapes', 'layout', 'align', 'size-mode',
|
|
84
|
+
'motion', 'direction', 'density', 'scale', 'speed', 'move', 'background',
|
|
85
|
+
'custom-colors', 'safe-zones', 'option', 'options'
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const BOOLEAN_FLAGS = new Set(['alpha', 'keep-frames', 'safe-area', 'help', 'version']);
|
|
89
|
+
|
|
90
|
+
const args = process.argv.slice(2);
|
|
91
|
+
const flags = {};
|
|
92
|
+
const extraOptions = {};
|
|
93
|
+
|
|
94
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
95
|
+
const argument = args[index];
|
|
96
|
+
if (!argument.startsWith('--')) fail(`unexpected argument: ${argument}`);
|
|
97
|
+
|
|
98
|
+
const equals = argument.indexOf('=');
|
|
99
|
+
const name = argument.slice(2, equals === -1 ? undefined : equals);
|
|
100
|
+
let value = equals === -1 ? undefined : argument.slice(equals + 1);
|
|
101
|
+
|
|
102
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
103
|
+
if (value !== undefined) fail(`--${name} does not take a value`);
|
|
104
|
+
flags[name] = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!VALUE_FLAGS.has(name)) fail(`unknown option: --${name}`);
|
|
108
|
+
|
|
109
|
+
if (value === undefined) {
|
|
110
|
+
index += 1;
|
|
111
|
+
value = args[index];
|
|
112
|
+
if (value === undefined) fail(`--${name} needs a value`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (name === 'option') {
|
|
116
|
+
const split = value.indexOf('=');
|
|
117
|
+
if (split < 1) fail('--option expects key=value');
|
|
118
|
+
extraOptions[value.slice(0, split)] = parseValue(value.slice(split + 1));
|
|
119
|
+
} else if (name === 'options') {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(value);
|
|
123
|
+
} catch {
|
|
124
|
+
fail('--options expects a JSON object');
|
|
125
|
+
}
|
|
126
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
127
|
+
fail('--options expects a JSON object');
|
|
128
|
+
}
|
|
129
|
+
Object.assign(extraOptions, parsed);
|
|
130
|
+
} else {
|
|
131
|
+
flags[name] = value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (flags.help) {
|
|
136
|
+
console.log(HELP);
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
if (flags.version) {
|
|
140
|
+
console.log(packageJson.version);
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const format = flags.format ? String(flags.format).toLowerCase() : '';
|
|
145
|
+
let output = flags.output;
|
|
146
|
+
if (!output) {
|
|
147
|
+
output = format === 'png' ? 'shapy-render-frames' : `shapy-render.${format || 'mp4'}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const options = { ...extraOptions };
|
|
151
|
+
for (const key of [
|
|
152
|
+
'seed', 'palette', 'shape-set', 'shapes', 'layout', 'align', 'size-mode',
|
|
153
|
+
'motion', 'direction', 'density', 'scale', 'speed', 'move', 'background',
|
|
154
|
+
'custom-colors', 'safe-zones'
|
|
155
|
+
]) {
|
|
156
|
+
if (flags[key] !== undefined) options[key] = parseValue(flags[key]);
|
|
157
|
+
}
|
|
158
|
+
if (flags['safe-area']) options.safeArea = true;
|
|
159
|
+
|
|
160
|
+
let lastProgress = -1;
|
|
161
|
+
const onProgress = (progress) => {
|
|
162
|
+
if (progress.phase === 'frames' && progress.total) {
|
|
163
|
+
const percent = Math.floor((progress.frame / progress.total) * 10) * 10;
|
|
164
|
+
if (percent !== lastProgress) {
|
|
165
|
+
lastProgress = percent;
|
|
166
|
+
process.stderr.write(`Captured ${progress.frame}/${progress.total} frames (${percent}%)\n`);
|
|
167
|
+
}
|
|
168
|
+
} else if (progress.phase === 'encode') {
|
|
169
|
+
process.stderr.write(`Encoding ${progress.message}...\n`);
|
|
170
|
+
} else if (progress.phase === 'poster') {
|
|
171
|
+
process.stderr.write('Writing poster...\n');
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const result = await render({
|
|
177
|
+
output,
|
|
178
|
+
format: format || undefined,
|
|
179
|
+
width: flags.width,
|
|
180
|
+
height: flags.height,
|
|
181
|
+
fps: flags.fps,
|
|
182
|
+
duration: flags.duration,
|
|
183
|
+
dpr: flags.dpr,
|
|
184
|
+
alpha: Boolean(flags.alpha),
|
|
185
|
+
poster: flags.poster,
|
|
186
|
+
posterTime: flags['poster-time'],
|
|
187
|
+
crf: flags.crf,
|
|
188
|
+
loop: flags.loop,
|
|
189
|
+
ffmpeg: flags.ffmpeg,
|
|
190
|
+
keepFrames: Boolean(flags['keep-frames']),
|
|
191
|
+
options,
|
|
192
|
+
onProgress
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log(`Rendered ${result.frames} frames at ${result.width}x${result.height}, ${result.fps} fps (${result.duration.toFixed(2)}s)`);
|
|
196
|
+
console.log(`Seed: ${result.seed}`);
|
|
197
|
+
console.log(`Wrote ${result.output}`);
|
|
198
|
+
if (result.poster) console.log(`Poster: ${result.poster}`);
|
|
199
|
+
if (result.framesDir) console.log(`Frames: ${result.framesDir}`);
|
|
200
|
+
if (result.ffmpeg) console.log(`ffmpeg: ${result.ffmpeg}`);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
fail(error.message);
|
|
203
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ShapyOptions } from 'shapy-bg';
|
|
2
|
+
|
|
3
|
+
export type ShapyRenderFormat = 'mp4' | 'webm' | 'png';
|
|
4
|
+
|
|
5
|
+
export interface ShapyRenderOptions {
|
|
6
|
+
/** Destination file for video, or directory for a PNG sequence. */
|
|
7
|
+
output: string;
|
|
8
|
+
/** Output format. Inferred from the output extension when omitted. */
|
|
9
|
+
format?: ShapyRenderFormat;
|
|
10
|
+
/** Capture width in CSS pixels. Defaults to 1080. */
|
|
11
|
+
width?: number;
|
|
12
|
+
/** Capture height in CSS pixels. Defaults to 1920. */
|
|
13
|
+
height?: number;
|
|
14
|
+
/** Frames per second. Defaults to 30. */
|
|
15
|
+
fps?: number;
|
|
16
|
+
/** Clip duration in seconds. Defaults to 8. */
|
|
17
|
+
duration?: number;
|
|
18
|
+
/** Device pixel ratio for the capture. Defaults to 1. */
|
|
19
|
+
dpr?: number;
|
|
20
|
+
/** Keep the background transparent. Not supported for MP4. */
|
|
21
|
+
alpha?: boolean;
|
|
22
|
+
/** Shapy element options passed through to setOptions(). */
|
|
23
|
+
options?: ShapyOptions;
|
|
24
|
+
/** Optional still frame path (.png, .jpg, or .jpeg). */
|
|
25
|
+
poster?: string;
|
|
26
|
+
/** Timeline position for the poster frame in seconds. Defaults to 0. */
|
|
27
|
+
posterTime?: number;
|
|
28
|
+
/** H.264 CRF for MP4 (0–51) or mapped VP9 quality for WebM. Defaults to 20. */
|
|
29
|
+
crf?: number;
|
|
30
|
+
/** Loop period passed to the element in seconds. */
|
|
31
|
+
loop?: number;
|
|
32
|
+
/** Use a specific ffmpeg executable. */
|
|
33
|
+
ffmpeg?: string;
|
|
34
|
+
/** Keep captured PNG frames after encoding. */
|
|
35
|
+
keepFrames?: boolean;
|
|
36
|
+
/** Progress callback for frame capture and encoding. */
|
|
37
|
+
onProgress?: (progress: ShapyRenderProgress) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ShapyRenderProgress {
|
|
41
|
+
phase: 'frames' | 'encode' | 'poster';
|
|
42
|
+
frame?: number;
|
|
43
|
+
total?: number;
|
|
44
|
+
time?: number;
|
|
45
|
+
message?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ShapyRenderResult {
|
|
49
|
+
output: string;
|
|
50
|
+
format: ShapyRenderFormat;
|
|
51
|
+
seed: string;
|
|
52
|
+
/** SHAPY_ENGINE_VERSION used for the capture. */
|
|
53
|
+
engineVersion: number;
|
|
54
|
+
width: number;
|
|
55
|
+
height: number;
|
|
56
|
+
fps: number;
|
|
57
|
+
duration: number;
|
|
58
|
+
frames: number;
|
|
59
|
+
poster?: string;
|
|
60
|
+
ffmpeg?: string;
|
|
61
|
+
framesDir?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Render a Shapy background to MP4, WebM, or a PNG sequence. */
|
|
65
|
+
export function render(options: ShapyRenderOptions): Promise<ShapyRenderResult>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shapy-render",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Export Shapy generative backgrounds to MP4, WebM, and PNG with Playwright.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Hemant Rao",
|
|
7
|
+
"url": "https://github.com/hemant-rao"
|
|
8
|
+
},
|
|
9
|
+
"funding": {
|
|
10
|
+
"type": "github",
|
|
11
|
+
"url": "https://github.com/sponsors/hemant-rao"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/hemant-rao/shapy-bg.git",
|
|
16
|
+
"directory": "packages/shapy-render"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/hemant-rao/shapy-bg/tree/main/packages/shapy-render#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/hemant-rao/shapy-bg/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./src/index.js",
|
|
24
|
+
"types": "./index.d.ts",
|
|
25
|
+
"bin": {
|
|
26
|
+
"shapy-render": "./bin/shapy-render.js"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./index.d.ts",
|
|
31
|
+
"import": "./src/index.js",
|
|
32
|
+
"default": "./src/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"bin",
|
|
39
|
+
"index.d.ts",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"keywords": [
|
|
44
|
+
"shapy-bg",
|
|
45
|
+
"video",
|
|
46
|
+
"export",
|
|
47
|
+
"mp4",
|
|
48
|
+
"webm",
|
|
49
|
+
"png",
|
|
50
|
+
"playwright",
|
|
51
|
+
"generative",
|
|
52
|
+
"background"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20.19"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"playwright": "^1.65.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"shapy-bg": ">=1.0.0"
|
|
62
|
+
},
|
|
63
|
+
"license": "MIT"
|
|
64
|
+
}
|
package/src/ffmpeg.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createReadStream, existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const encoderCache = new Map();
|
|
6
|
+
|
|
7
|
+
function binaryIn(directory, depth = 2) {
|
|
8
|
+
if (!existsSync(directory)) return '';
|
|
9
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
10
|
+
const full = join(directory, entry.name);
|
|
11
|
+
if (entry.isFile()) {
|
|
12
|
+
const name = entry.name.toLowerCase();
|
|
13
|
+
if (name.startsWith('ffmpeg') && !name.endsWith('.dll') && !name.endsWith('.so')) {
|
|
14
|
+
return full;
|
|
15
|
+
}
|
|
16
|
+
} else if (entry.isDirectory() && depth > 0) {
|
|
17
|
+
const found = binaryIn(full, depth - 1);
|
|
18
|
+
if (found) return found;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function bundledCandidates(executablePath) {
|
|
25
|
+
if (!executablePath) return [];
|
|
26
|
+
// Playwright stores browsers as <browsers>/<browser-version>/<platform>/...
|
|
27
|
+
const root = dirname(dirname(dirname(executablePath)));
|
|
28
|
+
if (!existsSync(root)) return [];
|
|
29
|
+
const found = [];
|
|
30
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
31
|
+
if (!entry.isDirectory() || !entry.name.startsWith('ffmpeg-')) continue;
|
|
32
|
+
const binary = binaryIn(join(root, entry.name));
|
|
33
|
+
if (binary) found.push(binary);
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function systemFfmpeg() {
|
|
39
|
+
const probe = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
|
|
40
|
+
return !probe.error && probe.status === 0 ? 'ffmpeg' : '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ffmpegSupports(executable, encoder) {
|
|
44
|
+
let text = encoderCache.get(executable);
|
|
45
|
+
if (text === undefined) {
|
|
46
|
+
const probe = spawnSync(executable, ['-hide_banner', '-encoders'], {
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
windowsHide: true
|
|
49
|
+
});
|
|
50
|
+
text = probe.status === 0 ? `${probe.stdout || ''}\n${probe.stderr || ''}` : '';
|
|
51
|
+
encoderCache.set(executable, text);
|
|
52
|
+
}
|
|
53
|
+
return new RegExp(`\\b${encoder}\\b`).test(text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ffmpegSupportsDecoder(executable, decoder) {
|
|
57
|
+
const key = `decoders:${executable}`;
|
|
58
|
+
let text = encoderCache.get(key);
|
|
59
|
+
if (text === undefined) {
|
|
60
|
+
const probe = spawnSync(executable, ['-hide_banner', '-decoders'], {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
windowsHide: true
|
|
63
|
+
});
|
|
64
|
+
text = probe.status === 0 ? `${probe.stdout || ''}\n${probe.stderr || ''}` : '';
|
|
65
|
+
encoderCache.set(key, text);
|
|
66
|
+
}
|
|
67
|
+
return new RegExp(`\\b${decoder}\\b`).test(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find an ffmpeg binary. MP4 requires libx264; WebM requires libvpx-vp9.
|
|
72
|
+
* An explicit path or SHAPY_FFMPEG wins over discovery.
|
|
73
|
+
*/
|
|
74
|
+
export function findFfmpeg({ ffmpeg, executablePath, format }) {
|
|
75
|
+
const encoder = format === 'mp4' ? 'libx264' : 'libvpx-vp9';
|
|
76
|
+
const explicit = ffmpeg || process.env.SHAPY_FFMPEG || '';
|
|
77
|
+
if (explicit) {
|
|
78
|
+
if (!ffmpegSupports(explicit, encoder)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${explicit} does not provide ${encoder}. ` +
|
|
81
|
+
(format === 'mp4'
|
|
82
|
+
? 'Install ffmpeg with libx264 or use --format webm.'
|
|
83
|
+
: 'Install ffmpeg with libvpx-vp9 or use --format png.')
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return explicit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const candidates = [];
|
|
90
|
+
const add = (path, source) => {
|
|
91
|
+
if (path && !candidates.some((item) => item.path === path)) {
|
|
92
|
+
candidates.push({ path, source });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
add(systemFfmpeg(), 'system PATH');
|
|
97
|
+
for (const path of bundledCandidates(executablePath)) add(path, 'Playwright');
|
|
98
|
+
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
if (ffmpegSupports(candidate.path, encoder)) return candidate.path;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const detail = candidates.length
|
|
104
|
+
? candidates.map((item) => `${item.path} (${item.source})`).join(', ')
|
|
105
|
+
: 'none found';
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No ffmpeg with ${encoder} found. Checked: ${detail}. ` +
|
|
108
|
+
(format === 'mp4'
|
|
109
|
+
? 'Install ffmpeg with libx264, or use --format webm or --format png.'
|
|
110
|
+
: 'Install ffmpeg with libvpx-vp9, or use --format png.')
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function oddDimensionFilter(width, height) {
|
|
115
|
+
if (width % 2 === 0 && height % 2 === 0) return [];
|
|
116
|
+
return ['-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2'];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeChunk(stream, chunk) {
|
|
120
|
+
return new Promise((resolveWrite, rejectWrite) => {
|
|
121
|
+
stream.write(chunk, (error) => (error ? rejectWrite(error) : resolveWrite()));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function encodeFrames({
|
|
126
|
+
ffmpeg,
|
|
127
|
+
format,
|
|
128
|
+
framesDir,
|
|
129
|
+
frameExt = 'png',
|
|
130
|
+
frames = 0,
|
|
131
|
+
output,
|
|
132
|
+
fps,
|
|
133
|
+
width,
|
|
134
|
+
height,
|
|
135
|
+
alpha = false,
|
|
136
|
+
crf = 20
|
|
137
|
+
}) {
|
|
138
|
+
const args = [
|
|
139
|
+
'-y',
|
|
140
|
+
'-hide_banner',
|
|
141
|
+
'-loglevel', 'error',
|
|
142
|
+
'-f', 'image2pipe',
|
|
143
|
+
'-vcodec', frameExt === 'jpg' ? 'mjpeg' : 'png',
|
|
144
|
+
'-framerate', String(fps),
|
|
145
|
+
'-i', 'pipe:0'
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
if (format === 'mp4') {
|
|
149
|
+
args.push(
|
|
150
|
+
'-c:v', 'libx264',
|
|
151
|
+
'-preset', 'medium',
|
|
152
|
+
'-crf', String(Math.min(51, Math.max(0, crf))),
|
|
153
|
+
...oddDimensionFilter(width, height),
|
|
154
|
+
'-pix_fmt', 'yuv420p',
|
|
155
|
+
'-movflags', '+faststart',
|
|
156
|
+
output
|
|
157
|
+
);
|
|
158
|
+
} else if (format === 'webm') {
|
|
159
|
+
const vp9Crf = Math.round(Math.min(63, Math.max(0, (crf / 51) * 63)));
|
|
160
|
+
args.push(
|
|
161
|
+
'-c:v', 'libvpx-vp9',
|
|
162
|
+
'-b:v', '0',
|
|
163
|
+
'-crf', String(vp9Crf),
|
|
164
|
+
'-row-mt', '1',
|
|
165
|
+
...(alpha ? ['-auto-alt-ref', '0'] : []),
|
|
166
|
+
...oddDimensionFilter(width, height),
|
|
167
|
+
'-pix_fmt', alpha ? 'yuva420p' : 'yuv420p',
|
|
168
|
+
output
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
throw new Error(`encodeFrames does not encode ${format}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const child = spawn(ffmpeg, args, {
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
stdio: ['pipe', 'ignore', 'pipe']
|
|
177
|
+
});
|
|
178
|
+
let stderr = '';
|
|
179
|
+
let stdinError = null;
|
|
180
|
+
child.stdin.on('error', (error) => {
|
|
181
|
+
stdinError = error;
|
|
182
|
+
});
|
|
183
|
+
child.stderr.on('data', (chunk) => {
|
|
184
|
+
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const closed = new Promise((resolveClose, rejectClose) => {
|
|
188
|
+
child.on('error', rejectClose);
|
|
189
|
+
child.on('close', (code) => {
|
|
190
|
+
if (code === 0) resolveClose();
|
|
191
|
+
else rejectClose(new Error(`ffmpeg exited with code ${code}\n${stderr.trim()}`));
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
closed.catch(() => {});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
for (let index = 1; index <= frames; index += 1) {
|
|
198
|
+
const file = join(framesDir, `frame-${String(index).padStart(6, '0')}.${frameExt}`);
|
|
199
|
+
for await (const chunk of createReadStream(file)) {
|
|
200
|
+
await writeChunk(child.stdin, chunk);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
child.stdin.end();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
child.kill();
|
|
206
|
+
const detail = await closed.catch((reason) => reason);
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Failed to send frames to ffmpeg: ${error.message}` +
|
|
209
|
+
(detail && detail.message ? `\n${detail.message}` : '')
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await closed;
|
|
214
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, extname, join, resolve, sep } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { encodeFrames, ffmpegSupportsDecoder, findFfmpeg } from './ffmpeg.js';
|
|
9
|
+
import { posterType, positiveInteger, positiveNumber, resolveFormat } from './options.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const MIME = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
15
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.json': 'application/json; charset=utf-8',
|
|
17
|
+
'.css': 'text/css; charset=utf-8',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.avif': 'image/avif',
|
|
24
|
+
'.woff': 'font/woff',
|
|
25
|
+
'.woff2': 'font/woff2'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function resolveShapyDir() {
|
|
29
|
+
try {
|
|
30
|
+
return dirname(require.resolve('shapy-bg/package.json'));
|
|
31
|
+
} catch {
|
|
32
|
+
const fallback = fileURLToPath(new URL('../../shapy-bg', import.meta.url));
|
|
33
|
+
if (existsSync(join(fallback, 'package.json'))) return fallback;
|
|
34
|
+
throw new Error('shapy-render needs the shapy-bg package installed in the same project.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pageHtml() {
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
<style>
|
|
45
|
+
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; }
|
|
46
|
+
shapy-background { position: fixed; inset: 0; display: block; width: 100%; height: 100%; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<shapy-background aria-hidden="true"></shapy-background>
|
|
51
|
+
<script type="module">
|
|
52
|
+
import { SHAPY_ENGINE_VERSION } from '/shapy-bg/src/index.js';
|
|
53
|
+
window.__SHAPY_ENGINE_VERSION__ = SHAPY_ENGINE_VERSION;
|
|
54
|
+
window.__SHAPY_RENDER_READY__ = false;
|
|
55
|
+
(async () => {
|
|
56
|
+
await customElements.whenDefined('shapy-background');
|
|
57
|
+
const element = document.querySelector('shapy-background');
|
|
58
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
59
|
+
window.__SHAPY_RENDER_ELEMENT__ = element;
|
|
60
|
+
window.__SHAPY_RENDER_READY__ = true;
|
|
61
|
+
})();
|
|
62
|
+
</script>
|
|
63
|
+
</body>
|
|
64
|
+
</html>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createStaticServer(shapyDir, html) {
|
|
68
|
+
return createServer(async (request, response) => {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
|
71
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
72
|
+
response.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' });
|
|
73
|
+
response.end(html);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!url.pathname.startsWith('/shapy-bg/')) {
|
|
77
|
+
response.writeHead(404);
|
|
78
|
+
response.end('Not found');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const relative = decodeURIComponent(url.pathname.slice('/shapy-bg/'.length));
|
|
82
|
+
const target = resolve(shapyDir, relative);
|
|
83
|
+
if (target !== shapyDir && !target.startsWith(`${shapyDir}${sep}`)) {
|
|
84
|
+
response.writeHead(403);
|
|
85
|
+
response.end('Forbidden');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const data = await readFile(target);
|
|
89
|
+
response.writeHead(200, {
|
|
90
|
+
'content-type': MIME[extname(target).toLowerCase()] || 'application/octet-stream',
|
|
91
|
+
'cache-control': 'no-store'
|
|
92
|
+
});
|
|
93
|
+
response.end(data);
|
|
94
|
+
} catch {
|
|
95
|
+
response.writeHead(404);
|
|
96
|
+
response.end('Not found');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function listen(server) {
|
|
102
|
+
return new Promise((resolveListen, reject) => {
|
|
103
|
+
server.once('error', reject);
|
|
104
|
+
server.listen(0, '127.0.0.1', resolveListen);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function closeServer(server) {
|
|
109
|
+
return new Promise((resolveClose) => {
|
|
110
|
+
if (!server) {
|
|
111
|
+
resolveClose();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
server.close(() => resolveClose());
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function applyOptions(page, options, alpha) {
|
|
119
|
+
return page.evaluate(async ({ next, transparent }) => {
|
|
120
|
+
const element = window.__SHAPY_RENDER_ELEMENT__;
|
|
121
|
+
element.setOptions({
|
|
122
|
+
...next,
|
|
123
|
+
live: false,
|
|
124
|
+
paused: false,
|
|
125
|
+
time: null,
|
|
126
|
+
aspect: 'auto',
|
|
127
|
+
...(transparent ? { background: 'transparent' } : {})
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await document.fonts.ready;
|
|
131
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
132
|
+
|
|
133
|
+
if (transparent && !element.shadowRoot.querySelector('#shapy-render-alpha')) {
|
|
134
|
+
const style = document.createElement('style');
|
|
135
|
+
style.id = 'shapy-render-alpha';
|
|
136
|
+
style.textContent = '.app { background: transparent !important; } .bg, .scrim, .grain { display: none !important; }';
|
|
137
|
+
element.shadowRoot.appendChild(style);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const images = [...element.shadowRoot.querySelectorAll('img')];
|
|
141
|
+
await Promise.all(images.map((image) => {
|
|
142
|
+
if (image.complete) return Promise.resolve();
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const done = () => resolve();
|
|
145
|
+
image.addEventListener('load', done, { once: true });
|
|
146
|
+
image.addEventListener('error', done, { once: true });
|
|
147
|
+
setTimeout(done, 5000);
|
|
148
|
+
});
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
await element.seek(0);
|
|
152
|
+
const snapshot = element.getOptions();
|
|
153
|
+
return {
|
|
154
|
+
seed: snapshot.seed,
|
|
155
|
+
options: snapshot,
|
|
156
|
+
engineVersion: window.__SHAPY_ENGINE_VERSION__
|
|
157
|
+
};
|
|
158
|
+
}, { next: options, transparent: alpha });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function capture(page, alpha, frameExt) {
|
|
162
|
+
const jpeg = frameExt === 'jpg';
|
|
163
|
+
return page.screenshot({
|
|
164
|
+
type: jpeg ? 'jpeg' : 'png',
|
|
165
|
+
...(jpeg ? { quality: 95 } : {}),
|
|
166
|
+
omitBackground: alpha,
|
|
167
|
+
animations: 'allow',
|
|
168
|
+
caret: 'hide'
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function seek(page, seconds) {
|
|
173
|
+
await page.evaluate(async (time) => {
|
|
174
|
+
await window.__SHAPY_RENDER_ELEMENT__.seek(time);
|
|
175
|
+
}, seconds);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render a Shapy background to MP4, WebM, or a PNG sequence.
|
|
180
|
+
*/
|
|
181
|
+
export async function render(input = {}) {
|
|
182
|
+
const {
|
|
183
|
+
output,
|
|
184
|
+
format: requestedFormat,
|
|
185
|
+
width: requestedWidth = 1080,
|
|
186
|
+
height: requestedHeight = 1920,
|
|
187
|
+
fps: requestedFps = 30,
|
|
188
|
+
duration: requestedDuration = 8,
|
|
189
|
+
dpr: requestedDpr = 1,
|
|
190
|
+
alpha = false,
|
|
191
|
+
options = {},
|
|
192
|
+
poster = '',
|
|
193
|
+
posterTime = 0,
|
|
194
|
+
crf = 20,
|
|
195
|
+
loop,
|
|
196
|
+
ffmpeg: requestedFfmpeg = '',
|
|
197
|
+
keepFrames = false,
|
|
198
|
+
onProgress
|
|
199
|
+
} = input;
|
|
200
|
+
|
|
201
|
+
if (!output || typeof output !== 'string') {
|
|
202
|
+
throw new Error('output is required');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const format = resolveFormat(requestedFormat, output);
|
|
206
|
+
const width = positiveInteger(requestedWidth, 1080, 'width', 16, 8192);
|
|
207
|
+
const height = positiveInteger(requestedHeight, 1920, 'height', 16, 8192);
|
|
208
|
+
const fps = positiveNumber(requestedFps, 30, 'fps', 1, 120);
|
|
209
|
+
const duration = positiveNumber(requestedDuration, 8, 'duration', 0.01, 3600);
|
|
210
|
+
const dpr = positiveNumber(requestedDpr, 1, 'dpr', 0.5, 4);
|
|
211
|
+
const quality = positiveNumber(crf, 20, 'crf', 0, 51);
|
|
212
|
+
const posterAt = positiveNumber(posterTime, 0, 'posterTime', 0, 86400);
|
|
213
|
+
const posterFormat = poster ? posterType(poster) : '';
|
|
214
|
+
const frameCount = Math.max(1, Math.round(duration * fps));
|
|
215
|
+
|
|
216
|
+
if (alpha && format === 'mp4') {
|
|
217
|
+
throw new Error('MP4 does not support alpha. Use --format webm or --format png.');
|
|
218
|
+
}
|
|
219
|
+
if (poster && alpha && posterFormat === 'jpeg') {
|
|
220
|
+
throw new Error('Alpha posters must be PNG.');
|
|
221
|
+
}
|
|
222
|
+
if (loop !== undefined && loop !== null) {
|
|
223
|
+
const loopValue = Number(loop);
|
|
224
|
+
if (!Number.isFinite(loopValue) || loopValue <= 0) {
|
|
225
|
+
throw new Error('loop must be a positive number of seconds');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let server;
|
|
230
|
+
let browser;
|
|
231
|
+
let framesDir = '';
|
|
232
|
+
let removeFrames = false;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
let chromium;
|
|
236
|
+
try {
|
|
237
|
+
({ chromium } = await import('playwright'));
|
|
238
|
+
} catch {
|
|
239
|
+
throw new Error('shapy-render needs playwright. Install it with: npm install playwright');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ffmpeg = format === 'mp4' || format === 'webm'
|
|
243
|
+
? findFfmpeg({
|
|
244
|
+
ffmpeg: requestedFfmpeg,
|
|
245
|
+
executablePath: chromium.executablePath(),
|
|
246
|
+
format
|
|
247
|
+
})
|
|
248
|
+
: '';
|
|
249
|
+
|
|
250
|
+
let frameExt = 'png';
|
|
251
|
+
if (ffmpeg) {
|
|
252
|
+
if (alpha && !ffmpegSupportsDecoder(ffmpeg, 'png')) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
'Alpha WebM needs ffmpeg with PNG decoding. Install a full ffmpeg or use --format png.'
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (!alpha && !ffmpegSupportsDecoder(ffmpeg, 'png')) {
|
|
258
|
+
if (!ffmpegSupportsDecoder(ffmpeg, 'mjpeg')) {
|
|
259
|
+
throw new Error('ffmpeg cannot decode PNG or JPEG input frames.');
|
|
260
|
+
}
|
|
261
|
+
frameExt = 'jpg';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const shapyDir = resolveShapyDir();
|
|
266
|
+
server = createStaticServer(shapyDir, pageHtml());
|
|
267
|
+
await listen(server);
|
|
268
|
+
const address = server.address();
|
|
269
|
+
const port = address && typeof address === 'object' ? address.port : 0;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
browser = await chromium.launch({
|
|
273
|
+
headless: true,
|
|
274
|
+
args: [
|
|
275
|
+
'--force-color-profile=srgb',
|
|
276
|
+
'--disable-lcd-text',
|
|
277
|
+
'--font-render-hinting=none',
|
|
278
|
+
'--hide-scrollbars'
|
|
279
|
+
]
|
|
280
|
+
});
|
|
281
|
+
} catch (error) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Could not launch Chromium. Run "npx playwright install chromium" and try again.\n${error.message}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const context = await browser.newContext({
|
|
288
|
+
viewport: { width, height },
|
|
289
|
+
deviceScaleFactor: dpr,
|
|
290
|
+
colorScheme: 'dark',
|
|
291
|
+
reducedMotion: 'no-preference'
|
|
292
|
+
});
|
|
293
|
+
const page = await context.newPage();
|
|
294
|
+
const pageErrors = [];
|
|
295
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
296
|
+
page.on('console', (message) => {
|
|
297
|
+
if (message.type() === 'error') pageErrors.push(message.text());
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' });
|
|
301
|
+
try {
|
|
302
|
+
await page.waitForFunction(() => window.__SHAPY_RENDER_READY__ === true, undefined, { timeout: 20000 });
|
|
303
|
+
} catch {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Shapy did not initialize in the browser.${pageErrors.length ? `\n${pageErrors.join('\n')}` : ''}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const applied = await applyOptions(
|
|
310
|
+
page,
|
|
311
|
+
loop === undefined || loop === null ? options : { ...options, loop },
|
|
312
|
+
alpha
|
|
313
|
+
);
|
|
314
|
+
const actualWidth = Math.round(width * dpr);
|
|
315
|
+
const actualHeight = Math.round(height * dpr);
|
|
316
|
+
|
|
317
|
+
if (format === 'png') {
|
|
318
|
+
framesDir = resolve(output);
|
|
319
|
+
await mkdir(framesDir, { recursive: true });
|
|
320
|
+
} else {
|
|
321
|
+
framesDir = await mkdtemp(join(tmpdir(), 'shapy-render-'));
|
|
322
|
+
removeFrames = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (let index = 0; index < frameCount; index += 1) {
|
|
326
|
+
const time = index / fps;
|
|
327
|
+
await seek(page, time);
|
|
328
|
+
const buffer = await capture(page, alpha, frameExt);
|
|
329
|
+
const file = join(framesDir, `frame-${String(index + 1).padStart(6, '0')}.${frameExt}`);
|
|
330
|
+
await writeFile(file, buffer);
|
|
331
|
+
if (onProgress) {
|
|
332
|
+
onProgress({ phase: 'frames', frame: index + 1, total: frameCount, time });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (poster) {
|
|
337
|
+
await seek(page, posterAt);
|
|
338
|
+
await mkdir(dirname(resolve(poster)), { recursive: true });
|
|
339
|
+
const buffer = await page.screenshot({
|
|
340
|
+
type: posterFormat,
|
|
341
|
+
omitBackground: alpha,
|
|
342
|
+
animations: 'allow',
|
|
343
|
+
caret: 'hide',
|
|
344
|
+
...(posterFormat === 'jpeg' ? { quality: 92 } : {})
|
|
345
|
+
});
|
|
346
|
+
await writeFile(resolve(poster), buffer);
|
|
347
|
+
if (onProgress) onProgress({ phase: 'poster', message: poster });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (format !== 'png') {
|
|
351
|
+
await mkdir(dirname(resolve(output)), { recursive: true });
|
|
352
|
+
if (onProgress) onProgress({ phase: 'encode', message: format });
|
|
353
|
+
await encodeFrames({
|
|
354
|
+
ffmpeg,
|
|
355
|
+
format,
|
|
356
|
+
framesDir,
|
|
357
|
+
frameExt,
|
|
358
|
+
frames: frameCount,
|
|
359
|
+
output: resolve(output),
|
|
360
|
+
fps,
|
|
361
|
+
width: actualWidth,
|
|
362
|
+
height: actualHeight,
|
|
363
|
+
alpha,
|
|
364
|
+
crf: quality
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
output: resolve(output),
|
|
370
|
+
format,
|
|
371
|
+
seed: applied.seed,
|
|
372
|
+
engineVersion: applied.engineVersion,
|
|
373
|
+
width: actualWidth,
|
|
374
|
+
height: actualHeight,
|
|
375
|
+
fps,
|
|
376
|
+
duration: frameCount / fps,
|
|
377
|
+
frames: frameCount,
|
|
378
|
+
poster: poster ? resolve(poster) : undefined,
|
|
379
|
+
ffmpeg: ffmpeg || undefined,
|
|
380
|
+
framesDir: keepFrames ? framesDir : undefined
|
|
381
|
+
};
|
|
382
|
+
} finally {
|
|
383
|
+
if (browser) await browser.close().catch(() => {});
|
|
384
|
+
await closeServer(server);
|
|
385
|
+
if (removeFrames && framesDir && !keepFrames) {
|
|
386
|
+
await rm(framesDir, { recursive: true, force: true }).catch(() => {});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/options.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const RENDER_FORMATS = new Set(['mp4', 'webm', 'png']);
|
|
4
|
+
|
|
5
|
+
export function positiveInteger(value, fallback, name, min, max) {
|
|
6
|
+
const number = value === undefined || value === null || value === '' ? fallback : Number(value);
|
|
7
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
8
|
+
throw new Error(`${name} must be an integer from ${min} to ${max}`);
|
|
9
|
+
}
|
|
10
|
+
return number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function positiveNumber(value, fallback, name, min, max) {
|
|
14
|
+
const number = value === undefined || value === null || value === '' ? fallback : Number(value);
|
|
15
|
+
if (!Number.isFinite(number) || number < min || number > max) {
|
|
16
|
+
throw new Error(`${name} must be a number from ${min} to ${max}`);
|
|
17
|
+
}
|
|
18
|
+
return number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveFormat(format, output) {
|
|
22
|
+
if (format) {
|
|
23
|
+
const token = String(format).toLowerCase();
|
|
24
|
+
if (!RENDER_FORMATS.has(token)) throw new Error('format must be mp4, webm, or png');
|
|
25
|
+
return token;
|
|
26
|
+
}
|
|
27
|
+
const extension = extname(output).slice(1).toLowerCase();
|
|
28
|
+
return RENDER_FORMATS.has(extension) ? extension : 'mp4';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function posterType(poster) {
|
|
32
|
+
const extension = extname(poster).toLowerCase();
|
|
33
|
+
if (extension === '.jpg' || extension === '.jpeg') return 'jpeg';
|
|
34
|
+
if (extension === '.png') return 'png';
|
|
35
|
+
throw new Error('poster must end in .png, .jpg, or .jpeg');
|
|
36
|
+
}
|