paper-motion 0.1.0 → 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.
Files changed (75) hide show
  1. package/README.md +14 -0
  2. package/bin/pamo.mjs +133 -4
  3. package/examples/halo-explainer/audio/script.txt +8 -0
  4. package/examples/halo-explainer/audio/words.json +554 -0
  5. package/examples/halo-explainer/brand/IDENTITY.md +24 -0
  6. package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
  7. package/examples/halo-explainer/brand/motion.json +9 -0
  8. package/examples/halo-explainer/brand/shader-set/set.json +104 -0
  9. package/examples/halo-explainer/brand/sound.json +33 -0
  10. package/examples/halo-explainer/brand/tokens.json +97 -0
  11. package/examples/halo-explainer/brief.md +34 -0
  12. package/examples/halo-explainer/poster.jpg +0 -0
  13. package/examples/halo-explainer/scene.html +372 -0
  14. package/examples/orbit-keynote/audio/script.txt +7 -0
  15. package/examples/orbit-keynote/audio/words.json +494 -0
  16. package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
  17. package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
  18. package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
  19. package/examples/orbit-keynote/brand/motion.json +9 -0
  20. package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
  21. package/examples/orbit-keynote/brand/sound.json +45 -0
  22. package/examples/orbit-keynote/brand/tokens.json +106 -0
  23. package/examples/orbit-keynote/brief.md +25 -0
  24. package/examples/orbit-keynote/poster.jpg +0 -0
  25. package/examples/orbit-keynote/scene.html +323 -0
  26. package/lib/customize.mjs +162 -0
  27. package/lib/live-board.js +128 -0
  28. package/lib/registry.mjs +344 -0
  29. package/lib/render.mjs +3 -3
  30. package/lib/server.mjs +1 -0
  31. package/lib/sfx.mjs +5 -3
  32. package/package.json +1 -1
  33. package/runtime/core.js +269 -0
  34. package/runtime/index.js +3 -260
  35. package/runtime/kit.js +98 -0
  36. package/runtime/moves/bar-reveal.js +19 -0
  37. package/runtime/moves/blur-in.js +7 -0
  38. package/runtime/moves/cells.js +17 -0
  39. package/runtime/moves/crossfade.js +6 -0
  40. package/runtime/moves/cut.js +3 -0
  41. package/runtime/moves/dive-in.js +21 -0
  42. package/runtime/moves/draw-on.js +12 -0
  43. package/runtime/moves/drift.js +7 -0
  44. package/runtime/moves/flash.js +15 -0
  45. package/runtime/moves/flip-3d.js +9 -0
  46. package/runtime/moves/flip-in.js +12 -0
  47. package/runtime/moves/flip-lift.js +24 -0
  48. package/runtime/moves/glitch.js +16 -0
  49. package/runtime/moves/iris.js +12 -0
  50. package/runtime/moves/magic-move.js +49 -0
  51. package/runtime/moves/mask-reveal.js +10 -0
  52. package/runtime/moves/odometer.js +32 -0
  53. package/runtime/moves/orbit.js +8 -0
  54. package/runtime/moves/pop-in.js +12 -0
  55. package/runtime/moves/punch.js +9 -0
  56. package/runtime/moves/push.js +14 -0
  57. package/runtime/moves/rgb-split.js +7 -0
  58. package/runtime/moves/roll.js +20 -0
  59. package/runtime/moves/scan-reveal.js +10 -0
  60. package/runtime/moves/scramble.js +21 -0
  61. package/runtime/moves/shake.js +15 -0
  62. package/runtime/moves/slices.js +14 -0
  63. package/runtime/moves/snap-scroll.js +23 -0
  64. package/runtime/moves/squash-drop.js +21 -0
  65. package/runtime/moves/tile-swirl.js +29 -0
  66. package/runtime/moves/track-in.js +7 -0
  67. package/runtime/moves/type-on.js +41 -0
  68. package/runtime/moves/unfold.js +20 -0
  69. package/runtime/moves/whip-pan.js +21 -0
  70. package/runtime/moves/whip.js +14 -0
  71. package/runtime/moves/wipe.js +9 -0
  72. package/runtime/moves/word-rise.js +10 -0
  73. package/runtime/moves/zoom-through.js +13 -0
  74. package/runtime/moves.js +66 -146
  75. package/templates/move-demo/scene.html +4 -3
package/lib/render.mjs CHANGED
@@ -94,12 +94,12 @@ 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 }) {
97
+ async function sfxFromPage(s, { from = 0, to, 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
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 }));
102
+ writeWav(out, synthesize(shifted, { duration: end - from, ...audio, normalize }));
103
103
  return { out, cues: cues.length };
