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.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/package.json +5 -1
  3. package/skill/SKILL.md +20 -9
  4. package/skill/THIRD_PARTY.md +102 -0
  5. package/skill/commands/launch-film.md +7 -0
  6. package/skill/reference/asset-reuse.md +13 -2
  7. package/skill/reference/backgrounds.md +63 -0
  8. package/skill/reference/beat-sync.md +25 -19
  9. package/skill/reference/captions.md +11 -5
  10. package/skill/reference/continuity.md +21 -2
  11. package/skill/reference/kit.md +139 -11
  12. package/skill/reference/launch-film.md +190 -0
  13. package/skill/reference/remotion-composition.md +4 -3
  14. package/skill/reference/scene-treatments.md +20 -0
  15. package/skill/reference/scriptwriting.md +4 -1
  16. package/skill/reference/three-d.md +134 -0
  17. package/skill/reference/voice-sync.md +108 -0
  18. package/src/agents.ts +23 -12
  19. package/src/api/client.ts +4 -1
  20. package/src/cli.ts +18 -7
  21. package/src/commands/assets.ts +310 -30
  22. package/src/commands/build.ts +148 -35
  23. package/src/commands/init.ts +1 -1
  24. package/src/commands/install.ts +1 -1
  25. package/src/commands/plan.ts +8 -5
  26. package/src/commands/ref.ts +5 -2
  27. package/src/contract/index.ts +4 -2
  28. package/src/pipeline/beatsnap.ts +72 -0
  29. package/src/pipeline/review.ts +44 -10
  30. package/src/pipeline/schema.ts +51 -4
  31. package/src/pipeline/timing.ts +27 -1
  32. package/src/project/background.ts +33 -0
  33. package/src/project/layers.ts +60 -0
  34. package/src/project/manifest.ts +59 -14
  35. package/src/project/music.ts +19 -5
  36. package/src/project/project.ts +4 -1
  37. package/src/project/soundreport.ts +347 -0
  38. package/src/project/svgcheck.ts +21 -0
  39. package/src/remotion/kit/Assemble3D.tsx +92 -0
  40. package/src/remotion/kit/BrowserFrame.tsx +83 -0
  41. package/src/remotion/kit/Camera.tsx +6 -4
  42. package/src/remotion/kit/Captions.tsx +33 -17
  43. package/src/remotion/kit/Card3D.tsx +211 -0
  44. package/src/remotion/kit/ChapterFrame.tsx +68 -0
  45. package/src/remotion/kit/CounterRoll.tsx +75 -0
  46. package/src/remotion/kit/GlassPanel.tsx +43 -0
  47. package/src/remotion/kit/Grounds.tsx +177 -0
  48. package/src/remotion/kit/Headline.tsx +97 -0
  49. package/src/remotion/kit/Hero3D.tsx +197 -0
  50. package/src/remotion/kit/HudOverlay.tsx +52 -0
  51. package/src/remotion/kit/ImageLayers.tsx +48 -0
  52. package/src/remotion/kit/Music.tsx +4 -4
  53. package/src/remotion/kit/NamedCursor.tsx +54 -0
  54. package/src/remotion/kit/Orbit3D.tsx +49 -0
  55. package/src/remotion/kit/Particles3D.tsx +74 -0
  56. package/src/remotion/kit/Place.tsx +12 -0
  57. package/src/remotion/kit/PromptBox.tsx +84 -0
  58. package/src/remotion/kit/Scene3D.tsx +70 -0
  59. package/src/remotion/kit/SceneFrame.tsx +88 -11
  60. package/src/remotion/kit/SoundCues.tsx +22 -0
  61. package/src/remotion/kit/TerminalLog.tsx +98 -0
  62. package/src/remotion/kit/Text3D.tsx +78 -0
  63. package/src/remotion/kit/TextOnImage.tsx +41 -0
  64. package/src/remotion/kit/Warp3D.tsx +59 -0
  65. package/src/remotion/kit/bg-math.ts +179 -0
  66. package/src/remotion/kit/caption-groups.ts +7 -3
  67. package/src/remotion/kit/caption-style.ts +45 -0
  68. package/src/remotion/kit/docs.ts +132 -11
  69. package/src/remotion/kit/image-layers-math.ts +115 -0
  70. package/src/remotion/kit/index.ts +43 -1
  71. package/src/remotion/kit/inter-bold-typeface.ts +3 -0
  72. package/src/remotion/kit/motion-math.ts +36 -2
  73. package/src/remotion/kit/music-math.ts +27 -10
  74. package/src/remotion/kit/quiet-three.ts +11 -0
  75. package/src/remotion/kit/sample-text.ts +55 -0
  76. package/src/remotion/kit/scene3d-context.ts +5 -0
  77. package/src/remotion/kit/seeded.ts +13 -0
  78. package/src/remotion/kit/sound-cues.ts +89 -0
  79. package/src/remotion/kit/sound-kinds.ts +122 -0
  80. package/src/remotion/kit/theme.ts +2 -0
  81. package/src/remotion/kit/three-fx-math.ts +192 -0
  82. package/src/remotion/kit/three-math.ts +145 -0
  83. package/src/remotion/kit/transition-math.ts +116 -0
  84. package/src/remotion/kit/ui-math.ts +145 -0
  85. package/src/remotion/kit/ui-theme.ts +25 -0
  86. package/src/remotion/kit/word-anchor.ts +107 -0
  87. package/src/render/contact-sheet.ts +39 -0
  88. package/src/render/continuity.ts +14 -4
  89. package/src/render/deps.ts +15 -3
  90. package/src/render/render.ts +15 -8
  91. package/src/render/sound-notes.ts +106 -0
  92. package/src/render/word-check.ts +181 -0
  93. package/src/testing/conformance.ts +12 -0
  94. package/src/testing/fake-api.ts +4 -4
  95. package/src/testing/fixtures.ts +3 -0
