paper-motion 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/bin/pamo.mjs +664 -0
- package/examples/breath/brief.md +11 -0
- package/examples/breath/poster.jpg +0 -0
- package/examples/breath/scene.html +59 -0
- package/examples/chrome-endcard/brief.md +7 -0
- package/examples/chrome-endcard/fonts/GeistMono-Variable.woff2 +0 -0
- package/examples/chrome-endcard/fonts/GeistSerif-Regular.woff2 +0 -0
- package/examples/chrome-endcard/poster.jpg +0 -0
- package/examples/chrome-endcard/scene.html +68 -0
- package/examples/chrome-endcard/wordmark.js +16 -0
- package/examples/halo-explainer/audio/script.txt +8 -0
- package/examples/halo-explainer/audio/words.json +554 -0
- package/examples/halo-explainer/brand/IDENTITY.md +24 -0
- package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
- package/examples/halo-explainer/brand/motion.json +9 -0
- package/examples/halo-explainer/brand/shader-set/set.json +104 -0
- package/examples/halo-explainer/brand/sound.json +33 -0
- package/examples/halo-explainer/brand/tokens.json +97 -0
- package/examples/halo-explainer/brief.md +34 -0
- package/examples/halo-explainer/poster.jpg +0 -0
- package/examples/halo-explainer/scene.html +372 -0
- package/examples/install-loop/brief.md +7 -0
- package/examples/install-loop/fonts/GeistMono-Variable.woff2 +0 -0
- package/examples/install-loop/fonts/GeistSerif-Regular.woff2 +0 -0
- package/examples/install-loop/poster.jpg +0 -0
- package/examples/install-loop/scene.html +46 -0
- package/examples/orbit-keynote/audio/script.txt +7 -0
- package/examples/orbit-keynote/audio/words.json +494 -0
- package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
- package/examples/orbit-keynote/brand/motion.json +9 -0
- package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
- package/examples/orbit-keynote/brand/sound.json +45 -0
- package/examples/orbit-keynote/brand/tokens.json +106 -0
- package/examples/orbit-keynote/brief.md +25 -0
- package/examples/orbit-keynote/poster.jpg +0 -0
- package/examples/orbit-keynote/scene.html +323 -0
- package/examples/signal/brief.md +10 -0
- package/examples/signal/poster.jpg +0 -0
- package/examples/signal/scene.html +50 -0
- package/lib/board.mjs +95 -0
- package/lib/browser.mjs +88 -0
- package/lib/catalog.mjs +77 -0
- package/lib/customize.mjs +162 -0
- package/lib/keys.mjs +93 -0
- package/lib/live-board.js +128 -0
- package/lib/modules.mjs +23 -0
- package/lib/react-hooks.mjs +9 -0
- package/lib/registry.mjs +344 -0
- package/lib/render.mjs +193 -0
- package/lib/server.mjs +89 -0
- package/lib/sfx.mjs +287 -0
- package/lib/shaderset.mjs +65 -0
- package/lib/wordmark.mjs +64 -0
- package/package.json +46 -4
- package/runtime/brand.js +71 -0
- package/runtime/core.js +269 -0
- package/runtime/index.js +349 -0
- package/runtime/jsx-shim.js +3 -0
- package/runtime/kit.js +98 -0
- package/runtime/moves/bar-reveal.js +19 -0
- package/runtime/moves/blur-in.js +7 -0
- package/runtime/moves/cells.js +17 -0
- package/runtime/moves/crossfade.js +6 -0
- package/runtime/moves/cut.js +3 -0
- package/runtime/moves/dive-in.js +21 -0
- package/runtime/moves/draw-on.js +12 -0
- package/runtime/moves/drift.js +7 -0
- package/runtime/moves/flash.js +15 -0
- package/runtime/moves/flip-3d.js +9 -0
- package/runtime/moves/flip-in.js +12 -0
- package/runtime/moves/flip-lift.js +24 -0
- package/runtime/moves/glitch.js +16 -0
- package/runtime/moves/iris.js +12 -0
- package/runtime/moves/magic-move.js +49 -0
- package/runtime/moves/mask-reveal.js +10 -0
- package/runtime/moves/odometer.js +32 -0
- package/runtime/moves/orbit.js +8 -0
- package/runtime/moves/pop-in.js +12 -0
- package/runtime/moves/punch.js +9 -0
- package/runtime/moves/push.js +14 -0
- package/runtime/moves/rgb-split.js +7 -0
- package/runtime/moves/roll.js +20 -0
- package/runtime/moves/scan-reveal.js +10 -0
- package/runtime/moves/scramble.js +21 -0
- package/runtime/moves/shake.js +15 -0
- package/runtime/moves/slices.js +14 -0
- package/runtime/moves/snap-scroll.js +23 -0
- package/runtime/moves/squash-drop.js +21 -0
- package/runtime/moves/tile-swirl.js +29 -0
- package/runtime/moves/track-in.js +7 -0
- package/runtime/moves/type-on.js +41 -0
- package/runtime/moves/unfold.js +20 -0
- package/runtime/moves/whip-pan.js +21 -0
- package/runtime/moves/whip.js +14 -0
- package/runtime/moves/wipe.js +9 -0
- package/runtime/moves/word-rise.js +10 -0
- package/runtime/moves/zoom-through.js +13 -0
- package/runtime/moves.js +94 -0
- package/runtime/player.js +69 -0
- package/runtime/react-shim.js +15 -0
- package/templates/blank/brief.md +39 -0
- package/templates/blank/scene.html +44 -0
- package/templates/brand-sheet/index.html +241 -0
- package/templates/move-demo/brief.md +4 -0
- package/templates/move-demo/scene.html +158 -0
- package/templates/piece/brief.md +39 -0
- package/templates/piece/scene.html +62 -0
- package/templates/shader-set/shot.js +47 -0
- package/templates/still/brief.md +3 -0
- package/templates/still/scene.html +56 -0
package/lib/modules.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Where an engine dependency lives on disk. Inside the repo it is packages/engine/node_modules; installed from npm,
|
|
2
|
+
// npm hoists it next to the engine (…/node_modules/<dep>, or npx's cache). Walk up like Node's resolver does.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
|
|
9
|
+
const found = new Map();
|
|
10
|
+
/** Absolute folder of an installed package (e.g. '@paper-design/shaders'), or null. */
|
|
11
|
+
export function moduleDir(name) {
|
|
12
|
+
if (found.has(name)) return found.get(name);
|
|
13
|
+
let dir = ENGINE, hit = null;
|
|
14
|
+
for (;;) {
|
|
15
|
+
const d = path.join(dir, 'node_modules', name);
|
|
16
|
+
if (fs.existsSync(path.join(d, 'package.json'))) { hit = d; break; }
|
|
17
|
+
const up = path.dirname(dir);
|
|
18
|
+
if (up === dir) break;
|
|
19
|
+
dir = up;
|
|
20
|
+
}
|
|
21
|
+
found.set(name, hit);
|
|
22
|
+
return hit;
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Node module hook: resolve `react` to the paper-motion shim so the official
|
|
2
|
+
// shader components can be inspected in Node (used by `paper-motion shaders`).
|
|
3
|
+
const shim = new URL('../runtime/react-shim.js', import.meta.url).href;
|
|
4
|
+
const jsx = new URL('../runtime/jsx-shim.js', import.meta.url).href;
|
|
5
|
+
export async function resolve(specifier, context, next) {
|
|
6
|
+
if (specifier === 'react') return { url: shim, shortCircuit: true, format: 'module' };
|
|
7
|
+
if (specifier === 'react/jsx-runtime') return { url: jsx, shortCircuit: true, format: 'module' };
|
|
8
|
+
return next(specifier, context);
|
|
9
|
+
}
|
package/lib/registry.mjs
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// The paper-motion registry: resources (moves, backgrounds, palettes, font pairs, recipes) that agents pick by
|
|
2
|
+
// name and install into a project as editable code or brand files. Source: registry/<type>/<id>/item.json in the
|
|
3
|
+
// repo. `build` publishes it for the site (registry.json, r/<id>.json, llms.txt); `add` installs from a local
|
|
4
|
+
// registry folder or from the site.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { apply as applyValues, parseSet } from './customize.mjs';
|
|
9
|
+
|
|
10
|
+
const ENGINE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const REPO = path.resolve(ENGINE, '..', '..');
|
|
12
|
+
export const SITE = (process.env.PAPER_MOTION_SITE ?? 'https://paper-motion.app').replace(/\/$/, '');
|
|
13
|
+
const TYPES = ['component', 'transition', 'text', 'camera', 'background', 'palette', 'font-pair', 'recipe'];
|
|
14
|
+
const TYPE_TITLES = { component: 'Component transitions', transition: 'Transitions', text: 'Text effects', camera: 'Camera and energy', background: 'Backgrounds', palette: 'Palettes', 'font-pair': 'Font pairs', recipe: 'Recipes' };
|
|
15
|
+
|
|
16
|
+
/** The registry to use: an explicit folder or URL, $PAPER_MOTION_REGISTRY, this repo's registry/ when present, else the site. */
|
|
17
|
+
export function resolveRegistry(given) {
|
|
18
|
+
const r = given ?? process.env.PAPER_MOTION_REGISTRY;
|
|
19
|
+
if (r) return /^https?:\/\//.test(r) ? { url: r.replace(/\/$/, '') } : { dir: path.resolve(r) };
|
|
20
|
+
const local = path.join(REPO, 'registry');
|
|
21
|
+
return fs.existsSync(path.join(local, 'schema.json')) ? { dir: local } : { url: SITE };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── reading and validating ──────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export function loadItems(dir) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const type of fs.readdirSync(dir)) {
|
|
29
|
+
const td = path.join(dir, type);
|
|
30
|
+
if (!fs.statSync(td).isDirectory() || type.startsWith('_')) continue;
|
|
31
|
+
for (const id of fs.readdirSync(td)) {
|
|
32
|
+
const f = path.join(td, id, 'item.json');
|
|
33
|
+
if (fs.existsSync(f)) out.push({ ...JSON.parse(fs.readFileSync(f, 'utf8')), _dir: path.join(td, id) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.sort((a, b) => TYPES.indexOf(a.type) - TYPES.indexOf(b.type) || a.id.localeCompare(b.id));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A small JSON Schema check (the subset registry/schema.json uses). Returns a list of problems. */
|
|
40
|
+
function check(value, schema, at = '') {
|
|
41
|
+
const errs = [];
|
|
42
|
+
const t = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
|
|
43
|
+
if (schema.type && schema.type !== t && !(schema.type === 'integer' && t === 'number' && Number.isInteger(value))) return [`${at || '/'}: expected ${schema.type}, got ${t}`];
|
|
44
|
+
if (schema.type === 'integer') { if (schema.minimum !== undefined && value < schema.minimum) errs.push(`${at}: below ${schema.minimum}`); if (schema.maximum !== undefined && value > schema.maximum) errs.push(`${at}: above ${schema.maximum}`); }
|
|
45
|
+
if (schema.enum && !schema.enum.includes(value)) errs.push(`${at}: "${value}" is not one of ${schema.enum.join(', ')}`);
|
|
46
|
+
if (t === 'string') {
|
|
47
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(value)) errs.push(`${at}: "${value}" does not match ${schema.pattern}`);
|
|
48
|
+
if (schema.maxLength && value.length > schema.maxLength) errs.push(`${at}: longer than ${schema.maxLength} characters`);
|
|
49
|
+
}
|
|
50
|
+
if (t === 'array') {
|
|
51
|
+
if (schema.minItems && value.length < schema.minItems) errs.push(`${at}: needs at least ${schema.minItems} items`);
|
|
52
|
+
if (schema.items) value.forEach((v, i) => errs.push(...check(v, schema.items, `${at}/${i}`)));
|
|
53
|
+
}
|
|
54
|
+
if (t === 'object') {
|
|
55
|
+
for (const k of schema.required ?? []) if (!(k in value)) errs.push(`${at}/${k}: required`);
|
|
56
|
+
for (const [k, v] of Object.entries(value)) {
|
|
57
|
+
if (schema.properties?.[k]) errs.push(...check(v, schema.properties[k], `${at}/${k}`));
|
|
58
|
+
else if (schema.additionalProperties === false && !k.startsWith('_')) errs.push(`${at}/${k}: not allowed`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return errs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validate(dir) {
|
|
65
|
+
const schema = JSON.parse(fs.readFileSync(path.join(dir, 'schema.json'), 'utf8'));
|
|
66
|
+
const items = loadItems(dir), ids = new Set(), problems = [];
|
|
67
|
+
for (const it of items) {
|
|
68
|
+
const where = path.relative(REPO, it._dir);
|
|
69
|
+
for (const e of check(it, schema)) problems.push(`${where}: ${e}`);
|
|
70
|
+
if (ids.has(it.id)) problems.push(`${where}: duplicate id "${it.id}"`);
|
|
71
|
+
ids.add(it.id);
|
|
72
|
+
if (path.basename(it._dir) !== it.id) problems.push(`${where}: folder name must equal id "${it.id}"`);
|
|
73
|
+
if (path.basename(path.dirname(it._dir)) !== it.type) problems.push(`${where}: lives under ${path.basename(path.dirname(it._dir))}/ but type is "${it.type}"`);
|
|
74
|
+
for (const f of it.files ?? []) if (!fs.existsSync(path.join(REPO, f.from))) problems.push(`${where}: missing file ${f.from}`);
|
|
75
|
+
}
|
|
76
|
+
for (const it of items) for (const r of [...(it.requires?.items ?? []), ...(it.related ?? [])]) if (!ids.has(r)) problems.push(`${it.id}: refers to unknown resource "${r}"`);
|
|
77
|
+
return { items, problems };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── publishing ──────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** Imports inside the repo point at the runtime; in a user's project they point at the package. */
|
|
83
|
+
export const rewriteImports = (code) => code.replace(/(['"])\.\.\/(index|core)\.js\1/g, "'paper-motion'").replace(/(['"])\.\.\/kit\.js\1/g, "'paper-motion/kit'");
|
|
84
|
+
|
|
85
|
+
const BINARY = /\.(png|jpe?g|webp|gif|avif|woff2?|ttf|otf|mp4|webm)$/i;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The full resource as served at r/<id>.json: the item plus its files, ready to write. Text files carry their content;
|
|
89
|
+
* binary files (images, fonts) carry a URL under <site>/files/ instead.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveItem(it, site = SITE) {
|
|
92
|
+
const { _dir, ...item } = it;
|
|
93
|
+
return { ...item, files: (it.files ?? []).map((f) => (BINARY.test(f.from)
|
|
94
|
+
? { path: f.to, url: `${site}/files/${path.basename(f.from)}`, from: f.from }
|
|
95
|
+
: { path: f.to, content: rewriteImports(fs.readFileSync(path.join(REPO, f.from), 'utf8')) })) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const summaryLine = (it, site) => `- [${it.title}](${site}/r/${it.id}.json): ${it.summary}${it.tags?.length ? ` _(${it.tags.filter((t) => t !== it.type).join(', ')})_` : ''}`;
|
|
99
|
+
|
|
100
|
+
export function llmsTxt(items, site, { full = false } = {}) {
|
|
101
|
+
const head = `# paper-motion
|
|
102
|
+
|
|
103
|
+
> Motion resources for AI agents: component transitions, transitions, text effects, camera moves, backgrounds
|
|
104
|
+
> (Paper shaders), palettes, font pairs and video recipes, each with a name. You pick them for the user's brand,
|
|
105
|
+
> propose options as a **board** link they open in a browser, install what they choose as code they own, and render
|
|
106
|
+
> the video with the paper-motion engine. Nothing here renders on a server: every preview plays in the user's browser.
|
|
107
|
+
|
|
108
|
+
## The flow (for agents)
|
|
109
|
+
|
|
110
|
+
1. **Set up.** \`npx skills add solanodz/paper-motion\` (the skills) and \`npx paper-motion setup\` (the engine: Node 18+,
|
|
111
|
+
ffmpeg, Chrome). The CLI is \`npx paper-motion\` (short: \`pamo\`). Every command answers \`--help\`.
|
|
112
|
+
2. **Interview.** Follow the \`make-video\` skill. Without the skills, ask at least: what the brand is in one plain
|
|
113
|
+
line; what this video is for and where it plays (format: 16:9, 9:16, 1:1); its ground, text and accent colors or
|
|
114
|
+
"no brand yet"; three words for the feel and one thing it must never look like; the one action at the end.
|
|
115
|
+
Mirror it back before proposing anything.
|
|
116
|
+
3. **Propose with boards.** For each visual decision (palette, type, background, moves, recipe) pick 2–4 options from
|
|
117
|
+
this registry and share a board link: \`pamo board --url --title … --option id … --look palette,font\` prints it
|
|
118
|
+
(format below). The user opens it, sees the options live in their look, may tune one, and pastes back one line:
|
|
119
|
+
\`paper-motion: B · mesh-sage --set speed=0.8 (bg-1)\`, or \`paper-motion: none of bg-1, calmer ones\`. The reply
|
|
120
|
+
carries every value that differs from the defaults, your pre-tuning included, so installing it reproduces what they saw.
|
|
121
|
+
4. **Install the choices.** \`npx paper-motion add <id> [--set key=value …]\` in the project root, with exactly the
|
|
122
|
+
\`--set\` arguments from the reply. Moves go to \`moves/\`, backgrounds to \`brand/shader-set/set.json\`, palettes and
|
|
123
|
+
fonts to \`brand/tokens.json\`, recipes to \`pieces/<id>/\`. Their choices also accumulate in a kit at ${site}/kit,
|
|
124
|
+
whose prompt carries the whole \`pamo add\` command.
|
|
125
|
+
5. **Make the video.** Build \`scene.html\` (a pure function of time), check stills with \`pamo still\`, render with
|
|
126
|
+
\`pamo render scene.html --motion-blur 5\`. The test shot and the final piece are real renders, not boards.
|
|
127
|
+
|
|
128
|
+
## Choosing well: the metadata
|
|
129
|
+
|
|
130
|
+
${site}/registry.json is the index; every item carries, beside id, type, title, summary, tags and the names of its
|
|
131
|
+
\`params\` (ranges and defaults are in ${site}/r/<id>.json, fetch it before writing a \`--set\`):
|
|
132
|
+
- \`when\`: 2–3 "use it when…" phrases.
|
|
133
|
+
- \`mood\`: calm, bold, playful, technical, editorial, warm, minimal, luxury, raw.
|
|
134
|
+
- Moves: \`energy\` 1 (still) to 5 (violent) and \`role\` (enter, exit, swap, emphasis, camera, text).
|
|
135
|
+
- Backgrounds and palettes: \`colors\` (dominant hex) and \`mode\` (light, dark); backgrounds also \`motion\` (still, gentle, lively).
|
|
136
|
+
- Font pairs: \`voice\` (serif, sans, display, mono). Recipes: \`duration\`, \`formats\`.
|
|
137
|
+
|
|
138
|
+
\`pamo search\` filters by them: \`pamo search --type background --mode dark --near "#0a0f1f,#eef1f8,#3d6bff"\`
|
|
139
|
+
sorts backgrounds by color fit (the mean distance from each of theirs to the nearest of yours; lower is closer; pass
|
|
140
|
+
ground, figure and accent so resources with white type are not punished); \`pamo search --type component --mood calm
|
|
141
|
+
--energy "<=3" --role swap\`.
|
|
142
|
+
|
|
143
|
+
Worked example, four backgrounds for a dark cobalt brand: filter \`type=background\`, \`mode=dark\`; rank by nearest
|
|
144
|
+
\`colors\` to the brand's ground, figure and accent; spread the \`motion\` as far as the catalog allows (a quiet one,
|
|
145
|
+
a lively one); prefer
|
|
146
|
+
\`mood\` overlapping the brand's words; tune one toward the accent (\`dither-slate:colorFront=#3d6bff\`); write a
|
|
147
|
+
one-line \`--note\` for each; share the link.
|
|
148
|
+
|
|
149
|
+
## The board link format
|
|
150
|
+
|
|
151
|
+
\`${site}/b#<payload>\` where payload = base64url(deflate-raw(JSON)). The JSON:
|
|
152
|
+
|
|
153
|
+
\`\`\`json
|
|
154
|
+
{ "v": 1, "kind": "resource", "title": "Backgrounds for Kiln", "ask": "Which one feels like the product?",
|
|
155
|
+
"reply": "bg-1",
|
|
156
|
+
"look": { "palette": { "id": "cobalt", "values": {} }, "font": { "id": "geist", "values": {} } },
|
|
157
|
+
"options": [
|
|
158
|
+
{ "label": "A", "id": "mesh-graphite", "values": {}, "note": "quiet, graphite" },
|
|
159
|
+
{ "label": "B", "id": "neuro-slate", "values": {}, "note": "a technical web" },
|
|
160
|
+
{ "label": "C", "id": "dither-slate", "values": { "colorFront": "#3d6bff" }, "note": "pixels in your accent" },
|
|
161
|
+
{ "label": "D", "id": "grain-slate", "values": {} } ] }
|
|
162
|
+
\`\`\`
|
|
163
|
+
|
|
164
|
+
Rules: 2–4 options, labels A–D, one resource type per \`resource\` board; \`kind: "kit"\` makes each option
|
|
165
|
+
\`{ "label", "items": [{ "id", "values" }], "note" }\` (a whole kit); \`values\` hold only what differs from the
|
|
166
|
+
defaults; the JSON stays under 8 KB. \`pamo board --url\` builds and validates it for you.
|
|
167
|
+
|
|
168
|
+
Resources by type (${items.length} in all):
|
|
169
|
+
`;
|
|
170
|
+
const sections = TYPES.filter((t) => items.some((i) => i.type === t)).map((t) => {
|
|
171
|
+
const list = items.filter((i) => i.type === t);
|
|
172
|
+
if (!full) return `\n## ${TYPE_TITLES[t]}\n\n${list.map((i) => summaryLine(i, site)).join('\n')}\n`;
|
|
173
|
+
return `\n## ${TYPE_TITLES[t]}\n\n${list.map((i) => [
|
|
174
|
+
`### ${i.title} · \`${i.id}\``, '', i.summary, '',
|
|
175
|
+
i.signature ? `Signature: \`${i.signature}\`` : '',
|
|
176
|
+
i.params?.length ? `Params: ${i.params.map((p) => `\`${p.name}\`${p.default !== undefined ? ` = ${JSON.stringify(p.default)}` : ''}${p.description ? ` (${p.description})` : ''}`).join('; ')}` : '',
|
|
177
|
+
i.notes?.length ? i.notes.map((n) => `- ${n}`).join('\n') : '',
|
|
178
|
+
`Install: \`npx paper-motion add ${i.id}\` · ${site}/r/${i.id}.json`, '',
|
|
179
|
+
'```js', i.usage, '```', '',
|
|
180
|
+
].filter((x) => x !== '').join('\n')).join('\n\n')}\n`;
|
|
181
|
+
});
|
|
182
|
+
return head + sections.join('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The site's live player: the moves, kit and core (browser-safe), the demo harness with its imports pointed at them,
|
|
187
|
+
* its stylesheet scoped to `.pm-play`, and its fonts. The site imports them at runtime from /play/.
|
|
188
|
+
*/
|
|
189
|
+
function publishPlayer(dir, out) {
|
|
190
|
+
const RT = path.join(ENGINE, 'runtime'), demo = path.join(dir, '_demo');
|
|
191
|
+
fs.rmSync(out, { recursive: true, force: true });
|
|
192
|
+
fs.mkdirSync(path.join(out, 'engine', 'moves'), { recursive: true });
|
|
193
|
+
for (const f of ['core.js', 'kit.js', 'moves.js']) fs.copyFileSync(path.join(RT, f), path.join(out, 'engine', f));
|
|
194
|
+
for (const f of fs.readdirSync(path.join(RT, 'moves'))) fs.copyFileSync(path.join(RT, 'moves', f), path.join(out, 'engine', 'moves', f));
|
|
195
|
+
const js = fs.readFileSync(path.join(demo, 'demos.js'), 'utf8')
|
|
196
|
+
.replace(/import \{ scene, ([^}]*) \} from 'paper-motion';/, "import { $1 } from './engine/core.js';\nconst scene = () => { throw new Error('the site mounts demos with mount()'); };")
|
|
197
|
+
.replace(/from 'paper-motion\/moves'/g, "from './engine/moves.js'");
|
|
198
|
+
fs.writeFileSync(path.join(out, 'demos.js'), js);
|
|
199
|
+
const css = fs.readFileSync(path.join(demo, 'demo.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^([^@\s{][^{]*)\{/gm, (m, sel) => `${sel.split(',').map((x) => (x.trim().startsWith('#stage') ? x.replace('#stage', '.pm-play') : `.pm-play ${x.trim()}`)).join(', ')} {`);
|
|
200
|
+
fs.writeFileSync(path.join(out, 'demo.css'), css);
|
|
201
|
+
fs.mkdirSync(path.join(out, 'fonts'), { recursive: true });
|
|
202
|
+
for (const f of fs.readdirSync(path.join(demo, 'fonts'))) fs.copyFileSync(path.join(demo, 'fonts', f), path.join(out, 'fonts', f));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Writes registry.json, r/<id>.json, schema.json, llms.txt and llms-full.txt into `out`. */
|
|
206
|
+
export function build({ dir = resolveRegistry().dir, out, site = SITE } = {}) {
|
|
207
|
+
const { items, problems } = validate(dir);
|
|
208
|
+
if (problems.length) { const e = new Error(`registry: ${problems.length} problem(s)\n ${problems.join('\n ')}`); e.problems = problems; throw e; }
|
|
209
|
+
fs.mkdirSync(path.join(out, 'r'), { recursive: true });
|
|
210
|
+
// publish only the preview files that were rendered: a background plays live and may have none
|
|
211
|
+
for (const it of items) if (it.preview) { const have = Object.fromEntries(Object.entries(it.preview).filter(([, p]) => fs.existsSync(path.join(out, p)))); it.preview = Object.keys(have).length ? have : undefined; }
|
|
212
|
+
const index = {
|
|
213
|
+
name: 'paper-motion', site, schema: `${site}/schema.json`, count: items.length, types: TYPES.filter((t) => items.some((i) => i.type === t)),
|
|
214
|
+
items: items.map(({ id, type, title, summary, tags, version, preview, related, when, mood, energy, role, colors, mode, motion, voice, duration, formats, params }) => ({ id, type, title, summary, tags, version, url: `${site}/r/${id}.json`, ...Object.fromEntries(Object.entries({ when, mood, energy, role, colors, mode, motion, voice, duration, formats, preview, related, params: params?.filter((p) => !p.hidden).map((p) => p.name) }).filter(([, v]) => v !== undefined)) })),
|
|
215
|
+
};
|
|
216
|
+
fs.writeFileSync(path.join(out, 'registry.json'), JSON.stringify(index, null, 2));
|
|
217
|
+
fs.mkdirSync(path.join(out, 'files'), { recursive: true });
|
|
218
|
+
for (const it of items) {
|
|
219
|
+
fs.writeFileSync(path.join(out, 'r', `${it.id}.json`), JSON.stringify(resolveItem(it, site), null, 2));
|
|
220
|
+
for (const f of it.files ?? []) if (BINARY.test(f.from)) fs.copyFileSync(path.join(REPO, f.from), path.join(out, 'files', path.basename(f.from)));
|
|
221
|
+
}
|
|
222
|
+
fs.copyFileSync(path.join(dir, 'schema.json'), path.join(out, 'schema.json'));
|
|
223
|
+
publishPlayer(dir, path.join(out, 'play'));
|
|
224
|
+
fs.writeFileSync(path.join(out, 'llms.txt'), llmsTxt(items, site));
|
|
225
|
+
fs.writeFileSync(path.join(out, 'llms-full.txt'), llmsTxt(items, site, { full: true }));
|
|
226
|
+
return { count: items.length, out };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── installing ──────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
async function fetchJson(url) {
|
|
232
|
+
const r = await fetch(url);
|
|
233
|
+
if (!r.ok) throw new Error(`${url}: ${r.status} ${r.statusText}`);
|
|
234
|
+
return r.json();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One resolved resource (with file contents) from a local folder or the site. */
|
|
238
|
+
export async function getItem(id, reg = resolveRegistry()) {
|
|
239
|
+
if (reg.dir) {
|
|
240
|
+
const it = loadItems(reg.dir).find((i) => i.id === id);
|
|
241
|
+
if (!it) throw new Error(`no resource "${id}" in ${reg.dir} (try \`pamo search\`)`);
|
|
242
|
+
return resolveItem(it);
|
|
243
|
+
}
|
|
244
|
+
return fetchJson(`${reg.url}/r/${id}.json`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function getIndex(reg = resolveRegistry()) {
|
|
248
|
+
if (reg.dir) return { items: loadItems(reg.dir).map(({ _dir, ...i }) => i) };
|
|
249
|
+
return fetchJson(`${reg.url}/registry.json`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const readJson = (f, fallback) => (fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : fallback);
|
|
253
|
+
const writeJson = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n'); };
|
|
254
|
+
|
|
255
|
+
/** Deep-merges `frag` into `base`; keys that already hold a different value are reported, and replaced only with force. */
|
|
256
|
+
function mergeInto(base, frag, force, at = '', conflicts = []) {
|
|
257
|
+
for (const [k, v] of Object.entries(frag)) {
|
|
258
|
+
const key = at ? `${at}.${k}` : k;
|
|
259
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) mergeInto(base[k], v, force, key, conflicts);
|
|
260
|
+
else if (base[k] !== undefined && JSON.stringify(base[k]) !== JSON.stringify(v)) { conflicts.push(key); if (force) base[k] = v; }
|
|
261
|
+
else base[k] = v;
|
|
262
|
+
}
|
|
263
|
+
return conflicts;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Installs resources into the project at `cwd`, resolving `requires.items` first. Returns a report per resource.
|
|
268
|
+
* Never overwrites a changed file or a different brand value unless `force`.
|
|
269
|
+
*/
|
|
270
|
+
export async function add(ids, { cwd = process.cwd(), registry, force = false, name, set = [] } = {}) {
|
|
271
|
+
const reg = resolveRegistry(registry), done = new Map(), report = [];
|
|
272
|
+
const lockFile = path.join(cwd, 'paper-motion.json'), lock = readJson(lockFile, { registry: reg.url ?? 'local', items: {} });
|
|
273
|
+
async function one(id) {
|
|
274
|
+
if (done.has(id)) return;
|
|
275
|
+
done.set(id, true);
|
|
276
|
+
let it = await getItem(id, reg);
|
|
277
|
+
for (const dep of it.requires?.items ?? []) await one(dep);
|
|
278
|
+
// --set key=value (or --set id:key=value with several ids): the same output the site's code tabs show
|
|
279
|
+
const pairs = set.filter((x) => (x.includes(':') && x.indexOf(':') < x.indexOf('=') ? x.startsWith(`${id}:`) : ids.length === 1 && ids[0] === id)).map((x) => (x.startsWith(`${id}:`) ? x.slice(id.length + 1) : x));
|
|
280
|
+
if (pairs.length) it = { ...it, ...applyValues(it, parseSet(it, pairs)) };
|
|
281
|
+
const r = { id, type: it.type, wrote: [], skipped: [], conflicts: [], usage: it.usage, notes: it.notes ?? [] };
|
|
282
|
+
const kind = it.install?.kind ?? 'code';
|
|
283
|
+
const base = kind === 'scene' ? path.join('pieces', name ?? it.id) : '';
|
|
284
|
+
for (const f of it.files ?? []) {
|
|
285
|
+
const rel = kind === 'scene' ? path.join(base, path.basename(f.path)) : f.path, dest = path.join(cwd, rel);
|
|
286
|
+
if (f.content === undefined) {
|
|
287
|
+
// a binary asset: kept if present, else copied from the local registry or downloaded from the site
|
|
288
|
+
if (fs.existsSync(dest) && !force) continue;
|
|
289
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
290
|
+
if (reg.dir && f.from && fs.existsSync(path.join(REPO, f.from))) fs.copyFileSync(path.join(REPO, f.from), dest);
|
|
291
|
+
else { const res = await fetch(f.url); if (!res.ok) throw new Error(`${f.url}: ${res.status}`); fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); }
|
|
292
|
+
r.wrote.push(rel); continue;
|
|
293
|
+
}
|
|
294
|
+
if (fs.existsSync(dest) && fs.readFileSync(dest, 'utf8') !== f.content && !force) { r.skipped.push(rel); continue; }
|
|
295
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
296
|
+
fs.writeFileSync(dest, f.content);
|
|
297
|
+
r.wrote.push(rel);
|
|
298
|
+
}
|
|
299
|
+
if (kind === 'shader-set') {
|
|
300
|
+
const f = path.join(cwd, 'brand/shader-set/set.json'), set = readJson(f, { format: { width: 1920, height: 1080, duration: 6, fps: 30 }, entries: [] });
|
|
301
|
+
set.entries ??= [];
|
|
302
|
+
const entry = { id: it.id, source: 'paper-motion.app', ...it.install.entry }, at = set.entries.findIndex((e) => e.id === it.id);
|
|
303
|
+
if (at < 0) set.entries.push(entry);
|
|
304
|
+
else if (JSON.stringify(set.entries[at]) !== JSON.stringify(entry)) { if (force) set.entries[at] = entry; else r.conflicts.push(`shader-set entry "${it.id}" already exists`); }
|
|
305
|
+
writeJson(f, set); r.wrote.push('brand/shader-set/set.json');
|
|
306
|
+
}
|
|
307
|
+
if (kind === 'tokens') {
|
|
308
|
+
const f = path.join(cwd, 'brand/tokens.json'), tokens = readJson(f, {});
|
|
309
|
+
r.conflicts.push(...mergeInto(tokens, it.install.entry ?? {}, force).map((k) => `brand/tokens.json ${k}`));
|
|
310
|
+
writeJson(f, tokens); r.wrote.push('brand/tokens.json');
|
|
311
|
+
}
|
|
312
|
+
lock.items[it.id] = it.version;
|
|
313
|
+
report.push(r);
|
|
314
|
+
}
|
|
315
|
+
for (const id of ids) await one(id);
|
|
316
|
+
writeJson(lockFile, lock);
|
|
317
|
+
return report;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
321
|
+
/** Distance between two hex colors (0 = same, ~441 = black to white). */
|
|
322
|
+
export const colorDistance = (a, b) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
|
|
323
|
+
/** How far a resource's colors sit from a brand's: the mean nearest distance (lower is closer). */
|
|
324
|
+
export const colorFit = (item, brand) => {
|
|
325
|
+
if (!item.colors?.length || !brand.length) return null;
|
|
326
|
+
const mean = item.colors.reduce((s, c) => s + Math.min(...brand.map((b) => colorDistance(c, b))), 0) / item.colors.length;
|
|
327
|
+
// a resource that declares one or two colors is thin evidence of a match: nudge it behind real palettes
|
|
328
|
+
return mean + (item.colors.length < 3 ? 40 * (3 - item.colors.length) : 0);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Finds resources: free words over id/title/summary/tags/when, plus filters: type, mode (light|dark), mood,
|
|
333
|
+
* motion (still|gentle|lively), role, energy ("3", "2-4", "<=3"), near (hex colors: sorts by closeness).
|
|
334
|
+
*/
|
|
335
|
+
export async function search(query, opts = {}) {
|
|
336
|
+
const { items } = await getIndex(resolveRegistry(opts.registry));
|
|
337
|
+
const words = String(query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
338
|
+
const hit = (i) => { const hay = [i.id, i.type, i.title, i.summary, ...(i.tags ?? []), ...(i.when ?? []), ...(i.mood ?? [])].join(' ').toLowerCase(); return words.every((w) => hay.includes(w)); };
|
|
339
|
+
const en = (v) => { if (!opts.energy) return true; const e = String(opts.energy), m = e.match(/^(\d)-(\d)$/); if (m) return v >= +m[1] && v <= +m[2]; if (e.startsWith('<=')) return v <= +e.slice(2); if (e.startsWith('>=')) return v >= +e.slice(2); return v === +e; };
|
|
340
|
+
const near = opts.near ? String(opts.near).split(',').map((c) => c.trim()).filter((c) => /^#[0-9a-f]{6}$/i.test(c)) : [];
|
|
341
|
+
let out = items.filter((i) => (!opts.type || i.type === opts.type) && (!opts.mode || i.mode === opts.mode) && (!opts.mood || (i.mood ?? []).includes(opts.mood)) && (!opts.motion || i.motion === opts.motion) && (!opts.role || i.role === opts.role) && (i.energy === undefined || en(i.energy)) && hit(i));
|
|
342
|
+
if (near.length) out = out.map((i) => ({ ...i, fit: colorFit(i, near) })).filter((i) => i.fit !== null).sort((a, b) => a.fit - b.fit);
|
|
343
|
+
return out;
|
|
344
|
+
}
|
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { launch, openScene } from './browser.mjs';
|
|
6
|
+
import { synthesize, writeWav } from './sfx.mjs';
|
|
7
|
+
|
|
8
|
+
function ffmpeg(args, { stdin = false } = {}) {
|
|
9
|
+
const p = spawn(process.env.PAPER_MOTION_FFMPEG || 'ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...args], { stdio: [stdin ? 'pipe' : 'ignore', 'inherit', 'pipe'] });
|
|
10
|
+
let err = '';
|
|
11
|
+
p.stderr.on('data', (d) => { err += d; });
|
|
12
|
+
if (stdin) p.stdin.on('error', () => {}); // surfaced through p.done instead
|
|
13
|
+
p.done = new Promise((res, rej) => {
|
|
14
|
+
p.on('error', (e) => rej(e.code === 'ENOENT' ? new Error('ffmpeg not found. Install it (macOS: brew install ffmpeg · Ubuntu: apt install ffmpeg · Windows: winget install ffmpeg)') : e));
|
|
15
|
+
p.on('close', (code) => (code === 0 ? res() : rej(new Error(`ffmpeg failed (${code}): ${err.trim()}`))));
|
|
16
|
+
});
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const write = (proc, buf) => {
|
|
21
|
+
if (proc.stdin.write(buf)) return Promise.resolve();
|
|
22
|
+
return Promise.race([
|
|
23
|
+
new Promise((res) => proc.stdin.once('drain', res)),
|
|
24
|
+
proc.done.then(() => { throw new Error('ffmpeg exited early'); }),
|
|
25
|
+
]);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Render stills at given times; returns file paths. Optionally a contact sheet. */
|
|
29
|
+
export async function renderStills({ url, times, count, outDir, dpr = 1, sheet = true, motionBlur = 1, shutter = 180 }) {
|
|
30
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
31
|
+
const browser = await launch();
|
|
32
|
+
try {
|
|
33
|
+
const s = await openScene(browser, url, { dpr });
|
|
34
|
+
const { duration, fps } = s.config;
|
|
35
|
+
if (!times?.length) {
|
|
36
|
+
const n = count ?? 6;
|
|
37
|
+
times = Array.from({ length: n }, (_, i) => (n === 1 ? 0 : (i * duration) / n));
|
|
38
|
+
}
|
|
39
|
+
const files = [];
|
|
40
|
+
for (const t of times) {
|
|
41
|
+
const tt = Math.round(Math.min(t, duration - 1 / fps) * fps) / fps;
|
|
42
|
+
const f = path.join(outDir, `t${tt.toFixed(2).padStart(6, '0')}.png`);
|
|
43
|
+
const k = Math.max(1, Math.round(motionBlur));
|
|
44
|
+
if (k === 1) { await s.seek(tt); fs.writeFileSync(f, await s.capture()); }
|
|
45
|
+
else {
|
|
46
|
+
const p = ffmpeg(['-f', 'image2pipe', '-framerate', String(k), '-c:v', 'png', '-i', '-', '-vf', `tmix=frames=${k},select='eq(n\\,${k - 1})'`, '-frames:v', '1', f], { stdin: true });
|
|
47
|
+
for (const st of subframes(Math.round(tt * fps), fps, k, shutter, duration)) { await s.seek(st); await write(p, await s.capture()); }
|
|
48
|
+
p.stdin.end(); await p.done;
|
|
49
|
+
}
|
|
50
|
+
files.push(f);
|
|
51
|
+
}
|
|
52
|
+
let sheetPath = null;
|
|
53
|
+
if (sheet && files.length > 1) {
|
|
54
|
+
sheetPath = path.join(outDir, 'contact-sheet.jpg');
|
|
55
|
+
const cols = files.length <= 4 ? files.length : Math.ceil(Math.sqrt(files.length));
|
|
56
|
+
const gap = 8;
|
|
57
|
+
const w = Math.min(640, s.config.width);
|
|
58
|
+
const h = Math.round((w * s.config.height) / s.config.width / 2) * 2;
|
|
59
|
+
const inputs = files.flatMap((f) => ['-i', f]);
|
|
60
|
+
const scaled = files.map((_, i) => `[${i}:v]scale=${w}:${h}:flags=lanczos[v${i}]`).join(';');
|
|
61
|
+
const stack = files.map((_, i) => `[v${i}]`).join('');
|
|
62
|
+
const layout = files.map((_, i) => `${(i % cols) * (w + gap)}_${Math.floor(i / cols) * (h + gap)}`).join('|');
|
|
63
|
+
const filter = `${scaled};${stack}xstack=inputs=${files.length}:layout=${layout}:fill=0x111111[out]`;
|
|
64
|
+
const p = ffmpeg([...inputs, '-filter_complex', filter, '-map', '[out]', '-q:v', '3', sheetPath]);
|
|
65
|
+
await p.done;
|
|
66
|
+
}
|
|
67
|
+
await s.close();
|
|
68
|
+
return { files, sheet: sheetPath, config: s.config, times };
|
|
69
|
+
} finally {
|
|
70
|
+
await browser.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const FORMATS = {
|
|
75
|
+
mp4: { ext: 'mp4', args: (o) => ['-c:v', 'libx264', '-preset', 'slow', '-crf', String(o.crf ?? 16), '-x264-params', 'aq-mode=3', '-pix_fmt', 'yuv420p', ...bt709, '-movflags', '+faststart'], vf: yuv },
|
|
76
|
+
hevc: { ext: 'mp4', args: (o) => ['-c:v', 'libx265', '-preset', 'slow', '-crf', String(o.crf ?? 18), '-pix_fmt', 'yuv420p10le', '-tag:v', 'hvc1', ...bt709, '-movflags', '+faststart'], vf: yuv },
|
|
77
|
+
prores: { ext: 'mov', args: () => ['-c:v', 'prores_ks', '-profile:v', '3', '-pix_fmt', 'yuv422p10le', '-vendor', 'apl0', ...bt709], vf: yuv },
|
|
78
|
+
webm: { ext: 'webm', args: (o) => ['-c:v', 'libvpx-vp9', '-crf', String(o.crf ?? 20), '-b:v', '0', '-row-mt', '1', '-pix_fmt', 'yuv420p', ...bt709], vf: yuv },
|
|
79
|
+
gif: { ext: 'gif', args: () => [], gif: true },
|
|
80
|
+
png: { ext: 'png', args: () => [], seq: true },
|
|
81
|
+
};
|
|
82
|
+
const bt709 = ['-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709'];
|
|
83
|
+
function yuv() { return 'scale=out_color_matrix=bt709:out_range=tv'; }
|
|
84
|
+
|
|
85
|
+
/** Render a scene to video. */
|
|
86
|
+
/** Synthesize the scene's sfx cues to a WAV file. Returns the path, or null when there are none. */
|
|
87
|
+
export async function renderSfx({ url, out, from = 0, to }) {
|
|
88
|
+
const browser = await launch();
|
|
89
|
+
try {
|
|
90
|
+
const s = await openScene(browser, url);
|
|
91
|
+
const r = await sfxFromPage(s, { from, to, out });
|
|
92
|
+
await s.close();
|
|
93
|
+
return r;
|
|
94
|
+
} finally { await browser.close(); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function sfxFromPage(s, { from = 0, to, out, normalize = true }) {
|
|
98
|
+
const { cues, audio } = await s.page.evaluate(() => ({ cues: window.__pm.cues ?? [], audio: window.__pm.audio ?? {} }));
|
|
99
|
+
if (!cues.length) return null;
|
|
100
|
+
const end = to ?? s.config.duration;
|
|
101
|
+
const shifted = cues.filter((c) => c.t < end).map((c) => ({ ...c, t: c.t - from }));
|
|
102
|
+
writeWav(out, synthesize(shifted, { duration: end - from, ...audio, normalize }));
|
|
103
|
+
return { out, cues: cues.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Subframe times for frame f: k samples spread over the shutter, centered on the frame (After Effects-style). */
|
|
107
|
+
function subframes(f, fps, k, shutter, duration) {
|
|
108
|
+
if (k <= 1) return [f / fps];
|
|
109
|
+
const open = shutter / 360;
|
|
110
|
+
return Array.from({ length: k }, (_, j) => Math.min(duration - 1e-4, Math.max(0, (f + ((j + 0.5) / k - 0.5) * open) / fps)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1, from, to, fps: fpsOverride, crf, audio, sfx = true, motionBlur = 1, shutter = 180, gifWidth = 720, gifFps = 24, onProgress }) {
|
|
114
|
+
const fmt = FORMATS[format];
|
|
115
|
+
if (!fmt) throw new Error(`Unknown format "${format}". Use one of: ${Object.keys(FORMATS).join(', ')}`);
|
|
116
|
+
const browser = await launch();
|
|
117
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'paper-motion-'));
|
|
118
|
+
const t0 = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const first = await openScene(browser, url, { dpr });
|
|
121
|
+
const cfg = first.config;
|
|
122
|
+
const fps = fpsOverride ?? cfg.fps;
|
|
123
|
+
const f0 = Math.round((from ?? 0) * fps);
|
|
124
|
+
const f1 = Math.round((to ?? cfg.duration) * fps);
|
|
125
|
+
const total = f1 - f0;
|
|
126
|
+
if (total <= 0) throw new Error('Nothing to render (check --from/--to).');
|
|
127
|
+
const n = Math.max(1, Math.min(workers ?? Math.min(4, Math.max(1, Math.floor(os.cpus().length / 2))), Math.ceil(total / 15)));
|
|
128
|
+
const chunk = Math.ceil(total / n);
|
|
129
|
+
let done = 0;
|
|
130
|
+
const tick = () => onProgress?.({ done, total, elapsed: (Date.now() - t0) / 1000 });
|
|
131
|
+
|
|
132
|
+
let sfxWav = null;
|
|
133
|
+
if (sfx && !fmt.gif && !fmt.seq) {
|
|
134
|
+
const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, out: path.join(tmp, 'sfx.wav'), normalize: !audio }); // with an external track, effects keep their own gains
|
|
135
|
+
sfxWav = r?.out ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const sessions = [first];
|
|
139
|
+
for (let i = 1; i < n; i++) sessions.push(await openScene(browser, url, { dpr }));
|
|
140
|
+
|
|
141
|
+
const segments = await Promise.all(sessions.map(async (s, i) => {
|
|
142
|
+
const a = f0 + i * chunk, b = Math.min(f1, a + chunk);
|
|
143
|
+
if (a >= b) return null;
|
|
144
|
+
const seg = path.join(tmp, `seg${String(i).padStart(3, '0')}.mkv`);
|
|
145
|
+
// Lossless RGB intermediate; the final encode happens once, after concat.
|
|
146
|
+
const k = Math.max(1, Math.round(motionBlur));
|
|
147
|
+
// Motion blur: k subframes per frame are averaged (tmix) and one frame is kept per group.
|
|
148
|
+
const blur = k > 1 ? ['-vf', `tmix=frames=${k},select='eq(mod(n\\,${k})\\,${k - 1})',setpts=N/(${fps}*TB)`, '-r', String(fps)] : [];
|
|
149
|
+
const p = ffmpeg(['-f', 'image2pipe', '-framerate', String(fps * k), '-c:v', 'png', '-i', '-', ...blur, '-c:v', 'libx264rgb', '-preset', 'ultrafast', '-crf', '0', seg], { stdin: true });
|
|
150
|
+
for (let f = a; f < b; f++) {
|
|
151
|
+
for (const tt of subframes(f, fps, k, shutter, cfg.duration)) {
|
|
152
|
+
await s.seek(tt);
|
|
153
|
+
await write(p, await s.capture());
|
|
154
|
+
}
|
|
155
|
+
done++; tick();
|
|
156
|
+
}
|
|
157
|
+
p.stdin.end();
|
|
158
|
+
await p.done;
|
|
159
|
+
await s.close();
|
|
160
|
+
return seg;
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
const list = path.join(tmp, 'list.txt');
|
|
164
|
+
fs.writeFileSync(list, segments.filter(Boolean).map((s) => `file '${s.replace(/'/g, "'\\''")}'`).join('\n'));
|
|
165
|
+
const W = cfg.width, H = cfg.height;
|
|
166
|
+
const scale = dpr !== 1 ? `scale=${W}:${H}:flags=lanczos` : null;
|
|
167
|
+
out = out ?? `render.${fmt.ext}`;
|
|
168
|
+
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
169
|
+
const input = ['-f', 'concat', '-safe', '0', '-i', list];
|
|
170
|
+
|
|
171
|
+
if (fmt.seq) {
|
|
172
|
+
const dir = out.replace(/\.png$/, '');
|
|
173
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
await ffmpeg([...input, ...(scale ? ['-vf', scale] : []), path.join(dir, 'frame_%05d.png')]).done;
|
|
175
|
+
out = dir;
|
|
176
|
+
} else if (fmt.gif) {
|
|
177
|
+
const vf = `fps=${gifFps},scale=${gifWidth}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=256:stats_mode=diff[p];[b][p]paletteuse=dither=sierra2_4a`;
|
|
178
|
+
await ffmpeg([...input, '-vf', vf, '-loop', '0', out]).done;
|
|
179
|
+
} else {
|
|
180
|
+
const vf = [scale, fmt.vf?.()].filter(Boolean).join(',');
|
|
181
|
+
const codec = ['-c:a', format === 'webm' ? 'libopus' : 'aac', '-b:a', '320k'];
|
|
182
|
+
const audios = [audio, sfxWav].filter(Boolean);
|
|
183
|
+
const audioArgs = audios.length === 0 ? []
|
|
184
|
+
: audios.length === 1 ? ['-i', audios[0], '-map', '0:v', '-map', '1:a', ...codec, '-shortest']
|
|
185
|
+
: ['-i', audios[0], '-i', audios[1], '-filter_complex', '[1:a][2:a]amix=inputs=2:normalize=0[a]', '-map', '0:v', '-map', '[a]', ...codec, '-shortest'];
|
|
186
|
+
await ffmpeg([...input, ...audioArgs, ...(vf ? ['-vf', vf] : []), '-r', String(fps), ...fmt.args({ crf }), out]).done;
|
|
187
|
+
}
|
|
188
|
+
return { out, frames: total, fps, seconds: (Date.now() - t0) / 1000, workers: n, config: cfg };
|
|
189
|
+
} finally {
|
|
190
|
+
await browser.close();
|
|
191
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
}
|