104
104
  }
105
105
 
@@ -131,7 +131,7 @@ export async function renderVideo({ url, out, format = 'mp4', workers, dpr = 1,
131
131
 
132
132
  let sfxWav = null;
133
133
  if (sfx && !fmt.gif && !fmt.seq) {
134
- const r = await sfxFromPage(first, { from: f0 / fps, to: f1 / fps, out: path.join(tmp, 'sfx.wav') });
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
135
135
  sfxWav = r?.out ?? null;
136
136
  }
137
137
 
package/lib/server.mjs CHANGED
@@ -21,6 +21,7 @@ const IMPORT_MAP = {
21
21
  imports: {
22
22
  'paper-motion': '/__pm/runtime/index.js',
23
23
  'paper-motion/moves': '/__pm/runtime/moves.js',
24
+ 'paper-motion/kit': '/__pm/runtime/kit.js',
24
25
  'paper-motion/brand': '/__pm/runtime/brand.js',
25
26
  '@paper-design/shaders': '/__pm/node_modules/@paper-design/shaders/dist/index.js',
26
27
  '@paper-design/shaders-react': '/__pm/node_modules/@paper-design/shaders-react/dist/index.js',
package/lib/sfx.mjs CHANGED
@@ -226,10 +226,12 @@ export const SFX_TYPES = Object.keys(VOICES);
226
226
 
227
227
  /**
228
228
  * @param cues [{ t, type, pan?, seed?, ...voiceOpts }]
229
- * @param options { duration, lowpass=2000, highpass=30, gain=1, peak=0.8 }
229
+ * @param options { duration, lowpass=2000, highpass=30, gain=1, peak=0.8, normalize=true }
230
+ * normalize: true scales the mix up or down to `peak`; false keeps the cues' own gains and only
231
+ * limits overs (used when the scene brings its own audio, so effects sit where their gains put them)
230
232
  * @returns { left: Float32Array, right: Float32Array, sampleRate }
231
233
  */
232
- export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain = 1, peak = 0.8 } = {}) {
234
+ export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain = 1, peak = 0.8, normalize = true } = {}) {
233
235
  const n = Math.ceil(duration * SR);
234
236
  const L = new Float32Array(n), R = new Float32Array(n);
235
237
  // Voices marked `bright` (keys) go through their own, gentler low-pass: a keystroke's click lives at 1–4 kHz.
@@ -260,7 +262,7 @@ export function synthesize(cues, { duration, lowpass = 2000, highpass = 30, gain
260
262
  });
261
263
  let max = 1e-9;
262
264
  for (let i = 0; i < n; i++) max = Math.max(max, Math.abs(L[i]), Math.abs(R[i]));
263
- const k = (peak / max) * gain;
265
+ const k = (normalize ? peak / max : Math.min(1, peak / max)) * gain;
264
266
  const fadeOut = Math.min(n, Math.round(0.05 * SR));
265
267
  for (let i = 0; i < n; i++) {
266
268
  const f = i > n - fadeOut ? (n - i) / fadeOut : 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paper-motion",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -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 CHANGED
@@ -323,269 +323,12 @@ function child(parent) {
323
323
  }
324
324
 
325
325
  /* ------------------------------------------------------------------ */
326
- /* Math, timing, easing */
327
326
  /* ------------------------------------------------------------------ */
328
-
329
- export const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
330
- export const lerp = (a, b, p) => a + (b - a) * p;
331
- export const invlerp = (a, b, v) => (b === a ? 0 : (v - a) / (b - a));
332
- export const remap = (v, a, b, c, d, doClamp = true) => lerp(c, d, doClamp ? clamp(invlerp(a, b, v)) : invlerp(a, b, v));
333
- export const smoothstep = (e0, e1, x) => { const p = clamp((x - e0) / (e1 - e0)); return p * p * (3 - 2 * p); };
334
- export const fract = (x) => x - Math.floor(x);
335
- export const TAU = Math.PI * 2;
336
-
337
- /** 0..1 progress of t through [start, end], eased. */
338
- export const progress = (t, start, end, easing = ease.linear) => easing(clamp(invlerp(start, end, t)));
339
-
340
- /** Progress for item i of a staggered group. */
341
- export const stagger = (t, i, { start = 0, each = 0.08, duration = 0.8, easing } = {}) =>
342
- progress(t, start + i * each, start + i * each + duration, easing);
343
-
344
- /** 0..1 phase of t in a cycle of `period` seconds. */
345
- export const phase = (t, period) => fract(t / period);
346
- /** Sine oscillator in -1..1. Use period = duration / n for perfect loops. */
347
- export const osc = (t, period, offset = 0) => Math.sin(TAU * (t / period + offset));
348
-
349
- /**
350
- * Keyframes: keys(t, [[0, 0], [2, 1, ease.outExpo], [5, '#ff0044']])
351
- * Values: numbers, arrays of numbers, or hex colors (mixed in OKLab).
352
- * The easing on a key applies to the segment arriving at that key.
353
- */
354
- export function keys(t, frames) {
355
- if (t <= frames[0][0]) return frames[0][1];
356
- for (let i = 1; i < frames.length; i++) {
357
- const [t1, v1, e = ease.inOutCubic] = frames[i];
358
- const [t0, v0] = frames[i - 1];
359
- if (t <= t1) return mix(v0, v1, e(invlerp(t0, t1, t)));
360
- }
361
- return frames[frames.length - 1][1];
362
- }
363
-
364
- export function mix(a, b, p) {
365
- if (typeof a === 'number') return lerp(a, b, p);
366
- if (Array.isArray(a)) return a.map((v, i) => mix(v, b[i], p));
367
- if (typeof a === 'string') return mixColor(a, b, p);
368
- return p < 0.5 ? a : b;
369
- }
370
-
371
- const c1 = 1.70158, c3 = c1 + 1;
372
- export const ease = {
373
- linear: (x) => x,
374
- inSine: (x) => 1 - Math.cos((x * Math.PI) / 2),
375
- outSine: (x) => Math.sin((x * Math.PI) / 2),
376
- inOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
377
- inQuad: (x) => x * x,
378
- outQuad: (x) => 1 - (1 - x) ** 2,
379
- inOutQuad: (x) => (x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2),
380
- inCubic: (x) => x ** 3,
381
- outCubic: (x) => 1 - (1 - x) ** 3,
382
- inOutCubic: (x) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2),
383
- inQuart: (x) => x ** 4,
384
- outQuart: (x) => 1 - (1 - x) ** 4,
385
- inOutQuart: (x) => (x < 0.5 ? 8 * x ** 4 : 1 - (-2 * x + 2) ** 4 / 2),
386
- inQuint: (x) => x ** 5,
387
- outQuint: (x) => 1 - (1 - x) ** 5,
388
- inOutQuint: (x) => (x < 0.5 ? 16 * x ** 5 : 1 - (-2 * x + 2) ** 5 / 2),
389
- inExpo: (x) => (x === 0 ? 0 : 2 ** (10 * x - 10)),
390
- outExpo: (x) => (x === 1 ? 1 : 1 - 2 ** (-10 * x)),
391
- inOutExpo: (x) => (x === 0 ? 0 : x === 1 ? 1 : x < 0.5 ? 2 ** (20 * x - 10) / 2 : (2 - 2 ** (-20 * x + 10)) / 2),
392
- inCirc: (x) => 1 - Math.sqrt(1 - x * x),
393
- outCirc: (x) => Math.sqrt(1 - (x - 1) ** 2),
394
- inOutCirc: (x) => (x < 0.5 ? (1 - Math.sqrt(1 - (2 * x) ** 2)) / 2 : (Math.sqrt(1 - (-2 * x + 2) ** 2) + 1) / 2),
395
- outBack: (x) => 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2,
396
- 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; },
397
- /** CSS-style cubic-bezier(x1, y1, x2, y2). */
398
- bezier(x1, y1, x2, y2) {
399
- const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = (a) => 3 * a;
400
- const calc = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
401
- const slope = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
402
- return (x) => {
403
- if (x <= 0 || x >= 1) return x;
404
- let t = x;
405
- 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; }
406
- return calc(clamp(t), y1, y2);
407
- };
408
- },
409
- };
410
-
411
- // After Effects-style curves (graph editor feel)
412
- ease.easy = ease.bezier(0.33, 0, 0.67, 1); // AE "Easy Ease" (33% influence)
413
- ease.smooth = ease.bezier(0.65, 0, 0.35, 1); // stronger in/out, motion-design default
414
- ease.snappy = ease.bezier(0.16, 1, 0.3, 1); // fast start, long soft landing (≈ expo out)
415
- ease.anticipate = ease.bezier(0.36, -0.35, 0.3, 1); // tiny pull-back before moving
416
-
417
- /**
418
- * Physical spring, analytic (deterministic). Returns 0→1 with overshoot for
419
- * seconds `dt` since the spring was released (negative dt → 0).
420
- * Lower damping = more bounce. stiffness 170 / damping 26 is a crisp UI spring.
421
- */
422
- export function spring(dt, { stiffness = 170, damping = 20, mass = 1 } = {}) {
423
- if (dt <= 0) return 0;
424
- const w0 = Math.sqrt(stiffness / mass), zeta = damping / (2 * Math.sqrt(stiffness * mass));
425
- if (zeta < 1) {
426
- const wd = w0 * Math.sqrt(1 - zeta * zeta);
427
- return 1 - Math.exp(-zeta * w0 * dt) * (Math.cos(wd * dt) + ((zeta * w0) / wd) * Math.sin(wd * dt));
428
- }
429
- return 1 - Math.exp(-w0 * dt) * (1 + w0 * dt);
430
- }
431
-
327
+ /* Helpers (core.js: math, easing, springs, noise, color, text, shapes) */
432
328
  /* ------------------------------------------------------------------ */
