reelkit-cli 0.6.0 → 0.8.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 +2 -2
- package/package.json +5 -1
- package/skill/SKILL.md +20 -9
- package/skill/THIRD_PARTY.md +102 -0
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +25 -19
- package/skill/reference/captions.md +11 -5
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +139 -11
- package/skill/reference/launch-film.md +190 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/three-d.md +134 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +18 -7
- package/src/commands/assets.ts +310 -30
- package/src/commands/build.ts +148 -35
- package/src/commands/init.ts +1 -1
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +4 -2
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +44 -10
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/layers.ts +60 -0
- package/src/project/manifest.ts +59 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +4 -1
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/remotion/kit/Assemble3D.tsx +92 -0
- package/src/remotion/kit/BrowserFrame.tsx +83 -0
- package/src/remotion/kit/Camera.tsx +6 -4
- package/src/remotion/kit/Captions.tsx +33 -17
- package/src/remotion/kit/Card3D.tsx +211 -0
- package/src/remotion/kit/ChapterFrame.tsx +68 -0
- package/src/remotion/kit/CounterRoll.tsx +75 -0
- package/src/remotion/kit/GlassPanel.tsx +43 -0
- package/src/remotion/kit/Grounds.tsx +177 -0
- package/src/remotion/kit/Headline.tsx +97 -0
- package/src/remotion/kit/Hero3D.tsx +197 -0
- package/src/remotion/kit/HudOverlay.tsx +52 -0
- package/src/remotion/kit/ImageLayers.tsx +48 -0
- package/src/remotion/kit/Music.tsx +4 -4
- package/src/remotion/kit/NamedCursor.tsx +54 -0
- package/src/remotion/kit/Orbit3D.tsx +49 -0
- package/src/remotion/kit/Particles3D.tsx +74 -0
- package/src/remotion/kit/Place.tsx +12 -0
- package/src/remotion/kit/PromptBox.tsx +84 -0
- package/src/remotion/kit/Scene3D.tsx +70 -0
- package/src/remotion/kit/SceneFrame.tsx +88 -11
- package/src/remotion/kit/SoundCues.tsx +22 -0
- package/src/remotion/kit/TerminalLog.tsx +98 -0
- package/src/remotion/kit/Text3D.tsx +78 -0
- package/src/remotion/kit/TextOnImage.tsx +41 -0
- package/src/remotion/kit/Warp3D.tsx +59 -0
- package/src/remotion/kit/bg-math.ts +179 -0
- package/src/remotion/kit/caption-groups.ts +7 -3
- package/src/remotion/kit/caption-style.ts +45 -0
- package/src/remotion/kit/docs.ts +132 -11
- package/src/remotion/kit/image-layers-math.ts +115 -0
- package/src/remotion/kit/index.ts +43 -1
- package/src/remotion/kit/inter-bold-typeface.ts +3 -0
- package/src/remotion/kit/motion-math.ts +36 -2
- package/src/remotion/kit/music-math.ts +27 -10
- package/src/remotion/kit/quiet-three.ts +11 -0
- package/src/remotion/kit/sample-text.ts +55 -0
- package/src/remotion/kit/scene3d-context.ts +5 -0
- package/src/remotion/kit/seeded.ts +13 -0
- package/src/remotion/kit/sound-cues.ts +89 -0
- package/src/remotion/kit/sound-kinds.ts +122 -0
- package/src/remotion/kit/theme.ts +2 -0
- package/src/remotion/kit/three-fx-math.ts +192 -0
- package/src/remotion/kit/three-math.ts +145 -0
- package/src/remotion/kit/transition-math.ts +116 -0
- package/src/remotion/kit/ui-math.ts +145 -0
- package/src/remotion/kit/ui-theme.ts +25 -0
- package/src/remotion/kit/word-anchor.ts +107 -0
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +12 -0
- package/src/testing/fake-api.ts +4 -4
- package/src/testing/fixtures.ts +3 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The arithmetic behind Assemble3D, Particles3D, Hero3D/Screen3D, Warp3D and Scene3D's moods, with no three.js in it. Everything is a pure function of the frame and
|
|
2
|
+
// of a seed (mulberry32 through hash01, never Math.random), so any frame can be drawn on its own and the same frame is always the same.
|
|
3
|
+
import { hash01 } from "./seeded";
|
|
4
|
+
import { locate, sortKeys, mix } from "./motion-math";
|
|
5
|
+
import type { Vec3 } from "./three-math";
|
|
6
|
+
|
|
7
|
+
const clamp01 = (n: number) => (n > 0 ? (n < 1 ? n : 1) : 0);
|
|
8
|
+
export const easeOutCubic = (t: number) => 1 - (1 - clamp01(t)) ** 3;
|
|
9
|
+
const smooth = (t: number) => { const x = clamp01(t); return x * x * (3 - 2 * x); };
|
|
10
|
+
|
|
11
|
+
// ---------- Assemble3D ----------
|
|
12
|
+
export type AssembleShape = "grid" | "ring" | "sphere" | "wall" | "text";
|
|
13
|
+
export type AssembleOrder = "y" | "x" | "radial" | "random";
|
|
14
|
+
export const MAX_PIECES = 4000;
|
|
15
|
+
|
|
16
|
+
// The slots of a shape, `count` of them, centred on the origin and scaled so that the widest side is 1 (the height of a ring or a sphere may be anything within it).
|
|
17
|
+
// "text" is made by the typeface sampler (sample-text.ts), not here; its slots arrive as `targets`.
|
|
18
|
+
export function shapeSlots(shape: Exclude<AssembleShape, "text">, count: number): Vec3[] {
|
|
19
|
+
const n = Math.max(1, Math.min(MAX_PIECES, Math.round(count)));
|
|
20
|
+
const out: Vec3[] = [];
|
|
21
|
+
if (shape === "sphere") {
|
|
22
|
+
// A Fibonacci lattice: evenly spread over the surface.
|
|
23
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
24
|
+
for (let i = 0; i < n; i++) { const y = n === 1 ? 0 : 1 - (i / (n - 1)) * 2, r = Math.sqrt(Math.max(0, 1 - y * y)), a = i * golden; out.push([Math.cos(a) * r * 0.5, y * 0.5, Math.sin(a) * r * 0.5]); }
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
if (shape === "ring") {
|
|
28
|
+
// A band: rows of pieces round a circle, the rows stacked, so it reads as a ring seen from the front too.
|
|
29
|
+
const rows = Math.max(1, Math.round(Math.sqrt(n / 6))), per = Math.ceil(n / rows);
|
|
30
|
+
for (let i = 0; i < n; i++) { const r = Math.floor(i / per), k = i % per, a = ((k + (r % 2) * 0.5) / per) * Math.PI * 2; out.push([Math.cos(a) * 0.5, (r - (rows - 1) / 2) * (0.5 / Math.max(rows, 4)) * 1.1, Math.sin(a) * 0.5]); }
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
if (shape === "wall") {
|
|
34
|
+
// A brick wall one piece deep, every other row shifted half a piece.
|
|
35
|
+
const cols = Math.max(1, Math.round(Math.sqrt(n * 1.6))), rows = Math.ceil(n / cols);
|
|
36
|
+
for (let i = 0; i < n; i++) { const r = Math.floor(i / cols), c = i % cols; out.push([(c + (r % 2) * 0.5) / cols - 0.5 + 0.5 / cols, 0.5 * (rows / cols) * 0.0 + ((rows - 1) / 2 - r) / cols, 0]); }
|
|
37
|
+
return centred(out);
|
|
38
|
+
}
|
|
39
|
+
// grid: a slab, close to a cube in proportion, a few pieces deep.
|
|
40
|
+
const d = Math.max(1, Math.round(Math.cbrt(n / 4))), cols = Math.max(1, Math.round(Math.sqrt((n / d) * 1.4))), rows = Math.ceil(n / d / cols);
|
|
41
|
+
for (let i = 0; i < n; i++) { const z = Math.floor(i / (cols * rows)), r = Math.floor(i / cols) % rows, c = i % cols; out.push([c / cols, (rows - 1 - r) / cols, z / cols]); }
|
|
42
|
+
return centred(out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Moves a set of slots so its middle is the origin and scales it so its widest side is 1.
|
|
46
|
+
export function centred(points: Vec3[]): Vec3[] {
|
|
47
|
+
if (!points.length) return points;
|
|
48
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
49
|
+
for (const p of points) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k]!, p[k]!); hi[k] = Math.max(hi[k]!, p[k]!); }
|
|
50
|
+
const size = Math.max(hi[0]! - lo[0]!, hi[1]! - lo[1]!, hi[2]! - lo[2]!, 1e-9);
|
|
51
|
+
return points.map((p) => [(p[0] - (lo[0]! + hi[0]!) / 2) / size, (p[1] - (lo[1]! + hi[1]!) / 2) / size, (p[2] - (lo[2]! + hi[2]!) / 2) / size] as Vec3);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Each piece's place in the order, 0 to 1: bottom to top ("y"), left to right ("x"), from the middle out ("radial") or shuffled ("random").
|
|
55
|
+
export function assembleRanks(targets: readonly Vec3[], order: AssembleOrder = "y", seed = 0): number[] {
|
|
56
|
+
const key = targets.map((p, i) => (order === "y" ? p[1] : order === "x" ? p[0] : order === "radial" ? Math.hypot(p[0], p[1], p[2]) : hash01(i, seed + 11)));
|
|
57
|
+
const idx = key.map((_, i) => i).sort((a, b) => key[a]! - key[b]! || a - b);
|
|
58
|
+
const ranks = new Array<number>(targets.length).fill(0);
|
|
59
|
+
idx.forEach((piece, place) => { ranks[piece] = targets.length > 1 ? place / (targets.length - 1) : 0; });
|
|
60
|
+
return ranks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type AssembleTiming = { ranks: readonly number[]; from?: number; frames?: number };
|
|
64
|
+
// How far piece `i` has come at `frame`, 0 (still in the cloud) to 1 (in its slot): the pieces start one after another in the order of their rank over the first
|
|
65
|
+
// 60 percent of `frames` and each takes the other 40 percent; every piece is home at from + frames and none moves before its own start.
|
|
66
|
+
export function assembleProgress(frame: number, i: number, o: AssembleTiming): number {
|
|
67
|
+
const from = o.from ?? 0, frames = Math.max(2, o.frames ?? 60), each = frames * 0.4, spread = frames - each;
|
|
68
|
+
const start = from + (o.ranks[i] ?? 0) * spread;
|
|
69
|
+
return clamp01((frame - start) / each);
|
|
70
|
+
}
|
|
71
|
+
// How many pieces are home at `frame`: for a piece counter.
|
|
72
|
+
export function assembledCount(frame: number, o: AssembleTiming): number {
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (let i = 0; i < o.ranks.length; i++) if (assembleProgress(frame, i, o) >= 1) n++;
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
// The frame the last piece lands: put the lock sound here (cuesFor "assemble": [from, this]).
|
|
78
|
+
export const assembleEnd = (o: { from?: number; frames?: number }): number => (o.from ?? 0) + Math.max(2, o.frames ?? 60);
|
|
79
|
+
|
|
80
|
+
export type PieceState = { position: Vec3; rotation: Vec3; scale: number };
|
|
81
|
+
// Where piece `i` is: a seeded start in a cloud (`spread` times the arrangement's size, around `from`), a flight to its slot on an arc with a lift and a turn
|
|
82
|
+
// that unwinds, then rest. `slot` is the slot in the arrangement's own units.
|
|
83
|
+
export function pieceAt(frame: number, i: number, slot: Vec3, o: AssembleTiming & { seed?: number; spread?: number }): PieceState {
|
|
84
|
+
const p = assembleProgress(frame, i, o), seed = o.seed ?? 0, spread = o.spread ?? 3;
|
|
85
|
+
const a = hash01(i, seed + 1) * Math.PI * 2, b = Math.acos(2 * hash01(i, seed + 2) - 1), r = spread * (0.55 + 0.45 * hash01(i, seed + 3));
|
|
86
|
+
const start: Vec3 = [Math.sin(b) * Math.cos(a) * r, Math.cos(b) * r * 0.8 + 0.4, Math.sin(b) * Math.sin(a) * r - 0.5];
|
|
87
|
+
const e = easeOutCubic(p), lift = Math.sin(Math.PI * p) * (0.25 + 0.35 * hash01(i, seed + 4));
|
|
88
|
+
const turn = (1 - e) * Math.PI * 2 * (0.5 + hash01(i, seed + 5));
|
|
89
|
+
return {
|
|
90
|
+
position: [mix(start[0], slot[0], e), mix(start[1], slot[1], e) + lift, mix(start[2], slot[2], e)],
|
|
91
|
+
rotation: [turn * (hash01(i, seed + 6) - 0.5) * 2, turn * (hash01(i, seed + 7) - 0.5) * 2, turn * (hash01(i, seed + 8) - 0.5)],
|
|
92
|
+
scale: 0.35 + 0.65 * smooth(p * 1.6),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- Particles3D ----------
|
|
97
|
+
export type ParticleShape = "sphere" | "torus" | "knot" | "galaxy" | "plane" | "text";
|
|
98
|
+
export const MAX_POINTS = 60_000;
|
|
99
|
+
// `count` points on a shape, as a flat [x, y, z, x, y, z, ...] array, centred, the widest side 1. Seeded: the same shape and seed give the same points.
|
|
100
|
+
export function particleShape(shape: Exclude<ParticleShape, "text">, count: number, seed = 0): Float32Array {
|
|
101
|
+
const n = Math.max(1, Math.min(MAX_POINTS, Math.round(count))), out = new Float32Array(n * 3);
|
|
102
|
+
const put = (i: number, x: number, y: number, z: number) => { out[i * 3] = x; out[i * 3 + 1] = y; out[i * 3 + 2] = z; };
|
|
103
|
+
for (let i = 0; i < n; i++) {
|
|
104
|
+
const u = hash01(i, seed + 21), v = hash01(i, seed + 22), w = hash01(i, seed + 23);
|
|
105
|
+
if (shape === "sphere") { const a = u * Math.PI * 2, c = 2 * v - 1, s = Math.sqrt(1 - c * c); put(i, Math.cos(a) * s * 0.5, c * 0.5, Math.sin(a) * s * 0.5); }
|
|
106
|
+
else if (shape === "torus") { const a = u * Math.PI * 2, b = v * Math.PI * 2, R = 0.32, r = 0.15 + 0.03 * w; put(i, (R + r * Math.cos(b)) * Math.cos(a), r * Math.sin(b), (R + r * Math.cos(b)) * Math.sin(a)); }
|
|
107
|
+
else if (shape === "knot") {
|
|
108
|
+
// A (2,3) torus knot curve with the points spread in a thin tube round it.
|
|
109
|
+
const t = u * Math.PI * 2, q = 3, p = 2, rad = 0.3 + 0.12 * Math.cos(q * t), tube = 0.035 * Math.sqrt(w), ang = v * Math.PI * 2;
|
|
110
|
+
put(i, rad * Math.cos(p * t) + tube * Math.cos(ang), 0.14 * Math.sin(q * t) + tube * Math.sin(ang), rad * Math.sin(p * t) + tube * Math.cos(ang * 2));
|
|
111
|
+
} else if (shape === "galaxy") {
|
|
112
|
+
// Three arms winding out from a bright core, thin in height.
|
|
113
|
+
const arm = Math.floor(w * 3), r = Math.pow(u, 0.7) * 0.5, a = arm * ((Math.PI * 2) / 3) + r * 7 + (v - 0.5) * 0.7 * (1 - r), h = (hash01(i, seed + 24) - 0.5) * 0.06 * (1 - r * 1.2);
|
|
114
|
+
put(i, Math.cos(a) * r, h, Math.sin(a) * r);
|
|
115
|
+
} else put(i, u - 0.5, (hash01(i, seed + 25) - 0.5) * 0.03, v - 0.5);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The point set at `morph` between two sets of the same length, with a little seeded drift that breathes with the frame and is zero at the ends of the morph.
|
|
121
|
+
export function morphPoints(a: Float32Array, b: Float32Array, morph: number, frame: number, fps: number, o: { drift?: number; seed?: number } = {}): Float32Array {
|
|
122
|
+
const n = Math.min(a.length, b.length), out = new Float32Array(n), t = smooth(morph), d = (o.drift ?? 0.012) * (1 + 3 * Math.sin(Math.PI * t)), s = (frame / fps) * 0.8;
|
|
123
|
+
for (let i = 0; i < n; i += 3) {
|
|
124
|
+
const k = i / 3, ph = hash01(k, (o.seed ?? 0) + 31) * 6.283;
|
|
125
|
+
// Points leave their start in a staggered way so the morph is a flow and not a slide.
|
|
126
|
+
const lt = smooth(clamp01((morph - 0.25 * hash01(k, (o.seed ?? 0) + 32)) / 0.75));
|
|
127
|
+
for (let c = 0; c < 3; c++) out[i + c] = a[i + c]! + (b[i + c]! - a[i + c]!) * lt + Math.sin(s + ph + c * 2.1) * d;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
// The morph between `range[0]` and `range[1]` frames (20 to 80 unless set): 0 before, 1 after.
|
|
132
|
+
export const morphAt = (frame: number, range: [number, number] = [20, 80]): number => clamp01((frame - range[0]) / Math.max(1, range[1] - range[0]));
|
|
133
|
+
|
|
134
|
+
// ---------- Hero3D / Screen3D ----------
|
|
135
|
+
export type PoseKey = { frame: number; rotation?: Vec3; position?: Vec3; scale?: number };
|
|
136
|
+
export type Pose = { rotation: Vec3; position: Vec3; scale: number };
|
|
137
|
+
export const REST_POSE: Pose = { rotation: [0, 0, 0], position: [0, 0, 0], scale: 1 };
|
|
138
|
+
// The pose at `frame`: each key's values carry forward when a key leaves them out, and the move toward a key starts 12 frames before its frame on a spring and lands on it
|
|
139
|
+
// (the same rule as Camera and Carry). Rotation is in degrees.
|
|
140
|
+
export function poseAt(keys: readonly PoseKey[] | undefined, frame: number, fps: number, base: Pose = REST_POSE): Pose {
|
|
141
|
+
if (!keys?.length) return base;
|
|
142
|
+
const sorted = sortKeys(keys);
|
|
143
|
+
let cur = base;
|
|
144
|
+
const resolved = sorted.map((k) => { cur = { rotation: k.rotation ?? cur.rotation, position: k.position ?? cur.position, scale: k.scale ?? cur.scale }; return { frame: k.frame, ...cur }; });
|
|
145
|
+
const first = { frame: resolved[0]!.frame - 1, ...base };
|
|
146
|
+
const all = [first, ...resolved];
|
|
147
|
+
const { from, to, p } = locate(all, frame, fps, {});
|
|
148
|
+
const m = (a: Vec3, b: Vec3): Vec3 => [mix(a[0], b[0], p), mix(a[1], b[1], p), mix(a[2], b[2], p)];
|
|
149
|
+
return { rotation: m(from.rotation, to.rotation), position: m(from.position, to.position), scale: mix(from.scale, to.scale, p) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------- Warp3D ----------
|
|
153
|
+
export type SpeedKey = { frame: number; speed: number };
|
|
154
|
+
// The warp's speed at `frame`: a number is constant; keys are smoothly joined (and hold their last value). `burst` makes a short rise and fall instead: for a
|
|
155
|
+
// transition burst of 12 to 20 frames, 0 outside it and `peak` in the middle.
|
|
156
|
+
export function warpSpeed(frame: number, speed: number | readonly SpeedKey[] | undefined, burst?: { frame: number; frames: number; peak?: number }): number {
|
|
157
|
+
if (burst) { const t = (frame - burst.frame) / Math.max(2, burst.frames); return t < 0 || t > 1 ? 0 : (burst.peak ?? 1) * Math.sin(Math.PI * t) ** 1.5; }
|
|
158
|
+
if (typeof speed === "number") return speed;
|
|
159
|
+
if (!speed?.length) return 1;
|
|
160
|
+
const k = sortKeys(speed);
|
|
161
|
+
if (frame <= k[0]!.frame) return k[0]!.speed;
|
|
162
|
+
for (let i = 1; i < k.length; i++) if (frame <= k[i]!.frame) return mix(k[i - 1]!.speed, k[i]!.speed, smooth((frame - k[i - 1]!.frame) / Math.max(1, k[i]!.frame - k[i - 1]!.frame)));
|
|
163
|
+
return k[k.length - 1]!.speed;
|
|
164
|
+
}
|
|
165
|
+
// How far the field has travelled by `frame`: the sum of the speed of every frame before it. Deterministic and the same in any order.
|
|
166
|
+
export function warpTravel(frame: number, speed: number | readonly SpeedKey[] | undefined, burst?: { frame: number; frames: number; peak?: number }): number {
|
|
167
|
+
let s = 0;
|
|
168
|
+
for (let f = 0; f < Math.max(0, Math.floor(frame)); f++) s += warpSpeed(f, speed, burst);
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
export type Streak = { x: number; y: number; z: number; mix: number };
|
|
172
|
+
// The streaks' fixed places: seeded angle and distance from the vanishing point and a depth, spread so that the middle stays quieter than the edges.
|
|
173
|
+
export function warpStreaks(count: number, seed = 0): Streak[] {
|
|
174
|
+
return Array.from({ length: Math.max(1, Math.min(4000, Math.round(count))) }, (_, i) => {
|
|
175
|
+
const a = hash01(i, seed + 41) * Math.PI * 2, r = 0.12 + 1.6 * Math.sqrt(hash01(i, seed + 42));
|
|
176
|
+
return { x: Math.cos(a) * r, y: Math.sin(a) * r, z: hash01(i, seed + 43), mix: hash01(i, seed + 44) };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------- Scene3D mood ----------
|
|
181
|
+
export type Mood = "studio" | "night" | "sunset" | "neon";
|
|
182
|
+
export const MOODS: readonly Mood[] = ["studio", "night", "sunset", "neon"];
|
|
183
|
+
export type MoodLights = { ambient: { color: string; intensity: number }; key: { color: string; intensity: number; position: Vec3 }; fill: { color: string; intensity: number; position: Vec3 }; rim: { color: string; intensity: number; position: Vec3 }; fog: { color: string; near: number; far: number } };
|
|
184
|
+
// The lights and the fog tint of each mood, chosen together so that they agree: the light colours are what the fog is the colour of in the distance.
|
|
185
|
+
export function moodLights(mood: Mood): MoodLights {
|
|
186
|
+
switch (mood) {
|
|
187
|
+
case "night": return { ambient: { color: "#8aa0e0", intensity: 1.0 }, key: { color: "#b9ceff", intensity: 2.4, position: [3, 6, 7] }, fill: { color: "#27345f", intensity: 0.7, position: [-6, 2, 4] }, rim: { color: "#5b7cff", intensity: 2.2, position: [0, 3, -8] }, fog: { color: "#0b1226", near: 9, far: 34 } };
|
|
188
|
+
case "sunset": return { ambient: { color: "#ffb48a", intensity: 0.9 }, key: { color: "#ffd2a0", intensity: 2.6, position: [6, 3, 6] }, fill: { color: "#b05a8a", intensity: 0.9, position: [-6, 1, 4] }, rim: { color: "#ff7a5a", intensity: 2.2, position: [-2, 3, -8] }, fog: { color: "#c9694f", near: 10, far: 36 } };
|
|
189
|
+
case "neon": return { ambient: { color: "#3a2a6a", intensity: 0.5 }, key: { color: "#ffffff", intensity: 1.2, position: [3, 5, 8] }, fill: { color: "#ff2fb4", intensity: 1.6, position: [-7, 1, 4] }, rim: { color: "#22d3ee", intensity: 3.0, position: [6, 2, -6] }, fog: { color: "#12062a", near: 9, far: 32 } };
|
|
190
|
+
default: return { ambient: { color: "#ffffff", intensity: 1.2 }, key: { color: "#fff7ee", intensity: 2.8, position: [4, 6, 8] }, fill: { color: "#dfe8ff", intensity: 1.1, position: [-6, 2, 4] }, rim: { color: "#ffffff", intensity: 1.4, position: [0, 3, -8] }, fog: { color: "#e8eaf0", near: 12, far: 40 } };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The arithmetic behind the kit's 3D components, with no three.js or React in it. Everything is a pure function of the frame (a renderer draws
|
|
2
|
+
// frames out of order and in parallel), and uses the same timing rule as the 2D Camera: the move toward a key starts `lead` frames before its
|
|
3
|
+
// frame on a spring and lands on the key's frame exactly.
|
|
4
|
+
import { locate, mix, sortKeys, travel } from "./motion-math";
|
|
5
|
+
import { springs } from "./theme";
|
|
6
|
+
|
|
7
|
+
export type Vec3 = [number, number, number];
|
|
8
|
+
export type Camera3DKey = { frame: number; x?: number; y?: number; z?: number; lookAt?: Vec3; fov?: number };
|
|
9
|
+
export type Camera3DView = { position: Vec3; lookAt: Vec3; fov: number };
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CAMERA: Camera3DView = { position: [0, 0, 6], lookAt: [0, 0, 0], fov: 50 };
|
|
12
|
+
|
|
13
|
+
// A key that leaves a value out keeps the one before it; the first key starts from the default camera.
|
|
14
|
+
function resolve(keys: readonly Camera3DKey[]): (Camera3DView & { frame: number })[] {
|
|
15
|
+
let last: Camera3DView = DEFAULT_CAMERA;
|
|
16
|
+
return sortKeys(keys).map((k) => {
|
|
17
|
+
last = { position: [k.x ?? last.position[0], k.y ?? last.position[1], k.z ?? last.position[2]], lookAt: k.lookAt ?? last.lookAt, fov: k.fov ?? last.fov };
|
|
18
|
+
return { frame: k.frame, ...last };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The scene units the camera sees. At fov 50 the height is about 0.93 times the distance to the object plane, and the width that times the frame's
|
|
23
|
+
// aspect (width over height): on a 9:16 phone frame about 0.52 times the distance, so 3.1 units across at z 6, not 5.5. Pure geometry.
|
|
24
|
+
export const visibleHeight = (z: number, fov = DEFAULT_CAMERA.fov): number => 2 * Math.abs(z) * Math.tan(degToRad(fov) / 2);
|
|
25
|
+
export const visibleWidth = (z: number, aspect: number, fov = DEFAULT_CAMERA.fov): number => visibleHeight(z, fov) * aspect;
|
|
26
|
+
|
|
27
|
+
// The camera's key views, resolved: the distance to a plane at `planeZ` and the field of view at each key. Without keys, the default camera.
|
|
28
|
+
export function cameraViews(keys: readonly Camera3DKey[] | undefined, planeZ = 0): { distance: number; fov: number }[] {
|
|
29
|
+
const views = keys?.length ? resolve(keys) : [{ position: DEFAULT_CAMERA.position, fov: DEFAULT_CAMERA.fov }];
|
|
30
|
+
return views.map((v) => ({ distance: Math.max(0.5, v.position[2] - planeZ), fov: v.fov }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// How much of the plane at `planeZ` the camera sees at the key where it sees the least (its closest, or its narrowest field of view): the width and
|
|
34
|
+
// height in scene units that a thing must fit inside to be whole at every moment of the shot.
|
|
35
|
+
export function nearestVisible(keys: readonly Camera3DKey[] | undefined, aspect: number, planeZ = 0): { width: number; height: number; distance: number } {
|
|
36
|
+
let best = { width: Infinity, height: Infinity, distance: 0 };
|
|
37
|
+
for (const v of cameraViews(keys, planeZ)) {
|
|
38
|
+
const width = visibleWidth(v.distance, aspect, v.fov);
|
|
39
|
+
if (width < best.width) best = { width, height: visibleHeight(v.distance, v.fov), distance: v.distance };
|
|
40
|
+
}
|
|
41
|
+
return best;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The size at which a word measured at size 1 (`unit` width and height) fills `fit` of the visible width, and no more than `fit` of the height.
|
|
45
|
+
export const fitTextSize = (unit: { width: number; height: number }, view: { width: number; height: number }, fit: number): number =>
|
|
46
|
+
Math.min((fit * view.width) / Math.max(1e-6, unit.width), (fit * view.height) / Math.max(1e-6, unit.height));
|
|
47
|
+
|
|
48
|
+
// The scale at which a ring (see ringPosition) lies inside `fit` of the frame at every key of the camera, however it has turned. `radius` is the ring's
|
|
49
|
+
// radius at scale 1, `itemW` and `itemH` the size of one card, `at` where the ring's middle is. An item seen side-on is narrow, a billboard is not.
|
|
50
|
+
export function ringFitScale(o: {
|
|
51
|
+
keys?: readonly Camera3DKey[]; aspect: number; fit: number; radius: number; itemW: number; itemH: number; tiltDeg?: number; billboard?: boolean; at?: Vec3;
|
|
52
|
+
}): number {
|
|
53
|
+
const at = o.at ?? [0, 0, 0], tilt = degToRad(o.tiltDeg ?? 0);
|
|
54
|
+
const views = (o.keys?.length ? resolve(o.keys) : [{ position: DEFAULT_CAMERA.position, fov: DEFAULT_CAMERA.fov }]);
|
|
55
|
+
let best = Infinity;
|
|
56
|
+
for (const view of views) {
|
|
57
|
+
const camZ = view.position[2];
|
|
58
|
+
const spread = (s: number): number => {
|
|
59
|
+
let worst = 0;
|
|
60
|
+
for (let deg = 0; deg < 360; deg += 6) {
|
|
61
|
+
const a = degToRad(deg);
|
|
62
|
+
const x = s * o.radius * Math.sin(a), z = s * o.radius * Math.cos(a) * Math.cos(tilt), y = -s * o.radius * Math.cos(a) * Math.sin(tilt);
|
|
63
|
+
const d = camZ - (at[2] + z);
|
|
64
|
+
if (d < 0.4) return Infinity;
|
|
65
|
+
const halfW = (s * o.itemW) / 2 * (o.billboard ? 1 : Math.max(Math.abs(Math.cos(a)), 0.1));
|
|
66
|
+
worst = Math.max(worst, (Math.abs(x + at[0] - view.position[0]) + halfW) / (visibleWidth(d, o.aspect, view.fov) / 2), (Math.abs(y + at[1] - view.position[1]) + (s * o.itemH) / 2) / (visibleHeight(d, view.fov) / 2));
|
|
67
|
+
}
|
|
68
|
+
return worst;
|
|
69
|
+
};
|
|
70
|
+
let lo = 0.001, hi = 60;
|
|
71
|
+
for (let i = 0; i < 40; i++) { const mid = (lo + hi) / 2; if (spread(mid) <= o.fit) lo = mid; else hi = mid; }
|
|
72
|
+
best = Math.min(best, lo);
|
|
73
|
+
}
|
|
74
|
+
return best;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A radius at which `count` cards of width `itemW` stand side by side on a ring without touching.
|
|
78
|
+
export const naturalRingRadius = (count: number, itemW: number): number => Math.max(itemW * 0.7, (itemW * 1.15) / (2 * Math.sin(Math.PI / Math.max(2, count))));
|
|
79
|
+
|
|
80
|
+
const mixVec = (a: Vec3, b: Vec3, p: number): Vec3 => [mix(a[0], b[0], p), mix(a[1], b[1], p), mix(a[2], b[2], p)];
|
|
81
|
+
|
|
82
|
+
// Where the 3D camera is, what it looks at and its field of view at `frame`.
|
|
83
|
+
export function camera3DAt(keys: readonly Camera3DKey[] | undefined, frame: number, fps: number, timing: { lead?: number; stiffness?: number; damping?: number } = {}): Camera3DView {
|
|
84
|
+
if (!keys?.length) return DEFAULT_CAMERA;
|
|
85
|
+
const { from, to, p } = locate(resolve(keys), frame, fps, timing);
|
|
86
|
+
return { position: mixVec(from.position, to.position, p), lookAt: mixVec(from.lookAt, to.lookAt, p), fov: Math.max(1, Math.min(170, mix(from.fov, to.fov, p))) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type Entrance3D = "rise" | "turn" | "fly" | "none";
|
|
90
|
+
export type EntranceState = { offset: Vec3; rotation: Vec3; scale: number; opacity: number };
|
|
91
|
+
export const ENTRANCE_FRAMES = 24;
|
|
92
|
+
|
|
93
|
+
// How an object arrives, as offsets from where it rests. `size` scales the distances, so a large word travels as far, in proportion, as a small one.
|
|
94
|
+
// Before `delay` it is not there; after the entrance it rests exactly (offsets 0, scale 1, opacity 1).
|
|
95
|
+
export function entranceAt(kind: Entrance3D, frame: number, delay: number, fps: number, size = 1): EntranceState {
|
|
96
|
+
const rest: EntranceState = { offset: [0, 0, 0], rotation: [0, 0, 0], scale: 1, opacity: 1 };
|
|
97
|
+
if (kind === "none") return rest;
|
|
98
|
+
const t = (frame - delay) / ENTRANCE_FRAMES;
|
|
99
|
+
const p = travel(t, fps, springs.smooth, ENTRANCE_FRAMES);
|
|
100
|
+
const left = 1 - p;
|
|
101
|
+
if (t <= 0) return { offset: kind === "rise" ? [0, -size * 0.8, 0] : kind === "fly" ? [0, size * 0.4, -size * 14] : [0, 0, 0], rotation: kind === "turn" ? [0, -Math.PI / 2, 0] : [0, 0, 0], scale: kind === "turn" ? 0.9 : 1, opacity: 0 };
|
|
102
|
+
const opacity = Math.min(1, Math.max(0, t * 3));
|
|
103
|
+
switch (kind) {
|
|
104
|
+
// Adding 0 turns a negative zero into a plain one, so a finished entrance equals the resting state exactly.
|
|
105
|
+
case "rise": return { ...rest, offset: [0, -size * 0.8 * left + 0, 0], opacity };
|
|
106
|
+
case "turn": return { ...rest, rotation: [0, (-Math.PI / 2) * left + 0, 0], scale: 0.9 + 0.1 * p, opacity };
|
|
107
|
+
case "fly": return { ...rest, offset: [0, size * 0.4 * left, -size * 14 * left + 0], opacity };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Where item `i` of `count` sits on a ring in the horizontal plane around the origin, which turns by `speed` degrees a frame and is tipped
|
|
112
|
+
// toward the camera by `tiltDeg`. Item 0 starts at the front. `facing` is the turn about the vertical axis that makes it look outward, so that
|
|
113
|
+
// the item at the front faces the camera.
|
|
114
|
+
export function ringPosition(i: number, count: number, frame: number, o: { radius?: number; speed?: number; tiltDeg?: number } = {}): { position: Vec3; facing: number; tilt: number } {
|
|
115
|
+
const radius = o.radius ?? 3, speed = o.speed ?? 0.5, tilt = ((o.tiltDeg ?? 0) * Math.PI) / 180;
|
|
116
|
+
const angle = (((i / Math.max(1, count)) * 360 + speed * frame) * Math.PI) / 180;
|
|
117
|
+
const x = radius * Math.sin(angle), z = radius * Math.cos(angle);
|
|
118
|
+
// The tilt turns the ring about the horizontal axis: the front dips and the back rises.
|
|
119
|
+
return { position: [x, -z * Math.sin(tilt), z * Math.cos(tilt)], facing: angle, tilt };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A slow drift for an object that floats: small, smooth, and the same for the same frame. `seed` gives each object its own phase.
|
|
123
|
+
export function floatAt(frame: number, fps: number, amount = 1, seed = 0): { offset: Vec3; rotation: Vec3 } {
|
|
124
|
+
const t = (frame / fps) * 2 * Math.PI;
|
|
125
|
+
return {
|
|
126
|
+
offset: [Math.sin(t / 7 + seed) * 0.04 * amount, Math.sin(t / 5 + seed * 1.7) * 0.07 * amount, 0],
|
|
127
|
+
rotation: [Math.sin(t / 9 + seed * 0.6) * 0.03 * amount, Math.sin(t / 6 + seed) * 0.05 * amount, Math.sin(t / 8 + seed * 2.1) * 0.015 * amount],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const degToRad = (d: number) => (d * Math.PI) / 180;
|
|
132
|
+
|
|
133
|
+
// Colour arithmetic for the faces of cards, on "#rrggbb" strings (the card normalises any CSS colour to that first).
|
|
134
|
+
const parseHex = (c: string): [number, number, number] => {
|
|
135
|
+
const h = c.replace("#", "");
|
|
136
|
+
const f = h.length === 3 ? h.split("").map((x) => x + x).join("") : h;
|
|
137
|
+
return [parseInt(f.slice(0, 2), 16) || 0, parseInt(f.slice(2, 4), 16) || 0, parseInt(f.slice(4, 6), 16) || 0];
|
|
138
|
+
};
|
|
139
|
+
const toHex = (v: number[]) => "#" + v.map((x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0")).join("");
|
|
140
|
+
// `a` moved `t` (0 to 1) of the way toward `b`.
|
|
141
|
+
export const mixHex = (a: string, b: string, t: number): string => { const x = parseHex(a), y = parseHex(b); return toHex(x.map((v, i) => v + (y[i]! - v) * t)); };
|
|
142
|
+
// Relative luminance, 0 (black) to 1 (white).
|
|
143
|
+
export const luminance = (c: string): number => { const [r, g, b] = parseHex(c).map((v) => v / 255); return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; };
|
|
144
|
+
// Dark ink on a light face and white ink on a dark one.
|
|
145
|
+
export const inkOn = (face: string): string => (luminance(face) > 0.62 ? "#14141c" : "#ffffff");
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The arithmetic behind SceneFrame's transitions, with no React in it. Everything is a pure function of the frame, so a renderer may draw
|
|
2
|
+
// frames out of order. A scene's exit plays in its last frames and the next scene's enter in its first, so nothing overlaps scenes and a
|
|
3
|
+
// transition is complete exactly at the boundary: the cut stays where the beat is.
|
|
4
|
+
export const TRANSITION_KINDS = ["fade", "cut", "zoom-in", "zoom-out", "zoom-through", "push-left", "push-right", "push-up", "push-down", "whip-left", "whip-right", "blur", "turn", "color-push", "rgb-whip", "flash", "ring", "flip"] as const;
|
|
5
|
+
export type TransitionKind = (typeof TRANSITION_KINDS)[number];
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TRANSITION_FRAMES = 8;
|
|
8
|
+
export const MAX_TRANSITION_FRAMES = 20;
|
|
9
|
+
|
|
10
|
+
// How a scene looks at one moment. tx and ty are fractions of the frame's width and height; blur is in fractions of the width (so the same
|
|
11
|
+
// transition looks the same at every size); rotateY is in degrees; scale grows from `origin`.
|
|
12
|
+
// `fx` is present only for the kinds that draw something over or around the scene: a field of colour, a channel split, a flash, a ring, an inverted frame.
|
|
13
|
+
// `amount` is how far from rest it is, 0 to 1, the same `away` as above: 1 is the scene fully covered (a field or a flash), fully closed (a ring) or inverted (a flip).
|
|
14
|
+
export type FxKind = "color-push" | "rgb-whip" | "flash" | "ring" | "flip";
|
|
15
|
+
export type TransitionFx = { kind: FxKind; phase: "enter" | "exit"; amount: number };
|
|
16
|
+
export type TransitionState = { opacity: number; scale: number; tx: number; ty: number; blurX: number; blurY: number; rotateY: number; fx?: TransitionFx };
|
|
17
|
+
export const IDENTITY: TransitionState = { opacity: 1, scale: 1, tx: 0, ty: 0, blurX: 0, blurY: 0, rotateY: 0 };
|
|
18
|
+
|
|
19
|
+
// How many frames one edge of a scene takes for a kind: `transitionFrames` if given, else the kind's own length (a colour push 10, a rgb whip 6, a ring 12, 8
|
|
20
|
+
// for every other). A flash is never longer than 4 frames (3 unless set) and a flip is always 1 frame: they are hits, not moves.
|
|
21
|
+
export function transitionSpan(kind: TransitionKind | undefined, frames: number | undefined): number {
|
|
22
|
+
if (kind === "flip") return 1;
|
|
23
|
+
const own = kind === "color-push" ? 10 : kind === "rgb-whip" ? 6 : kind === "ring" ? 12 : kind === "flash" ? 3 : DEFAULT_TRANSITION_FRAMES;
|
|
24
|
+
const n = transitionLength(Number.isFinite(frames) ? frames : own);
|
|
25
|
+
return kind === "flash" ? Math.min(4, n) : n;
|
|
26
|
+
}
|
|
27
|
+
// The kinds whose picture changes most on the scene boundary itself (the frame the next scene starts): the field, the flash, the closed ring and the inverted frame are at their fullest there.
|
|
28
|
+
export const PEAKS_ON_BOUNDARY: readonly TransitionKind[] = ["cut", "color-push", "flash", "ring", "flip"];
|
|
29
|
+
|
|
30
|
+
const clamp01 = (n: number) => (n > 0 ? (n < 1 ? n : 1) : 0);
|
|
31
|
+
const inOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
32
|
+
const out = (t: number) => 1 - (1 - t) ** 3;
|
|
33
|
+
const inn = (t: number) => t * t * t;
|
|
34
|
+
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
35
|
+
|
|
36
|
+
// The number of frames a transition takes: 8 unless asked, never fewer than 1 and at most 20.
|
|
37
|
+
export function transitionLength(frames: number | undefined): number {
|
|
38
|
+
const n = Number.isFinite(frames) ? Math.round(frames as number) : DEFAULT_TRANSITION_FRAMES;
|
|
39
|
+
return Math.max(1, Math.min(MAX_TRANSITION_FRAMES, n));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const BLUR = 0.03; // of the frame's width: about 32 px on a phone-wide frame
|
|
43
|
+
const WHIP_BLUR = 0.09;
|
|
44
|
+
|
|
45
|
+
// The state of a scene that is `away` (0 is the scene as it should be, 1 is as far away as the transition takes it), for one kind and phase.
|
|
46
|
+
// For an enter the scene travels from away to the identity and for an exit from the identity to away, so p runs 0 to 1 either way
|
|
47
|
+
// and the caller says which end is "away". Opacity of every kind other than "cut" and "fade" is 1 except where a blur or a scale hides it.
|
|
48
|
+
function awayState(kind: TransitionKind, phase: "enter" | "exit", away: number): TransitionState {
|
|
49
|
+
const a = clamp01(away);
|
|
50
|
+
// An entering scene comes from the positive side and an exiting one leaves toward the negative side, so a push moves everything one way.
|
|
51
|
+
const dir = phase === "enter" ? 1 : -1;
|
|
52
|
+
switch (kind) {
|
|
53
|
+
case "fade": return { ...IDENTITY, opacity: 1 - a };
|
|
54
|
+
case "cut": return IDENTITY;
|
|
55
|
+
case "zoom-in": return { ...IDENTITY, scale: mix(1, 1.25, a), blurX: BLUR * a, blurY: BLUR * a, opacity: 1 - clamp01((a - 0.3) / 0.7) };
|
|
56
|
+
case "zoom-out": return { ...IDENTITY, scale: mix(1, 0.8, a), blurX: BLUR * a, blurY: BLUR * a, opacity: 1 - clamp01((a - 0.3) / 0.7) };
|
|
57
|
+
// Going through: the old scene rushes toward the aimed point and blurs away; the new one arrives from small at the same point.
|
|
58
|
+
case "zoom-through": return phase === "exit"
|
|
59
|
+
? { ...IDENTITY, scale: mix(1, 3, a), blurX: BLUR * 1.5 * a, blurY: BLUR * 1.5 * a, opacity: 1 - clamp01((a - 0.35) / 0.65) }
|
|
60
|
+
: { ...IDENTITY, scale: mix(1, 0.4, a), blurX: BLUR * a, blurY: BLUR * a, opacity: 1 - clamp01(a * 1.4 - 0.4) };
|
|
61
|
+
case "push-left": return { ...IDENTITY, tx: dir * a };
|
|
62
|
+
case "push-right": return { ...IDENTITY, tx: -dir * a };
|
|
63
|
+
case "push-up": return { ...IDENTITY, ty: dir * a };
|
|
64
|
+
case "push-down": return { ...IDENTITY, ty: -dir * a };
|
|
65
|
+
// A whip is a push that is fast and smeared along its own direction; the smear grows with how far from rest the scene is.
|
|
66
|
+
case "whip-left": return { ...IDENTITY, tx: dir * a, blurX: WHIP_BLUR * a };
|
|
67
|
+
case "whip-right": return { ...IDENTITY, tx: -dir * a, blurX: WHIP_BLUR * a };
|
|
68
|
+
case "blur": return { ...IDENTITY, blurX: BLUR * 1.5 * a, blurY: BLUR * 1.5 * a, opacity: 1 - a * 0.9 };
|
|
69
|
+
// A quarter turn about the vertical axis: the old scene turns edge-on and is gone, the new one turns in from edge-on.
|
|
70
|
+
case "turn": return { ...IDENTITY, rotateY: dir * 90 * a, opacity: 1 - clamp01((a - 0.85) / 0.15) };
|
|
71
|
+
case "color-push": case "flash": case "ring": return a > 0 ? { ...IDENTITY, fx: { kind, phase, amount: a } } : IDENTITY;
|
|
72
|
+
case "flip": return a > 0 ? { ...IDENTITY, fx: { kind, phase, amount: 1 } } : IDENTITY;
|
|
73
|
+
// A whip with the red and blue channels pulled apart: the split grows with the same amount as the smear.
|
|
74
|
+
case "rgb-whip": return a > 0 ? { ...IDENTITY, tx: dir * a * 0.6, blurX: WHIP_BLUR * 0.7 * a, fx: { kind, phase, amount: a } } : IDENTITY;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// How far along a scene's enter is at `frame` and how far its exit is, as 0..1 progress, given the scene's length and each edge's own length.
|
|
79
|
+
export function edges(frame: number, durationInFrames: number, enterFrames: number, exitFrames = enterFrames): { enter: number; exit: number } {
|
|
80
|
+
const en = Math.min(enterFrames, Math.floor(durationInFrames / 2)), ex = Math.min(exitFrames, Math.floor(durationInFrames / 2));
|
|
81
|
+
return { enter: en <= 0 ? 1 : clamp01(frame / en), exit: ex <= 0 ? 0 : clamp01((frame - (durationInFrames - ex)) / ex) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The state of one scene at one frame. The default for both is "fade", which is the scene's opacity going 0 to 1 over the first frames and
|
|
85
|
+
// 1 to 0 over the last, linearly, as it always was.
|
|
86
|
+
export function transitionAt(frame: number, durationInFrames: number, o: { enter?: TransitionKind; exit?: TransitionKind; transitionFrames?: number } = {}): TransitionState {
|
|
87
|
+
const ek = o.enter ?? "fade", xk = o.exit ?? "fade";
|
|
88
|
+
const { enter, exit } = edges(frame, durationInFrames, transitionSpan(ek, o.transitionFrames), transitionSpan(xk, o.transitionFrames));
|
|
89
|
+
const ease = (kind: TransitionKind, phase: "enter" | "exit", p: number) => {
|
|
90
|
+
// A fade stays linear; a whip accelerates out and brakes in; a flash is linear (it is 3 frames); everything else eases both ways.
|
|
91
|
+
if (kind === "fade" || kind === "flash" || kind === "flip") return p;
|
|
92
|
+
if (kind === "whip-left" || kind === "whip-right" || kind === "rgb-whip") return phase === "exit" ? inn(p) : out(p);
|
|
93
|
+
return inOut(p);
|
|
94
|
+
};
|
|
95
|
+
const entering = awayState(ek, "enter", 1 - ease(ek, "enter", enter));
|
|
96
|
+
const leaving = awayState(xk, "exit", ease(xk, "exit", exit));
|
|
97
|
+
// Only one of the two is ever away from the identity (the windows do not overlap); multiply to be safe at the one frame where both are 0 and 1.
|
|
98
|
+
const state: TransitionState = {
|
|
99
|
+
opacity: entering.opacity * leaving.opacity, scale: entering.scale * leaving.scale, tx: entering.tx + leaving.tx, ty: entering.ty + leaving.ty,
|
|
100
|
+
blurX: Math.max(entering.blurX, leaving.blurX), blurY: Math.max(entering.blurY, leaving.blurY), rotateY: entering.rotateY + leaving.rotateY,
|
|
101
|
+
};
|
|
102
|
+
const fx = entering.fx && leaving.fx ? (entering.fx.amount >= leaving.fx.amount ? entering.fx : leaving.fx) : entering.fx ?? leaving.fx;
|
|
103
|
+
return fx ? { ...state, fx } : state;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const r = (n: number, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
|
|
107
|
+
|
|
108
|
+
// The CSS for a state in a frame of w by h pixels. `blurFilter` is set when the blur is the same in both directions and `directional`
|
|
109
|
+
// when it is not (a whip), which needs an SVG filter because CSS blur has no direction.
|
|
110
|
+
export function transitionCss(s: TransitionState, w: number, h: number, origin: { x: number; y: number } = { x: 0.5, y: 0.5 }): { opacity: number; transform: string; transformOrigin: string; blurPx: { x: number; y: number } } {
|
|
111
|
+
const parts: string[] = [];
|
|
112
|
+
if (s.rotateY) parts.push(`perspective(${Math.round(w * 1.6)}px) rotateY(${r(s.rotateY, 2)}deg)`);
|
|
113
|
+
if (s.tx || s.ty) parts.push(`translate(${r(s.tx * w, 2)}px, ${r(s.ty * h, 2)}px)`);
|
|
114
|
+
if (s.scale !== 1) parts.push(`scale(${r(s.scale)})`);
|
|
115
|
+
return { opacity: r(s.opacity), transform: parts.length ? parts.join(" ") : "none", transformOrigin: `${r(origin.x * 100, 2)}% ${r(origin.y * 100, 2)}%`, blurPx: { x: r(s.blurX * w, 2), y: r(s.blurY * w, 2) } };
|
|
116
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The arithmetic behind the product-surface components, with no React in it. Every component's timing props are frames counted from the component's own
|
|
2
|
+
// mount: the frame useCurrentFrame() gives where the component stands (inside a SceneFrame, the scene's own frame; inside a Sequence, the sequence's).
|
|
3
|
+
// Each component has a companion function here that takes the SAME props and returns the frames of its events, in that same clock, so a sound can be
|
|
4
|
+
// placed on them: add the scene's startFrame (or pass it as `offset` to cuesFor) to make them absolute.
|
|
5
|
+
import { measureSpring } from "remotion";
|
|
6
|
+
|
|
7
|
+
export const clamp01 = (n: number) => (n > 0 ? (n < 1 ? n : 1) : 0);
|
|
8
|
+
export const outCubic = (t: number) => 1 - (1 - clamp01(t)) ** 3;
|
|
9
|
+
export const inOutCubic = (t: number) => { const x = clamp01(t); return x < 0.5 ? 4 * x * x * x : 1 - (-2 * x + 2) ** 3 / 2; };
|
|
10
|
+
|
|
11
|
+
// The frame at which each character of `text` appears when typed at `cps` characters a second from frame `start`: the first at `start`. Length is text.length.
|
|
12
|
+
export function typedFrames(text: string, cps: number, fps: number, start = 0): number[] {
|
|
13
|
+
const step = fps / Math.max(0.1, cps);
|
|
14
|
+
return Array.from({ length: [...text].length }, (_, i) => start + Math.floor(i * step));
|
|
15
|
+
}
|
|
16
|
+
// How many characters are showing at `frame`.
|
|
17
|
+
export function typedCount(frame: number, text: string, cps: number, fps: number, start = 0): number {
|
|
18
|
+
const f = typedFrames(text, cps, fps, start);
|
|
19
|
+
let n = 0;
|
|
20
|
+
while (n < f.length && f[n]! <= frame) n++;
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PromptTiming = { text: string; cps?: number; delay?: number; reply?: string; replyCps?: number; sendPause?: number; replyDelay?: number };
|
|
25
|
+
export type PromptEvents = { type: number[]; typedEnd: number; send: number; replyStart: number; reply: number[]; end: number };
|
|
26
|
+
export const PROMPT_DEFAULTS = { cps: 22, delay: 6, replyCps: 70, sendPause: 9, replyDelay: 12 } as const;
|
|
27
|
+
// The events of a PromptBox, from the same props: the frame of every typed character, the frame typing is over, the frame the send button presses, the frame
|
|
28
|
+
// the reply starts and the frame of each of its characters, and the last frame of it all.
|
|
29
|
+
export function promptFrames(p: PromptTiming, fps: number): PromptEvents {
|
|
30
|
+
const cps = p.cps ?? PROMPT_DEFAULTS.cps, delay = p.delay ?? PROMPT_DEFAULTS.delay;
|
|
31
|
+
const type = typedFrames(p.text, cps, fps, delay);
|
|
32
|
+
const typedEnd = (type[type.length - 1] ?? delay) + 1;
|
|
33
|
+
const send = typedEnd + (p.sendPause ?? PROMPT_DEFAULTS.sendPause);
|
|
34
|
+
const replyStart = send + (p.replyDelay ?? PROMPT_DEFAULTS.replyDelay);
|
|
35
|
+
const reply = p.reply ? typedFrames(p.reply, p.replyCps ?? PROMPT_DEFAULTS.replyCps, fps, replyStart) : [];
|
|
36
|
+
return { type, typedEnd, send, replyStart, reply, end: Math.max(send + 8, (reply[reply.length - 1] ?? 0) + 1) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LineKind = "cmd" | "out" | "ok" | "warn" | "edit";
|
|
40
|
+
export type TerminalLine = { kind: LineKind; text: string; add?: number; del?: number; wait?: number };
|
|
41
|
+
// The frame each line of a TerminalLog appears: `rate` lines a second (default 3) from `delay` (default 6); a line's `wait` adds frames before it.
|
|
42
|
+
export function lineFrames(lines: readonly TerminalLine[], rate = 3, fps = 30, delay = 6): number[] {
|
|
43
|
+
const step = fps / Math.max(0.1, rate);
|
|
44
|
+
let t = delay;
|
|
45
|
+
return lines.map((l, i) => { t += (i === 0 ? 0 : step) + (l.wait ?? 0); return Math.round(t); });
|
|
46
|
+
}
|
|
47
|
+
export type TerminalTiming = { lines: readonly TerminalLine[]; rate?: number; delay?: number; checklist?: readonly string[]; checkEvery?: number };
|
|
48
|
+
// The events of a TerminalLog: each line's frame, each checklist item's frame (the strike-through, one every `checkEvery` frames, default 16, starting a beat
|
|
49
|
+
// after the last line), and the frame the work is done.
|
|
50
|
+
export function terminalFrames(p: TerminalTiming, fps: number): { lines: number[]; checks: number[]; done: number } {
|
|
51
|
+
const lines = lineFrames(p.lines, p.rate, fps, p.delay);
|
|
52
|
+
const first = (lines[lines.length - 1] ?? p.delay ?? 6) + 6, every = p.checkEvery ?? 16;
|
|
53
|
+
const checks = (p.checklist ?? []).map((_, i) => first + i * every);
|
|
54
|
+
return { lines, checks, done: Math.max(lines[lines.length - 1] ?? 0, checks[checks.length - 1] ?? 0) + 10 };
|
|
55
|
+
}
|
|
56
|
+
// "m:ss" for `seconds`.
|
|
57
|
+
export const clock = (seconds: number): string => `${Math.floor(Math.max(0, seconds) / 60)}:${String(Math.floor(Math.max(0, seconds) % 60)).padStart(2, "0")}`;
|
|
58
|
+
|
|
59
|
+
export type CursorPoint = { x: number; y: number; click?: boolean; hold?: number };
|
|
60
|
+
export const CURSOR_MOVE_FRAMES = 22;
|
|
61
|
+
// The frame the cursor reaches each point: the first at `delay` (default 4), then `moveFrames` (default 22) after leaving the one before, which it does
|
|
62
|
+
// after `hold` frames (default 10 on a click, 5 otherwise).
|
|
63
|
+
export function cursorArrivals(points: readonly CursorPoint[], o: { delay?: number; moveFrames?: number } = {}): number[] {
|
|
64
|
+
const move = o.moveFrames ?? CURSOR_MOVE_FRAMES;
|
|
65
|
+
let t = o.delay ?? 4;
|
|
66
|
+
return points.map((p, i) => { if (i > 0) t += (points[i - 1]!.hold ?? (points[i - 1]!.click ? 10 : 5)) + move; return t; });
|
|
67
|
+
}
|
|
68
|
+
// The frame of each click: 3 frames after the cursor reaches a point marked `click`.
|
|
69
|
+
export function clickFrames(points: readonly CursorPoint[], o: { delay?: number; moveFrames?: number } = {}): number[] {
|
|
70
|
+
const at = cursorArrivals(points, o);
|
|
71
|
+
return points.flatMap((p, i) => (p.click ? [at[i]! + 3] : []));
|
|
72
|
+
}
|
|
73
|
+
// Where the cursor is at `frame`, as fractions of the frame: it travels each leg on an ease with a gentle arc, never a straight line, and rests on the points.
|
|
74
|
+
export function cursorAt(points: readonly CursorPoint[], frame: number, o: { delay?: number; moveFrames?: number } = {}): { x: number; y: number } {
|
|
75
|
+
const at = cursorArrivals(points, o), move = o.moveFrames ?? CURSOR_MOVE_FRAMES;
|
|
76
|
+
if (points.length === 0) return { x: 0.5, y: 0.5 };
|
|
77
|
+
for (let i = points.length - 1; i > 0; i--) {
|
|
78
|
+
if (frame < at[i]! - move) continue;
|
|
79
|
+
const a = points[i - 1]!, b = points[i]!, t = inOutCubic((frame - (at[i]! - move)) / move);
|
|
80
|
+
const dx = b.x - a.x, dy = b.y - a.y, bend = 0.14 * (i % 2 ? 1 : -1);
|
|
81
|
+
const cx = (a.x + b.x) / 2 - dy * bend, cy = (a.y + b.y) / 2 + dx * bend;
|
|
82
|
+
return { x: (1 - t) ** 2 * a.x + 2 * (1 - t) * t * cx + t * t * b.x, y: (1 - t) ** 2 * a.y + 2 * (1 - t) * t * cy + t * t * b.y };
|
|
83
|
+
}
|
|
84
|
+
return { x: points[0]!.x, y: points[0]!.y };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The frame at which a spring of the given kind has settled (within 1% of its end): the frame a panel or a badge is seen to land.
|
|
88
|
+
export const settledAfter = (config: { stiffness: number; damping: number }, fps: number): number => measureSpring({ fps, config, threshold: 0.01 });
|
|
89
|
+
export const PANEL_SPRING = { stiffness: 170, damping: 19 };
|
|
90
|
+
// The frame a GlassPanel has settled, from its `delay` (default 0).
|
|
91
|
+
export const panelSettleFrame = (delay = 0, fps = 30): number => delay + settledAfter(PANEL_SPRING, fps);
|
|
92
|
+
|
|
93
|
+
export type ChapterTiming = { delay?: number; badges?: number };
|
|
94
|
+
// The events of a ChapterFrame: the frame each badge pops (the first 14 frames in, then 6 apart) and the frame the progress rule has filled.
|
|
95
|
+
export function chapterFrames(p: ChapterTiming = {}): { badges: number[]; progressDone: number } {
|
|
96
|
+
const d = p.delay ?? 0;
|
|
97
|
+
return { badges: Array.from({ length: Math.min(3, p.badges ?? 0) }, (_, i) => d + 14 + i * 6), progressDone: d + 24 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const headlineWords = (text: string): string[] => text.trim().split(/\s+/).filter(Boolean);
|
|
101
|
+
const bare = (w: string) => w.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase();
|
|
102
|
+
// The index of `keyword` among the words of `text`, ignoring case and punctuation; throws, listing the words, when it is not one of them.
|
|
103
|
+
export function keywordIndex(text: string, keyword: string): number {
|
|
104
|
+
const words = headlineWords(text), i = words.findIndex((w) => bare(w) === bare(keyword));
|
|
105
|
+
if (i < 0) throw new Error(`Headline: the keyword "${keyword}" is not one of the words of "${text}". The words are: ${words.join(", ")}.`);
|
|
106
|
+
return i;
|
|
107
|
+
}
|
|
108
|
+
// The events of a Headline: the frame each word lands (`per` frames apart, 4 unless set, from `delay`) and the frame its emphasis finishes.
|
|
109
|
+
export function headlineFrames(text: string, o: { delay?: number; per?: number } = {}): { words: number[]; emphasisDone: number } {
|
|
110
|
+
const n = headlineWords(text).length, d = o.delay ?? 0, per = o.per ?? 4;
|
|
111
|
+
const words = Array.from({ length: n }, (_, i) => d + i * per + 6);
|
|
112
|
+
return { words, emphasisDone: (words[n - 1] ?? d) + 14 };
|
|
113
|
+
}
|
|
114
|
+
// Splits the words into the fewest lines that keep the longest near `maxChars`, in order, and returns the lines' word indices.
|
|
115
|
+
export function headlineLines(words: readonly string[], maxChars = 12): number[][] {
|
|
116
|
+
const lines: number[][] = [[]];
|
|
117
|
+
let len = 0;
|
|
118
|
+
words.forEach((w, i) => {
|
|
119
|
+
const cur = lines[lines.length - 1]!;
|
|
120
|
+
if (cur.length && len + 1 + w.length > maxChars) { lines.push([i]); len = w.length; } else { cur.push(i); len += (cur.length > 1 ? 1 : 0) + w.length; }
|
|
121
|
+
});
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The value of a CounterRoll at `frame`: it eases out over `frames` frames from `delay` and holds `to`.
|
|
126
|
+
export function rollValue(frame: number, o: { from?: number; to: number; frames?: number; delay?: number }): number {
|
|
127
|
+
const from = o.from ?? 0, t = (frame - (o.delay ?? 0)) / Math.max(1, o.frames ?? 45);
|
|
128
|
+
return from + (o.to - from) * (1 - (1 - clamp01(t)) ** 4);
|
|
129
|
+
}
|
|
130
|
+
// The frame a CounterRoll lands on its number (`delay` + `frames`, defaults 0 and 45): put the settle chime here.
|
|
131
|
+
export const settleFrame = (o: { delay?: number; frames?: number } = {}): number => (o.delay ?? 0) + (o.frames ?? 45);
|
|
132
|
+
// The frame of the last tick of the roll: it starts at `delay`.
|
|
133
|
+
export const rollStart = (o: { delay?: number } = {}): number => o.delay ?? 0;
|
|
134
|
+
|
|
135
|
+
// A timecode "mm:ss:ff" of `frame`.
|
|
136
|
+
export const timecode = (frame: number, fps: number): string => {
|
|
137
|
+
const s = Math.floor(frame / fps);
|
|
138
|
+
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}:${String(Math.floor(frame % fps)).padStart(2, "0")}`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// The frames of a BrowserFrame: the frame it has settled, and the frames the sheen starts and ends.
|
|
142
|
+
export function browserFrames(o: { delay?: number; fps?: number } = {}): { settled: number; sheenStart: number; sheenEnd: number } {
|
|
143
|
+
const d = o.delay ?? 0;
|
|
144
|
+
return { settled: d + settledAfter(PANEL_SPRING, o.fps ?? 30), sheenStart: d + 18, sheenEnd: d + 44 };
|
|
145
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { inkOn, luminance, mixHex } from "./three-math";
|
|
2
|
+
|
|
3
|
+
// What the product-surface components (PromptBox, TerminalLog, GlassPanel, ...) share: the three looks, the colours each one gives, and the helpers that
|
|
4
|
+
// say where a component sits. Nothing here needs React.
|
|
5
|
+
export type UiTheme = "light" | "dark" | "glass";
|
|
6
|
+
export const DEFAULT_HERO = "#7C3AED";
|
|
7
|
+
|
|
8
|
+
// "light" on a light ground and "dark" on a dark one: the theme a component should take when its ground is `bg`.
|
|
9
|
+
export const themeFor = (bg: string): UiTheme => (luminance(bg) > 0.5 ? "light" : "dark");
|
|
10
|
+
|
|
11
|
+
export type UiColors = { panel: string; stroke: string; ink: string; dim: string; field: string; chip: string; shadow: string; heroInk: string; ok: string; warn: string; bad: string };
|
|
12
|
+
|
|
13
|
+
// One set of colours per theme. `glass` is the dark slab with a lit hairline: it is meant to sit over a coloured glow and is a little see-through.
|
|
14
|
+
export function uiColors(theme: UiTheme, hero: string): UiColors {
|
|
15
|
+
const heroInk = inkOn(hero);
|
|
16
|
+
if (theme === "light") return { panel: "#ffffff", stroke: "rgba(20,20,30,0.12)", ink: "#16161d", dim: "#6b6b78", field: "#f1f1f5", chip: "#e9e9f0", shadow: "rgba(20,20,40,0.22)", heroInk, ok: "#16a34a", warn: "#d97706", bad: "#dc2626" };
|
|
17
|
+
if (theme === "glass") return { panel: "rgba(14,14,22,0.62)", stroke: "rgba(255,255,255,0.20)", ink: "#f4f4f5", dim: "#a8a8b6", field: "rgba(255,255,255,0.07)", chip: "rgba(255,255,255,0.12)", shadow: "rgba(0,0,0,0.5)", heroInk, ok: "#4ade80", warn: "#fbbf24", bad: "#f87171" };
|
|
18
|
+
return { panel: "#121219", stroke: "rgba(255,255,255,0.10)", ink: "#f4f4f5", dim: "#9a9aa6", field: "#1b1b26", chip: "#262634", shadow: "rgba(0,0,0,0.55)", heroInk, ok: "#4ade80", warn: "#fbbf24", bad: "#f87171" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const rgba = (hex: string, a: number): string => {
|
|
22
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(mixHex(hex, hex, 0));
|
|
23
|
+
const n = parseInt(m ? m[1]! : "000000", 16);
|
|
24
|
+
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
|
25
|
+
};
|