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 +16 -0
- package/bin/pamo.mjs +1 -1
- package/examples/breath/stills/contact-sheet.jpg +0 -0
- package/lib/chain.mjs +20 -11
- package/lib/edit.mjs +20 -4
- package/lib/inventory.mjs +14 -6
- package/lib/render.mjs +13 -9
- package/lib/studio.mjs +1 -1
- package/package.json +1 -1
- package/runtime/index.js +10 -1
- package/runtime/player.js +13 -4
- package/studio/index.html +21 -5
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.
|
|
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;
|
|
Binary file
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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 ?? '',
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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.
|
|
415
|
-
// is too long loses its last beat that is not a must instead
|
|
416
|
-
//
|
|
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
|
|
429
|
-
list.forEach((b) => { b.duration = +(b.
|
|
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
|
-
|
|
102
|
-
|
|
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 =
|
|
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
|
|
152
|
-
await s.seek(
|
|
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
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
|
|
9
|
-
|
|
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="
|
|
330
|
-
|
|
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
|
-
|
|
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) => {
|
|
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);
|