reelkit-cli 0.6.0 → 0.10.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 (144) hide show
  1. package/README.md +5 -3
  2. package/package.json +52 -9
  3. package/skill/SKILL.md +40 -19
  4. package/skill/THIRD_PARTY.md +104 -2
  5. package/skill/commands/launch-film.md +7 -0
  6. package/skill/reference/art-styles.md +70 -0
  7. package/skill/reference/asset-reuse.md +13 -2
  8. package/skill/reference/backgrounds.md +63 -0
  9. package/skill/reference/beat-sync.md +25 -19
  10. package/skill/reference/brand-motion.md +62 -0
  11. package/skill/reference/captions.md +11 -5
  12. package/skill/reference/clips.md +3 -3
  13. package/skill/reference/component-authoring.md +1 -1
  14. package/skill/reference/continuity.md +26 -7
  15. package/skill/reference/delivery-review.md +40 -0
  16. package/skill/reference/hebrew-rtl.md +3 -4
  17. package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +18 -11
  18. package/skill/reference/kit.md +144 -12
  19. package/skill/reference/launch-film.md +194 -0
  20. package/skill/reference/motion-design.md +18 -16
  21. package/skill/reference/scene-treatments.md +20 -0
  22. package/skill/reference/scriptwriting.md +4 -1
  23. package/skill/reference/sound-design.md +34 -12
  24. package/skill/reference/studio-editing.md +55 -0
  25. package/skill/reference/styles.md +9 -6
  26. package/skill/reference/three-d.md +135 -0
  27. package/skill/reference/voice-sync.md +108 -0
  28. package/src/agents.ts +23 -12
  29. package/src/api/client.ts +4 -1
  30. package/src/cli.ts +18 -7
  31. package/src/commands/assets.ts +314 -30
  32. package/src/commands/build.ts +148 -35
  33. package/src/commands/init.ts +1 -1
  34. package/src/commands/install.ts +1 -1
  35. package/src/commands/plan.ts +8 -5
  36. package/src/commands/ref.ts +5 -2
  37. package/src/contract/index.ts +5 -3
  38. package/src/hyperframes/Root.tsx +1 -0
  39. package/src/hyperframes/fonts.ts +54 -0
  40. package/src/hyperframes/frame.tsx +46 -0
  41. package/src/hyperframes/host.tsx +38 -0
  42. package/src/hyperframes/kit/Assemble3D.tsx +92 -0
  43. package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
  44. package/src/hyperframes/kit/BrowserFrame.tsx +83 -0
  45. package/src/{remotion → hyperframes}/kit/Camera.tsx +7 -5
  46. package/src/{remotion → hyperframes}/kit/Captions.tsx +34 -18
  47. package/src/hyperframes/kit/Card3D.tsx +211 -0
  48. package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
  49. package/src/hyperframes/kit/ChapterFrame.tsx +68 -0
  50. package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
  51. package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
  52. package/src/hyperframes/kit/CounterRoll.tsx +75 -0
  53. package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
  54. package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
  55. package/src/hyperframes/kit/GlassPanel.tsx +43 -0
  56. package/src/hyperframes/kit/Grounds.tsx +177 -0
  57. package/src/hyperframes/kit/Headline.tsx +97 -0
  58. package/src/hyperframes/kit/Hero3D.tsx +197 -0
  59. package/src/hyperframes/kit/HudOverlay.tsx +52 -0
  60. package/src/hyperframes/kit/ImageLayers.tsx +48 -0
  61. package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
  62. package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
  63. package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
  64. package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
  65. package/src/hyperframes/kit/Music.tsx +19 -0
  66. package/src/hyperframes/kit/NamedCursor.tsx +54 -0
  67. package/src/hyperframes/kit/Orbit3D.tsx +49 -0
  68. package/src/hyperframes/kit/Particles3D.tsx +74 -0
  69. package/src/hyperframes/kit/Place.tsx +12 -0
  70. package/src/hyperframes/kit/PromptBox.tsx +84 -0
  71. package/src/hyperframes/kit/Scene3D.tsx +70 -0
  72. package/src/hyperframes/kit/SceneFrame.tsx +96 -0
  73. package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
  74. package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
  75. package/src/hyperframes/kit/SoundCues.tsx +22 -0
  76. package/src/hyperframes/kit/TerminalLog.tsx +98 -0
  77. package/src/hyperframes/kit/Text3D.tsx +78 -0
  78. package/src/hyperframes/kit/TextOnImage.tsx +41 -0
  79. package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
  80. package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
  81. package/src/hyperframes/kit/Warp3D.tsx +59 -0
  82. package/src/hyperframes/kit/bg-math.ts +179 -0
  83. package/src/hyperframes/kit/brand-transform.ts +25 -0
  84. package/src/{remotion → hyperframes}/kit/caption-groups.ts +7 -3
  85. package/src/hyperframes/kit/caption-style.ts +45 -0
  86. package/src/hyperframes/kit/docs.ts +249 -0
  87. package/src/hyperframes/kit/image-layers-math.ts +115 -0
  88. package/src/hyperframes/kit/index.ts +74 -0
  89. package/src/hyperframes/kit/inter-bold-typeface.ts +3 -0
  90. package/src/{remotion → hyperframes}/kit/motion-math.ts +36 -2
  91. package/src/hyperframes/kit/music-math.ts +59 -0
  92. package/src/hyperframes/kit/quiet-three.ts +11 -0
  93. package/src/hyperframes/kit/sample-text.ts +55 -0
  94. package/src/hyperframes/kit/scene3d-context.ts +5 -0
  95. package/src/hyperframes/kit/seeded.ts +13 -0
  96. package/src/hyperframes/kit/sound-cues.ts +89 -0
  97. package/src/hyperframes/kit/sound-kinds.ts +135 -0
  98. package/src/{remotion → hyperframes}/kit/theme.ts +43 -39
  99. package/src/hyperframes/kit/three-fx-math.ts +192 -0
  100. package/src/hyperframes/kit/three-math.ts +145 -0
  101. package/src/hyperframes/kit/transition-math.ts +116 -0
  102. package/src/hyperframes/kit/ui-math.ts +145 -0
  103. package/src/hyperframes/kit/ui-theme.ts +25 -0
  104. package/src/hyperframes/kit/word-anchor.ts +107 -0
  105. package/src/hyperframes/math.ts +62 -0
  106. package/src/hyperframes/three.tsx +10 -0
  107. package/src/pipeline/beatsnap.ts +72 -0
  108. package/src/pipeline/review.ts +44 -10
  109. package/src/pipeline/schema.ts +51 -4
  110. package/src/pipeline/timing.ts +27 -1
  111. package/src/project/background.ts +33 -0
  112. package/src/project/chromakey.ts +1 -1
  113. package/src/project/layers.ts +60 -0
  114. package/src/project/manifest.ts +59 -14
  115. package/src/project/music.ts +19 -5
  116. package/src/project/project.ts +4 -1
  117. package/src/project/serve.ts +2 -2
  118. package/src/project/soundreport.ts +347 -0
  119. package/src/project/svgcheck.ts +21 -0
  120. package/src/render/component-preview.ts +11 -55
  121. package/src/render/contact-sheet.ts +39 -0
  122. package/src/render/continuity.ts +14 -4
  123. package/src/render/deps.ts +15 -3
  124. package/src/render/render.ts +62 -57
  125. package/src/render/serve.ts +31 -0
  126. package/src/render/sound-notes.ts +106 -0
  127. package/src/render/static-check.ts +15 -4
  128. package/src/render/validate.ts +4 -4
  129. package/src/render/word-check.ts +181 -0
  130. package/src/render/worker.ts +71 -0
  131. package/src/testing/conformance.ts +12 -0
  132. package/src/testing/fake-api.ts +4 -4
  133. package/src/testing/fixtures.ts +4 -1
  134. package/src/remotion/Root.tsx +0 -31
  135. package/src/remotion/kit/Music.tsx +0 -19
  136. package/src/remotion/kit/SceneFrame.tsx +0 -19
  137. package/src/remotion/kit/docs.ts +0 -124
  138. package/src/remotion/kit/index.ts +0 -29
  139. package/src/remotion/kit/music-math.ts +0 -42
  140. /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
  141. /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
  142. /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
  143. /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
  144. /package/src/{remotion → hyperframes}/types.ts +0 -0
