paper-motion 0.3.0 → 0.4.1

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/bin/pamo.mjs +222 -21
  3. package/examples/breath/stills/contact-sheet.jpg +0 -0
  4. package/examples/breath/stills/t000.00.png +0 -0
  5. package/examples/breath/stills/t002.00.png +0 -0
  6. package/examples/breath/stills/t004.00.png +0 -0
  7. package/examples/breath/stills/t006.00.png +0 -0
  8. package/examples/breath/stills/t008.00.png +0 -0
  9. package/examples/breath/stills/t010.00.png +0 -0
  10. package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
  11. package/examples/chrome-endcard/stills/t000.00.png +0 -0
  12. package/examples/chrome-endcard/stills/t001.33.png +0 -0
  13. package/examples/chrome-endcard/stills/t002.67.png +0 -0
  14. package/examples/chrome-endcard/stills/t004.00.png +0 -0
  15. package/examples/chrome-endcard/stills/t005.33.png +0 -0
  16. package/examples/chrome-endcard/stills/t006.67.png +0 -0
  17. package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
  18. package/examples/halo-explainer/stills/t000.00.png +0 -0
  19. package/examples/halo-explainer/stills/t006.00.png +0 -0
  20. package/examples/halo-explainer/stills/t012.00.png +0 -0
  21. package/examples/halo-explainer/stills/t018.00.png +0 -0
  22. package/examples/halo-explainer/stills/t024.00.png +0 -0
  23. package/examples/halo-explainer/stills/t030.00.png +0 -0
  24. package/examples/install-loop/stills/contact-sheet.jpg +0 -0
  25. package/examples/install-loop/stills/t000.00.png +0 -0
  26. package/examples/install-loop/stills/t001.00.png +0 -0
  27. package/examples/install-loop/stills/t002.00.png +0 -0
  28. package/examples/install-loop/stills/t003.00.png +0 -0
  29. package/examples/install-loop/stills/t004.00.png +0 -0
  30. package/examples/install-loop/stills/t005.00.png +0 -0
  31. package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
  32. package/examples/orbit-keynote/stills/t000.00.png +0 -0
  33. package/examples/orbit-keynote/stills/t006.50.png +0 -0
  34. package/examples/orbit-keynote/stills/t013.00.png +0 -0
  35. package/examples/orbit-keynote/stills/t019.50.png +0 -0
  36. package/examples/orbit-keynote/stills/t026.00.png +0 -0
  37. package/examples/orbit-keynote/stills/t032.50.png +0 -0
  38. package/examples/signal/stills/contact-sheet.jpg +0 -0
  39. package/examples/signal/stills/t000.00.png +0 -0
  40. package/examples/signal/stills/t001.67.png +0 -0
  41. package/examples/signal/stills/t003.33.png +0 -0
  42. package/examples/signal/stills/t005.00.png +0 -0
  43. package/examples/signal/stills/t006.67.png +0 -0
  44. package/examples/signal/stills/t008.33.png +0 -0
  45. package/lib/chain.mjs +95 -18
  46. package/lib/customize.mjs +40 -2
  47. package/lib/edit.mjs +342 -0
  48. package/lib/gather.mjs +241 -0
  49. package/lib/host.mjs +25 -0
  50. package/lib/inventory.mjs +531 -0
  51. package/lib/piece.mjs +93 -17
  52. package/lib/registry.mjs +22 -6
  53. package/lib/render.mjs +13 -9
  54. package/lib/studio.mjs +347 -33
  55. package/package.json +1 -1
  56. package/runtime/index.js +10 -1
  57. package/runtime/player.js +18 -6
  58. package/runtime/swatch.html +21 -0
  59. package/studio/fonts/GeistPixel-Square.woff2 +0 -0
  60. package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
  61. package/studio/index.html +455 -327
package/lib/chain.mjs CHANGED
@@ -13,16 +13,29 @@
13
13
  // whip the frame slides out sideways with a motion smear and the next slides in (0.35 s, the default)
14
14
  // dissolve a cross-fade (0.5 s)
15
15
  // dip out to the brand's ground color and back in (0.5 s)
16
+ // or a transition from the registry the user chose: wipe, push, zoom-through, iris, crossfade, slices, flash,
17
+ // scan-reveal, mask-reveal, glitch, cells, flip-3d, whip-pan (see MOVE_JOINS)
16
18
  // Each part plays from `from` (default 0) for `seconds` (default: the whole scene). A part cut before its
17
19
  // land (the beat a block declares with B.cue(s, 'land', t)) gets a warning with the length that keeps it.
