paper-motion 0.4.0 → 0.5.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/CHANGELOG.md +94 -0
- package/bin/pamo.mjs +63 -6
- package/lib/chain.mjs +20 -11
- package/lib/edit.mjs +51 -7
- package/lib/inventory.mjs +14 -6
- package/lib/piece.mjs +121 -10
- package/lib/registry.mjs +10 -4
- package/lib/render.mjs +13 -9
- package/lib/studio.mjs +195 -7
- package/package.json +2 -1
- package/runtime/brand.js +14 -2
- package/runtime/index.js +10 -1
- package/runtime/player.js +17 -6
- package/studio/index.html +255 -60
- package/examples/breath/stills/contact-sheet.jpg +0 -0
- package/examples/breath/stills/t000.00.png +0 -0
- package/examples/breath/stills/t002.00.png +0 -0
- package/examples/breath/stills/t004.00.png +0 -0
- package/examples/breath/stills/t006.00.png +0 -0
- package/examples/breath/stills/t008.00.png +0 -0
- package/examples/breath/stills/t010.00.png +0 -0
- package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
- package/examples/chrome-endcard/stills/t000.00.png +0 -0
- package/examples/chrome-endcard/stills/t001.33.png +0 -0
- package/examples/chrome-endcard/stills/t002.67.png +0 -0
- package/examples/chrome-endcard/stills/t004.00.png +0 -0
- package/examples/chrome-endcard/stills/t005.33.png +0 -0
- package/examples/chrome-endcard/stills/t006.67.png +0 -0
- package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
- package/examples/halo-explainer/stills/t000.00.png +0 -0
- package/examples/halo-explainer/stills/t006.00.png +0 -0
- package/examples/halo-explainer/stills/t012.00.png +0 -0
- package/examples/halo-explainer/stills/t018.00.png +0 -0
- package/examples/halo-explainer/stills/t024.00.png +0 -0
- package/examples/halo-explainer/stills/t030.00.png +0 -0
- package/examples/install-loop/stills/contact-sheet.jpg +0 -0
- package/examples/install-loop/stills/t000.00.png +0 -0
- package/examples/install-loop/stills/t001.00.png +0 -0
- package/examples/install-loop/stills/t002.00.png +0 -0
- package/examples/install-loop/stills/t003.00.png +0 -0
- package/examples/install-loop/stills/t004.00.png +0 -0
- package/examples/install-loop/stills/t005.00.png +0 -0
- package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
- package/examples/orbit-keynote/stills/t000.00.png +0 -0
- package/examples/orbit-keynote/stills/t006.50.png +0 -0
- package/examples/orbit-keynote/stills/t013.00.png +0 -0
- package/examples/orbit-keynote/stills/t019.50.png +0 -0
- package/examples/orbit-keynote/stills/t026.00.png +0 -0
- package/examples/orbit-keynote/stills/t032.50.png +0 -0
- package/examples/signal/stills/contact-sheet.jpg +0 -0
- package/examples/signal/stills/t000.00.png +0 -0
- package/examples/signal/stills/t001.67.png +0 -0
- package/examples/signal/stills/t003.33.png +0 -0
- package/examples/signal/stills/t005.00.png +0 -0
- package/examples/signal/stills/t006.67.png +0 -0
- package/examples/signal/stills/t008.33.png +0 -0
package/runtime/brand.js
CHANGED
|
@@ -11,11 +11,23 @@ const get = (p, json) => fetch(p).then((r) => (r.ok ? (json ? r.json() : r.text(
|
|
|
11
11
|
const sections = (md) => Object.fromEntries((md ?? '').split(/^## /m).slice(1).map((b) => { const i = b.indexOf('\n'); return [b.slice(0, i).trim(), b.slice(i + 1).trim()]; }));
|
|
12
12
|
|
|
13
13
|
export async function loadBrand(root = '/brand') {
|
|
14
|
-
|
|
14
|
+
let [tokens, identityMd, motion, sound, setRaw] = await Promise.all([
|
|
15
15
|
get(`${root}/tokens.json`, true), get(`${root}/IDENTITY.md`), get(`${root}/motion.json`, true), get(`${root}/sound.json`, true), get(`${root}/shader-set/set.json`, true),
|
|
16
16
|
]);
|
|
17
17
|
const identity = sections(identityMd);
|
|
18
|
-
|
|
18
|
+
let set = Array.isArray(setRaw) ? { entries: setRaw } : setRaw ?? { entries: [] };
|
|
19
|
+
// the studio's How it looks preview lays a look over the brand: `?embed&look=<base64url json>` (colours by role,
|
|
20
|
+
// fonts, a background entry, pace, entrance). Only an embedded preview reads it; a render never passes ?embed.
|
|
21
|
+
const q = new URLSearchParams(typeof location === 'undefined' ? '' : location.search);
|
|
22
|
+
let look = null;
|
|
23
|
+
if (q.has('embed') && q.get('look')) { try { look = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(q.get('look').replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)))); } catch { look = null; } }
|
|
24
|
+
if (look) {
|
|
25
|
+
tokens = { ...(tokens ?? {}) };
|
|
26
|
+
if (look.colors) { tokens.colors = { ...(tokens.colors ?? {}), ...Object.fromEntries(Object.entries(look.colors).filter(([, v]) => v).map(([k, v]) => [k, { hex: v }])) }; delete tokens.ramp; }
|
|
27
|
+
if (look.fonts) tokens.fonts = look.fonts;
|
|
28
|
+
if (look.background) set = { ...set, entries: [look.background, ...(set.entries ?? []).filter((e) => e.id !== look.background.id)] };
|
|
29
|
+
if (look.pace || look.entrance) motion = { ...(motion ?? {}), ...(look.pace ? { pace: look.pace } : {}), ...(look.entrance ? { entrance: look.entrance } : {}) };
|
|
30
|
+
}
|
|
19
31
|
|
|
20
32
|
// fonts: stylesheets and files, all loaded before the first frame
|
|
21
33
|
const fonts = Object.values(tokens?.fonts ?? {}).filter(Boolean);
|
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 = `
|
|
@@ -43,20 +46,28 @@ export function mountPlayer(scene, startT = 0) {
|
|
|
43
46
|
if (!embed) { try { sessionStorage.setItem(`pm:t:${location.pathname}`, String(t)); } catch { /* storage blocked */ } }
|
|
44
47
|
busy = true; await scene.seek(t); busy = false;
|
|
45
48
|
}
|
|
46
|
-
|
|
49
|
+
let looping = false; // the frame loop runs only while something plays (an embedded preview that ended is idle)
|
|
50
|
+
function setPlaying(p) { playing = p; playBtn.textContent = p ? '❚❚' : '▶'; last = performance.now(); if (p && !looping) { looping = true; requestAnimationFrame(tick); } }
|
|
47
51
|
|
|
52
|
+
// embedded, it draws at most 30 frames a second: a preview beside an editor, not the render
|
|
53
|
+
const step = embed ? 1000 / 30 - 2 : 0;
|
|
54
|
+
let drawn = 0;
|
|
48
55
|
function tick(now) {
|
|
56
|
+
if (embed && now - drawn < step) { requestAnimationFrame(tick); return; }
|
|
57
|
+
drawn = now;
|
|
49
58
|
if (playing && !busy) {
|
|
50
|
-
let nt = t + (now - last) / 1000;
|
|
51
|
-
if (nt >= duration) { if (loop) nt %= duration; else { nt = duration; setPlaying(false); } }
|
|
59
|
+
let nt = t + ((now - last) / 1000) * rate;
|
|
60
|
+
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
61
|
show(nt);
|
|
53
62
|
}
|
|
54
63
|
last = now;
|
|
64
|
+
if (embed && !playing) { looping = false; return; }
|
|
55
65
|
requestAnimationFrame(tick);
|
|
56
66
|
}
|
|
57
|
-
requestAnimationFrame(tick);
|
|
67
|
+
looping = true; requestAnimationFrame(tick);
|
|
58
68
|
|
|
59
69
|
playBtn.onclick = () => setPlaying(!playing);
|
|
70
|
+
if (embed) addEventListener('click', () => { if (!playing) show(0); setPlaying(true); });
|
|
60
71
|
loopBtn.onclick = () => { loop = !loop; loopBtn.classList.toggle('on', loop); };
|
|
61
72
|
range.oninput = () => { setPlaying(false); show(Number(range.value)); };
|
|
62
73
|
addEventListener('keydown', (e) => {
|