@@ -1,42 +1,44 @@
1
- import { loadFont as loadBody } from "@remotion/google-fonts/Inter";
2
- import { loadFont as loadDisplay } from "@remotion/google-fonts/Sora";
3
- import { loadFont as loadArchivoBlack } from "@remotion/google-fonts/ArchivoBlack";
4
- import { loadFont as loadBebasNeue } from "@remotion/google-fonts/BebasNeue";
5
- import { loadFont as loadGreatVibes } from "@remotion/google-fonts/GreatVibes";
6
- import { loadFont as loadKanit } from "@remotion/google-fonts/Kanit";
7
- import { loadFont as loadModak } from "@remotion/google-fonts/Modak";
8
- import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat";
9
- import { loadFont as loadRoboto } from "@remotion/google-fonts/Roboto";
10
- import { loadFont as loadRobotoSlab } from "@remotion/google-fonts/RobotoSlab";
11
- import { loadFont as loadSacramento } from "@remotion/google-fonts/Sacramento";
12
- import { loadFont as loadUnbounded } from "@remotion/google-fonts/Unbounded";
13
- import { loadFont as loadBungeeShade } from "@remotion/google-fonts/BungeeShade";
14
- import { loadFont as loadCinzel } from "@remotion/google-fonts/Cinzel";
15
- import { loadFont as loadCreepster } from "@remotion/google-fonts/Creepster";
16
- import { loadFont as loadHeebo } from "@remotion/google-fonts/Heebo";
17
- import { loadFont as loadKaushanScript } from "@remotion/google-fonts/KaushanScript";
18
- import { loadFont as loadLilitaOne } from "@remotion/google-fonts/LilitaOne";
19
- import { loadFont as loadMrsSaintDelafield } from "@remotion/google-fonts/MrsSaintDelafield";
20
- import { loadFont as loadOrbitron } from "@remotion/google-fonts/Orbitron";
21
- import { loadFont as loadOutfit } from "@remotion/google-fonts/Outfit";
22
- import { loadFont as loadPermanentMarker } from "@remotion/google-fonts/PermanentMarker";
23
- import { loadFont as loadPlayfairDisplay } from "@remotion/google-fonts/PlayfairDisplay";
24
- import { loadFont as loadRubik } from "@remotion/google-fonts/Rubik";
25
- import { loadFont as loadRye } from "@remotion/google-fonts/Rye";
26
- import { loadFont as loadSyne } from "@remotion/google-fonts/Syne";
27
- import { loadFont as loadYellowtail } from "@remotion/google-fonts/Yellowtail";
28
- import { loadFont as loadAlef } from "@remotion/google-fonts/Alef";
29
- import { loadFont as loadAmaticSC } from "@remotion/google-fonts/AmaticSC";
30
- import { loadFont as loadAssistant } from "@remotion/google-fonts/Assistant";
31
- import { loadFont as loadDavidLibre } from "@remotion/google-fonts/DavidLibre";
32
- import { loadFont as loadFrankRuhlLibre } from "@remotion/google-fonts/FrankRuhlLibre";
33
- import { loadFont as loadFredoka } from "@remotion/google-fonts/Fredoka";
34
- import { loadFont as loadKarantina } from "@remotion/google-fonts/Karantina";
35
- import { loadFont as loadNotoSansHebrew } from "@remotion/google-fonts/NotoSansHebrew";
36
- import { loadFont as loadSecularOne } from "@remotion/google-fonts/SecularOne";
37
- import { loadFont as loadSuezOne } from "@remotion/google-fonts/SuezOne";
38
- import { loadFont as loadVarelaRound } from "@remotion/google-fonts/VarelaRound";
39
- import { Easing } from "remotion";
1
+ import { loadFont } from "../fonts";
2
+ const loadBody = (_style: string, _options: unknown) => loadFont("Inter", "inter");
3
+ const loadDisplay = (_style: string, _options: unknown) => loadFont("Sora", "sora");
4
+ const loadArchivoBlack = (_style: string, _options: unknown) => loadFont("Archivo Black", "archivo-black");
5
+ const loadBebasNeue = (_style: string, _options: unknown) => loadFont("Bebas Neue", "bebas-neue");
6
+ const loadGreatVibes = (_style: string, _options: unknown) => loadFont("Great Vibes", "great-vibes");
7
+ const loadJetBrainsMono = (_style: string, _options: unknown) => loadFont("JetBrains Mono", "jetbrains-mono");
8
+ const loadKanit = (_style: string, _options: unknown) => loadFont("Kanit", "kanit");
9
+ const loadModak = (_style: string, _options: unknown) => loadFont("Modak", "modak");
10
+ const loadMontserrat = (_style: string, _options: unknown) => loadFont("Montserrat", "montserrat");
11
+ const loadRoboto = (_style: string, _options: unknown) => loadFont("Roboto", "roboto");
12
+ const loadRobotoSlab = (_style: string, _options: unknown) => loadFont("Roboto Slab", "roboto-slab");
13
+ const loadSacramento = (_style: string, _options: unknown) => loadFont("Sacramento", "sacramento");
14
+ const loadUnbounded = (_style: string, _options: unknown) => loadFont("Unbounded", "unbounded");
15
+ const loadBungeeShade = (_style: string, _options: unknown) => loadFont("Bungee Shade", "bungee-shade");
16
+ const loadCinzel = (_style: string, _options: unknown) => loadFont("Cinzel", "cinzel");
17
+ const loadCreepster = (_style: string, _options: unknown) => loadFont("Creepster", "creepster");
18
+ const loadHeebo = (_style: string, _options: unknown) => loadFont("Heebo", "heebo");
19
+ const loadKaushanScript = (_style: string, _options: unknown) => loadFont("Kaushan Script", "kaushan-script");
20
+ const loadLilitaOne = (_style: string, _options: unknown) => loadFont("Lilita One", "lilita-one");
21
+ const loadMrsSaintDelafield = (_style: string, _options: unknown) => loadFont("Mrs Saint Delafield", "mrs-saint-delafield");
22
+ const loadOrbitron = (_style: string, _options: unknown) => loadFont("Orbitron", "orbitron");
23
+ const loadOutfit = (_style: string, _options: unknown) => loadFont("Outfit", "outfit");
24
+ const loadPermanentMarker = (_style: string, _options: unknown) => loadFont("Permanent Marker", "permanent-marker");
25
+ const loadPlayfairDisplay = (_style: string, _options: unknown) => loadFont("Playfair Display", "playfair-display");
26
+ const loadRubik = (_style: string, _options: unknown) => loadFont("Rubik", "rubik");
27
+ const loadRye = (_style: string, _options: unknown) => loadFont("Rye", "rye");
28
+ const loadSyne = (_style: string, _options: unknown) => loadFont("Syne", "syne");
29
+ const loadYellowtail = (_style: string, _options: unknown) => loadFont("Yellowtail", "yellowtail");
30
+ const loadAlef = (_style: string, _options: unknown) => loadFont("Alef", "alef");
31
+ const loadAmaticSC = (_style: string, _options: unknown) => loadFont("Amatic SC", "amatic-sc");
32
+ const loadAssistant = (_style: string, _options: unknown) => loadFont("Assistant", "assistant");
33
+ const loadDavidLibre = (_style: string, _options: unknown) => loadFont("David Libre", "david-libre");
34
+ const loadFrankRuhlLibre = (_style: string, _options: unknown) => loadFont("Frank Ruhl Libre", "frank-ruhl-libre");
35
+ const loadFredoka = (_style: string, _options: unknown) => loadFont("Fredoka", "fredoka");
36
+ const loadKarantina = (_style: string, _options: unknown) => loadFont("Karantina", "karantina");
37
+ const loadNotoSansHebrew = (_style: string, _options: unknown) => loadFont("Noto Sans Hebrew", "noto-sans-hebrew");
38
+ const loadSecularOne = (_style: string, _options: unknown) => loadFont("Secular One", "secular-one");
39
+ const loadSuezOne = (_style: string, _options: unknown) => loadFont("Suez One", "suez-one");
40
+ const loadVarelaRound = (_style: string, _options: unknown) => loadFont("Varela Round", "varela-round");
41
+ import { Easing } from "../frame";
40
42
 
