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
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { Color, Object3D, type InstancedMesh } from "three";
4
+ import { CameraKeysContext } from "./scene3d-context";
5
+ import { hash01 } from "./seeded";
6
+ import { textSlots } from "./sample-text";
7
+ import { assembleRanks, centred, MAX_PIECES, pieceAt, shapeSlots, type AssembleOrder, type AssembleShape } from "./three-fx-math";
8
+ import { nearestVisible, type Vec3 } from "./three-math";
9
+
10
+ // Instanced blocks that fly from a seeded scattered cloud into an arrangement, one after another, each on an arc with a turn that unwinds, then rest: the build-up
11
+ // beat, up to 4000 pieces in one draw. `shape` is "grid" (a slab), "ring" (a band), "sphere", "wall" (bricks) or "text" (the blocks fall inside the letters of `text`, a
12
+ // short Latin word in the kit's typeface), or give `targets` (any [x, y, z] list; it is centred and fitted). `count` (400 unless set, at most 4000) is how many pieces;
13
+ // `order` is who goes first: "y" bottom to top (default), "x", "radial" from the middle out or "random"; `from` is the frame it starts (default 0) and `frames` how long
14
+ // the whole build takes (default 60: every piece is home at from + frames); `colors` are two or three colours, jittered a little per piece; `fit` is how much of the
15
+ // frame's width the arrangement fills at the closest the Scene3D camera comes (0.8), or give `size` in scene units. `spread` is how far the cloud reaches, in arrangements
16
+ // (1.6). Everything is seeded (`seed`), so a frame is the same every time. assembleProgress, assembledCount and assembleEnd (exported) give a piece counter and the frame of the lock sound.
17
+ export const Assemble3D: React.FC<{
18
+ shape?: AssembleShape;
19
+ text?: string;
20
+ targets?: Vec3[];
21
+ count?: number;
22
+ order?: AssembleOrder;
23
+ from?: number;
24
+ frames?: number;
25
+ colors?: string[];
26
+ fit?: number;
27
+ size?: number;
28
+ spread?: number;
29
+ seed?: number;
30
+ position?: Vec3;
31
+ }> = ({ shape = "grid", text = "Relay", targets, count = 400, order = "y", from = 0, frames = 60, colors = ["#7C3AED", "#22D3EE"], fit = 0.8, size, spread = 1.6, seed = 0, position = [0, 0, 0] }) => {
32
+ const frame = useCurrentFrame();
33
+ const { width, height } = useVideoConfig();
34
+ const keys = React.useContext(CameraKeysContext);
35
+ const n0 = Math.max(1, Math.min(MAX_PIECES, Math.round(count)));
36
+ const slots = React.useMemo(() => (targets?.length ? centred(targets.slice(0, MAX_PIECES)) : shape === "text" ? textSlots(text, n0) : shapeSlots(shape, n0)), [shape, text, targets, n0]);
37
+ const n = slots.length;
38
+ const ranks = React.useMemo(() => assembleRanks(slots, order, seed), [slots, order, seed]);
39
+ // The arrangement's extent, so that it fits the frame, and the size of a piece from how close its neighbours are.
40
+ const geo = React.useMemo(() => {
41
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
42
+ for (const p of slots) { x0 = Math.min(x0, p[0]); x1 = Math.max(x1, p[0]); y0 = Math.min(y0, p[1]); y1 = Math.max(y1, p[1]); }
43
+ const probe = Math.min(n, 48), gaps: number[] = [];
44
+ for (let a = 0; a < probe; a++) {
45
+ const i = Math.floor((a / probe) * n);
46
+ let best = Infinity;
47
+ for (let j = 0; j < n; j++) if (j !== i) best = Math.min(best, Math.hypot(slots[i]![0] - slots[j]![0], slots[i]![1] - slots[j]![1], slots[i]![2] - slots[j]![2]));
48
+ if (Number.isFinite(best)) gaps.push(best);
49
+ }
50
+ gaps.sort((a, b) => a - b);
51
+ return { w: Math.max(1e-3, x1 - x0), h: Math.max(1e-3, y1 - y0), gap: gaps[Math.floor(gaps.length / 2)] ?? 0.1 };
52
+ }, [slots, n]);
53
+ const view = nearestVisible(keys, width / height, position[2]);
54
+ const scale = size ? size / geo.w : Math.min((fit * view.width) / geo.w, (fit * view.height) / geo.h);
55
+ const piece = geo.gap * 0.84;
56
+ const mesh = React.useRef<InstancedMesh>(null);
57
+ const dummy = React.useMemo(() => new Object3D(), []);
58
+ const palette = React.useMemo(() => colors.map((c) => new Color(c)), [colors]);
59
+ React.useLayoutEffect(() => {
60
+ const m = mesh.current;
61
+ if (!m) return;
62
+ const c = new Color();
63
+ for (let i = 0; i < n; i++) {
64
+ const t = hash01(i, seed + 61) * (palette.length - 1), k = Math.min(palette.length - 2, Math.floor(t));
65
+ c.copy(palette[Math.max(0, k)]!).lerp(palette[Math.min(palette.length - 1, k + 1)]!, palette.length > 1 ? t - k : 0);
66
+ c.offsetHSL(0, 0, (hash01(i, seed + 62) - 0.5) * 0.16);
67
+ m.setColorAt(i, c);
68
+ }
69
+ if (m.instanceColor) m.instanceColor.needsUpdate = true;
70
+ }, [palette, n, seed]);
71
+ React.useLayoutEffect(() => {
72
+ const m = mesh.current;
73
+ if (!m) return;
74
+ for (let i = 0; i < n; i++) {
75
+ const s = pieceAt(frame, i, slots[i]!, { ranks, from, frames, seed, spread });
76
+ dummy.position.set(s.position[0] * scale, s.position[1] * scale, s.position[2] * scale);
77
+ dummy.rotation.set(s.rotation[0], s.rotation[1], s.rotation[2]);
78
+ dummy.scale.setScalar(piece * scale * s.scale);
79
+ dummy.updateMatrix();
80
+ m.setMatrixAt(i, dummy.matrix);
81
+ }
82
+ m.instanceMatrix.needsUpdate = true;
83
+ }, [frame, n, slots, ranks, from, frames, seed, spread, scale, piece, dummy]);
84
+ return (
85
+ <group position={position}>
86
+ <instancedMesh ref={mesh} args={[undefined, undefined, n]} frustumCulled={false}>
87
+ <boxGeometry args={[1, 1, 1]} />
88
+ <meshStandardMaterial roughness={0.42} metalness={0.12} />
89
+ </instancedMesh>
90
+ </group>
91
+ );
92
+ };
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { brandTransformAt, type BrandTransform, type BrandTransformTiming } from "./brand-transform";
4
+
5
+ // A branded object turning into view, arriving on an arc, or launching from depth. Use inside Scene3D around a Card3D, Hero3D or Text3D.
6
+ // The final pose holds exactly: the real logo or product can be read without a continuous spin. Sound stays outside Scene3D; brandTransformFrames gives its landing frame.
7
+ export const BrandTransform3D: React.FC<BrandTransformTiming & { mode?: BrandTransform; strength?: number; children?: React.ReactNode }> = ({ children, ...options }) => {
8
+ const frame = useCurrentFrame(), { fps } = useVideoConfig();
9
+ const pose = brandTransformAt(frame, fps, options);
10
+ if (!pose.visible) return null;
11
+ return <group position={pose.position} rotation={pose.rotation} scale={pose.scale}>{children}</group>;
12
+ };
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+ import { continueRender, delayRender, interpolate, spring, useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { Place } from "./Place";
4
+ import { useMedia } from "./media";
5
+ import { fonts } from "./theme";
6
+ import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
7
+ import { PANEL_SPRING, inOutCubic, browserFrames } from "./ui-math";
8
+
9
+ // The natural size of an image, read once. The frame waits for it, so every frame knows how far the picture can scroll.
10
+ function useImageSize(url: string): { w: number; h: number } | null {
11
+ const [size, setSize] = React.useState<{ w: number; h: number } | null>(null);
12
+ React.useEffect(() => {
13
+ const handle = delayRender(`size of ${url.slice(0, 40)}`);
14
+ const img = new Image();
15
+ img.onload = () => { setSize({ w: img.naturalWidth, h: img.naturalHeight }); continueRender(handle); };
16
+ img.onerror = () => continueRender(handle);
17
+ img.src = url;
18
+ return () => { continueRender(handle); };
19
+ }, [url]);
20
+ return size;
21
+ }
22
+
23
+ // A browser window (or, with device="phone", a phone) in perspective, carrying your own screenshot `src` (a project path or urls[...]). The picture starts at
24
+ // its top and scrolls down by `scroll`, a fraction of the window's height (default 0.7), over `frames` (default 90) frames from `delay`; it never scrolls past
25
+ // the end of the picture. A soft shadow lies under it and a sheen sweeps across once as it lands. `tilt` is the turn about the vertical axis in degrees
26
+ // (default -12 for a browser, -16 for a phone; 0 faces the camera), `url` the text of the address bar. CSS 3D, no three.js. Times are frames from the
27
+ // component's own mount; browserFrames({ delay }) gives the frame it settles and the sheen's span. `width` is a fraction of the frame's width (0.86 for a
28
+ // browser, 0.5 for a phone); `theme` "dark" (default) or "light" colours the frame.
29
+ export const BrowserFrame: React.FC<{
30
+ src: string;
31
+ device?: "browser" | "phone";
32
+ scroll?: number;
33
+ frames?: number;
34
+ delay?: number;
35
+ tilt?: number;
36
+ url?: string;
37
+ theme?: UiTheme;
38
+ hero?: string;
39
+ font?: string;
40
+ width?: number;
41
+ viewAspect?: number;
42
+ y?: number;
43
+ }> = ({ src, device = "browser", scroll = 0.7, frames = 90, delay = 0, tilt, url = "relay.app", theme = "dark", hero = DEFAULT_HERO, font = fonts.body, width: given, viewAspect, y = 0.5 }) => {
44
+ const frame = useCurrentFrame();
45
+ const { fps, width: W, height: H } = useVideoConfig();
46
+ const link = useMedia(src);
47
+ const size = useImageSize(link);
48
+ const c = uiColors(theme, hero);
49
+ const phone = device === "phone";
50
+ const P = W * (given ?? (phone ? 0.5 : 0.86));
51
+ const turn = tilt ?? (phone ? -16 : -12);
52
+ const p = spring({ frame: frame - delay, fps, config: PANEL_SPRING });
53
+ const bar = phone ? 0 : P * 0.085, bezel = phone ? P * 0.035 : 0;
54
+ const viewW = P - bezel * 2, viewH = viewW * (viewAspect ?? (phone ? 2.05 : 0.78));
55
+ const imgH = size ? (viewW * size.h) / size.w : viewH;
56
+ const travel = Math.max(0, Math.min(scroll * viewH, imgH - viewH));
57
+ const ev = browserFrames({ delay, fps });
58
+ const sc = inOutCubic((frame - delay - 10) / Math.max(1, frames)) * travel;
59
+ const sheen = interpolate(frame, [ev.sheenStart, ev.sheenEnd], [-0.4, 1.4], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
60
+ const totalH = bar + viewH + bezel * 2;
61
+ return (
62
+ <Place y={y}>
63
+ <div style={{ position: "relative", width: P, height: totalH, perspective: P * 2.4, opacity: Math.min(1, p * 1.5) }}>
64
+ <div style={{ position: "absolute", left: "6%", right: "6%", bottom: -P * 0.07, height: P * 0.1, background: "radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0.55), rgba(0,0,0,0) 70%)", filter: `blur(${P * 0.03}px)`, transform: `translateY(${(1 - p) * H * 0.05}px)` }} />
65
+ <div style={{ position: "absolute", inset: 0, transform: `translateY(${(1 - p) * H * 0.06}px) rotateY(${turn * p}deg) rotateX(${Math.abs(turn) * 0.38 * p}deg)`, transformStyle: "preserve-3d" }}>
66
+ <div style={{ position: "absolute", inset: 0, borderRadius: phone ? P * 0.12 : P * 0.035, background: phone ? "#0b0b10" : c.panel, border: `${Math.max(2, P * 0.004)}px solid ${phone ? "rgba(255,255,255,0.28)" : c.stroke}`, boxShadow: `0 ${P * 0.06}px ${P * 0.14}px ${c.shadow}, 0 0 ${P * 0.18}px ${rgba(hero, 0.2)}`, overflow: "hidden" }}>
67
+ {!phone ? (
68
+ <div style={{ height: bar, display: "flex", alignItems: "center", gap: P * 0.015, padding: `0 ${P * 0.035}px`, background: c.field, borderBottom: `1px solid ${c.stroke}` }}>
69
+ {["#ff5f57", "#febc2e", "#28c840"].map((d) => <div key={d} style={{ width: P * 0.022, height: P * 0.022, borderRadius: 999, background: d }} />)}
70
+ <div style={{ flex: 1, margin: `0 ${P * 0.03}px 0 ${P * 0.03}px`, background: c.chip, borderRadius: 999, padding: `${P * 0.008}px ${P * 0.03}px`, fontFamily: font, fontSize: P * 0.032, color: c.dim, textAlign: "center", fontWeight: 500 }}>{url}</div>
71
+ </div>
72
+ ) : null}
73
+ <div style={{ position: "absolute", left: bezel, right: bezel, top: bar + bezel, height: viewH, overflow: "hidden", borderRadius: phone ? P * 0.09 : 0, background: "#ffffff" }}>
74
+ <img src={link} style={{ display: "block", width: "100%", height: "auto", transform: `translateY(${-sc}px)` }} />
75
+ {phone ? <div style={{ position: "absolute", left: "50%", top: viewW * 0.03, width: viewW * 0.3, height: viewW * 0.085, borderRadius: 999, background: "#000", transform: "translateX(-50%)" }} /> : null}
76
+ </div>
77
+ <div style={{ position: "absolute", inset: 0, pointerEvents: "none", background: `linear-gradient(105deg, rgba(255,255,255,0) ${sheen * 100 - 14}%, rgba(255,255,255,0.22) ${sheen * 100}%, rgba(255,255,255,0) ${sheen * 100 + 14}%)` }} />
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </Place>
82
+ );
83
+ };
@@ -1,22 +1,24 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
3
- import { cameraAt, cameraTransform, type CameraKey } from "./motion-math";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { applyPunches, cameraAt, cameraTransform, type CameraKey, type Punch } from "./motion-math";
4
4
 
5
5
  // Moves the whole picture. Wrap all the scenes in it for one camera that never cuts, or wrap one scene's content.
6
6
  // Each key gives the point of the content, as fractions of the frame, that sits at the middle of the screen (x, y), the zoom (1 shows
7
7
  // the content as it fits) and a turn in degrees; what a key leaves out stays as it was. The move toward a key starts `lead` frames
8
8
  // before its frame (12 unless set) on a spring and lands on the key's frame. drift is a very slow push on top, as a fraction of the
9
- // zoom per second (0.02 is two percent a second), so a held shot is never perfectly still.
9
+ // zoom per second (0.02 is two percent a second), so a held shot is never perfectly still. punches are quick emphasis pushes of 8 to 15 percent onto a
10
+ // point, { frame, x, y, amount?: 0.1, frames?: 5, hold?: 6 }, on top of the keys. To push into an element until it fills the frame, put zoomTo(frame, box) in keys.
10
11
  export const Camera: React.FC<{
11
12
  keys: CameraKey[];
12
13
  drift?: number;
14
+ punches?: Punch[];
13
15
  lead?: number;
14
16
  stiffness?: number;
15
17
  damping?: number;
16
18
  children: React.ReactNode;
17
- }> = ({ keys, drift, lead, stiffness, damping, children }) => {
19
+ }> = ({ keys, drift, punches, lead, stiffness, damping, children }) => {
18
20
  const frame = useCurrentFrame();
19
21
  const { fps, width, height } = useVideoConfig();
20
- const { origin, transform } = cameraTransform(cameraAt(keys, frame, fps, { lead, stiffness, damping, drift }), width, height);
22
+ const { origin, transform } = cameraTransform(applyPunches(cameraAt(keys, frame, fps, { lead, stiffness, damping, drift }), punches ?? [], frame), width, height);
21
23
  return <AbsoluteFill style={{ transformOrigin: origin, transform }}>{children}</AbsoluteFill>;
22
24
  };
@@ -1,7 +1,8 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
2
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "../frame";
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 "../frame";
3
+ import { useLoader } from "@react-three/fiber";
4
+ import { CanvasTexture, FrontSide, Shape, ShapeGeometry, SRGBColorSpace, TextureLoader, type Texture } from "three";
5
+ import { delayRender, continueRender } from "../frame";
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
+ };
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { useCurrentFrame, useVideoConfig } from "remotion";
2
+ import { useCurrentFrame, useVideoConfig } from "../frame";
3
3
  import { boxAt, type Box, type BoxKey } from "./motion-math";
4
4
 
5
5
  // What a function child is given: the box now, as fractions of the frame, and its size in pixels.
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "../frame";
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
+ };