433
- /* Randomness and noise (seeded, deterministic) */
434
- /* ------------------------------------------------------------------ */
435
-
436
- export function rng(seed = 1) {
437
- let a = typeof seed === 'string' ? [...seed].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) : seed >>> 0;
438
- const next = () => {
439
- a |= 0; a = (a + 0x6d2b79f5) | 0;
440
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
441
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
442
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
443
- };
444
- return {
445
- next,
446
- range: (lo, hi) => lo + (hi - lo) * next(),
447
- int: (lo, hi) => Math.floor(lo + (hi - lo + 1) * next()),
448
- pick: (arr) => arr[Math.floor(next() * arr.length)],
449
- sign: () => (next() < 0.5 ? -1 : 1),
450
- };
451
- }
452
-
453
- const hash3 = (x, y, z) => {
454
- let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 2147483647);
455
- h = Math.imul(h ^ (h >>> 13), 1274126177);
456
- return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
457
- };
458
- /** Smooth 3D value noise in 0..1. noise(x), noise(x, y), noise(x, y, z). */
459
- export function noise(x, y = 0, z = 0) {
460
- const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
461
- const xf = x - xi, yf = y - yi, zf = z - zi;
462
- const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf);
463
- const n = (a, b, c) => hash3(xi + a, yi + b, zi + c);
464
- const x00 = lerp(n(0, 0, 0), n(1, 0, 0), u), x10 = lerp(n(0, 1, 0), n(1, 1, 0), u);
465
- const x01 = lerp(n(0, 0, 1), n(1, 0, 1), u), x11 = lerp(n(0, 1, 1), n(1, 1, 1), u);
466
- return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w);
467
- }
468
- /** Fractal noise (octaves of value noise), 0..1. */
469
- export function fbm(x, y = 0, z = 0, octaves = 4) {
470
- let s = 0, amp = 0.5, f = 1, norm = 0;
471
- for (let i = 0; i < octaves; i++) { s += amp * noise(x * f, y * f, z * f); norm += amp; amp *= 0.5; f *= 2; }
472
- return s / norm;
473
- }
474
329
 
