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.
- package/README.md +14 -0
- package/bin/pamo.mjs +133 -4
- 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/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/lib/customize.mjs +162 -0
- package/lib/live-board.js +128 -0
- package/lib/registry.mjs +344 -0
- package/lib/render.mjs +3 -3
- package/lib/server.mjs +1 -0
- package/lib/sfx.mjs +5 -3
- package/package.json +1 -1
- package/runtime/core.js +269 -0
- package/runtime/index.js +3 -260
- 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 +66 -146
- package/templates/move-demo/scene.html +4 -3
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ease, progress } from '../core.js';
|
|
2
|
+
|
|
3
|
+
/** Letters settle from wide spacing (`from`, em) to `to`. */
|
|
4
|
+
export function trackIn(el, t, { start = 0, duration = 1.0, from = 0.4, to = 0, curve = ease.outExpo } = {}) {
|
|
5
|
+
const q = curve(progress(t, start, start + duration));
|
|
6
|
+
el.style.letterSpacing = `${from + (to - from) * q}em`; el.style.opacity = Math.min(1, q * 2);
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { clamp, rng } from '../core.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When each character of `text` appears, typed by a person: quick bursts inside words, a beat after spaces,
|
|
5
|
+
* longer after punctuation, the odd hesitation. Deterministic per seed. Use the same times for the text
|
|
6
|
+
* (typeOn's `times`) and the sound (keySounds), so every letter lands on its keystroke.
|
|
7
|
+
* @returns {number[]} times in seconds, one per character
|
|
8
|
+
*/
|
|
9
|
+
export function typingTimes(text, { start = 0, cps = 11, seed = 1 } = {}) {
|
|
10
|
+
const r = rng(seed).next, base = 1 / cps, out = [];
|
|
11
|
+
let t = start;
|
|
12
|
+
for (let i = 0; i < text.length; i++) {
|
|
13
|
+
const prev = text[i - 1] ?? '', ch = text[i];
|
|
14
|
+
let k = 0.7 + 0.45 * r(); // inside a word: fast, uneven
|
|
15
|
+
if (prev === ' ') k = 1.35 + 0.5 * r(); // starting a new word
|
|
16
|
+
if (/[.,;:!?]/.test(prev)) k = 2.2 + 1.2 * r(); // after punctuation
|
|
17
|
+
if (/[\/\-_@]/.test(ch) || /[\/\-_@]/.test(prev)) k *= 1.25; // symbols need a reach
|
|
18
|
+
if (r() < 0.04) k *= 2.4; // a hesitation
|
|
19
|
+
t += i === 0 ? 0 : base * k;
|
|
20
|
+
out.push(t);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Schedules one keystroke sound per character at `times` (from typingTimes), plus an optional enter. */
|
|
26
|
+
export function keySounds(s, text, times, { bank, style, gain = 0.16, seed = 1, enter = null, pan = 0.04 } = {}) {
|
|
27
|
+
[...text].forEach((ch, i) => s.sfx(times[i], 'key', { kind: ch === ' ' ? 'space' : 'letter', gain, seed: seed * 1000 + i, pan: ((i % 5) - 2) * pan, ...(bank ? { bank } : {}), ...(style ? { style } : {}) }));
|
|
28
|
+
if (enter !== null) s.sfx(enter, 'key', { kind: 'enter', gain: gain * 1.1, seed: seed * 1000 + 999, ...(bank ? { bank } : {}), ...(style ? { style } : {}) });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Types `text` from `start` at `cps` characters a second, or at explicit `times` (typingTimes); `cursor` (an element) is solid while typing, blinks at `blink` Hz after. */
|
|
32
|
+
export function typeOn(el, text, t, { start = 0, cps = 35, times = null, cursor = null, blink = 2.2 } = {}) {
|
|
33
|
+
if (times) start = times[0] ?? start;
|
|
34
|
+
const n = times ? times.filter((x) => x <= t).length : clamp(Math.floor((t - start) * cps), 0, text.length);
|
|
35
|
+
el.textContent = text.slice(0, n);
|
|
36
|
+
if (cursor) {
|
|
37
|
+
const typing = t >= start && (n < text.length || (times && t - times[times.length - 1] < 0.25));
|
|
38
|
+
cursor.style.opacity = t < start ? 0 : typing || Math.floor((t - start) * blink * 2) % 2 === 0 ? 1 : 0;
|
|
39
|
+
}
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ease, clamp, lerp, progress } from '../core.js';
|
|
2
|
+
import { dims, items, show, sp, settle, blur, layer, hide, resetAll } from '../kit.js';
|
|
3
|
+
|
|
4
|
+
/** A's items leave, A folds into a glowing line, a glint runs along it, and B unfolds out of the line with overshoot as its items rise from it. */
|
|
5
|
+
export function unfold(a, b, p, { duration = 1.2, radius = 14 } = {}) {
|
|
6
|
+
const slot = a.parentElement, [w, h] = dims(a);
|
|
7
|
+
if (p <= 0 || p >= 1) { hide(slot, 'ufLine'); resetAll(a, b, ...items(a), ...items(b)); show(a, b, p); return; }
|
|
8
|
+
items(a).forEach((c, i) => { const l = progress(p, i * 0.03, i * 0.03 + 0.22); c.style.transform = `translateY(${-30 * ease.inCubic(l)}px)`; c.style.opacity = 1 - l; c.style.filter = blur(10 * l); });
|
|
9
|
+
const f = ease.inOutCubic(progress(p, 0.16, 0.42)), ins = (h / 2 - 1.5) * f;
|
|
10
|
+
a.style.clipPath = `inset(${ins}px 0 ${ins}px 0 round ${lerp(radius, 2, f)}px)`;
|
|
11
|
+
a.style.opacity = p < 0.44 ? 1 : 0;
|
|
12
|
+
const lp = progress(p, 0.36, 0.64), breathe = Math.sin(Math.PI * lp), g = ease.inOutCubic(lp) * 120 - 10;
|
|
13
|
+
layer(slot, 'ufLine', `display:${lp > 0 && lp < 1 ? 'block' : 'none'};left:${(w - w * (1 + 0.08 * breathe)) / 2}px;width:${w * (1 + 0.08 * breathe)}px;top:${h / 2 - 1.5}px;height:3px;z-index:4;border-radius:2px;`
|
|
14
|
+
+ `background:linear-gradient(90deg,rgba(255,255,255,.35) ${g - 14}%,#fff ${g}%,rgba(255,255,255,.35) ${g + 14}%);box-shadow:0 0 ${18 * breathe}px rgba(255,255,255,${0.6 * breathe})`);
|
|
15
|
+
const u = settle(sp(p, 0.5, duration, { stiffness: 230, damping: 16 }), p), bi = Math.max(0, (h / 2 - 1.5) * (1 - u));
|
|
16
|
+
b.style.clipPath = `inset(${bi}px 0 ${bi}px 0 round ${lerp(2, radius, clamp(u))}px)`;
|
|
17
|
+
b.style.transform = `scaleY(${1 + Math.max(0, u - 1) * 0.6})`;
|
|
18
|
+
b.style.opacity = p > 0.5 ? 1 : 0;
|
|
19
|
+
items(b).forEach((c, i) => { const q = ease.outExpo(progress(p, 0.6 + i * 0.04, 0.88 + i * 0.03)); c.style.transform = `translateY(${40 * (1 - q)}px)`; c.style.opacity = q; c.style.filter = blur(8 * (1 - q)); });
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ease, clamp, lerp, progress } from '../core.js';
|
|
2
|
+
import { dims, items, show, sp, settle, layer, hide, resetAll } from '../kit.js';
|
|
3
|
+
|
|
4
|
+
/** A winds up, then whips out tilted and smeared; a light streak crosses at full speed; B whips in, overshoots and settles, its content trailing. */
|
|
5
|
+
export function whipPan(a, b, p, { duration = 0.9, dir = 'left', tilt = 7, travel = 1.35 } = {}) {
|
|
6
|
+
const slot = a.parentElement, [w] = dims(a), s = dir === 'left' ? -1 : 1;
|
|
7
|
+
if (p <= 0 || p >= 1) { hide(slot, 'wpStreak'); resetAll(a, b, ...items(a), ...items(b)); show(a, b, p); return; }
|
|
8
|
+
const wind = ease.outCubic(progress(p, 0, 0.18)) * (1 - ease.inCubic(progress(p, 0.18, 0.3)));
|
|
9
|
+
const out = ease.inExpo(progress(p, 0.16, 0.5));
|
|
10
|
+
a.style.transformOrigin = '50% 70%';
|
|
11
|
+
a.style.transform = `translateX(${-s * 40 * wind + s * w * travel * out}px) rotate(${-s * 1.6 * wind + s * tilt * out}deg) skewX(${-s * 12 * out}deg) scale(${1 - 0.03 * wind})`;
|
|
12
|
+
a.style.opacity = p < 0.5 ? 1 : 0;
|
|
13
|
+
const e = settle(sp(p, 0.42, duration, { stiffness: 250, damping: 19 }), p), r = 1 - e;
|
|
14
|
+
b.style.transformOrigin = '50% 70%';
|
|
15
|
+
b.style.transform = `translateX(${-s * w * travel * r}px) rotate(${-s * tilt * r}deg) skewX(${s * 12 * clamp(r) * clamp(r)}deg)`;
|
|
16
|
+
b.style.opacity = p > 0.42 ? 1 : 0;
|
|
17
|
+
items(b).forEach((c, i) => { const ci = settle(sp(p, 0.47 + i * 0.03, duration, { stiffness: 230, damping: 17 }), p); c.style.transform = `translateX(${-s * 110 * (1 - ci)}px)`; });
|
|
18
|
+
const st = progress(p, 0.28, 0.64), so = Math.sin(Math.PI * st), sw = w * 0.45;
|
|
19
|
+
layer(slot, 'wpStreak', `display:${so > 0.01 ? 'block' : 'none'};left:${lerp(s < 0 ? w : -sw, s < 0 ? -sw : w, ease.inOutCubic(st))}px;top:-15%;width:${sw}px;height:130%;z-index:5;opacity:${0.7 * so};`
|
|
20
|
+
+ `background:linear-gradient(90deg,transparent,rgba(255,255,255,.85),transparent);transform:skewX(${-s * 18}deg);mix-blend-mode:screen;filter:blur(16px)`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ease, clamp } from '../core.js';
|
|
2
|
+
import { size, hblur } from '../kit.js';
|
|
3
|
+
|
|
4
|
+
/** B pushes A out very fast, with horizontal motion blur that follows the speed. `dir` is where A leaves to. */
|
|
5
|
+
export function whip(a, b, p, { dir = 'left', blur = 70, curve = ease.inOutQuint } = {}) {
|
|
6
|
+
const q = curve(clamp(p)), v = (curve(clamp(p + 0.01)) - curve(clamp(p - 0.01))) / 0.02;
|
|
7
|
+
const [w] = size(a, b), s = dir === 'left' ? -1 : 1;
|
|
8
|
+
a.style.transform = `translateX(${q * w * s}px)`;
|
|
9
|
+
b.style.transform = `translateX(${(q - 1) * w * s}px)`;
|
|
10
|
+
const id = (a.parentElement._pmWhipId ??= `pm-whip-${Math.random().toString(36).slice(2, 8)}`); // one blur filter per stage, so two whips on a page don't share it
|
|
11
|
+
const f = v > 0.15 ? hblur(id, (blur * v) / 5) : 'none';
|
|
12
|
+
a.style.filter = f; b.style.filter = f;
|
|
13
|
+
a.style.opacity = 1; b.style.opacity = 1;
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { E } from '../kit.js';
|
|
2
|
+
|
|
3
|
+
/** B is uncovered by a soft edge at `angle` degrees; A stays underneath. */
|
|
4
|
+
export function wipe(a, b, p, { angle = 100, soft = 30, curve } = {}) {
|
|
5
|
+
const w = E(p, curve) * (100 + soft) - soft;
|
|
6
|
+
const m = `linear-gradient(${angle}deg, #000 ${w}%, transparent ${w + soft}%)`;
|
|
7
|
+
b.style.opacity = 1; b.style.maskImage = m; b.style.webkitMaskImage = m;
|
|
8
|
+
if (a) a.style.opacity = p >= 1 ? 0 : 1;
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ease, progress } from '../core.js';
|
|
2
|
+
import { words } from '../kit.js';
|
|
3
|
+
|
|
4
|
+
/** Words rise one by one from behind a mask line. */
|
|
5
|
+
export function wordRise(el, t, { start = 0, each = 0.08, duration = 0.5, curve = ease.outExpo } = {}) {
|
|
6
|
+
words(el).forEach((w, i) => {
|
|
7
|
+
const q = curve(progress(t, start + i * each, start + i * each + duration));
|
|
8
|
+
w.style.transform = `translateY(${(1 - q) * 110}%)`;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ease, clamp } from '../core.js';
|
|
2
|
+
import { E } from '../kit.js';
|
|
3
|
+
|
|
4
|
+
/** The camera dives through A (scaling past the lens) and lands on B. */
|
|
5
|
+
/** `origin` is where the camera dives in (put it on the subject, e.g. '30% 50%'). B lands from slightly larger, so no edge shows. */
|
|
6
|
+
export function zoomThrough(a, b, p, { scale = 6, origin = '50% 50%', curve = ease.inOutCubic } = {}) {
|
|
7
|
+
const q = E(p, curve);
|
|
8
|
+
a.style.transformOrigin = origin;
|
|
9
|
+
a.style.transform = `scale(${1 + (scale - 1) * Math.pow(q, 2)})`;
|
|
10
|
+
a.style.opacity = 1 - clamp((q - 0.55) / 0.3);
|
|
11
|
+
b.style.transform = `scale(${1.12 - 0.12 * q})`;
|
|
12
|
+
b.style.opacity = clamp((q - 0.45) / 0.35);
|
|
13
|
+
}
|
package/runtime/moves.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// that sets styles on elements, so it can be called from s.update() and stays frame-exact.
|
|
3
3
|
// import { wipe, typeOn, PACE } from 'paper-motion/moves';
|
|
4
4
|
// Transitions take progress p (0 → 1); text and camera moves take scene time t.
|
|
5
|
-
|
|
5
|
+
// Each move lives in its own file under moves/ (the registry copies them into projects with `pamo add`);
|
|
6
|
+
// shared helpers are in kit.js (paper-motion/kit).
|
|
6
7
|
|
|
7
8
|
/** Timing per pace: transition length, hold between beats, stagger between items (seconds). */
|
|
8
9
|
export const PACE = {
|
|
@@ -25,150 +26,69 @@ export const MOVES = {
|
|
|
25
26
|
blurIn: 'text comes into focus from a blur',
|
|
26
27
|
trackIn: 'letters settle from wide spacing to their tracking',
|
|
27
28
|
drift: 'a slow, constant push-in that keeps a still frame alive',
|
|
29
|
+
scramble: 'letters cycle through random glyphs and lock into place, left to right',
|
|
30
|
+
popIn: 'letters pop up one by one on a spring, with a little overshoot',
|
|
31
|
+
flipIn: 'letters flip up into place in 3D, one after another',
|
|
32
|
+
roll: 'the last word rolls like a slot machine through a list of words',
|
|
33
|
+
odometer: 'a number counts up with rolling digit wheels, like an odometer',
|
|
34
|
+
barReveal: 'a solid bar sweeps across and leaves the line behind it',
|
|
35
|
+
whip: 'a fast push with horizontal motion blur',
|
|
36
|
+
cells: 'B appears through a grid of squares that grow in a diagonal wave',
|
|
37
|
+
slices: 'B comes in as horizontal strips from alternating sides',
|
|
38
|
+
iris: 'B opens from a circle that grows out of a point',
|
|
39
|
+
glitch: 'a few frames of torn bands and RGB split, then B',
|
|
40
|
+
flip3D: 'the frame flips over like a card and shows B on the back',
|
|
41
|
+
shake: 'a camera shake on each hit that dies out fast',
|
|
42
|
+
punch: 'the frame punches in on each hit and springs back',
|
|
43
|
+
orbit: 'a 3D camera moves around layers set at different depths',
|
|
44
|
+
rgbSplit: 'red and cyan fringes burst out on each hit and fade',
|
|
45
|
+
flash: 'an overexposed white flash hides the cut from A to B',
|
|
46
|
+
drawOn: 'lines and shapes draw themselves stroke by stroke',
|
|
47
|
+
magicMove: 'each part of A flies on its own spring into the matching part of B; text cross-blurs; the box reshapes',
|
|
48
|
+
whipPan: 'A winds up, whips out tilted and smeared under a light streak; B whips in, overshoots, its content trails',
|
|
49
|
+
diveIn: 'the camera dives into a detail of A; B opens inside it, a ring breaks outward, B\'s parts rise into focus',
|
|
50
|
+
flipLift: 'A lifts toward the camera, flips with a wobble and a sheen, lands as B with an echo; B\'s parts pop in',
|
|
51
|
+
squashDrop: 'A squashes, launches up stretched; B falls in, squashes on impact, wobbles; its content follows through',
|
|
52
|
+
snapScroll: 'a vertical reel of cards scrolls past in 3D and snaps onto B; neighbours dim and tilt away',
|
|
53
|
+
unfold: 'A\'s items leave, A folds into a glowing line, B unfolds out of the line with overshoot, items rise from it',
|
|
54
|
+
tileSwirl: 'A breaks into tiles that lift and swirl in a wave, flipping over in flight to land as B',
|
|
28
55
|
};
|
|
29
56
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
export
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** B draws in from the top under a scan line element (`line`, absolutely positioned inside B's parent). */
|
|
70
|
-
export function scanReveal(a, b, p, { line, curve = ease.linear } = {}) {
|
|
71
|
-
const q = E(p, curve);
|
|
72
|
-
b.style.clipPath = `inset(0 0 ${(1 - q) * 100}% 0)`; b.style.opacity = 1;
|
|
73
|
-
if (line) { line.style.top = `${b.offsetTop + q * b.offsetHeight}px`; line.style.opacity = q > 0 && q < 1 ? 1 : 0; }
|
|
74
|
-
if (a) a.style.opacity = p >= 1 ? 0 : 1;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The camera dives through A (scaling past the lens) and lands on B. */
|
|
78
|
-
/** `origin` is where the camera dives in (put it on the subject, e.g. '30% 50%'). B lands from slightly larger, so no edge shows. */
|
|
79
|
-
export function zoomThrough(a, b, p, { scale = 6, origin = '50% 50%', curve = ease.inOutCubic } = {}) {
|
|
80
|
-
const q = E(p, curve);
|
|
81
|
-
a.style.transformOrigin = origin;
|
|
82
|
-
a.style.transform = `scale(${1 + (scale - 1) * Math.pow(q, 2)})`;
|
|
83
|
-
a.style.opacity = 1 - clamp((q - 0.55) / 0.3);
|
|
84
|
-
b.style.transform = `scale(${1.12 - 0.12 * q})`;
|
|
85
|
-
b.style.opacity = clamp((q - 0.45) / 0.35);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ── text: (el, t, options) ──────────────────────────────────────
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* When each character of `text` appears, typed by a person: quick bursts inside words, a beat after spaces,
|
|
92
|
-
* longer after punctuation, the odd hesitation. Deterministic per seed. Use the same times for the text
|
|
93
|
-
* (typeOn's `times`) and the sound (keySounds), so every letter lands on its keystroke.
|
|
94
|
-
* @returns {number[]} times in seconds, one per character
|
|
95
|
-
*/
|
|
96
|
-
export function typingTimes(text, { start = 0, cps = 11, seed = 1 } = {}) {
|
|
97
|
-
const r = rng(seed).next, base = 1 / cps, out = [];
|
|
98
|
-
let t = start;
|
|
99
|
-
for (let i = 0; i < text.length; i++) {
|
|
100
|
-
const prev = text[i - 1] ?? '', ch = text[i];
|
|
101
|
-
let k = 0.7 + 0.45 * r(); // inside a word: fast, uneven
|
|
102
|
-
if (prev === ' ') k = 1.35 + 0.5 * r(); // starting a new word
|
|
103
|
-
if (/[.,;:!?]/.test(prev)) k = 2.2 + 1.2 * r(); // after punctuation
|
|
104
|
-
if (/[\/\-_@]/.test(ch) || /[\/\-_@]/.test(prev)) k *= 1.25; // symbols need a reach
|
|
105
|
-
if (r() < 0.04) k *= 2.4; // a hesitation
|
|
106
|
-
t += i === 0 ? 0 : base * k;
|
|
107
|
-
out.push(t);
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Schedules one keystroke sound per character at `times` (from typingTimes), plus an optional enter. */
|
|
113
|
-
export function keySounds(s, text, times, { bank, style, gain = 0.16, seed = 1, enter = null, pan = 0.04 } = {}) {
|
|
114
|
-
[...text].forEach((ch, i) => s.sfx(times[i], 'key', { kind: ch === ' ' ? 'space' : 'letter', gain, seed: seed * 1000 + i, pan: ((i % 5) - 2) * pan, ...(bank ? { bank } : {}), ...(style ? { style } : {}) }));
|
|
115
|
-
if (enter !== null) s.sfx(enter, 'key', { kind: 'enter', gain: gain * 1.1, seed: seed * 1000 + 999, ...(bank ? { bank } : {}), ...(style ? { style } : {}) });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Types `text` from `start` at `cps` characters a second, or at explicit `times` (typingTimes); `cursor` (an element) is solid while typing, blinks at `blink` Hz after. */
|
|
119
|
-
export function typeOn(el, text, t, { start = 0, cps = 35, times = null, cursor = null, blink = 2.2 } = {}) {
|
|
120
|
-
if (times) start = times[0] ?? start;
|
|
121
|
-
const n = times ? times.filter((x) => x <= t).length : clamp(Math.floor((t - start) * cps), 0, text.length);
|
|
122
|
-
el.textContent = text.slice(0, n);
|
|
123
|
-
if (cursor) {
|
|
124
|
-
const typing = t >= start && (n < text.length || (times && t - times[times.length - 1] < 0.25));
|
|
125
|
-
cursor.style.opacity = t < start ? 0 : typing || Math.floor((t - start) * blink * 2) % 2 === 0 ? 1 : 0;
|
|
126
|
-
}
|
|
127
|
-
return n;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const words = (el) => {
|
|
131
|
-
if (!el._pmWords) {
|
|
132
|
-
// keep <br> line breaks: split each text run into words, leave the breaks where they were
|
|
133
|
-
const parts = [...el.childNodes].flatMap((n) => (n.nodeName === 'BR' ? ['\n'] : (n.textContent ?? '').split(/(\s+)/)));
|
|
134
|
-
el.textContent = '';
|
|
135
|
-
el._pmWords = parts.map((w) => {
|
|
136
|
-
if (w === '\n') { el.append(document.createElement('br')); return null; }
|
|
137
|
-
if (!w) return null;
|
|
138
|
-
if (/^\s+$/.test(w)) { el.append(w); return null; }
|
|
139
|
-
const outer = Object.assign(document.createElement('span'), { style: 'display:inline-block;overflow:hidden;vertical-align:bottom;padding-bottom:0.08em;margin-bottom:-0.08em' });
|
|
140
|
-
const inner = Object.assign(document.createElement('span'), { textContent: w, style: 'display:inline-block' });
|
|
141
|
-
outer.append(inner); el.append(outer);
|
|
142
|
-
return inner;
|
|
143
|
-
}).filter(Boolean);
|
|
144
|
-
}
|
|
145
|
-
return el._pmWords;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
/** Words rise one by one from behind a mask line. */
|
|
149
|
-
export function wordRise(el, t, { start = 0, each = 0.08, duration = 0.5, curve = ease.outExpo } = {}) {
|
|
150
|
-
words(el).forEach((w, i) => {
|
|
151
|
-
const q = curve(progress(t, start + i * each, start + i * each + duration));
|
|
152
|
-
w.style.transform = `translateY(${(1 - q) * 110}%)`;
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Text comes into focus from a blur. */
|
|
157
|
-
export function blurIn(el, t, { start = 0, duration = 0.8, blur = 14, curve = ease.outCubic } = {}) {
|
|
158
|
-
const q = curve(progress(t, start, start + duration));
|
|
159
|
-
el.style.filter = `blur(${(1 - q) * blur}px)`; el.style.opacity = q;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Letters settle from wide spacing (`from`, em) to `to`. */
|
|
163
|
-
export function trackIn(el, t, { start = 0, duration = 1.0, from = 0.4, to = 0, curve = ease.outExpo } = {}) {
|
|
164
|
-
const q = curve(progress(t, start, start + duration));
|
|
165
|
-
el.style.letterSpacing = `${from + (to - from) * q}em`; el.style.opacity = Math.min(1, q * 2);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── camera ──────────────────────────────────────────────────────
|
|
169
|
-
|
|
170
|
-
/** A slow constant push-in over `duration` seconds (default: the whole scene). */
|
|
171
|
-
export function drift(el, t, { amount = 0.04, duration = 10, origin = '50% 50%' } = {}) {
|
|
172
|
-
el.style.transformOrigin = origin;
|
|
173
|
-
el.style.transform = `scale(${1 + amount * clamp(t / duration)})`;
|
|
174
|
-
}
|
|
57
|
+
export { cut } from './moves/cut.js';
|
|
58
|
+
export { crossfade } from './moves/crossfade.js';
|
|
59
|
+
export { wipe } from './moves/wipe.js';
|
|
60
|
+
export { push } from './moves/push.js';
|
|
61
|
+
export { maskReveal } from './moves/mask-reveal.js';
|
|
62
|
+
export { scanReveal } from './moves/scan-reveal.js';
|
|
63
|
+
export { zoomThrough } from './moves/zoom-through.js';
|
|
64
|
+
export { typingTimes, keySounds, typeOn } from './moves/type-on.js';
|
|
65
|
+
export { wordRise } from './moves/word-rise.js';
|
|
66
|
+
export { blurIn } from './moves/blur-in.js';
|
|
67
|
+
export { trackIn } from './moves/track-in.js';
|
|
68
|
+
export { drift } from './moves/drift.js';
|
|
69
|
+
export { scramble } from './moves/scramble.js';
|
|
70
|
+
export { popIn } from './moves/pop-in.js';
|
|
71
|
+
export { flipIn } from './moves/flip-in.js';
|
|
72
|
+
export { roll } from './moves/roll.js';
|
|
73
|
+
export { odometer } from './moves/odometer.js';
|
|
74
|
+
export { barReveal } from './moves/bar-reveal.js';
|
|
75
|
+
export { whip } from './moves/whip.js';
|
|
76
|
+
export { cells } from './moves/cells.js';
|
|
77
|
+
export { slices } from './moves/slices.js';
|
|
78
|
+
export { iris } from './moves/iris.js';
|
|
79
|
+
export { glitch } from './moves/glitch.js';
|
|
80
|
+
export { flip3D } from './moves/flip-3d.js';
|
|
81
|
+
export { flash } from './moves/flash.js';
|
|
82
|
+
export { shake } from './moves/shake.js';
|
|
83
|
+
export { punch } from './moves/punch.js';
|
|
84
|
+
export { orbit } from './moves/orbit.js';
|
|
85
|
+
export { rgbSplit } from './moves/rgb-split.js';
|
|
86
|
+
export { drawOn } from './moves/draw-on.js';
|
|
87
|
+
export { magicMove } from './moves/magic-move.js';
|
|
88
|
+
export { whipPan } from './moves/whip-pan.js';
|
|
89
|
+
export { diveIn } from './moves/dive-in.js';
|
|
90
|
+
export { flipLift } from './moves/flip-lift.js';
|
|
91
|
+
export { squashDrop } from './moves/squash-drop.js';
|
|
92
|
+
export { snapScroll } from './moves/snap-scroll.js';
|
|
93
|
+
export { unfold } from './moves/unfold.js';
|
|
94
|
+
export { tileSwirl } from './moves/tile-swirl.js';
|
|
@@ -23,7 +23,7 @@ import { scene, ease, clamp } from 'paper-motion';
|
|
|
23
23
|
import * as M from 'paper-motion/moves';
|
|
24
24
|
|
|
25
25
|
// The four choices this demo shows. `pamo new <name> --move … --pace … --text … --camera …` sets them.
|
|
26
|
-
const MOVE = 'crossfade'; // transition between frames: cut crossfade wipe push maskReveal scanReveal zoomThrough
|
|
26
|
+
const MOVE = 'crossfade'; // transition between frames: cut crossfade wipe push maskReveal scanReveal zoomThrough whip cells slices iris glitch flip3D flash
|
|
27
27
|
const PACE = 'medium'; // calm | medium | fast
|
|
28
28
|
const TEXT = 'plain'; // how the line appears: typing | rise | blur | track | plain
|
|
29
29
|
const CAMERA = 'still'; // still | drift | travel (travel moves the camera between frames and replaces MOVE)
|
|
@@ -105,9 +105,10 @@ s.update((t) => {
|
|
|
105
105
|
if (k < 0) beats[0].el.style.opacity = 1;
|
|
106
106
|
else {
|
|
107
107
|
const a = beats[k].el, b = beats[k + 1].el;
|
|
108
|
-
({ cut: () => M.cut(a, b, p >= 0.5 ? 1 : 0), crossfade: () => M.crossfade(a, b, p), wipe: () => M.wipe(a, b, p),
|
|
108
|
+
(({ cut: () => M.cut(a, b, p >= 0.5 ? 1 : 0), crossfade: () => M.crossfade(a, b, p), wipe: () => M.wipe(a, b, p),
|
|
109
109
|
push: () => M.push(a, b, p), maskReveal: () => M.maskReveal(a, b, p), scanReveal: () => M.scanReveal(a, b, p),
|
|
110
|
-
zoomThrough: () => M.zoomThrough(a, b, p, { origin: beats[k].kind === 'name' ? '30% 50%' : '50% 50%' }) })[MOVE]
|
|
110
|
+
zoomThrough: () => M.zoomThrough(a, b, p, { origin: beats[k].kind === 'name' ? '30% 50%' : '50% 50%' }) })[MOVE]
|
|
111
|
+
?? (() => M[MOVE]?.(a, b, p)))(); // any other transition in the library: whip, cells, slices, iris, glitch, flip3D, flash
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
// the line appears as its frame lands
|