20
+ import crypto from 'node:crypto';
18
21
  import fs from 'node:fs';
19
22
  import os from 'node:os';
20
23
  import path from 'node:path';
21
24
  import { spawn, spawnSync } from 'node:child_process';
25
+ import { lengthRange } from './inventory.mjs';
22
26
 
23
27
  export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
24
- const OVERLAP = new Set(['whip', 'dissolve']); // joins that need the outgoing part to run on under the next
25
- const LAND_HOLD = 0.6; // seconds after a land for the hit to read
28
+ // the registry's transitions the user may have chosen, as joins between parts: [ffmpeg xfade transition, seconds].
29
+ // So a chosen transition plays where one part becomes the next (it can also be built inside a part, by hand).
30
+ export const MOVE_JOINS = {
31
+ crossfade: ['fade', 0.5], wipe: ['wipeleft', 0.5], push: ['slideleft', 0.45], 'zoom-through': ['zoomin', 0.5],
32
+ iris: ['circleopen', 0.55], slices: ['hrslice', 0.5], flash: ['fadewhite', 0.4], 'scan-reveal': ['wipedown', 0.5],
33
+ 'mask-reveal': ['radial', 0.55], glitch: ['pixelize', 0.35], cells: ['dissolve', 0.5], 'flip-3d': ['squeezeh', 0.45],
34
+ 'whip-pan': ['slideleft', 0.35],
35
+ };
36
+ for (const [k, [, d]] of Object.entries(MOVE_JOINS)) JOINS[k] = d;
37
+ JOINS.whip = 0.35; // the engine's own whip (slide + smear) wins over the registry's whip move of the same name
38
+ const OVERLAP = new Set(['whip', 'dissolve', ...Object.keys(MOVE_JOINS)]); // joins that need the outgoing part to run on under the next
26
39
 
27
40
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
28
41
  const pad = (i) => String(i + 1).padStart(2, '0');
@@ -54,8 +67,10 @@ export async function structureFromBoard(ref, option, { site }) {
54
67
  if (String(ref).includes('#')) board = await decodeBoard(String(ref).slice(String(ref).indexOf('#') + 1));
55
68
  else {
56
69
  const id = String(ref).replace(/\/$/, '').split('/').pop();
57
- const res = await fetch(`${site}/api/boards/${id}`);
58
- if (!res.ok) throw new Error(`board ${id}: ${res.status} from ${site} (expired, or a long link is needed)`);
70
+ // a full link says where its board lives; a bare id is looked up on the site (--site, or the default)
71
+ const from = /^https?:\/\//i.test(String(ref)) ? new URL(String(ref)).origin : site;
72
+ const res = await fetch(`${from}/api/boards/${id}`);
73
+ if (!res.ok) throw new Error(`board ${id}: ${res.status} from ${from} (expired or never stored there: pass the board's full link, the long #… link, or --site <url>)`);
59
74
  board = (await res.json()).board;
60
75
  }
61
76
  const opts = board?.options ?? [];
@@ -141,28 +156,76 @@ export function plan(chain, infos) {
141
156
  const info = infos[i], D = info.config.duration, from = p.from ?? 0;
142
157
  const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
143
158
  if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
144
- let seconds = p.seconds ?? D - from;
145
- if (from + seconds > D + 1e-6) { warnings.push(`${p.dir}: ${seconds} s from ${from} runs past its end (${D} s); it plays ${+(D - from).toFixed(2)} s`); seconds = D - from; }
146
- const lands = info.marks.filter((m) => m.gesture === 'land').map((m) => m.t).filter((t) => t >= from);
147
- const land = lands.length ? Math.max(...lands) : null;
148
- if (land !== null && from + seconds < land + LAND_HOLD) warnings.push(`${p.dir}: cut at ${seconds} s loses its land at ${+(land - from).toFixed(2)} s; give it at least ${+(land - from + LAND_HOLD).toFixed(1)} s`);
149
- return { ...p, from, seconds, join, D };
159
+ // a part plays its whole scene: another length plays it faster or slower (never cut, never looped), within limits
160
+ const natural = +(D - from).toFixed(3), range = lengthRange(natural);
161
+ let seconds = p.seconds ?? natural;
162
+ if (seconds < range.min - 1e-6 || seconds > range.max + 1e-6) {
163
+ const held = Math.min(range.max, Math.max(range.min, seconds));
164
+ warnings.push(`${p.dir}: ${seconds} s would play its ${natural} s scene too ${seconds < range.min ? 'fast' : 'slow'} (it takes ${range.min}–${range.max} s); it plays ${held} s`);
165
+ seconds = held;
166
+ }
167
+ return { ...p, from, seconds, join, D, natural, rate: natural / seconds };
150
168
  });
151
- // the outgoing part runs on under an overlapping join, as far as it can
169
+ // under an overlapping join the outgoing part holds its last frame while the next one comes in
152
170
  for (const [i, p] of parts.entries()) {
153
171
  const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
154
- p.to = Math.min(p.D, p.from + p.seconds + d);
172
+ p.out = +(p.seconds + d).toFixed(3);
155
173
  }
156
174
  return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
157
175
  }
158
176
 
177
+ // ── is a joined render still current, and is one being made right now ──────────────────────────────────────
178
+ /**
179
+ * A fingerprint of everything a joined render shows: the order, lengths and joins in chain.json, each part's
180
+ * files, the brand's tokens and backgrounds, the project's moves. A render made from another fingerprint is stale.
181
+ */
182
+ export function chainSig(cwd, dir) {
183
+ const h = crypto.createHash('sha1');
184
+ const chain = readJson(path.join(dir, 'chain.json'), { parts: [] });
185
+ h.update(JSON.stringify([chain.format ?? null, chain.join ?? null, (chain.parts ?? []).map((p) => [p.dir, p.id, p.seconds ?? null, p.from ?? null, p.join ?? null])]));
186
+ const add = (f) => {
187
+ try {
188
+ const st = fs.statSync(f); if (st.isDirectory()) return;
189
+ h.update(path.basename(f));
190
+ if (/\.(html|json|js|mjs|css|svg)$/.test(f)) h.update(fs.readFileSync(f)); else h.update(`${st.size}:${Math.round(st.mtimeMs)}`);
191
+ } catch { /* gone: nothing to add */ }
192
+ };
193
+ const all = (d, skip = () => false) => { try { for (const f of fs.readdirSync(d).sort()) if (!skip(f)) add(path.join(d, f)); } catch { /* no such folder */ } };
194
+ for (const p of chain.parts ?? []) if (p.dir) all(path.join(dir, p.dir), (f) => f.endsWith('.md'));
195
+ for (const f of ['brand/tokens.json', 'brand/shader-set/set.json', 'brand/sound.json']) add(path.join(cwd, f));
196
+ all(path.join(cwd, 'moves'));
197
+ return h.digest('hex').slice(0, 16);
198
+ }
199
+ const sigFile = (dir) => path.join(dir, 'renders', '.sig.json');
200
+ /** What each joined render in renders/ was made from: { "<file>": { sig, sfx, audio } }. */
201
+ export const readSigs = (dir) => readJson(sigFile(dir), {});
202
+
203
+ // the join in progress, for the studio's "updating" notice and so two joins of the same file never run at once
204
+ const jobFile = (dir) => path.join(dir, '.job.json');
205
+ /** The join running on this piece right now (its process still alive), or null. */
206
+ export function readJob(dir) {
207
+ const j = readJson(jobFile(dir), null);
208
+ if (!j) return null;
209
+ try { process.kill(j.pid, 0); return j; } catch { return null; }
210
+ }
211
+ const writeJob = (dir, j) => { try { fs.writeFileSync(jobFile(dir), JSON.stringify({ pid: process.pid, at: Date.now(), ...j })); } catch { /* read-only: no notice */ } };
212
+ const endJob = (dir) => { try { if (readJson(jobFile(dir), {}).pid === process.pid) fs.rmSync(jobFile(dir)); } catch { /* already gone */ } };
213
+
159
214
  /**
160
215
  * Renders every part in the brand's look and joins them into one file.
161
216
  * Must run from the project root (where brand/ is): scenes load the brand from /brand.
162
217
  */
163
218
  export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur = 1, dpr = 1, audio, sfx = true, draft = false, onStep = () => {} }) {
164
219
  if (draft) { dpr = 0.5; motionBlur = 1; }
165
- return withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
220
+ // the piece's own draft and final are tracked (what they were made from, and that they are being made);
221
+ // a render to another file (--out) is the caller's business
222
+ const home = pieceDir(piece, cwd), kind = out ? null : draft ? 'assembly' : 'final', sig = kind ? chainSig(cwd, home) : null;
223
+ if (kind) {
224
+ const busy = readJob(home);
225
+ if (busy && busy.pid !== process.pid && busy.kind === kind) throw new Error(`this piece's ${kind === 'assembly' ? 'draft' : 'final'} is already being rendered (${busy.of ? `part ${busy.part} of ${busy.of}` : 'starting'}): wait for it, then look at piece status`);
226
+ writeJob(home, { kind });
227
+ }
228
+ try { return await withChain({ cwd, piece }, async ({ dir, url, parts, warnings, total, config }) => {
166
229
  const { renderVideo } = await import('./render.mjs');
167
230
  for (const w of warnings) onStep({ warning: w });
168
231
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-chain-'));
@@ -170,8 +233,9 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
170
233
  const files = [];
171
234
  for (const [i, p] of parts.entries()) {
172
235
  onStep({ part: i + 1, of: parts.length, dir: p.dir });
236
+ if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
173
237
  const f = path.join(tmp, `${pad(i)}.mov`);
174
- await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.to, sfx, motionBlur, dpr });
238
+ await renderVideo({ url: url(p), out: f, format: 'prores', from: p.from, to: p.D, rate: p.rate, seconds: p.out, sfx, motionBlur, dpr });
175
239
  files.push(f);
176
240
  }
177
241
  const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
@@ -180,10 +244,12 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
180
244
  out = path.resolve(cwd, out ?? path.join(dir, 'renders', `${path.basename(dir)}${draft ? '-draft' : ''}.mp4`));
181
245
  fs.mkdirSync(path.dirname(out), { recursive: true });
182
246
  onStep({ joining: true });
247
+ if (kind) writeJob(home, { kind, part: parts.length, of: parts.length, joining: true });
183
248
  const j = await join({ files, parts, out, ground, fps, music: audio, lufs: sound.loudness ?? -16 });
249
+ if (kind) fs.writeFileSync(sigFile(home), JSON.stringify({ ...readSigs(home), [path.basename(out)]: { sig, sfx, ...(audio ? { audio } : {}) } }, null, 2) + '\n');
184
250
  return { out, total: j.seconds, planned: total, parts: parts.length, warnings, W, H };
185
251
  } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
186
- });
252
+ }); } finally { if (kind) endJob(home); }
187
253
  }
188
254
 
189
255
  /**
@@ -205,7 +271,7 @@ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep =
205
271
  mark(p.dir, { status: 'rendering' });
206
272
  const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
207
273
  try {
208
- await renderVideo({ url: url(p), out, from: p.from, to: p.from + p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
274
+ await renderVideo({ url: url(p), out, from: p.from, to: p.D, rate: p.rate, seconds: p.seconds, dpr: 0.5, motionBlur: 1, crf: 26 });
209
275
  } catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
210
276
  const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
211
277
  mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
@@ -233,9 +299,20 @@ async function withChain({ cwd, piece }, fn) {
233
299
  try {
234
300
  const browser = await launch(), infos = [];
235
301
  try { for (const p of chain.parts) infos.push(await sceneInfo(browser, url(p))); } finally { await browser.close(); }
302
+ // what each scene really lasts (some depend on their content: one beat per word), for the studio's length slider
303
+ try {
304
+ fs.writeFileSync(path.join(dir, '.lengths.json'), JSON.stringify(Object.fromEntries(chain.parts.map((p, i) => [p.dir, {
305
+ duration: infos[i].config.duration, mtime: Math.round(fs.statSync(path.join(dir, p.dir, 'scene.html')).mtimeMs),
306
+ }])), null, 2) + '\n');
307
+ } catch { /* the slider falls back to the block's usual length */ }
236
308
  const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
237
309
  if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
238
- return await fn({ dir, chain, url, config: infos[0].config, ...plan(chain, infos) });
310
+ const planned = plan(chain, infos);
311
+ // the length the brief asked for ("unos 20 s", "~20s", "16:9 · 20 s"): say so when the chain drifts from it
312
+ const brief = readJson(path.join(dir, 'piece.json'), null)?.brief?.format ?? '';
313
+ const want = Number(String(brief).match(/(\d+(?:[.,]\d+)?)\s*(?:s\b|sec|seg)/i)?.[1]?.replace(',', '.'));
314
+ if (want > 0 && Math.abs(planned.total - want) > Math.max(1.5, want * 0.08)) planned.warnings.push(`the chain runs ${planned.total.toFixed(1)} s and the brief asks for about ${want} s: change some parts' seconds in chain.json, or the brief`);
315
+ return await fn({ dir, chain, url, config: infos[0].config, ...planned });
239
316
  } finally { await server.close(); }
240
317
  }
241
318
 