@@ -1,7 +1,8 @@
1
1
  import React from "react";
2
2
  import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
3
  import type { WordTiming } from "../../pipeline/schema";
4
- import { captionGroups, groupAt, type CaptionGroup } from "./caption-groups";
4
+ import { captionGroups, DEFAULT_GROUP_WORDS, groupAt, type CaptionGroup } from "./caption-groups";
5
+ import { inkOnAccent, legibleAccent, PLATE_COLOR } from "./caption-style";
5
6
  import { fonts, springs } from "./theme";
6
7
 
7
8
  export type CaptionMode = "highlight" | "pop" | "karaoke";
@@ -10,11 +11,15 @@ export type CaptionMode = "highlight" | "pop" | "karaoke";
10
11
  // highlight: a line of words, the spoken one in the highlight colour (calm, educational)
11
12
  // pop: one to three words at a time, each popping in as it is spoken (high energy)
12
13
  // karaoke: a line that fills with the highlight colour as it is spoken (voiceover, music)
13
- // group, when given, decides which words share the screen and replaces perLine:
14
- // "word": exactly one word at a time
15
- // "phrase": words grouped as they are spoken: a group ends at a sentence end, at a comma or dash after three words, or at a pause of
16
- // 0.35 s, and never holds more than 6 words or about 32 characters
14
+ // The words sit on a dark rounded plate (about 58% opaque) in white, so they read on cream and on a busy photo alike without choosing colours; the
15
+ // spoken word is on a chip of `highlight` (the film's accent) with white or dark ink, whichever reads better on it (karaoke has no chip: its fill is
16
+ // the accent mixed toward white only as far as it needs to stay legible on the plate).
17
+ // group decides which words share the screen:
18
+ // "word": exactly one word at a time (only when the plan says `captions: "word"`)
19
+ // "phrase": words grouped as they are spoken, at most `maxWords` (default 3) and about 32 characters: a group ends at a sentence end (. ? !, never
20
+ // spanning two sentences), at a comma or dash after three words, or at a pause of 0.35 s
17
21
  // "none": nothing is drawn (the plan's `captions: "none"`), so the manifest's value can be passed straight through
