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 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
- # Temporary Holding Version
1
+ # shapy-render
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }