paper-motion 0.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
4
4
 
5
+ ## 0.4.1 · 2026-11-12
6
+
7
+ - **The studio's live previews are much lighter.** A scene opened with `?embed` draws its shaders at the size it is
8
+ shown, not the full frame times the screen's density (a Retina screen drew 8 million pixels a frame for a box
9
+ 700 px wide), skips layers no one can see, and plays at 30 frames a second. In a test of a part with six shots,
10
+ shader draws went from 180 to about 10 a second. Renders are unchanged.
11
+ - **A part always plays its whole scene: its length is its speed.** Shorter plays it faster, longer slower, never
12
+ cut short and never looped; sounds move with the picture and keep their pitch. Each part may run from 1.6× faster
13
+ to 0.6× slower than its scene (`SPEED`, `lengthRange` in inventory.mjs); a length outside that range is held at
14
+ the limit with a warning. The studio's Length slider runs over that range, says how much faster or slower the
15
+ part plays, and its live preview plays at that speed. The planner proposes lengths inside the range.
16
+ - **Previews in the studio play once and hold their last frame** (a click plays one again); "Play the whole video"
17
+ moves on when each part ends, instead of after a timer that let a part loop.
18
+ - **Beat cuts puts one word on each beat, never repeating, and ends on its last word**: its length is the words
19
+ at the tempo. The chain records each scene's real length, so the studio knows it.
20
+
5
21
  ## 0.4.0 · 2026-11-12
6
22
 
7
23
  A piece is built from what the user has to show, not from a mood.
package/bin/pamo.mjs CHANGED
@@ -550,7 +550,7 @@ async function main() {
550
550
  await ensureDeps();
551
551
  if (args.check) {
552
552
  const r = await chainLib.checkChain({ piece });
553
- for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.from}–${+(p.from + p.seconds).toFixed(2)} s of ${p.D}`);
553
+ for (const p of r.parts) console.log(` ${p.dir} · ${p.join} · ${p.seconds} s (its ${p.natural} s scene${Math.abs(p.rate - 1) > 0.005 ? `, ×${p.rate.toFixed(2)}` : ''})`);
554
554
  for (const w of r.warnings) console.log(` ! ${w}`);
555
555
  console.log(`${r.total.toFixed(1)} s in all`);
556
556
  break;
package/lib/chain.mjs CHANGED
@@ -22,6 +22,7 @@ import fs from 'node:fs';
22
22
  import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { spawn, spawnSync } from 'node:child_process';
25
+ import { lengthRange } from './inventory.mjs';
25
26
 
26
27
  export const JOINS = { cut: 0, whip: 0.35, dissolve: 0.5, dip: 0.5 };
27
28
  // the registry's transitions the user may have chosen, as joins between parts: [ffmpeg xfade transition, seconds].
@@ -35,7 +36,6 @@ export const MOVE_JOINS = {
35
36
  for (const [k, [, d]] of Object.entries(MOVE_JOINS)) JOINS[k] = d;
36
37
  JOINS.whip = 0.35; // the engine's own whip (slide + smear) wins over the registry's whip move of the same name
37
38
  const OVERLAP = new Set(['whip', 'dissolve', ...Object.keys(MOVE_JOINS)]); // joins that need the outgoing part to run on under the next
38
- const LAND_HOLD = 0.6; // seconds after a land for the hit to read
39
39
 
40
40
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
41
41
  const pad = (i) => String(i + 1).padStart(2, '0');
@@ -156,17 +156,20 @@ export function plan(chain, infos) {
156
156
  const info = infos[i], D = info.config.duration, from = p.from ?? 0;
157
157
  const join = i === 0 ? 'cut' : (p.join ?? chain.join ?? 'whip');
158
158
  if (!JOINS.hasOwnProperty(join)) throw new Error(`part ${i + 1}: join "${join}" (use ${Object.keys(JOINS).join(', ')})`);
159
- let seconds = p.seconds ?? D - from;
160
- 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; }
161
- const lands = info.marks.filter((m) => m.gesture === 'land').map((m) => m.t).filter((t) => t >= from);
162
- const land = lands.length ? Math.max(...lands) : null;
163
- 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`);
164
- 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 };
165
168
  });
166
- // 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
167
170
  for (const [i, p] of parts.entries()) {
168
171
  const next = parts[i + 1], d = next && OVERLAP.has(next.join) ? JOINS[next.join] : 0;
169
- p.to = Math.min(p.D, p.from + p.seconds + d);
172
+ p.out = +(p.seconds + d).toFixed(3);
170
173
  }
171
174
  return { parts, warnings, total: parts.reduce((a, p) => a + p.seconds, 0) };
172
175
  }
@@ -232,7 +235,7 @@ export async function renderChain({ cwd = process.cwd(), piece, out, motionBlur
232
235
  onStep({ part: i + 1, of: parts.length, dir: p.dir });
233
236
  if (kind) writeJob(home, { kind, part: i + 1, of: parts.length });
234
237
  const f = path.join(tmp, `${pad(i)}.mov`);
235
- 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 });
236
239
  files.push(f);
237
240
  }
238
241
  const tokens = readJson(path.join(cwd, 'brand/tokens.json'), {}), sound = readJson(path.join(cwd, 'brand/sound.json'), {});
@@ -268,7 +271,7 @@ export async function renderDrafts({ cwd = process.cwd(), piece, only, onStep =
268
271
  mark(p.dir, { status: 'rendering' });
269
272
  const out = path.join(dir, 'drafts', `${p.dir}.mp4`);
270
273
  try {
271
- 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 });
272
275
  } catch (e) { mark(p.dir, { status: 'review', error: String(e.message ?? e).slice(0, 300) }); throw e; }
273
276
  const v = (readPiece(dir)?.parts?.[p.dir]?.v ?? 0) + 1;
274
277
  mark(p.dir, { status: 'review', v, error: undefined, note: undefined });
@@ -296,6 +299,12 @@ async function withChain({ cwd, piece }, fn) {
296
299
  try {
297
300
  const browser = await launch(), infos = [];
298
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 */ }
299
308
  const sizes = new Set(infos.map((x) => `${x.config.width}x${x.config.height}`));
300
309
  if (sizes.size > 1) throw new Error(`parts come in different sizes (${[...sizes].join(', ')}): set the same format in each CONFIG`);
301
310
  const planned = plan(chain, infos);
package/lib/edit.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import vm from 'node:vm';
12
- import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe } from './inventory.mjs';
12
+ import { fillConfig, evalFill, normalize, loadRaw, propose, writePlan, describe, lengthRange } from './inventory.mjs';
13
13
 
14
14
  const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
15
15
  const writeJson = (f, v) => fs.writeFileSync(f, JSON.stringify(v, null, 2) + '\n');
@@ -106,16 +106,29 @@ export const canUndo = (dir) => readJson(histFile(dir), []).at(-1)?.label ?? nul
106
106
 
107
107
  // ── reading the video ──────────────────────────────────────────────────────────────────────────────────────────
108
108
 
109
+ /**
110
+ * A part's length within what its scene allows: it always plays the whole scene, so a length is a speed. Returns the
111
+ * length (held inside the range), the scene's own length, the range and the speed (above 1 is faster).
112
+ */
113
+ export function timing(seconds, natural) {
114
+ const range = lengthRange(natural), s = Math.min(range.max, Math.max(range.min, seconds ?? natural));
115
+ return { seconds: s, duration: natural, range: { min: range.min, max: range.max }, rate: +(natural / s).toFixed(3) };
116
+ }
117
+
109
118
  /** The parts as the editor shows them: each with its block, its words, motion, background, settings and join. */
110
119
  export function videoView(cwd, dir, scenes) {
111
120
  const chain = readJson(path.join(dir, 'chain.json'), null);
112
121
  if (!chain) return null;
113
122
  const byId = new Map(scenes.map((b) => [b.id, b]));
123
+ // what each scene really lasts, read by the last join (a scene's length can depend on its content); stale once edited
124
+ const lengths = readJson(path.join(dir, '.lengths.json'), {});
125
+ const real = (p) => { const l = lengths[p.dir]; try { return l && l.mtime === Math.round(fs.statSync(partFile(dir, p)).mtimeMs) ? l.duration : null; } catch { return null; } };
114
126
  const parts = (chain.parts ?? []).filter((p) => p.dir && fs.existsSync(partFile(dir, p))).map((p, i, all) => {
115
127
  const html = fs.readFileSync(partFile(dir, p), 'utf8'), cfg = readConfig(html), b = byId.get(p.id) ?? { id: p.id, params: [], needs: [] };
128
+ const D = real(p);
116
129
  const motion = b.repeat ? { param: b.repeat.param, value: cfg[b.repeat.param] ?? null, options: (b.params ?? []).find((x) => x.name === b.repeat.param)?.options ?? [], previews: b.repeat.previews ?? {} } : null;
117
130
  return {
118
- dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '', seconds: p.seconds ?? b.duration ?? 4, duration: b.duration ?? null,
131
+ dir: p.dir, id: p.id, title: b.title ?? p.id, kind: b.needs?.[0]?.kind ?? null, summary: b.summary ?? '', ...timing(p.seconds, D ?? b.duration ?? 4), exact: D !== null,
119
132
  join: i < all.length - 1 ? p.join ?? chain.join ?? 'whip' : null, shows: p.shows ?? [], what: p.what ?? '',
120
133
  words: wordFields(b, cfg), motion, background: cfg.background ?? null, takesBackground: (b.params ?? []).some((x) => x.name === 'background'), tune: tunables(b, cfg),
121
134
  mtime: Math.round(fs.statSync(partFile(dir, p)).mtimeMs),
@@ -193,8 +206,11 @@ export async function editVideo(cwd, dir, a, scenes, brief = {}) {
193
206
  }
194
207
  case 'seconds': {
195
208
  const p = find(), n = Number(a.value); if (!Number.isFinite(n)) throw new Error('give a length in seconds');
196
- keep([chainFile], `the length of ${title(p)}`); p.seconds = Math.max(1, Math.min(30, +n.toFixed(1))); writeJson(chainFile, chain);
197
- return { say: `${title(p)}: ${p.seconds} s`, part: p.dir };
209
+ const b = byId.get(p.id) ?? {}, natural = readJson(path.join(dir, '.lengths.json'), {})[p.dir]?.duration ?? b.duration ?? 4;
210
+ const t = timing(+n.toFixed(1), natural);
211
+ keep([chainFile], `the length of ${title(p)}`); p.seconds = t.seconds; writeJson(chainFile, chain);
212
+ const pace = t.rate > 1.005 ? `, ${Math.round((t.rate - 1) * 100)}% faster` : t.rate < 0.995 ? `, ${Math.round((1 - t.rate) * 100)}% slower` : '';
213
+ return { say: `${title(p)}: ${p.seconds} s${pace}`, part: p.dir };
198
214
  }
199
215
  case 'join': {
200
216
  const { JOINS } = await import('./chain.mjs'); if (!(a.value in JOINS)) throw new Error('bad transition');
package/lib/inventory.mjs CHANGED
@@ -281,7 +281,14 @@ const lead = (b) => b.needs[0];
281
281
  // the shortest a scene can run in a chain: its land (where its last beat, the hold, begins) plus the time for the
282
282
  // hit to read; a scene that ends on something else keeps most of its length
283
283
  const LAND_HOLD = 0.6;
284
- const floorOf = (b) => { const d = b.duration ?? 5, last = b.beats?.at(-1); return +(last && /^hold$/i.test(last.name) ? Math.min(d, last.from + LAND_HOLD + 0.1) : d * 0.8).toFixed(1); };
284
+ /**
285
+ * A part always plays its whole scene: a different length plays it faster or slower, never cut short and never
286
+ * looped. These are how far it may go, so motion still reads (too fast) and still moves (too slow).
287
+ */
288
+ export const SPEED = { fast: 1.6, slow: 0.6 };
289
+ /** The lengths a part may take, from its scene's own length: { natural, min, max } in seconds. */
290
+ export const lengthRange = (natural) => ({ natural, min: Math.ceil((natural / SPEED.fast) * 10) / 10, max: Math.floor((natural / SPEED.slow) * 10) / 10 });
291
+ const floorOf = (b) => lengthRange(b.duration ?? 5).min; // as fast as it may play
285
292
  const fits = (need, it) => it.kind === need.kind && (need.has ?? []).every((f) => it[f] !== undefined && it[f] !== '') && Object.entries(need.where ?? {}).every(([k, v]) => it[k] === v);
286
293
  // an item that says otherwise ("device: phone" for a block that prefers desktop) goes to this block last
287
294
  const offKey = (need, it) => Object.entries(need.prefer ?? {}).some(([k, v]) => it[k] !== undefined && it[k] !== v);
@@ -411,9 +418,9 @@ export function propose(items, scenes, { seconds, brief = {} } = {}) {
411
418
  return options.map((o, i) => ({ letter: String.fromCharCode(65 + i), ...o }));
412
419
  }
413
420
 
414
- // Fits the beats to the length asked for. No beat runs shorter than its floor (its land must read), so a piece that
415
- // is too long loses its last beat that is not a must instead; what time is left over the floors is shared out. A
416
- // short piece stays short: a scene cannot run past its own length, and padding it would be a lie about the content.
421
+ // Fits the beats to the length asked for. Every beat plays its whole scene, faster or slower: none faster than its
422
+ // floor (SPEED.fast), so a piece that is too long loses its last beat that is not a must instead. A short piece plays
423
+ // a little slower (up to a quarter), never more: padding it further would be a lie about the content.
417
424
  function timed(beats, target) {
418
425
  let list = beats.map((b) => ({ ...b })), notes = [];
419
426
  const sum = (k) => list.reduce((s, b) => s + b[k], 0);
@@ -425,8 +432,9 @@ function timed(beats, target) {
425
432
  notes.push(`dropped "${list[i].what}" to fit ${target} s`); list.splice(i, 1);
426
433
  }
427
434
  // share out what is left over the floors; a scene never runs past its own length, so a short piece stays short
428
- const room = sum('duration') - sum('min'), k = target >= sum('duration') ? 1 : Math.max(0, (target - sum('min')) / (room || 1));
429
- list.forEach((b) => { b.duration = +(b.min + (b.duration - b.min) * k).toFixed(1); });
435
+ const total = sum('duration'), room = total - sum('min');
436
+ if (target >= total) { const g = Math.min(1.25, target / (total || 1)); list.forEach((b) => { b.duration = +(b.duration * g).toFixed(1); }); }
437
+ else { const k = Math.max(0, (target - sum('min')) / (room || 1)); list.forEach((b) => { b.duration = +(b.min + (b.duration - b.min) * k).toFixed(1); }); }
430
438
  if (sum('duration') > target * 1.05) notes.push(`runs ${sum('duration').toFixed(1)} s, over the ${target} s asked: every beat left is a must and none can run shorter`);
431
439
  if (sum('duration') < target * 0.9) notes.push(`runs ${sum('duration').toFixed(1)} s of the ${target} s asked: there is no more material to show`);
432
440
  }
package/lib/render.mjs CHANGED
@@ -94,12 +94,13 @@ export async function renderSfx({ url, out, from = 0, to }) {
94
94
  } finally { await browser.close(); }
95
95
  }
96
96
 
97
- async function sfxFromPage(s, { from = 0, to, out, normalize = true }) {
97
+ async function sfxFromPage(s, { from = 0, to, rate = 1, length, out, normalize = true }) {
98
98
  const { cues, audio } = await s.page.evaluate(() => ({ cues: window.__pm.cues ?? [], audio: window.__pm.audio ?? {} }));
99
99
  if (!cues.length) return null;
100
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 }));
101
+ // a faster or slower part moves its hits with the picture; each sound keeps its own pitch and length
102
+ const shifted = cues.filter((c) => c.t < end).map((c) => ({ ...c, t: (c.t - from) / rate }));
103
+ writeWav(out, synthesize(shifted, { duration: length ?? (end - from) / rate, ...audio, normalize }));
103
104
  return { out, cues: cues.length };
104
105
  }
105
106
 
@@ -110,7 +111,7 @@ function subframes(f, fps, k, shutter, duration) {
110
111
  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
  }
112
113
 
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
+ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1, from, to, rate = 1, seconds, fps: fpsOverride, crf, audio, sfx = true, motionBlur = 1, shutter = 180, gifWidth = 720, gifFps = 24, onProgress }) {
114
115
  const fmt = FORMATS[format];
115
116
  if (!fmt) throw new Error(`Unknown format "${format}". Use one of: ${Object.keys(FORMATS).join(', ')}`);
116
117
  const browser = await launch();
@@ -120,9 +121,12 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
120
121
  const first = await openScene(browser, url, { dpr });
121
122
  const cfg = first.config;
122
123
  const fps = fpsOverride ?? cfg.fps;
124
+ // the scene's window [from, to], played at `rate` (scene seconds per second of video): 2 plays it twice as fast.
125
+ // `seconds` sets the video's length; past the window's end the last frame holds (under an overlapping join)
123
126
  const f0 = Math.round((from ?? 0) * fps);
124
127
  const f1 = Math.round((to ?? cfg.duration) * fps);
125
- const total = f1 - f0;
128
+ const total = seconds !== undefined ? Math.round(seconds * fps) : Math.round((f1 - f0) / rate);
129
+ const sceneT = (st) => Math.min(f1 / fps - 1e-4, f0 / fps + st * rate); // a time in the video → in the scene
126
130
  if (total <= 0) throw new Error('Nothing to render (check --from/--to).');
127
131
  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
132
  const chunk = Math.ceil(total / n);
@@ -131,7 +135,7 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
131
135
 
132
136
  let sfxWav = null;
133
137
  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
138
+ const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, rate, length: total / fps, out: path.join(tmp, 'sfx.wav'), normalize: !audio }); // with an external track, effects keep their own gains
135
139
  sfxWav = r?.out ?? null;
136
140
  }
137
141
 
@@ -139,7 +143,7 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
139
143
  for (let i = 1; i < n; i++) sessions.push(await openScene(browser, url, { dpr }));
140
144
 
141
145
  const segments = await Promise.all(sessions.map(async (s, i) => {
142
- const a = f0 + i * chunk, b = Math.min(f1, a + chunk);
146
+ const a = i * chunk, b = Math.min(total, a + chunk);
143
147
  if (a >= b) return null;
144
148
  const seg = path.join(tmp, `seg${String(i).padStart(3, '0')}.mkv`);
145
149
  // Lossless RGB intermediate; the final encode happens once, after concat.
@@ -148,8 +152,8 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
148
152
  const blur = k > 1 ? ['-vf', `tmix=frames=${k},select='eq(mod(n\\,${k})\\,${k - 1})',setpts=N/(${fps}*TB)`, '-r', String(fps)] : [];
149
153
  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
154
  for (let f = a; f < b; f++) {
151
- for (const tt of subframes(f, fps, k, shutter, cfg.duration)) {
152
- await s.seek(tt);
155
+ for (const st of subframes(f, fps, k, shutter, Infinity)) {
156
+ await s.seek(sceneT(st));
153
157
  await write(p, await s.capture());
154
158
  }
155
159
  done++; tick();
package/lib/studio.mjs CHANGED
@@ -177,7 +177,7 @@ export function view(cwd, piece) {
177
177
  video: (() => {
178
178
  const v = videoView(cwd, dir, SCENES); if (!v) return null;
179
179
  const rel = path.relative(cwd, dir).split(path.sep).map(encodeURIComponent).join('/');
180
- for (const x of v.parts) { x.live = LIVE ? `${LIVE}/${rel}/${encodeURIComponent(x.dir)}/scene.html?embed&v=${x.mtime}-${styleView(cwd).stamp}` : null; x.poster = preview(x.id, 'jpg'); x.clip = preview(x.id, 'webm');
180
+ for (const x of v.parts) { x.live = LIVE ? `${LIVE}/${rel}/${encodeURIComponent(x.dir)}/scene.html?embed&rate=${x.rate}&v=${x.mtime}-${styleView(cwd).stamp}` : null; x.poster = preview(x.id, 'jpg'); x.clip = preview(x.id, 'webm');
181
181
  if (x.motion) x.motion.demos = x.motion.options.map((o) => ({ value: o, title: TITLES.get(x.motion.previews[o]) ?? o, poster: x.motion.previews[o] ? preview(x.motion.previews[o], 'jpg') : null, clip: x.motion.previews[o] ? preview(x.motion.previews[o], 'webm') : null })); }
182
182
  return v;
183
183
  })(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Frame-exact video renderer for HTML scenes built with Paper Shaders: the engine behind the paper-motion agent skills.",
5
5
  "keywords": [
6
6
  "video",
package/runtime/index.js CHANGED
@@ -144,6 +144,13 @@ const imageCache = new Map();
144
144
  // A transparent pixel stands in for an image shader with no image (PaperTexture and Water also work standalone).
145
145
  const BLANK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
146
146
 
147
+ // A scene shown inside another page (?embed: the studio's live previews) draws its shaders at the size it is shown,
148
+ // not at the frame's full size times the screen's density (8 million pixels a frame on a Retina screen for a box
149
+ // 700 px wide). Renders never pass ?embed, so they keep every pixel.
150
+ const EMBED_PIXELS = typeof location !== 'undefined' && /[?&]embed(?:[=&]|$)/.test(location.search)
151
+ ? Math.max(480 * 270, Math.round(innerWidth * innerHeight * Math.max(1, globalThis.devicePixelRatio ?? 1) ** 2))
152
+ : null;
153
+
147
154
  class ShaderLayer {
148
155
  /**
149
156
  * @param el element the canvas fills (give it a size: e.g. position:absolute; inset:0)
@@ -177,7 +184,7 @@ class ShaderLayer {
177
184
  { preserveDrawingBuffer: true, ...(props.webGlContextAttributes ?? {}) },
178
185
  0, this.frame0,
179
186
  this.options.minPixelRatio ?? 2,
180
- this.options.maxPixelCount ?? 3840 * 2160 * 2,
187
+ this.options.maxPixelCount ?? EMBED_PIXELS ?? 3840 * 2160 * 2,
181
188
  props.mipmaps,
182
189
  );
183
190
  if (this.options.loop) {
@@ -220,6 +227,8 @@ class ShaderLayer {
220
227
 
221
228
  seek(t) {
222
229
  if (!this.a) return;
230
+ // embedded, a layer no one can see (a later shot waiting at opacity 0, display none) is not drawn this frame
231
+ if (EMBED_PIXELS && this.el.checkVisibility && !this.el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return;
223
232
  const time = this.time ?? (t + (this.options.timeOffset ?? 0));
224
233
  const ms = (s) => this.frame0 + s * 1000 * this.speed;
225
234
  const L = this.options.loop;
package/runtime/player.js CHANGED
@@ -5,8 +5,11 @@
5
5
 
6
6
  export function mountPlayer(scene, startT = 0) {
7
7
  const { stage, duration, fps } = scene;
8
- const embed = new URLSearchParams(location.search).has('embed');
9
- let t = startT, playing = true, loop = true, last = performance.now(), busy = false;
8
+ const q = new URLSearchParams(location.search), embed = q.has('embed');
9
+ // a part longer or shorter than its scene plays the whole scene slower or faster (the studio passes its rate)
10
+ const rate = Math.min(4, Math.max(0.25, Number(q.get('rate')) || 1));
11
+ // embedded, a scene plays once and holds its last frame (a click plays it again); on its own page it loops
12
+ let t = startT, playing = true, loop = !embed, last = performance.now(), busy = false;
10
13
 
11
14
  const bar = document.createElement('div');
12
15
  bar.innerHTML = `
@@ -45,10 +48,15 @@ export function mountPlayer(scene, startT = 0) {
45
48
  }
46
49
  function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); }
47
50
 
51
+ // embedded, it draws at most 30 frames a second: a preview beside an editor, not the render
52
+ const step = embed ? 1000 / 30 - 2 : 0;
53
+ let drawn = 0;
48
54
  function tick(now) {
55
+ if (embed && now - drawn < step) { requestAnimationFrame(tick); return; }
56
+ drawn = now;
49
57
  if (playing && !busy) {
50
- let nt = t + (now - last) / 1000;
51
- if (nt >= duration) { if (loop) nt %= duration; else { nt = duration; setPlaying(false); } }
58
+ let nt = t + ((now - last) / 1000) * rate;
59
+ if (nt >= duration) { if (loop) nt %= duration; else { nt = duration - 1 / fps; setPlaying(false); if (embed) { try { parent.postMessage({ pm: 'ended' }, '*'); } catch { /* no parent */ } } } }
52
60
  show(nt);
53
61
  }
54
62
  last = now;
@@ -57,6 +65,7 @@ export function mountPlayer(scene, startT = 0) {
57
65
  requestAnimationFrame(tick);
58
66
 
59
67
  playBtn.onclick = () => setPlaying(!playing);
68
+ if (embed) addEventListener('click', () => { if (!playing) show(0); setPlaying(true); });
60
69
  loopBtn.onclick = () => { loop = !loop; loopBtn.classList.toggle('on', loop); };
61
70
  range.oninput = () => { setPlaying(false); show(Number(range.value)); };
62
71
  addEventListener('keydown', (e) => {
package/studio/index.html CHANGED
@@ -326,8 +326,8 @@ function inspHTML(p) {
326
326
  tune: `${p.tune.map((t) => t.kind === 'number'
327
327
  ? `<div class="slider"><div>${esc(t.label)}<em id="tv-${esc(t.name)}">${esc(t.value)}</em></div><input type="range" min="${t.min}" max="${t.max}" step="${t.step}" value="${esc(t.value)}" data-tune="${esc(t.name)}"></div>`
328
328
  : `<div class="fld">${esc(t.label)}<div class="seg">${t.options.map((o) => `<button class="btn sm${String(t.value) === String(o) ? ' on' : ''}" data-tunev="${esc(t.name)}" data-v="${esc(o)}">${esc(o)}</button>`).join('')}</div></div>`).join('')}
329
- <div class="slider"><div>Length<em id="tv-seconds">${Number(p.seconds).toFixed(1)} s</em></div><input type="range" min="1.5" max="${Math.max(10, Math.ceil((p.duration ?? 6) * 1.6))}" step="0.1" value="${p.seconds}" data-seconds></div>
330
- ${p.duration && p.seconds < p.duration * 0.7 ? '<p class="hint" style="color:var(--warn)">Shorter than this part needs to land: it may be cut.</p>' : ''}`,
329
+ <div class="slider"><div>Length<em id="tv-seconds">${Number(p.seconds).toFixed(1)} s</em></div><input type="range" min="${p.range.min}" max="${p.range.max}" step="0.1" value="${p.seconds}" data-seconds data-natural="${p.duration}"></div>
330
+ <p class="hint" id="tv-pace">${pace(p.seconds, p.duration)}</p>`,
331
331
  };
332
332
  return `<div class="lbl">Part ${n} of ${P.video.parts.length}</div><h2>${esc(p.title)}</h2><p class="hint sum">${esc(p.summary)}</p>
333
333
  <div class="tabs">${[['words', 'Words'], ['motion', 'Motion'], ['bg', 'Background'], ['tune', 'Tune']].map(([k, l]) => `<button class="${tab === k ? 'on' : ''}" data-tab="${k}">${l}</button>`).join('')}</div>
@@ -452,11 +452,18 @@ document.addEventListener('click', async (e) => {
452
452
  let sheetOpenAt = 0;
453
453
  const _open = openSheet; openSheet = (k, a) => { if (k === 'add') sheetOpenAt = a; _open(k, a); };
454
454
  function playAll() {
455
- if (playing) { clearTimeout(playing); playing = null; render(); return; }
455
+ if (playing) { clearTimeout(playing); playing = null; onEnded = null; render(); return; }
456
456
  let i = 0; const parts = P.video.parts;
457
- const next = () => { if (i >= parts.length || view !== 'video') { playing = null; render(); return; } sel = parts[i].dir; render(); playing = setTimeout(next, (parts[i++].seconds + 0.6) * 1000); };
457
+ // each part plays once in the player; the next one starts when it says it ended (or after its length, if it never does)
458
+ const next = () => {
459
+ clearTimeout(playing);
460
+ if (i >= parts.length || view !== 'video') { playing = null; onEnded = null; render(); return; }
461
+ sel = parts[i].dir; render(); onEnded = next; playing = setTimeout(next, (parts[i++].seconds + 3) * 1000);
462
+ };
458
463
  playing = 1; next();
459
464
  }
465
+ let onEnded = null;
466
+ addEventListener('message', (e) => { if (e.data?.pm === 'ended' && onEnded && e.source === $('player')?.querySelector('iframe')?.contentWindow) onEnded(); });
460
467
  document.addEventListener('change', (e) => {
461
468
  const d = e.target.dataset;
462
469
  if (d.word !== undefined) vact('word', { key: d.word, value: e.target.value });
@@ -464,7 +471,16 @@ document.addEventListener('change', (e) => {
464
471
  else if ('seconds' in d) vact('seconds', { value: e.target.value });
465
472
  else if (d.color) act({ type: 'style-color', key: d.color, value: e.target.value });
466
473
  });
467
- document.addEventListener('input', (e) => { const d = e.target.dataset; const k = d.tune ?? ('seconds' in d ? 'seconds' : null); if (k) { const o = $(`tv-${k}`); if (o) o.textContent = k === 'seconds' ? `${Number(e.target.value).toFixed(1)} s` : e.target.value; } });
474
+ document.addEventListener('input', (e) => {
475
+ const d = e.target.dataset; const k = d.tune ?? ('seconds' in d ? 'seconds' : null); if (!k) return;
476
+ const o = $(`tv-${k}`); if (o) o.textContent = k === 'seconds' ? `${Number(e.target.value).toFixed(1)} s` : e.target.value;
477
+ if (k === 'seconds' && $('tv-pace')) $('tv-pace').textContent = pace(Number(e.target.value), Number(d.natural));
478
+ });
479
+ // a part always plays its whole scene: its length is a speed
480
+ function pace(s, natural) {
481
+ const r = natural / s, pct = Math.round(Math.abs(r - 1) * 100);
482
+ return pct < 1 ? 'Plays its whole scene at its own speed.' : `Plays its whole scene ${pct}% ${r > 1 ? 'faster' : 'slower'}.`;
483
+ }
468
484
  document.addEventListener('focusout', (e) => {
469
485
  const d = e.target.dataset ?? {}, v = e.target.isContentEditable ? e.target.innerText.trim() : null;
470
486
  if (d.brief && v !== P.brief[d.brief]) act({ type: 'brief', key: d.brief, value: v }, true);