paper-motion 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -2
- package/bin/pamo.mjs +664 -0
- package/examples/breath/brief.md +11 -0
- package/examples/breath/poster.jpg +0 -0
- package/examples/breath/scene.html +59 -0
- package/examples/chrome-endcard/brief.md +7 -0
- package/examples/chrome-endcard/fonts/GeistMono-Variable.woff2 +0 -0
- package/examples/chrome-endcard/fonts/GeistSerif-Regular.woff2 +0 -0
- package/examples/chrome-endcard/poster.jpg +0 -0
- package/examples/chrome-endcard/scene.html +68 -0
- package/examples/chrome-endcard/wordmark.js +16 -0
- package/examples/halo-explainer/audio/script.txt +8 -0
- package/examples/halo-explainer/audio/words.json +554 -0
- package/examples/halo-explainer/brand/IDENTITY.md +24 -0
- package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
- package/examples/halo-explainer/brand/motion.json +9 -0
- package/examples/halo-explainer/brand/shader-set/set.json +104 -0
- package/examples/halo-explainer/brand/sound.json +33 -0
- package/examples/halo-explainer/brand/tokens.json +97 -0
- package/examples/halo-explainer/brief.md +34 -0
- package/examples/halo-explainer/poster.jpg +0 -0
- package/examples/halo-explainer/scene.html +372 -0
- package/examples/install-loop/brief.md +7 -0
- package/examples/install-loop/fonts/GeistMono-Variable.woff2 +0 -0
- package/examples/install-loop/fonts/GeistSerif-Regular.woff2 +0 -0
- package/examples/install-loop/poster.jpg +0 -0
- package/examples/install-loop/scene.html +46 -0
- package/examples/orbit-keynote/audio/script.txt +7 -0
- package/examples/orbit-keynote/audio/words.json +494 -0
- package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
- package/examples/orbit-keynote/brand/motion.json +9 -0
- package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
- package/examples/orbit-keynote/brand/sound.json +45 -0
- package/examples/orbit-keynote/brand/tokens.json +106 -0
- package/examples/orbit-keynote/brief.md +25 -0
- package/examples/orbit-keynote/poster.jpg +0 -0
- package/examples/orbit-keynote/scene.html +323 -0
- package/examples/signal/brief.md +10 -0
- package/examples/signal/poster.jpg +0 -0
- package/examples/signal/scene.html +50 -0
- package/lib/board.mjs +95 -0
- package/lib/browser.mjs +88 -0
- package/lib/catalog.mjs +77 -0
- package/lib/customize.mjs +162 -0
- package/lib/keys.mjs +93 -0
- package/lib/live-board.js +128 -0
- package/lib/modules.mjs +23 -0
- package/lib/react-hooks.mjs +9 -0
- package/lib/registry.mjs +344 -0
- package/lib/render.mjs +193 -0
- package/lib/server.mjs +89 -0
- package/lib/sfx.mjs +287 -0
- package/lib/shaderset.mjs +65 -0
- package/lib/wordmark.mjs +64 -0
- package/package.json +46 -4
- package/runtime/brand.js +71 -0
- package/runtime/core.js +269 -0
- package/runtime/index.js +349 -0
- package/runtime/jsx-shim.js +3 -0
- package/runtime/kit.js +98 -0
- package/runtime/moves/bar-reveal.js +19 -0
- package/runtime/moves/blur-in.js +7 -0
- package/runtime/moves/cells.js +17 -0
- package/runtime/moves/crossfade.js +6 -0
- package/runtime/moves/cut.js +3 -0
- package/runtime/moves/dive-in.js +21 -0
- package/runtime/moves/draw-on.js +12 -0
- package/runtime/moves/drift.js +7 -0
- package/runtime/moves/flash.js +15 -0
- package/runtime/moves/flip-3d.js +9 -0
- package/runtime/moves/flip-in.js +12 -0
- package/runtime/moves/flip-lift.js +24 -0
- package/runtime/moves/glitch.js +16 -0
- package/runtime/moves/iris.js +12 -0
- package/runtime/moves/magic-move.js +49 -0
- package/runtime/moves/mask-reveal.js +10 -0
- package/runtime/moves/odometer.js +32 -0
- package/runtime/moves/orbit.js +8 -0
- package/runtime/moves/pop-in.js +12 -0
- package/runtime/moves/punch.js +9 -0
- package/runtime/moves/push.js +14 -0
- package/runtime/moves/rgb-split.js +7 -0
- package/runtime/moves/roll.js +20 -0
- package/runtime/moves/scan-reveal.js +10 -0
- package/runtime/moves/scramble.js +21 -0
- package/runtime/moves/shake.js +15 -0
- package/runtime/moves/slices.js +14 -0
- package/runtime/moves/snap-scroll.js +23 -0
- package/runtime/moves/squash-drop.js +21 -0
- package/runtime/moves/tile-swirl.js +29 -0
- package/runtime/moves/track-in.js +7 -0
- package/runtime/moves/type-on.js +41 -0
- package/runtime/moves/unfold.js +20 -0
- package/runtime/moves/whip-pan.js +21 -0
- package/runtime/moves/whip.js +14 -0
- package/runtime/moves/wipe.js +9 -0
- package/runtime/moves/word-rise.js +10 -0
- package/runtime/moves/zoom-through.js +13 -0
- package/runtime/moves.js +94 -0
- package/runtime/player.js +69 -0
- package/runtime/react-shim.js +15 -0
- package/templates/blank/brief.md +39 -0
- package/templates/blank/scene.html +44 -0
- package/templates/brand-sheet/index.html +241 -0
- package/templates/move-demo/brief.md +4 -0
- package/templates/move-demo/scene.html +158 -0
- package/templates/piece/brief.md +39 -0
- package/templates/piece/scene.html +62 -0
- package/templates/shader-set/shot.js +47 -0
- package/templates/still/brief.md +3 -0
- package/templates/still/scene.html +56 -0
package/runtime/core.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// paper-motion core: the pure helpers (math, easing, springs, keyframes, noise, color, text split, shapes).
|
|
2
|
+
// Browser-safe on its own: no shaders, no location, no scene. The moves import from here, so they run anywhere
|
|
3
|
+
// (the render engine, the site's live player). index.js re-exports all of it as 'paper-motion'.
|
|
4
|
+
|
|
5
|
+
const resolveEl = (el) => (typeof el === 'string' ? document.querySelector(el) : el);
|
|
6
|
+
|
|
7
|
+
/* Math, timing, easing */
|
|
8
|
+
/* ------------------------------------------------------------------ */
|
|
9
|
+
|
|
10
|
+
export const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
|
|
11
|
+
export const lerp = (a, b, p) => a + (b - a) * p;
|
|
12
|
+
export const invlerp = (a, b, v) => (b === a ? 0 : (v - a) / (b - a));
|
|
13
|
+
export const remap = (v, a, b, c, d, doClamp = true) => lerp(c, d, doClamp ? clamp(invlerp(a, b, v)) : invlerp(a, b, v));
|
|
14
|
+
export const smoothstep = (e0, e1, x) => { const p = clamp((x - e0) / (e1 - e0)); return p * p * (3 - 2 * p); };
|
|
15
|
+
export const fract = (x) => x - Math.floor(x);
|
|
16
|
+
export const TAU = Math.PI * 2;
|
|
17
|
+
|
|
18
|
+
/** 0..1 progress of t through [start, end], eased. */
|
|
19
|
+
export const progress = (t, start, end, easing = ease.linear) => easing(clamp(invlerp(start, end, t)));
|
|
20
|
+
|
|
21
|
+
/** Progress for item i of a staggered group. */
|
|
22
|
+
export const stagger = (t, i, { start = 0, each = 0.08, duration = 0.8, easing } = {}) =>
|
|
23
|
+
progress(t, start + i * each, start + i * each + duration, easing);
|
|
24
|
+
|
|
25
|
+
/** 0..1 phase of t in a cycle of `period` seconds. */
|
|
26
|
+
export const phase = (t, period) => fract(t / period);
|
|
27
|
+
/** Sine oscillator in -1..1. Use period = duration / n for perfect loops. */
|
|
28
|
+
export const osc = (t, period, offset = 0) => Math.sin(TAU * (t / period + offset));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keyframes: keys(t, [[0, 0], [2, 1, ease.outExpo], [5, '#ff0044']])
|
|
32
|
+
* Values: numbers, arrays of numbers, or hex colors (mixed in OKLab).
|
|
33
|
+
* The easing on a key applies to the segment arriving at that key.
|
|
34
|
+
*/
|
|
35
|
+
export function keys(t, frames) {
|
|
36
|
+
if (t <= frames[0][0]) return frames[0][1];
|
|
37
|
+
for (let i = 1; i < frames.length; i++) {
|
|
38
|
+
const [t1, v1, e = ease.inOutCubic] = frames[i];
|
|
39
|
+
const [t0, v0] = frames[i - 1];
|
|
40
|
+
if (t <= t1) return mix(v0, v1, e(invlerp(t0, t1, t)));
|
|
41
|
+
}
|
|
42
|
+
return frames[frames.length - 1][1];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mix(a, b, p) {
|
|
46
|
+
if (typeof a === 'number') return lerp(a, b, p);
|
|
47
|
+
if (Array.isArray(a)) return a.map((v, i) => mix(v, b[i], p));
|
|
48
|
+
if (typeof a === 'string') return mixColor(a, b, p);
|
|
49
|
+
return p < 0.5 ? a : b;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const c1 = 1.70158, c3 = c1 + 1;
|
|
53
|
+
export const ease = {
|
|
54
|
+
linear: (x) => x,
|
|
55
|
+
inSine: (x) => 1 - Math.cos((x * Math.PI) / 2),
|
|
56
|
+
outSine: (x) => Math.sin((x * Math.PI) / 2),
|
|
57
|
+
inOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
|
|
58
|
+
inQuad: (x) => x * x,
|
|
59
|
+
outQuad: (x) => 1 - (1 - x) ** 2,
|
|
60
|
+
inOutQuad: (x) => (x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2),
|
|
61
|
+
inCubic: (x) => x ** 3,
|
|
62
|
+
outCubic: (x) => 1 - (1 - x) ** 3,
|
|
63
|
+
inOutCubic: (x) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2),
|
|
64
|
+
inQuart: (x) => x ** 4,
|
|
65
|
+
outQuart: (x) => 1 - (1 - x) ** 4,
|
|
66
|
+
inOutQuart: (x) => (x < 0.5 ? 8 * x ** 4 : 1 - (-2 * x + 2) ** 4 / 2),
|
|
67
|
+
inQuint: (x) => x ** 5,
|
|
68
|
+
outQuint: (x) => 1 - (1 - x) ** 5,
|
|
69
|
+
inOutQuint: (x) => (x < 0.5 ? 16 * x ** 5 : 1 - (-2 * x + 2) ** 5 / 2),
|
|
70
|
+
inExpo: (x) => (x === 0 ? 0 : 2 ** (10 * x - 10)),
|
|
71
|
+
outExpo: (x) => (x === 1 ? 1 : 1 - 2 ** (-10 * x)),
|
|
72
|
+
inOutExpo: (x) => (x === 0 ? 0 : x === 1 ? 1 : x < 0.5 ? 2 ** (20 * x - 10) / 2 : (2 - 2 ** (-20 * x + 10)) / 2),
|
|
73
|
+
inCirc: (x) => 1 - Math.sqrt(1 - x * x),
|
|
74
|
+
outCirc: (x) => Math.sqrt(1 - (x - 1) ** 2),
|
|
75
|
+
inOutCirc: (x) => (x < 0.5 ? (1 - Math.sqrt(1 - (2 * x) ** 2)) / 2 : (Math.sqrt(1 - (-2 * x + 2) ** 2) + 1) / 2),
|
|
76
|
+
outBack: (x) => 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2,
|
|
77
|
+
inOutBack: (x) => { const c2 = c1 * 1.525; return x < 0.5 ? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2 : ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; },
|
|
78
|
+
/** CSS-style cubic-bezier(x1, y1, x2, y2). */
|
|
79
|
+
bezier(x1, y1, x2, y2) {
|
|
80
|
+
const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = (a) => 3 * a;
|
|
81
|
+
const calc = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
|
|
82
|
+
const slope = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
|
|
83
|
+
return (x) => {
|
|
84
|
+
if (x <= 0 || x >= 1) return x;
|
|
85
|
+
let t = x;
|
|
86
|
+
for (let i = 0; i < 8; i++) { const s = slope(t, x1, x2); if (Math.abs(s) < 1e-6) break; t -= (calc(t, x1, x2) - x) / s; }
|
|
87
|
+
return calc(clamp(t), y1, y2);
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// After Effects-style curves (graph editor feel)
|
|
93
|
+
ease.easy = ease.bezier(0.33, 0, 0.67, 1); // AE "Easy Ease" (33% influence)
|
|
94
|
+
ease.smooth = ease.bezier(0.65, 0, 0.35, 1); // stronger in/out, motion-design default
|
|
95
|
+
ease.snappy = ease.bezier(0.16, 1, 0.3, 1); // fast start, long soft landing (≈ expo out)
|
|
96
|
+
ease.anticipate = ease.bezier(0.36, -0.35, 0.3, 1); // tiny pull-back before moving
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Physical spring, analytic (deterministic). Returns 0→1 with overshoot for
|
|
100
|
+
* seconds `dt` since the spring was released (negative dt → 0).
|
|
101
|
+
* Lower damping = more bounce. stiffness 170 / damping 26 is a crisp UI spring.
|
|
102
|
+
*/
|
|
103
|
+
export function spring(dt, { stiffness = 170, damping = 20, mass = 1 } = {}) {
|
|
104
|
+
if (dt <= 0) return 0;
|
|
105
|
+
const w0 = Math.sqrt(stiffness / mass), zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
106
|
+
if (zeta < 1) {
|
|
107
|
+
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
108
|
+
return 1 - Math.exp(-zeta * w0 * dt) * (Math.cos(wd * dt) + ((zeta * w0) / wd) * Math.sin(wd * dt));
|
|
109
|
+
}
|
|
110
|
+
return 1 - Math.exp(-w0 * dt) * (1 + w0 * dt);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------------------------------------------ */
|
|
114
|
+
/* Randomness and noise (seeded, deterministic) */
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
|
|
117
|
+
export function rng(seed = 1) {
|
|
118
|
+
let a = typeof seed === 'string' ? [...seed].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) : seed >>> 0;
|
|
119
|
+
const next = () => {
|
|
120
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
121
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
122
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
123
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
next,
|
|
127
|
+
range: (lo, hi) => lo + (hi - lo) * next(),
|
|
128
|
+
int: (lo, hi) => Math.floor(lo + (hi - lo + 1) * next()),
|
|
129
|
+
pick: (arr) => arr[Math.floor(next() * arr.length)],
|
|
130
|
+
sign: () => (next() < 0.5 ? -1 : 1),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const hash3 = (x, y, z) => {
|
|
135
|
+
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 2147483647);
|
|
136
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
137
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
|
138
|
+
};
|
|
139
|
+
/** Smooth 3D value noise in 0..1. noise(x), noise(x, y), noise(x, y, z). */
|
|
140
|
+
export function noise(x, y = 0, z = 0) {
|
|
141
|
+
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
|
142
|
+
const xf = x - xi, yf = y - yi, zf = z - zi;
|
|
143
|
+
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf);
|
|
144
|
+
const n = (a, b, c) => hash3(xi + a, yi + b, zi + c);
|
|
145
|
+
const x00 = lerp(n(0, 0, 0), n(1, 0, 0), u), x10 = lerp(n(0, 1, 0), n(1, 1, 0), u);
|
|
146
|
+
const x01 = lerp(n(0, 0, 1), n(1, 0, 1), u), x11 = lerp(n(0, 1, 1), n(1, 1, 1), u);
|
|
147
|
+
return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w);
|
|
148
|
+
}
|
|
149
|
+
/** Fractal noise (octaves of value noise), 0..1. */
|
|
150
|
+
export function fbm(x, y = 0, z = 0, octaves = 4) {
|
|
151
|
+
let s = 0, amp = 0.5, f = 1, norm = 0;
|
|
152
|
+
for (let i = 0; i < octaves; i++) { s += amp * noise(x * f, y * f, z * f); norm += amp; amp *= 0.5; f *= 2; }
|
|
153
|
+
return s / norm;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/* Color (OKLab mixing) */
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
function hexToRgb(hex) {
|
|
161
|
+
let h = hex.replace('#', '');
|
|
162
|
+
if (h.length <= 4) h = [...h].map((c) => c + c).join('');
|
|
163
|
+
const n = parseInt(h.slice(0, 6), 16);
|
|
164
|
+
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
165
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, a];
|
|
166
|
+
}
|
|
167
|
+
const toLin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
168
|
+
const toSrgb = (c) => Math.round(255 * clamp(c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055));
|
|
169
|
+
function rgbToOklab([r, g, b]) {
|
|
170
|
+
r = toLin(r); g = toLin(g); b = toLin(b);
|
|
171
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
172
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
173
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
174
|
+
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
|
|
175
|
+
}
|
|
176
|
+
function oklabToRgb([L, A, B]) {
|
|
177
|
+
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
|
178
|
+
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
|
179
|
+
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
|
180
|
+
return [toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)];
|
|
181
|
+
}
|
|
182
|
+
/** Perceptual color mix of two hex colors -> hex. */
|
|
183
|
+
export function mixColor(a, b, p) {
|
|
184
|
+
const A = hexToRgb(a), B = hexToRgb(b);
|
|
185
|
+
const [r, g, bl] = oklabToRgb(mix(rgbToOklab(A), rgbToOklab(B), p));
|
|
186
|
+
const al = lerp(A[3], B[3], p);
|
|
187
|
+
const hx = (n) => n.toString(16).padStart(2, '0');
|
|
188
|
+
return `#${hx(r)}${hx(g)}${hx(bl)}${al < 1 ? hx(Math.round(al * 255)) : ''}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* Typography */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
/** Wrap each word or char of an element in spans for staggered motion. Returns the spans. */
|
|
196
|
+
export function split(el, by = 'chars') {
|
|
197
|
+
el = resolveEl(el);
|
|
198
|
+
const text = el.textContent;
|
|
199
|
+
el.textContent = '';
|
|
200
|
+
el.setAttribute('aria-label', text);
|
|
201
|
+
const out = [];
|
|
202
|
+
const words = text.split(/(\s+)/);
|
|
203
|
+
for (const w of words) {
|
|
204
|
+
if (/^\s+$/.test(w)) { el.appendChild(document.createTextNode(w)); continue; }
|
|
205
|
+
const ws = document.createElement('span');
|
|
206
|
+
ws.style.display = 'inline-block';
|
|
207
|
+
ws.style.whiteSpace = 'pre';
|
|
208
|
+
if (by === 'words') { ws.textContent = w; out.push(ws); }
|
|
209
|
+
else for (const ch of w) { const c = document.createElement('span'); c.style.display = 'inline-block'; c.textContent = ch; ws.appendChild(c); out.push(c); }
|
|
210
|
+
el.appendChild(ws);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ------------------------------------------------------------------ */
|
|
216
|
+
/* Shapes */
|
|
217
|
+
/* ------------------------------------------------------------------ */
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* SVG path `d` for a hand-cut rounded rectangle: the "organic" shape language.
|
|
221
|
+
* Deterministic for a given seed, so a shape keeps its edges across frames and renders.
|
|
222
|
+
* @param {number} w,h size in px
|
|
223
|
+
* @param {object} [o]
|
|
224
|
+
* @param {number} [o.radius] corner radius in px (default 24)
|
|
225
|
+
* @param {number} [o.wobble] edge irregularity in px (default 0.6% of the short side; keep it low)
|
|
226
|
+
* @param {number} [o.seed] changes the irregularity, not its amount
|
|
227
|
+
*/
|
|
228
|
+
export function organicPath(w, h, { radius = 24, wobble, seed = 1 } = {}) {
|
|
229
|
+
const amp = wobble ?? Math.min(w, h) * 0.014;
|
|
230
|
+
// draw on a box inset by the wobble, so the shape never leaves w×h (and never gets clipped flat)
|
|
231
|
+
const W0 = w, H0 = h; w -= amp * 2; h -= amp * 2;
|
|
232
|
+
const r = Math.min(radius, w / 2, h / 2);
|
|
233
|
+
const per = 2 * (w + h - 4 * r) + 2 * Math.PI * r;
|
|
234
|
+
const n = Math.max(16, Math.round(per / 40));
|
|
235
|
+
const pt = (d) => {
|
|
236
|
+
// walk the rounded-rect perimeter clockwise from the top-left corner's end
|
|
237
|
+
const segs = [[w - 2 * r, 'top'], [Math.PI * r / 2, 'tr'], [h - 2 * r, 'right'], [Math.PI * r / 2, 'br'], [w - 2 * r, 'bottom'], [Math.PI * r / 2, 'bl'], [h - 2 * r, 'left'], [Math.PI * r / 2, 'tl']];
|
|
238
|
+
for (const [len, k] of segs) {
|
|
239
|
+
if (d > len) { d -= len; continue; }
|
|
240
|
+
const a = r ? d / r : 0;
|
|
241
|
+
switch (k) {
|
|
242
|
+
case 'top': return [r + d, 0, 0, -1];
|
|
243
|
+
case 'tr': return [w - r + r * Math.sin(a), r - r * Math.cos(a), Math.sin(a), -Math.cos(a)];
|
|
244
|
+
case 'right': return [w, r + d, 1, 0];
|
|
245
|
+
case 'br': return [w - r + r * Math.cos(a), h - r + r * Math.sin(a), Math.cos(a), Math.sin(a)];
|
|
246
|
+
case 'bottom': return [w - r - d, h, 0, 1];
|
|
247
|
+
case 'bl': return [r - r * Math.sin(a), h - r + r * Math.cos(a), -Math.sin(a), Math.cos(a)];
|
|
248
|
+
case 'left': return [0, h - r - d, -1, 0];
|
|
249
|
+
default: return [r - r * Math.cos(a), r - r * Math.sin(a), -Math.cos(a), -Math.sin(a)];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return [r, 0, 0, -1];
|
|
253
|
+
};
|
|
254
|
+
const P = Array.from({ length: n }, (_, i) => {
|
|
255
|
+
const [x, y, nx, ny] = pt((i / n) * per);
|
|
256
|
+
const o = amp * (noise(i * 0.55, seed * 7.13) * 2 - 1);
|
|
257
|
+
return [x + nx * o + amp, y + ny * o + amp];
|
|
258
|
+
});
|
|
259
|
+
void W0; void H0;
|
|
260
|
+
// closed Catmull-Rom through the points, as cubic Béziers
|
|
261
|
+
let d = `M${P[0][0].toFixed(2)},${P[0][1].toFixed(2)}`;
|
|
262
|
+
for (let i = 0; i < n; i++) {
|
|
263
|
+
const p0 = P[(i - 1 + n) % n], p1 = P[i], p2 = P[(i + 1) % n], p3 = P[(i + 2) % n];
|
|
264
|
+
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
|
|
265
|
+
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
|
|
266
|
+
d += `C${c1[0].toFixed(2)},${c1[1].toFixed(2)} ${c2[0].toFixed(2)},${c2[1].toFixed(2)} ${p2[0].toFixed(2)},${p2[1].toFixed(2)}`;
|
|
267
|
+
}
|
|
268
|
+
return d + 'Z';
|
|
269
|
+
}
|
package/runtime/index.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// paper-motion runtime
|
|
2
|
+
// A scene is a pure function of time t (seconds). The engine seeks t frame by
|
|
3
|
+
// frame and captures the page, so everything visible must be derived from t.
|
|
4
|
+
|
|
5
|
+
import { ShaderMount } from '@paper-design/shaders';
|
|
6
|
+
import * as PaperReact from '@paper-design/shaders-react';
|
|
7
|
+
import { mountPlayer } from './player.js';
|
|
8
|
+
|
|
9
|
+
const params = new URLSearchParams(location.search);
|
|
10
|
+
export const RENDER = params.has('render');
|
|
11
|
+
|
|
12
|
+
/* ------------------------------------------------------------------ */
|
|
13
|
+
/* Scene */
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
|
|
16
|
+
let current = null;
|
|
17
|
+
|
|
18
|
+
export function scene(config = {}) {
|
|
19
|
+
if (current) throw new Error('paper-motion: only one scene per page');
|
|
20
|
+
current = new Scene(config);
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class Scene {
|
|
25
|
+
constructor({ width = 1920, height = 1080, fps = 30, duration = 10, background = '#000', stage } = {}) {
|
|
26
|
+
this.width = width;
|
|
27
|
+
this.height = height;
|
|
28
|
+
this.fps = fps;
|
|
29
|
+
this.duration = duration;
|
|
30
|
+
this.frames = Math.round(duration * fps);
|
|
31
|
+
this.background = background;
|
|
32
|
+
this.layers = [];
|
|
33
|
+
this.updaters = [];
|
|
34
|
+
this.pending = [];
|
|
35
|
+
this.cues = [];
|
|
36
|
+
this.audio = {};
|
|
37
|
+
this.t = 0;
|
|
38
|
+
this.stage = typeof stage === 'string' ? document.querySelector(stage) : stage
|
|
39
|
+
?? document.querySelector('[data-stage], #stage') ?? document.body;
|
|
40
|
+
this.#layout();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#layout() {
|
|
44
|
+
const s = this.stage.style;
|
|
45
|
+
s.position = 'relative';
|
|
46
|
+
s.width = `${this.width}px`;
|
|
47
|
+
s.height = `${this.height}px`;
|
|
48
|
+
s.overflow = 'hidden';
|
|
49
|
+
s.background = this.background;
|
|
50
|
+
s.transformOrigin = '0 0';
|
|
51
|
+
document.documentElement.style.background = RENDER ? this.background : '#111';
|
|
52
|
+
document.body.style.margin = '0';
|
|
53
|
+
if (RENDER) document.body.style.overflow = 'hidden';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Register a function called every frame with (t, info). */
|
|
57
|
+
update(fn) { this.updaters.push(fn); return this; }
|
|
58
|
+
|
|
59
|
+
/** Mount a Paper shader into an element. Returns a layer (see ShaderLayer). */
|
|
60
|
+
shader(el, name, shaderParams = {}, options = {}) {
|
|
61
|
+
const layer = new ShaderLayer(resolveEl(el), name, shaderParams, options, this);
|
|
62
|
+
this.layers.push(layer);
|
|
63
|
+
this.pending.push(layer.ready);
|
|
64
|
+
return layer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Add any custom layer: an object with seek(t). */
|
|
68
|
+
add(layer) { this.layers.push(layer); if (layer.ready) this.pending.push(layer.ready); return layer; }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Schedule a synthesized sound effect at time t (seconds). Declare cues at setup
|
|
72
|
+
* time (not inside update). Types: drone, swell, pen, paper, thud, knock, tick, sweep.
|
|
73
|
+
* Common opts: gain, pan (-1..1), seed; see references/sound.md for each voice.
|
|
74
|
+
*/
|
|
75
|
+
sfx(t, type, opts = {}) { this.cues.push({ t, type, ...opts }); return this; }
|
|
76
|
+
|
|
77
|
+
/** Master sound options: { lowpass: 2000 (Hz, caps brightness), gain: 1 }. */
|
|
78
|
+
sound(opts) { Object.assign(this.audio, opts); return this; }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Live film grain over the whole stage (a new grain pattern every frame, like
|
|
82
|
+
* AE's Add Grain). { opacity: 0.07, size: 0.9 (baseFrequency), blend: 'multiply' }
|
|
83
|
+
*/
|
|
84
|
+
grain({ opacity = 0.07, size = 0.9, blend = 'multiply' } = {}) {
|
|
85
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
86
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
87
|
+
svg.setAttribute('width', this.width); svg.setAttribute('height', this.height);
|
|
88
|
+
svg.style.cssText = `position:absolute;left:0;top:0;pointer-events:none;z-index:9999;opacity:${opacity};mix-blend-mode:${blend}`;
|
|
89
|
+
svg.innerHTML = `<filter id="pm-grain" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="${size}" numOctaves="2" seed="1" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/><feComponentTransfer><feFuncR type="linear" slope="1.6" intercept="-0.3"/><feFuncG type="linear" slope="1.6" intercept="-0.3"/><feFuncB type="linear" slope="1.6" intercept="-0.3"/></feComponentTransfer></filter><rect width="100%" height="100%" filter="url(#pm-grain)"/>`;
|
|
90
|
+
this.stage.appendChild(svg);
|
|
91
|
+
const turb = svg.querySelector('feTurbulence');
|
|
92
|
+
const fps = this.fps;
|
|
93
|
+
return this.add({ seek: (t) => turb.setAttribute('seed', String(1 + Math.floor(t * fps))) });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Await something before the first frame (images, data, fonts...). */
|
|
97
|
+
wait(promise) { this.pending.push(promise); return promise; }
|
|
98
|
+
|
|
99
|
+
/** Render the frame for time t (seconds). */
|
|
100
|
+
async seek(t) {
|
|
101
|
+
this.t = t;
|
|
102
|
+
const info = { t, frame: Math.round(t * this.fps), progress: this.duration ? t / this.duration : 0, duration: this.duration, fps: this.fps };
|
|
103
|
+
for (const l of this.layers) l.beginFrame?.(t);
|
|
104
|
+
for (const fn of this.updaters) fn(t, info);
|
|
105
|
+
const waits = [];
|
|
106
|
+
for (const l of this.layers) { const r = l.seek?.(t); if (r?.then) waits.push(r); }
|
|
107
|
+
syncAnimations(t);
|
|
108
|
+
waits.push(...syncMedia(t));
|
|
109
|
+
if (waits.length) await Promise.all(waits);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Call once everything is defined. Resolves when the first frame is ready. */
|
|
113
|
+
async start() {
|
|
114
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
115
|
+
await Promise.all(this.pending);
|
|
116
|
+
await nextFrame(); await nextFrame();
|
|
117
|
+
let saved = null;
|
|
118
|
+
if (!RENDER) { try { saved = sessionStorage.getItem(`pm:t:${location.pathname}`); } catch { /* storage blocked */ } }
|
|
119
|
+
const startT = params.has('t') ? Number(params.get('t')) : saved !== null ? Number(saved) : 0;
|
|
120
|
+
await this.seek(startT);
|
|
121
|
+
await nextFrame();
|
|
122
|
+
window.__pm = {
|
|
123
|
+
config: { width: this.width, height: this.height, fps: this.fps, duration: this.duration, frames: this.frames, background: this.background },
|
|
124
|
+
seek: async (t) => { await this.seek(t); await nextFrame(); await nextFrame(); return true; },
|
|
125
|
+
cues: this.cues,
|
|
126
|
+
audio: this.audio,
|
|
127
|
+
};
|
|
128
|
+
document.documentElement.dataset.pmReady = '1';
|
|
129
|
+
if (!RENDER) mountPlayer(this, startT);
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ------------------------------------------------------------------ */
|
|
135
|
+
/* Shader layer */
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
|
|
138
|
+
export const SHADERS = Object.keys(PaperReact).filter(
|
|
139
|
+
(k) => /^[A-Z]/.test(k) && k !== 'ShaderMount' && typeof PaperReact[k] === 'function',
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const imageCache = new Map();
|
|
143
|
+
// A transparent pixel stands in for an image shader with no image (PaperTexture and Water also work standalone).
|
|
144
|
+
const BLANK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
|
145
|
+
|
|
146
|
+
class ShaderLayer {
|
|
147
|
+
/**
|
|
148
|
+
* @param el element the canvas fills (give it a size: e.g. position:absolute; inset:0)
|
|
149
|
+
* @param name Paper shader component name, e.g. 'MeshGradient', 'Dithering', 'GrainGradient'
|
|
150
|
+
* @param params same props as the React component (colors, distortion, speed, scale, image...)
|
|
151
|
+
* @param options { loop: seconds of crossfade for seamless loops, timeOffset, minPixelRatio, maxPixelCount }
|
|
152
|
+
*/
|
|
153
|
+
constructor(el, name, shaderParams, options, sceneRef) {
|
|
154
|
+
const Comp = PaperReact[name];
|
|
155
|
+
if (!SHADERS.includes(name)) throw new Error(`paper-motion: unknown shader "${name}". Available: ${SHADERS.join(', ')}`);
|
|
156
|
+
this.el = el;
|
|
157
|
+
this.name = name;
|
|
158
|
+
this.Comp = Comp;
|
|
159
|
+
this.params = { ...shaderParams };
|
|
160
|
+
this.options = options;
|
|
161
|
+
this.scene = sceneRef;
|
|
162
|
+
/** Override shader time (seconds) for the current frame; resets every frame. */
|
|
163
|
+
this.time = null;
|
|
164
|
+
this.ready = this.#init();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async #init() {
|
|
168
|
+
const props = await resolveProps(this.Comp, this.params);
|
|
169
|
+
const uniforms = await loadUniforms(props.uniforms);
|
|
170
|
+
this.speed = props.speed ?? 0;
|
|
171
|
+
this.frame0 = props.frame ?? 0;
|
|
172
|
+
const cs = getComputedStyle(this.el);
|
|
173
|
+
if (cs.position === 'static') this.el.style.position = 'relative';
|
|
174
|
+
const make = (host) => new ShaderMount(
|
|
175
|
+
host, props.fragmentShader, uniforms,
|
|
176
|
+
{ preserveDrawingBuffer: true, ...(props.webGlContextAttributes ?? {}) },
|
|
177
|
+
0, this.frame0,
|
|
178
|
+
this.options.minPixelRatio ?? 2,
|
|
179
|
+
this.options.maxPixelCount ?? 3840 * 2160 * 2,
|
|
180
|
+
props.mipmaps,
|
|
181
|
+
);
|
|
182
|
+
if (this.options.loop) {
|
|
183
|
+
// Two stacked mounts crossfaded over the last `loop` seconds -> seamless loop.
|
|
184
|
+
this.hostA = child(this.el); this.hostB = child(this.el);
|
|
185
|
+
this.a = make(this.hostA); this.b = make(this.hostB);
|
|
186
|
+
this.hostB.style.opacity = '0';
|
|
187
|
+
} else {
|
|
188
|
+
this.a = make(this.el);
|
|
189
|
+
}
|
|
190
|
+
this.lastUniforms = uniforms;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Change params (sync). For a new image use `await layer.load({ image })`. */
|
|
194
|
+
set(partial) {
|
|
195
|
+
Object.assign(this.params, partial);
|
|
196
|
+
const props = resolvePropsSync(this.Comp, this.params);
|
|
197
|
+
const u = {};
|
|
198
|
+
for (const [k, v] of Object.entries(props.uniforms)) {
|
|
199
|
+
if (typeof v === 'string') {
|
|
200
|
+
const img = imageCache.get(v) ?? (v === '' ? imageCache.get(BLANK) : undefined);
|
|
201
|
+
if (!img) throw new Error(`paper-motion: image "${v}" not loaded yet; use await layer.load({...})`);
|
|
202
|
+
u[k] = img;
|
|
203
|
+
} else u[k] = v;
|
|
204
|
+
}
|
|
205
|
+
this.speed = props.speed ?? this.speed;
|
|
206
|
+
this.a?.setUniforms(u); this.b?.setUniforms(u);
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Change params that need loading (images). */
|
|
211
|
+
async load(partial) {
|
|
212
|
+
Object.assign(this.params, partial);
|
|
213
|
+
const props = await resolveProps(this.Comp, this.params);
|
|
214
|
+
const u = await loadUniforms(props.uniforms);
|
|
215
|
+
this.a?.setUniforms(u); this.b?.setUniforms(u);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
beginFrame() { this.time = null; }
|
|
219
|
+
|
|
220
|
+
seek(t) {
|
|
221
|
+
if (!this.a) return;
|
|
222
|
+
const time = this.time ?? (t + (this.options.timeOffset ?? 0));
|
|
223
|
+
const ms = (s) => this.frame0 + s * 1000 * this.speed;
|
|
224
|
+
const L = this.options.loop;
|
|
225
|
+
if (!L) { this.a.setFrame(ms(time)); return; }
|
|
226
|
+
const D = this.scene.duration;
|
|
227
|
+
// A plays time+L, B plays time+L-D; at t=D, B shows exactly what A showed at t=0.
|
|
228
|
+
this.a.setFrame(ms(time + L));
|
|
229
|
+
const fade = clamp((t - (D - L)) / L, 0, 1);
|
|
230
|
+
this.hostB.style.opacity = String(fade);
|
|
231
|
+
if (fade > 0) this.b.setFrame(ms(time + L - D));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function resolveProps(Comp, p) {
|
|
236
|
+
for (let i = 0; i < 20; i++) {
|
|
237
|
+
try { return Comp({ ...p, suspendWhenProcessingImage: true }).props; }
|
|
238
|
+
catch (e) { if (e && typeof e.then === 'function') { await e.catch(() => {}); continue; } throw e; }
|
|
239
|
+
}
|
|
240
|
+
throw new Error('paper-motion: could not resolve shader props');
|
|
241
|
+
}
|
|
242
|
+
function resolvePropsSync(Comp, p) {
|
|
243
|
+
try { return Comp({ ...p, suspendWhenProcessingImage: true }).props; }
|
|
244
|
+
catch (e) { if (e && typeof e.then === 'function') throw new Error('paper-motion: this change needs `await layer.load(...)`'); throw e; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function loadUniforms(uniforms) {
|
|
248
|
+
const out = {};
|
|
249
|
+
await Promise.all(Object.entries(uniforms).map(async ([k, v]) => {
|
|
250
|
+
if (typeof v === 'string') out[k] = await loadImage(v || BLANK);
|
|
251
|
+
else if (v instanceof HTMLImageElement) { await v.decode(); minSize(v); out[k] = v; }
|
|
252
|
+
else out[k] = v;
|
|
253
|
+
}));
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function loadImage(url) {
|
|
258
|
+
if (imageCache.has(url)) return Promise.resolve(imageCache.get(url));
|
|
259
|
+
return new Promise((res, rej) => {
|
|
260
|
+
const img = new Image();
|
|
261
|
+
img.crossOrigin = 'anonymous';
|
|
262
|
+
img.onload = () => { minSize(img); imageCache.set(url, img); res(img); };
|
|
263
|
+
img.onerror = () => rej(new Error(`paper-motion: failed to load image ${url}`));
|
|
264
|
+
img.src = url;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
function minSize(img) {
|
|
268
|
+
if (img.naturalWidth < 1024 && img.naturalHeight < 1024 && img.naturalWidth > 0) {
|
|
269
|
+
const a = img.naturalWidth / img.naturalHeight;
|
|
270
|
+
img.width = Math.round(a > 1 ? 1024 * a : 1024);
|
|
271
|
+
img.height = Math.round(a > 1 ? 1024 : 1024 / a);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ------------------------------------------------------------------ */
|
|
276
|
+
/* Determinism helpers */
|
|
277
|
+
/* ------------------------------------------------------------------ */
|
|
278
|
+
|
|
279
|
+
// CSS animations / transitions / WAAPI are paused and driven by t.
|
|
280
|
+
function syncAnimations(t) {
|
|
281
|
+
if (!document.getAnimations) return;
|
|
282
|
+
for (const a of document.getAnimations()) { a.pause(); a.currentTime = t * 1000; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// <video data-pm-sync data-pm-offset="0"> elements follow scene time.
|
|
286
|
+
function syncMedia(t) {
|
|
287
|
+
const out = [];
|
|
288
|
+
const W = window.innerWidth, H = window.innerHeight;
|
|
289
|
+
for (const v of document.querySelectorAll('video[data-pm-sync]')) {
|
|
290
|
+
v.pause();
|
|
291
|
+
// only what the camera sees: an off-screen clip is seeked when it comes into view. A wall of dozens of
|
|
292
|
+
// clips otherwise exhausts the hardware decoders and their seeks stop reporting back.
|
|
293
|
+
const r = v.getBoundingClientRect();
|
|
294
|
+
if (r.width < 1 || r.height < 1 || r.right < 0 || r.bottom < 0 || r.left > W || r.top > H) continue;
|
|
295
|
+
const target = t + Number(v.dataset.pmOffset ?? 0);
|
|
296
|
+
const d = v.duration || Infinity;
|
|
297
|
+
// wrap negative offsets too (a looping clip that starts mid-way), and never ask for a time outside [0, d]
|
|
298
|
+
const tt = v.loop && Number.isFinite(d) ? ((target % d) + d) % d : Math.max(0, Math.min(target, d));
|
|
299
|
+
if (Math.abs(v.currentTime - tt) > 1e-4) {
|
|
300
|
+
// a seek that never reports back (decoder stall, error) must not hang the render: nudge it twice, then give up
|
|
301
|
+
out.push(new Promise((r) => {
|
|
302
|
+
const done = () => { clearTimeout(timer); v.removeEventListener('seeked', done); v.removeEventListener('error', done); r(); };
|
|
303
|
+
let tries = 0;
|
|
304
|
+
const retry = () => { if (++tries < 3) { v.currentTime = tt + tries * 1e-3; timer = setTimeout(retry, 1500); } else { console.warn(`paper-motion: seek timed out on ${v.currentSrc || v.src} at ${tt.toFixed(3)}s`); done(); } };
|
|
305
|
+
let timer = setTimeout(retry, 1500);
|
|
306
|
+
v.addEventListener('seeked', done); v.addEventListener('error', done);
|
|
307
|
+
v.currentTime = tt;
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// rAF is paused in hidden/occluded windows (even when document.hidden is false);
|
|
315
|
+
// race it with a timer so a background preview still boots.
|
|
316
|
+
const nextFrame = () => new Promise((r) => { requestAnimationFrame(() => r()); setTimeout(r, 100); });
|
|
317
|
+
const resolveEl = (el) => (typeof el === 'string' ? document.querySelector(el) : el);
|
|
318
|
+
function child(parent) {
|
|
319
|
+
const d = document.createElement('div');
|
|
320
|
+
d.style.cssText = 'position:absolute;inset:0;';
|
|
321
|
+
parent.appendChild(d);
|
|
322
|
+
return d;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ------------------------------------------------------------------ */
|
|
326
|
+
/* ------------------------------------------------------------------ */
|
|
327
|
+
/* Helpers (core.js: math, easing, springs, noise, color, text, shapes) */
|
|
328
|
+
/* ------------------------------------------------------------------ */
|
|
329
|
+
|
|
330
|
+
export * from './core.js';
|
|
331
|
+
import { clamp, lerp, invlerp, progress, ease, keys, mix, mixColor, spring, rng, noise, split } from './core.js';
|
|
332
|
+
|
|
333
|
+
/* ------------------------------------------------------------------ */
|
|
334
|
+
/* Live shaders (web pages, not scenes) */
|
|
335
|
+
/* ------------------------------------------------------------------ */
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Mount a Paper shader that plays in real time inside any web page (the brand sheet, a preview).
|
|
339
|
+
* Scenes use s.shader() instead, which is seeked frame by frame.
|
|
340
|
+
* @returns {Promise<ShaderMount>}
|
|
341
|
+
*/
|
|
342
|
+
export async function live(el, name, params = {}) {
|
|
343
|
+
const Comp = PaperReact[name];
|
|
344
|
+
if (!SHADERS.includes(name)) throw new Error(`paper-motion: unknown shader "${name}"`);
|
|
345
|
+
const props = await resolveProps(Comp, params);
|
|
346
|
+
const uniforms = await loadUniforms(props.uniforms);
|
|
347
|
+
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
|
|
348
|
+
return new ShaderMount(el, props.fragmentShader, uniforms, props.webGlContextAttributes ?? {}, props.speed ?? 1, props.frame ?? 0, 1.5, 1920 * 1080 * 2, props.mipmaps);
|
|
349
|
+
}
|