475
- /* ------------------------------------------------------------------ */
476
- /* Color (OKLab mixing) */
477
- /* ------------------------------------------------------------------ */
478
-
479
- function hexToRgb(hex) {
480
- let h = hex.replace('#', '');
481
- if (h.length <= 4) h = [...h].map((c) => c + c).join('');
482
- const n = parseInt(h.slice(0, 6), 16);
483
- const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
484
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255, a];
485
- }
486
- const toLin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
487
- const toSrgb = (c) => Math.round(255 * clamp(c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055));
488
- function rgbToOklab([r, g, b]) {
489
- r = toLin(r); g = toLin(g); b = toLin(b);
490
- const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
491
- const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
492
- const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
493
- 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];
494
- }
495
- function oklabToRgb([L, A, B]) {
496
- const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
497
- const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
498
- const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
499
- 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)];
500
- }
501
- /** Perceptual color mix of two hex colors -> hex. */
502
- export function mixColor(a, b, p) {
503
- const A = hexToRgb(a), B = hexToRgb(b);
504
- const [r, g, bl] = oklabToRgb(mix(rgbToOklab(A), rgbToOklab(B), p));
505
- const al = lerp(A[3], B[3], p);
506
- const hx = (n) => n.toString(16).padStart(2, '0');
507
- return `#${hx(r)}${hx(g)}${hx(bl)}${al < 1 ? hx(Math.round(al * 255)) : ''}`;
508
- }
509
-
510
- /* ------------------------------------------------------------------ */
511
- /* Typography */
512
- /* ------------------------------------------------------------------ */
513
-
514
- /** Wrap each word or char of an element in spans for staggered motion. Returns the spans. */
515
- export function split(el, by = 'chars') {
516
- el = resolveEl(el);
517
- const text = el.textContent;
518
- el.textContent = '';
519
- el.setAttribute('aria-label', text);
520
- const out = [];
521
- const words = text.split(/(\s+)/);
522
- for (const w of words) {
523
- if (/^\s+$/.test(w)) { el.appendChild(document.createTextNode(w)); continue; }
524
- const ws = document.createElement('span');
525
- ws.style.display = 'inline-block';
526
- ws.style.whiteSpace = 'pre';
527
- if (by === 'words') { ws.textContent = w; out.push(ws); }
528
- 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); }
529
- el.appendChild(ws);
530
- }
531
- return out;
532
- }
533
-
534
- /* ------------------------------------------------------------------ */
535
- /* Shapes */
536
- /* ------------------------------------------------------------------ */
537
-
538
- /**
539
- * SVG path `d` for a hand-cut rounded rectangle: the "organic" shape language.
540
- * Deterministic for a given seed, so a shape keeps its edges across frames and renders.
541
- * @param {number} w,h size in px
542
- * @param {object} [o]
543
- * @param {number} [o.radius] corner radius in px (default 24)
544
- * @param {number} [o.wobble] edge irregularity in px (default 0.6% of the short side; keep it low)
545
- * @param {number} [o.seed] changes the irregularity, not its amount
546
- */
547
- export function organicPath(w, h, { radius = 24, wobble, seed = 1 } = {}) {
548
- const amp = wobble ?? Math.min(w, h) * 0.014;
549
- // draw on a box inset by the wobble, so the shape never leaves w×h (and never gets clipped flat)
550
- const W0 = w, H0 = h; w -= amp * 2; h -= amp * 2;
551
- const r = Math.min(radius, w / 2, h / 2);
552
- const per = 2 * (w + h - 4 * r) + 2 * Math.PI * r;
553
- const n = Math.max(16, Math.round(per / 40));
554
- const pt = (d) => {
555
- // walk the rounded-rect perimeter clockwise from the top-left corner's end
556
- 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']];
557
- for (const [len, k] of segs) {
558
- if (d > len) { d -= len; continue; }
559
- const a = r ? d / r : 0;
560
- switch (k) {
561
- case 'top': return [r + d, 0, 0, -1];
562
- case 'tr': return [w - r + r * Math.sin(a), r - r * Math.cos(a), Math.sin(a), -Math.cos(a)];
563
- case 'right': return [w, r + d, 1, 0];
564
- case 'br': return [w - r + r * Math.cos(a), h - r + r * Math.sin(a), Math.cos(a), Math.sin(a)];
565
- case 'bottom': return [w - r - d, h, 0, 1];
566
- case 'bl': return [r - r * Math.sin(a), h - r + r * Math.cos(a), -Math.sin(a), Math.cos(a)];
567
- case 'left': return [0, h - r - d, -1, 0];
568
- default: return [r - r * Math.cos(a), r - r * Math.sin(a), -Math.cos(a), -Math.sin(a)];
569
- }
570
- }
571
- return [r, 0, 0, -1];
572
- };
573
- const P = Array.from({ length: n }, (_, i) => {
574
- const [x, y, nx, ny] = pt((i / n) * per);
575
- const o = amp * (noise(i * 0.55, seed * 7.13) * 2 - 1);
576
- return [x + nx * o + amp, y + ny * o + amp];
577
- });
578
- void W0; void H0;
579
- // closed Catmull-Rom through the points, as cubic Béziers
580
- let d = `M${P[0][0].toFixed(2)},${P[0][1].toFixed(2)}`;
581
- for (let i = 0; i < n; i++) {
582
- const p0 = P[(i - 1 + n) % n], p1 = P[i], p2 = P[(i + 1) % n], p3 = P[(i + 2) % n];
583
- const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
584
- const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
585
- 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)}`;
586
- }
587
- return d + 'Z';
588
- }
330
+ export * from './core.js';
331
+ import { clamp, lerp, invlerp, progress, ease, keys, mix, mixColor, spring, rng, noise, split } from './core.js';
589
332
 
590
333
  /* ------------------------------------------------------------------ */
591
334
  /* Live shaders (web pages, not scenes) */