22
+ // With neither group nor perLine the groups are the same as "phrase": a few words (3), never one. perLine alone counts words as it always did.
18
23
  export const Captions: React.FC<{
19
24
  words: WordTiming[];
20
25
  mode?: CaptionMode;
@@ -22,28 +27,37 @@ export const Captions: React.FC<{
22
27
  color?: string;
23
28
  perLine?: number;
24
29
  group?: CaptionGroup;
30
+ // The most words in a group (default 3) for "phrase" and for the default.
31
+ maxWords?: number;
25
32
  uppercase?: boolean;
26
33
  // Distance of the caption baseline from the bottom, as a fraction of the height. Default keeps it clear of platform UI.
27
34
  bottom?: number;
28
35
  // Font family. Set to font("heebo") or font("rubik") for Hebrew; rtl also reverses the word order on the line.
29
36
  face?: string;
30
37
  rtl?: boolean;
31
- }> = ({ words, mode = "highlight", highlight = "#ffe14d", color = "#ffffff", perLine, group, uppercase = false, bottom = 0.16, face, rtl = false }) => {
38
+ }> = ({ words, mode = "highlight", highlight = "#ffe14d", color = "#ffffff", perLine, group, maxWords, uppercase = false, bottom = 0.16, face, rtl = false }) => {
32
39
  const frame = useCurrentFrame();
33
40
  const { fps, width, height } = useVideoConfig();
34
41
  const t = frame / fps;
35
42
  if (words.length === 0 || group === "none") return null;
36
43
 
37
44
  if (group === "word" && t < words[0]!.startSec) return null;
45
+ // Without group or perLine a group is a few words (3), as with "phrase".
46
+ const grouping = group ?? (perLine === undefined ? "phrase" : undefined);
47
+ const accent = legibleAccent(highlight);
48
+ const chipInk = inkOnAccent(highlight);
49
+ // Every word has the same padding, so the line does not shift as the chip moves from word to word.
50
+ const pad: React.CSSProperties = { borderRadius: "0.3em", padding: "0 0.14em" };
51
+ const chip: React.CSSProperties = { ...pad, background: highlight, color: chipInk, textShadow: "none" };
38
52
  let active: number, lineStart: number, line: WordTiming[];
39
- if (group) {
40
- const groups = captionGroups(words, group);
53
+ if (grouping) {
54
+ const groups = captionGroups(words, grouping, { maxWords: maxWords ?? DEFAULT_GROUP_WORDS });
41
55
  const at = groupAt(words, groups, t);
42
56
  active = at.active;
43
57
  [lineStart] = groups[at.index]!;
44
58
  line = words.slice(lineStart, groups[at.index]![1]);
45
59
  } else {
46
- const size = perLine ?? (mode === "pop" ? 2 : 4);
60
+ const size = perLine ?? DEFAULT_GROUP_WORDS;
47
61
  active = words.findIndex((w) => t < w.endSec);
48
62
  if (active === -1) active = words.length - 1;
49
63
  lineStart = Math.floor(active / size) * size;
@@ -53,15 +67,16 @@ export const Captions: React.FC<{
53
67
 
54
68
  const base: React.CSSProperties = {
55
69
  fontFamily: face ?? fonts.body, fontWeight: 800, textAlign: "center", direction: rtl ? "rtl" : "ltr", padding: `0 ${width * 0.06}px`,
56
- fontSize: width * (mode === "pop" || group === "word" ? 0.075 : 0.055), lineHeight: 1.15, color,
57
- // A dark outline plus shadow keeps contrast above 4.5:1 on any background.
58
- WebkitTextStroke: `${width * 0.006}px rgba(0,0,0,0.85)`, paintOrder: "stroke fill",
59
- textShadow: "0 4px 18px rgba(0,0,0,0.7)",
70
+ fontSize: width * (mode === "pop" || group === "word" ? 0.075 : 0.055), lineHeight: 1.5, color,
71
+ textShadow: "0 2px 10px rgba(0,0,0,0.45)",
60
72
  };
73
+ // The plate: one dark rounded box behind each line of the group, so the white ink has 4.5:1 or more on any ground.
74
+ const plate: React.CSSProperties = { background: PLATE_COLOR, borderRadius: "0.42em", padding: "0.12em 0.5em", boxDecorationBreak: "clone", WebkitBoxDecorationBreak: "clone" };
61
75
 
62
76
  return (
63
77
  <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: height * bottom }}>
64
78
  <div style={base}>
79
+ <span style={plate}>
65
80
  {line.map((w, i) => {
66
81
  const index = lineStart + i;
67
82
  const started = t >= w.startSec;
@@ -69,7 +84,7 @@ export const Captions: React.FC<{
69
84
  // A word appears on the frame it starts, never before, with a small overshoot.
70
85
  const p = spring({ frame: frame - Math.round(w.startSec * fps), fps, config: springs.snappy });
71
86
  return (
72
- <span key={index} style={{ display: "inline-block", margin: `0 ${width * 0.012}px`, opacity: started ? 1 : 0, transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`, color: index === active ? highlight : color }}>
87
+ <span key={index} style={{ display: "inline-block", margin: `0 ${width * 0.012}px`, opacity: started ? 1 : 0, transform: `scale(${interpolate(p, [0, 1], [0.6, 1])})`, color, ...pad, ...(index === active ? chip : {}) }}>
73
88
  {show(w)}
74
89
  </span>
75
90
  );
@@ -77,17 +92,18 @@ export const Captions: React.FC<{
77
92
  if (mode === "karaoke") {
78
93
  const fill = interpolate(t, [w.startSec, Math.max(w.endSec, w.startSec + 0.01)], [0, 100], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
79
94
  return (
80
- <span key={index} style={{ display: "inline-block", marginRight: "0.28em", backgroundImage: `linear-gradient(90deg, ${highlight} ${fill}%, ${color} ${fill}%)`, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent", WebkitTextStroke: "0px transparent", textShadow: "none", filter: "drop-shadow(0 3px 6px rgba(0,0,0,0.75))" }}>
95
+ <span key={index} style={{ display: "inline-block", marginRight: "0.28em", backgroundImage: `linear-gradient(90deg, ${accent} ${fill}%, ${color} ${fill}%)`, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent", WebkitTextStroke: "0px transparent", textShadow: "none", filter: "drop-shadow(0 3px 6px rgba(0,0,0,0.75))" }}>
81
96
  {show(w)}
82
97
  </span>
83
98
  );
84
99
  }
85
100
  return (
86
- <span key={index} style={{ color: index === active && started ? highlight : color }}>
87
- {show(w)}{" "}
101
+ <span key={index} style={index === active && started ? chip : { color, ...pad }}>
102
+ {show(w)}{i < line.length - 1 ? " " : ""}
88
103
  </span>
89
104
  );
90
105
  })}
106
+ </span>
91
107
  </div>
92
108
  </AbsoluteFill>
93
109
  );
@@ -0,0 +1,211 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { useLoader } from "@react-three/fiber";
4
+ import { CanvasTexture, FrontSide, Shape, ShapeGeometry, SRGBColorSpace, TextureLoader, type Texture } from "three";
5
+ import { delayRender, continueRender } from "remotion";
6
+ import { useMedia } from "./media";
7
+ import { fonts } from "./theme";
8
+ import { degToRad, floatAt, inkOn, mixHex, type Vec3 } from "./three-math";
9
+
10
+ // A rounded rectangle, with its texture coordinates running 0 to 1 across it so that a picture fills it.
11
+ function roundedCard(w: number, h: number, r: number): ShapeGeometry {
12
+ const x = -w / 2, y = -h / 2, k = Math.min(r, w / 2, h / 2);
13
+ const s = new Shape();
14
+ s.moveTo(x + k, y);
15
+ s.lineTo(x + w - k, y); s.quadraticCurveTo(x + w, y, x + w, y + k);
16
+ s.lineTo(x + w, y + h - k); s.quadraticCurveTo(x + w, y + h, x + w - k, y + h);
17
+ s.lineTo(x + k, y + h); s.quadraticCurveTo(x, y + h, x, y + h - k);
18
+ s.lineTo(x, y + k); s.quadraticCurveTo(x, y, x + k, y);
19
+ const g = new ShapeGeometry(s, 8);
20
+ const p = g.attributes.position!, uv = g.attributes.uv!;
21
+ for (let i = 0; i < p.count; i++) uv.setXY(i, (p.getX(i) - x) / w, (p.getY(i) - y) / h);
22
+ return g;
23
+ }
24
+
25
+ // A soft dark blur in the card's shape, for the shadow behind it. Drawn once on a canvas; it needs no light.
26
+ function shadowTexture(): CanvasTexture {
27
+ const c = document.createElement("canvas");
28
+ c.width = 256; c.height = 256;
29
+ const g = c.getContext("2d")!;
30
+ g.filter = "blur(18px)";
31
+ g.fillStyle = "rgba(0,0,0,0.55)";
32
+ g.beginPath();
33
+ g.roundRect(48, 48, 160, 160, 28);
34
+ g.fill();
35
+ const t = new CanvasTexture(c);
36
+ t.colorSpace = SRGBColorSpace;
37
+ return t;
38
+ }
39
+
40
+ // Any CSS colour as "#rrggbb", by letting a canvas parse it.
41
+ function normalise(color: string): string {
42
+ const g = document.createElement("canvas").getContext("2d")!;
43
+ g.fillStyle = "#000000";
44
+ g.fillStyle = color;
45
+ const v = String(g.fillStyle);
46
+ return v.startsWith("#") ? v : "#e8e8f0";
47
+ }
48
+
49
+ type Face = { color: string; accent?: string; label?: string; icon?: HTMLImageElement; family: string; ratio: number; radius: number };
50
+
51
+ // The face of a card drawn on a canvas: a gradient (lighter at the top left), a soft highlight, a rim in the accent and, if given, an icon above a large
52
+ // label. Drawn once; nothing here depends on the frame, so every frame shows the same face. The ink is white on a dark face and dark on a light one.
53
+ function drawFace(f: Face): CanvasTexture {
54
+ const long = 1024, W = Math.round(long * Math.min(1, f.ratio)), H = Math.round(long * Math.min(1, 1 / f.ratio));
55
+ const c = document.createElement("canvas");
56
+ c.width = W; c.height = H;
57
+ const g = c.getContext("2d")!;
58
+ const base = normalise(f.color), accent = normalise(f.accent ?? mixHex(base, "#ffffff", 0.55));
59
+ const r = Math.min(W, H) * Math.min(0.5, f.radius / Math.min(f.ratio, 1) * 0.33);
60
+ const body = new Path2D();
61
+ body.roundRect(0, 0, W, H, r);
62
+ g.save();
63
+ g.clip(body);
64
+ const grad = g.createLinearGradient(0, 0, W * 0.8, H);
65
+ grad.addColorStop(0, mixHex(base, "#ffffff", 0.16));
66
+ grad.addColorStop(0.55, base);
67
+ grad.addColorStop(1, mixHex(base, "#000000", 0.3));
68
+ g.fillStyle = grad;
69
+ g.fillRect(0, 0, W, H);
70
+ const hi = g.createLinearGradient(0, 0, 0, H * 0.5);
71
+ hi.addColorStop(0, "rgba(255,255,255,0.20)");
72
+ hi.addColorStop(1, "rgba(255,255,255,0)");
73
+ g.fillStyle = hi;
74
+ g.fillRect(0, 0, W, H * 0.5);
75
+ g.restore();
76
+ // The rim: an outer line in the accent and a fine light line just inside it.
77
+ const edge = Math.max(4, Math.min(W, H) * 0.018);
78
+ g.lineWidth = edge;
79
+ g.strokeStyle = accent;
80
+ g.beginPath(); g.roundRect(edge / 2, edge / 2, W - edge, H - edge, Math.max(1, r - edge / 2)); g.stroke();
81
+ g.lineWidth = Math.max(1.5, edge * 0.35);
82
+ g.strokeStyle = "rgba(255,255,255,0.28)";
83
+ g.beginPath(); g.roundRect(edge * 1.7, edge * 1.7, W - edge * 3.4, H - edge * 3.4, Math.max(1, r - edge * 1.7)); g.stroke();
84
+
85
+ const ink = inkOn(base), m = Math.min(W, H);
86
+ const lines = (f.label ?? "").split("\n").filter(Boolean);
87
+ let px = m * 0.2;
88
+ if (lines.length) {
89
+ g.font = `800 ${px}px ${f.family}`;
90
+ const widest = Math.max(...lines.map((l) => g.measureText(l).width));
91
+ if (widest > W * 0.8) px *= (W * 0.8) / widest;
92
+ px = Math.min(px, H * 0.22);
93
+ }
94
+ const labelH = lines.length * px * 1.12;
95
+ const iconSize = f.icon ? (lines.length ? m * 0.4 : m * 0.55) : 0;
96
+ const gap = f.icon && lines.length ? m * 0.07 : 0;
97
+ let y = (H - (iconSize + gap + labelH)) / 2;
98
+ if (f.icon) {
99
+ // Fitted into a square of iconSize, keeping its shape.
100
+ const ratio = (f.icon.naturalWidth || 1) / (f.icon.naturalHeight || 1);
101
+ const dw = ratio >= 1 ? iconSize : iconSize * ratio, dh = ratio >= 1 ? iconSize / ratio : iconSize;
102
+ g.drawImage(f.icon, (W - dw) / 2, y + (iconSize - dh) / 2, dw, dh);
103
+ y += iconSize + gap;
104
+ }
105
+ g.fillStyle = ink;
106
+ g.textAlign = "center"; g.textBaseline = "middle";
107
+ g.font = `800 ${px}px ${f.family}`;
108
+ lines.forEach((l, i) => g.fillText(l, W / 2, y + (i + 0.5) * px * 1.12));
109
+ const t = new CanvasTexture(c);
110
+ t.colorSpace = SRGBColorSpace;
111
+ t.anisotropy = 8;
112
+ return t;
113
+ }
114
+
115
+ // Waits, before the frame is drawn, for the typeface and the icon, so that the label is set in the film's font and never in a fallback.
116
+ function useFaceAssets(label: string | undefined, iconUrl: string | undefined, family: string): { ready: boolean; icon?: HTMLImageElement } {
117
+ const need = Boolean(label || iconUrl);
118
+ const [state, setState] = React.useState<{ ready: boolean; icon?: HTMLImageElement }>({ ready: !need });
119
+ React.useLayoutEffect(() => {
120
+ if (!need) { setState({ ready: true }); return; }
121
+ const handle = delayRender("Card3D face");
122
+ let live = true;
123
+ (async () => {
124
+ try { await Promise.all([document.fonts.load(`800 64px ${family}`, label ?? "A"), document.fonts.ready]); } catch { /* the fallback face is fine */ }
125
+ let icon: HTMLImageElement | undefined;
126
+ if (iconUrl) {
127
+ icon = new Image();
128
+ try { await new Promise<void>((ok, bad) => { icon!.onload = () => ok(); icon!.onerror = () => bad(new Error("icon")); icon!.src = iconUrl; }); } catch { icon = undefined; }
129
+ }
130
+ if (live) setState({ ready: true, icon });
131
+ })().finally(() => continueRender(handle));
132
+ return () => { live = false; };
133
+ }, [need, label, iconUrl, family]);
134
+ return state;
135
+ }
136
+
137
+ const Designed: React.FC<{ width: number; height: number; radius: number; color: string; accent?: string; label?: string; icon?: string; family: string }> = ({ width, height, radius, color, accent, label, icon, family }) => {
138
+ const iconUrl = useMedia(icon ?? "");
139
+ const assets = useFaceAssets(label, icon ? iconUrl : undefined, family);
140
+ const flat = React.useMemo(() => roundedCard(width, height, radius), [width, height, radius]);
141
+ const texture = React.useMemo(() => drawFace({ color, accent, label, icon: assets.icon, family, ratio: width / height, radius }), [color, accent, label, assets, family, width, height, radius]);
142
+ React.useEffect(() => () => texture.dispose(), [texture]);
143
+ return <Body geometry={flat} width={width} height={height}><meshBasicMaterial map={texture} toneMapped={false} /></Body>;
144
+ };
145
+
146
+ const Picture: React.FC<{ url: string; width: number; height?: number; radius: number }> = ({ url, width, height, radius }) => {
147
+ const texture = useLoader(TextureLoader, url) as Texture;
148
+ texture.colorSpace = SRGBColorSpace;
149
+ const img = texture.image as { width: number; height: number } | undefined;
150
+ const h = height ?? (img ? (width * img.height) / img.width : width * 0.66);
151
+ const geometry = React.useMemo(() => roundedCard(width, h, radius), [width, h, radius]);
152
+ return <Body geometry={geometry} width={width} height={h}><meshBasicMaterial map={texture} toneMapped={false} /></Body>;
153
+ };
154
+
155
+ // The card's body with its soft shadow. A face is drawn on both sides, so that one seen from behind (the far side of a ring) reads the right way round
156
+ // rather than as a mirror image.
157
+ const Body: React.FC<{ geometry: ShapeGeometry; width: number; height: number; children: React.ReactElement }> = ({ geometry, width, height, children }) => {
158
+ const shadow = React.useMemo(shadowTexture, []);
159
+ const face = React.cloneElement(children, { side: FrontSide } as never);
160
+ return (
161
+ <>
162
+ <mesh position={[0.04 * width, -0.07 * height, -0.04]} scale={[width * 1.7, height * 1.7, 1]}>
163
+ <planeGeometry args={[1, 1]} />
164
+ <meshBasicMaterial map={shadow} transparent depthWrite={false} toneMapped={false} />
165
+ </mesh>
166
+ <mesh geometry={geometry}>{face}</mesh>
167
+ <mesh geometry={geometry} rotation={[0, Math.PI, 0]} position={[0, 0, -0.004]}>{React.cloneElement(children, { side: FrontSide } as never)}</mesh>
168
+ </>
169
+ );
170
+ };
171
+
172
+ // A rounded plane in space that shows a picture (`src`, a project path or urls[...]) or a designed face, for screenshots and for cards that carry a
173
+ // word or a mark. Without `src` the face is a gradient of `color` with a soft highlight and a rim in `accent` (default a light tint of the colour);
174
+ // `label` is drawn large on it in the film's font (`font`, default the kit's display face) and `icon` (a project path to an image or svg, drawn in its
175
+ // own colours) above the label; the ink is white on a dark colour and dark on a light one. Give every card in a ring a label or an icon. width is in
176
+ // scene units (default 3); the height follows the picture's shape unless set. position is [x, y, z]; rotation is [x, y, z] in degrees; `tilt` is a
177
+ // shorthand for the one tilt a card needs: a turn about the vertical axis in degrees, with a lean the other way about the horizontal. A soft shadow sits
178
+ // behind it, and `float` makes it drift slowly (true, or a number for how much), the same at the same frame.
179
+ export const Card3D: React.FC<{
180
+ src?: string;
181
+ color?: string;
182
+ width?: number;
183
+ height?: number;
184
+ radius?: number;
185
+ position?: Vec3;
186
+ rotation?: Vec3;
187
+ tilt?: number;
188
+ float?: boolean | number;
189
+ label?: string;
190
+ icon?: string;
191
+ accent?: string;
192
+ font?: string;
193
+ }> = ({ src, color = "#e8e8f0", width = 3, height, radius = 0.12, position = [0, 0, 0], rotation = [0, 0, 0], tilt = 0, float: drift, label, icon, accent, font = fonts.display }) => {
194
+ const frame = useCurrentFrame();
195
+ const { fps } = useVideoConfig();
196
+ const url = useMedia(src ?? "");
197
+ const f = drift ? floatAt(frame, fps, drift === true ? 1 : drift, position[0] * 3 + position[1]) : { offset: [0, 0, 0] as Vec3, rotation: [0, 0, 0] as Vec3 };
198
+ const h = height ?? width * 0.66;
199
+ return (
200
+ <group
201
+ position={[position[0] + f.offset[0], position[1] + f.offset[1], position[2] + f.offset[2]]}
202
+ rotation={[degToRad(rotation[0] - tilt * 0.3) + f.rotation[0], degToRad(rotation[1] + tilt) + f.rotation[1], degToRad(rotation[2]) + f.rotation[2]]}
203
+ >
204
+ {src ? (
205
+ <React.Suspense fallback={null}><Picture url={url} width={width} height={height} radius={radius} /></React.Suspense>
206
+ ) : (
207
+ <Designed width={width} height={h} radius={radius} color={color} accent={accent} label={label} icon={icon} family={font} />
208
+ )}
209
+ </group>
210
+ );
211
+ };
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { font as loadFont, fonts, springs } from "./theme";
4
+ import { inkOn, mixHex } from "./three-math";
5
+ import { chapterFrames, clamp01 } from "./ui-math";
6
+ import { rgba } from "./ui-theme";
7
+
8
+ // The grammar of one chapter of a film, as slots, so that every scene of a film is built the same way: a counter "03 / 07" at the top with a progress rule
9
+ // under it, a large ghost word behind everything, `children` as the hero in the middle, up to three `badges` (pills that pop in) and one `caption` line at the
10
+ // bottom. `color` is the chapter's ground and fills the frame (a different one per chapter is the colour script); the ink is chosen to read on it. Use it
11
+ // once per scene, with the same props shape in every scene. Times are frames from the component's own mount; chapterFrames({ delay, badges }) gives the
12
+ // frames the badges pop, for sound. `index` counts from 1. `hero` tints the first badge and the rule's head.
13
+ export const ChapterFrame: React.FC<{
14
+ index: number;
15
+ total: number;
16
+ color: string;
17
+ ghost?: string;
18
+ caption?: string;
19
+ badges?: string[];
20
+ hero?: string;
21
+ ink?: string;
22
+ font?: string;
23
+ delay?: number;
24
+ children?: React.ReactNode;
25
+ }> = ({ index, total, color, ghost, caption, badges = [], hero, ink, font = fonts.display, delay = 0, children }) => {
26
+ const frame = useCurrentFrame();
27
+ const { fps, width: W, height: H } = useVideoConfig();
28
+ const text = ink ?? inkOn(color);
29
+ const mono = loadFont("jetbrainsMono");
30
+ const ev = chapterFrames({ delay, badges: badges.length });
31
+ const fill = interpolate(frame, [delay, ev.progressDone], [(index - 1) / total, index / total], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
32
+ const letters = ghost ? [...ghost.toUpperCase()].length : 1;
33
+ const ghostSize = Math.max(W * 0.16, Math.min(W * 0.46, (W * 0.96) / (letters * 0.66)));
34
+ const ghostIn = spring({ frame: frame - delay, fps, config: springs.heavy });
35
+ const capIn = spring({ frame: frame - delay - 8, fps, config: springs.smooth });
36
+ const heroIn = spring({ frame: frame - delay + 2, fps, config: springs.smooth });
37
+ return (
38
+ <AbsoluteFill style={{ background: color, color: text, fontFamily: font, overflow: "hidden" }}>
39
+ {ghost ? (
40
+ <div style={{ position: "absolute", left: 0, right: 0, top: H * 0.5, transform: `translate(${interpolate(frame, [0, 150], [-W * 0.012, W * 0.012], { extrapolateRight: "extend" })}px, -50%) scale(${0.94 + 0.06 * ghostIn})`, textAlign: "center", fontSize: ghostSize, fontWeight: 900, letterSpacing: "-0.04em", lineHeight: 1, color: rgba(text, 0.1), whiteSpace: "nowrap", opacity: ghostIn }}>
41
+ {ghost.toUpperCase()}
42
+ </div>
43
+ ) : null}
44
+ <div style={{ position: "absolute", left: W * 0.07, right: W * 0.07, top: H * 0.055 }}>
45
+ <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", fontFamily: mono, fontWeight: 700, fontSize: W * 0.04, letterSpacing: "0.04em" }}>
46
+ <span>{String(index).padStart(2, "0")}<span style={{ opacity: 0.45 }}> / {String(total).padStart(2, "0")}</span></span>
47
+ </div>
48
+ <div style={{ position: "relative", marginTop: W * 0.025, height: Math.max(4, W * 0.007), background: rgba(text, 0.18), borderRadius: 999 }}>
49
+ <div style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: `${clamp01(fill) * 100}%`, background: text, borderRadius: 999 }} />
50
+ {hero ? <div style={{ position: "absolute", top: "50%", left: `${clamp01(fill) * 100}%`, width: W * 0.026, height: W * 0.026, borderRadius: 999, background: hero, transform: "translate(-50%, -50%)", boxShadow: `0 0 ${W * 0.02}px ${rgba(hero, 0.8)}` }} /> : null}
51
+ </div>
52
+ </div>
53
+ <div style={{ position: "absolute", left: 0, right: 0, top: H * 0.15, bottom: H * 0.3, display: "flex", alignItems: "center", justifyContent: "center", opacity: heroIn, transform: `translateY(${(1 - heroIn) * H * 0.03}px)` }}>{children}</div>
54
+ <div style={{ position: "absolute", left: W * 0.05, right: W * 0.05, top: H * 0.72, display: "flex", justifyContent: "center", gap: W * 0.025, flexWrap: "wrap" }}>
55
+ {badges.slice(0, 3).map((b, i) => {
56
+ const p = spring({ frame: frame - ev.badges[i]!, fps, config: { stiffness: 260, damping: 14 } });
57
+ const lead = i === 0 && hero;
58
+ return (
59
+ <div key={i} style={{ transform: `scale(${p})`, opacity: clamp01(p * 2), fontWeight: 700, fontSize: W * 0.034, padding: `${W * 0.014}px ${W * 0.04}px`, borderRadius: 999, background: lead ? hero : rgba(text, 0.12), color: lead ? inkOn(hero!) : text, border: lead ? "none" : `${Math.max(2, W * 0.002)}px solid ${rgba(text, 0.3)}`, whiteSpace: "nowrap" }}>{b}</div>
60
+ );
61
+ })}
62
+ </div>
63
+ {caption ? (
64
+ <div style={{ position: "absolute", left: W * 0.07, right: W * 0.07, top: H * 0.8, textAlign: "center", fontWeight: 700, fontSize: W * 0.05, lineHeight: 1.18, letterSpacing: "-0.01em", color: mixHex(text, color, 0.08), opacity: capIn, transform: `translateY(${(1 - capIn) * W * 0.03}px)` }}>{caption}</div>
65
+ ) : null}
66
+ </AbsoluteFill>
67
+ );
68
+ };
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import { interpolate, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { Place } from "./Place";
4
+ import { fonts } from "./theme";
5
+ import { DEFAULT_HERO } from "./ui-theme";
6
+ import { rollValue } from "./ui-math";
7
+
8
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
9
+
10
+ // An odometer: a number whose digits roll like wheels from `from` to `to` over `frames`, easing out and landing exactly, in tabular digits so nothing
11
+ // shifts sideways. `prefix` and `suffix` ("$", "%", "x") sit around it, `decimals` shows places after the point, `group` puts a comma between thousands
12
+ // (default on), `label` is a small line under it. It is sized to fill `width` (a fraction of the frame, default 0.86) at most `maxSize` of the width (0.3).
13
+ // Times are frames from the component's own mount: it starts at `delay` (default 0) and settleFrame({ delay, frames }) is the frame it lands, for a
14
+ // chime. The wheels stay on the final number after that. Colour: `ink` for the digits (default white), `hero` for the prefix, suffix and label.
15
+ export const CounterRoll: React.FC<{
16
+ to: number;
17
+ from?: number;
18
+ frames?: number;
19
+ delay?: number;
20
+ prefix?: string;
21
+ suffix?: string;
22
+ label?: string;
23
+ decimals?: number;
24
+ group?: boolean;
25
+ hero?: string;
26
+ ink?: string;
27
+ font?: string;
28
+ width?: number;
29
+ maxSize?: number;
30
+ y?: number;
31
+ }> = ({ to, from = 0, frames = 45, delay = 0, prefix = "", suffix = "", label, decimals = 0, group = true, hero = DEFAULT_HERO, ink = "#f4f4f5", font = fonts.display, width: fraction = 0.86, maxSize = 0.3, y = 0.5 }) => {
32
+ const frame = useCurrentFrame();
33
+ const { width: W } = useVideoConfig();
34
+ const scale = 10 ** decimals;
35
+ const value = rollValue(frame, { from, to, frames, delay });
36
+ const scaled = value * scale;
37
+ const cols = Math.max(decimals + 1, String(Math.round(Math.max(Math.abs(to), Math.abs(from)) * scale)).length);
38
+ const extra = prefix.length * 0.6 + suffix.length * 0.6 + (group ? Math.floor((cols - 1 - decimals) / 3) * 0.3 : 0) + (decimals ? 0.3 : 0);
39
+ const em = cols * 0.64 + extra;
40
+ const size = Math.min(W * maxSize, (W * fraction) / em);
41
+ const lh = size * 1.06;
42
+ const cells: React.ReactNode[] = [];
43
+ for (let k = cols - 1; k >= 0; k--) {
44
+ const place = 10 ** k;
45
+ const lead = Math.floor(scaled / place) === 0 && k > decimals;
46
+ // The ones wheel turns continuously; every wheel above it only turns as the one below comes round to its carry, like a real odometer.
47
+ const x = scaled / place, whole = Math.floor(x + 1e-9), frac = Math.max(0, x - whole);
48
+ const pos = (whole % 10) + (k === 0 ? frac : Math.max(0, Math.min(1, (frac - 0.8) / 0.2)));
49
+ if (k === decimals - 1 && decimals > 0) cells.push(<span key="dot" style={{ width: size * 0.3, textAlign: "center", lineHeight: `${lh}px` }}>.</span>);
50
+ cells.push(
51
+ <span key={k} style={{ display: "inline-block", width: size * 0.64, height: lh, overflow: "hidden", position: "relative", opacity: lead ? 0 : 1, WebkitMaskImage: "linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%)", maskImage: "linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%)" }}>
52
+ <span style={{ display: "block", transform: `translateY(${-pos * lh}px)` }}>
53
+ {DIGITS.map((d, i) => <span key={i} style={{ display: "block", height: lh, lineHeight: `${lh}px`, textAlign: "center" }}>{d}</span>)}
54
+ </span>
55
+ </span>,
56
+ );
57
+ if (group && k > decimals && (k - decimals) % 3 === 0) {
58
+ const hide = Math.floor(scaled / 10 ** (k - 1)) === 0 && k - 1 > decimals;
59
+ cells.push(<span key={`c${k}`} style={{ display: "inline-block", width: size * 0.3, textAlign: "center", height: lh, lineHeight: `${lh}px`, opacity: hide || lead ? 0 : 1 }}>,</span>);
60
+ }
61
+ }
62
+ const settle = interpolate(frame, [delay, delay + frames], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
63
+ return (
64
+ <Place y={y}>
65
+ <div style={{ textAlign: "center", fontFamily: font, fontWeight: 800, color: ink, fontVariantNumeric: "tabular-nums" }}>
66
+ <div style={{ display: "inline-flex", alignItems: "center", fontSize: size, height: lh }}>
67
+ {prefix ? <span style={{ color: hero, lineHeight: `${lh}px`, marginRight: size * 0.04 }}>{prefix}</span> : null}
68
+ {cells}
69
+ {suffix ? <span style={{ color: hero, lineHeight: `${lh}px`, marginLeft: size * 0.04 }}>{suffix}</span> : null}
70
+ </div>
71
+ {label ? <div style={{ marginTop: size * 0.1, fontSize: Math.max(W * 0.034, size * 0.2), fontWeight: 600, color: hero, opacity: 0.55 + 0.45 * settle, letterSpacing: "0.02em" }}>{label}</div> : null}
72
+ </div>
73
+ </Place>
74
+ );
75
+ };
@@ -0,0 +1,43 @@
1
+ import React from "react";
2
+ import { spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { Place } from "./Place";
4
+ import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
5
+ import { PANEL_SPRING } from "./ui-math";
6
+
7
+ // A slab for your own content: a near-black rounded panel (or a light one with theme="light") with a hairline stroke lit along its top edge, a coloured glow pool
8
+ // behind it, an optional slight perspective `tilt` (degrees about the vertical axis, default 0; the horizontal tilt follows at 0.4 of it) and an entrance that
9
+ // slides up and settles with a small overshoot. `delay` is a frame from the component's own mount (default 0); panelSettleFrame(delay, fps) is the frame it
10
+ // lands, for a "snap" sound. `width` is a fraction of the frame's width (0.86), `height` a fraction of the frame's height (default: as tall as the content),
11
+ // `glow` the colour of the pool (default the hero colour), `y` the height of its middle as a fraction of the frame (0.5). Children are laid out inside
12
+ // with padding; text inside should be at least 0.032 of the frame's width.
13
+ export const GlassPanel: React.FC<{
14
+ children?: React.ReactNode;
15
+ hero?: string;
16
+ glow?: string;
17
+ theme?: UiTheme;
18
+ width?: number;
19
+ height?: number;
20
+ tilt?: number;
21
+ delay?: number;
22
+ radius?: number;
23
+ padding?: number;
24
+ y?: number;
25
+ }> = ({ children, hero = DEFAULT_HERO, glow, theme = "dark", width: fraction = 0.86, height, tilt = 0, delay = 0, radius = 0.06, padding = 0.05, y = 0.5 }) => {
26
+ const frame = useCurrentFrame();
27
+ const { fps, width: W, height: H } = useVideoConfig();
28
+ const c = uiColors(theme === "glass" ? "glass" : theme, hero);
29
+ const P = W * fraction;
30
+ const p = spring({ frame: frame - delay, fps, config: PANEL_SPRING });
31
+ const g = glow ?? hero;
32
+ const alpha = Math.min(1, p * 1.4);
33
+ return (
34
+ <Place y={y}>
35
+ <div style={{ position: "relative", width: P, perspective: P * 2.2, opacity: alpha }}>
36
+ <div style={{ position: "absolute", left: "-12%", right: "-12%", top: "-14%", bottom: "-14%", background: `radial-gradient(ellipse at 50% 55%, ${rgba(g, theme === "light" ? 0.35 : 0.62)} 0%, ${rgba(g, 0.0)} 66%)`, filter: `blur(${P * 0.06}px)`, transform: `translateY(${(1 - p) * H * 0.07}px)` }} />
37
+ <div style={{ position: "relative", transform: `translateY(${(1 - p) * H * 0.07}px) rotateY(${tilt * p}deg) rotateX(${-Math.abs(tilt) * 0.4 * p}deg)`, transformStyle: "preserve-3d", width: P, height: height === undefined ? undefined : H * height, boxSizing: "border-box", padding: P * padding, borderRadius: P * radius, background: theme === "light" ? c.panel : `linear-gradient(160deg, ${rgba("#2a2a3c", 0.72)} 0%, ${c.panel} 46%)`, border: `${Math.max(2, P * 0.003)}px solid ${c.stroke}`, boxShadow: `0 ${P * 0.05}px ${P * 0.12}px ${c.shadow}, inset 0 ${Math.max(2, P * 0.003)}px 0 ${theme === "light" ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,0.22)"}` }}>
38
+ {children}
39
+ </div>
40
+ </div>
41
+ </Place>
42
+ );
43
+ };