41
43
  // Loaded once for every video. Hero text must never fall back to a system default font.
42
44
  const display = loadDisplay("normal", { weights: ["600", "700", "800"], subsets: ["latin"] });
@@ -77,6 +79,7 @@ const loaders = {
77
79
  // Genre faces for film-style titles (original designs, not imitations of any film's logo):
78
80
  cinzel: () => loadCinzel("normal", { weights: ["700", "900"], ...latin }), // Roman inscription capitals: epic, historical
79
81
  playfairDisplay: () => loadPlayfairDisplay("normal", { weights: ["700", "900"], ...latin }), // high-contrast serif: elegant, editorial
82
+ jetbrainsMono: () => loadJetBrainsMono("normal", { weights: ["400", "700"], ...latin }), // monospace: terminals, code, small labels
80
83
  orbitron: () => loadOrbitron("normal", { weights: ["700", "900"], ...latin }), // geometric sci-fi
81
84
  rye: () => loadRye("normal", { weights: ["400"], ...latin }), // western poster
82
85
  creepster: () => loadCreepster("normal", { weights: ["400"], ...latin }), // horror
@@ -96,6 +99,7 @@ const loaders = {
96
99
  amaticSC: () => loadAmaticSC("normal", { weights: ["400", "700"], ...hebrew }), // hand-lettered capitals
97
100
  };
98
101
  export type KitFont = keyof typeof loaders;
102
+ export const KIT_FONTS = Object.keys(loaders) as KitFont[];
99
103
  const loaded = new Map<KitFont, string>();
100
104
  export function font(name: KitFont): string {
101
105
  let family = loaded.get(name);
@@ -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
+ }