@@ -264,7 +341,7 @@ async function join({ files, parts, out, ground, fps, music, lufs }) {
264
341
  const j = parts[i].join, d = JOINS[j];
265
342
  if (OVERLAP.has(j)) {
266
343
  const off = Math.max(0, len - d);
267
- g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
344
+ g.push(`${v}${V[i]}xfade=transition=${j === 'whip' ? 'slideleft' : MOVE_JOINS[j]?.[0] ?? 'fade'}:duration=${d}:offset=${off.toFixed(3)}[x${i}]`);
268
345
  g.push(`${a}${A[i]}acrossfade=d=${d}:c1=tri:c2=tri[y${i}]`);
269
346
  if (j === 'whip') smears.push([off + d * 0.12, off + d * 0.88]);
270
347
  len = off + lens[i].duration;
package/lib/customize.mjs CHANGED
@@ -63,8 +63,15 @@ export function recolor(item, pal) {
63
63
  return atLuminance(near.h, y) + (m[2] ? m[2] : '');
64
64
  };
65
65
  const out = {};
66
+ // the color behind everything takes the brand's ground exactly (its alpha kept), so the frame edge matches the brand
67
+ const ground = typeof pal?.ground === 'string' ? pal.ground : pal?.ground?.hex;
68
+ const behind = (name, v) => {
69
+ if (!/back/i.test(name) || !HEX.test(ground ?? '')) return null;
70
+ const m = typeof v === 'string' && v.match(HEX);
71
+ return m ? ground.slice(0, 7) + (m[2] ?? '') : null;
72
+ };
66
73
  for (const p of paramsOf(item)) {
67
- if (p.type === 'color') out[p.name] = one(p.default);
74
+ if (p.type === 'color') out[p.name] = behind(p.name, p.default) ?? one(p.default);
68
75
  else if (p.type === 'colors' && Array.isArray(p.default)) out[p.name] = p.default.map(one);
69
76
  }
70
77
  return out;
@@ -124,7 +131,38 @@ function backgroundEntry(item, values) {
124
131
  function paletteTokens(item, values) {
125
132
  const e = item.install.entry ?? {};
126
133
  const colors = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [role, { ...c, hex: values[role] ?? c.hex }]));
127
- return { ...e, colors, ramp: values.ramp ?? e.ramp };
134
+ return { ...e, colors, ramp: tunedRamp(e, colors, values.ramp) };
135
+ }
136
+
137
+ /**
138
+ * The ramp after the colors were tuned: steps equal to a role's old color take its new one; the steps between keep
139
+ * their tone and take the hue of the nearest new color. A ramp given explicitly wins.
140
+ */
141
+ function tunedRamp(e, colors, given) {
142
+ const ramp = e.ramp;
143
+ if (!Array.isArray(ramp) || (Array.isArray(given) && !same(given, ramp))) return given ?? ramp;
144
+ const old = Object.fromEntries(Object.entries(e.colors ?? {}).map(([role, c]) => [String(c.hex).toLowerCase(), role]));
145
+ const fresh = Object.values(colors).map((c) => c.hex).filter((h) => HEX.test(h ?? ''));
146
+ if (Object.entries(e.colors ?? {}).every(([role, c]) => colors[role].hex === c.hex)) return ramp;
147
+ // a ramp from one role to another (ground → accent, the usual): the steps between are mixed again from the new ends,
148
+ // at the same place along the way (by tone), so the ramp keeps its hue
149
+ const a0 = old[String(ramp[0]).toLowerCase()], a1 = old[String(ramp.at(-1)).toLowerCase()];
150
+ if (a0 && a1 && a0 !== a1) {
151
+ const [y0, y1] = [lum(ramp[0]), lum(ramp.at(-1))], [n0, n1] = [srgb(colors[a0].hex).map(lin), srgb(colors[a1].hex).map(lin)];
152
+ return ramp.map((h, i) => {
153
+ if (i === 0) return colors[a0].hex;
154
+ if (i === ramp.length - 1) return colors[a1].hex;
155
+ const t = Math.min(1, Math.max(0, (lum(h) - y0) / ((y1 - y0) || 1)));
156
+ return hex(n0.map((c, k) => c + t * (n1[k] - c)));
157
+ });
158
+ }
159
+ const ladder = fresh.map((h) => ({ h, y: lum(h) }));
160
+ return ramp.map((h) => {
161
+ const role = old[String(h).toLowerCase()];
162
+ if (role) return colors[role].hex;
163
+ const y = lum(h), near = ladder.reduce((a, b) => (Math.abs(b.y - y) < Math.abs(a.y - y) ? b : a));
164
+ return atLuminance(near.h, y);
165
+ });
128
166
  }
129
167
 
130
168
  function fontTokens(item, values) {