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
@@ -0,0 +1,177 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Img, OffthreadVideo, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { auroraBlobs, beamStates, breath, checkItems, grainSeed, gridOffset, gridRows, groundStrength, hueShift, mixHex, plateView, withAlpha, sequenceLayers, videoLayers, type Drift, type SequenceItem, type SequenceTransition } from "./bg-math";
4
+ import { useMedia } from "./media";
5
+
6
+ const isVideo = (src: string) => /\.(mp4|mov|webm|m4v)(\?|$)/i.test(src);
7
+
8
+ // What every media ground shares: a dark layer so type stays readable, and a tint laid over at low opacity so a clip or picture takes the film's accent.
9
+ const Finish: React.FC<{ dim: number; tint?: string }> = ({ dim, tint }) => (
10
+ <>
11
+ {tint ? <AbsoluteFill style={{ backgroundColor: tint, opacity: 0.18 }} /> : null}
12
+ {dim > 0 ? <AbsoluteFill style={{ backgroundColor: `rgba(0,0,0,${Math.min(0.8, Math.max(0, dim))})` }} /> : null}
13
+ </>
14
+ );
15
+
16
+ type PlateProps = { src: string; scale: number; tx: number; ty: number; blurPx: number; opacity?: number; wipe?: number; loopVideo?: boolean };
17
+
18
+ // One picture or clip, covering the frame, moved by the given scale and shift. It is drawn muted, always.
19
+ const Plate: React.FC<PlateProps> = ({ src, scale, tx, ty, blurPx, opacity = 1, wipe }) => {
20
+ const { width, height } = useVideoConfig();
21
+ const url = useMedia(src);
22
+ const mask = wipe === undefined ? undefined : `linear-gradient(90deg, #000 ${wipe * 115 - 15}%, transparent ${wipe * 115}%)`;
23
+ const style: React.CSSProperties = { width: "100%", height: "100%", objectFit: "cover" };
24
+ return (
25
+ <AbsoluteFill style={{ opacity, transform: `translate(${tx * width}px, ${ty * height}px) scale(${scale})`, filter: blurPx > 0 ? `blur(${blurPx}px)` : undefined, WebkitMaskImage: mask, maskImage: mask, overflow: "hidden" }}>
26
+ {isVideo(src) ? <OffthreadVideo src={url} muted style={style} /> : <Img src={url} style={style} />}
27
+ </AbsoluteFill>
28
+ );
29
+ };
30
+
31
+ // A video as the ground, for the whole film or for one scene. dim (0 to 0.8, default 0.35) darkens it so type stays readable; blur is in pixels; zoom is a
32
+ // slow push as a fraction over `durationInFrames` (default the film's length); tint is a colour laid over at low opacity; playbackRate 0.5 is a calmer ground.
33
+ // The clip is always muted. When loop is on (the default) and `durationSec` is given (manifest.background.durationSec), a film longer than the clip starts it
34
+ // again with a short cross-fade over the join, so the restart is not seen; without durationSec the clip plays once and holds its last frame.
35
+ export const BgVideo: React.FC<{ src: string; dim?: number; blur?: number; zoom?: number; tint?: string; loop?: boolean; playbackRate?: number; durationSec?: number; durationInFrames?: number }> = ({
36
+ src, dim = 0.35, blur = 0, zoom = 0.04, tint, loop = true, playbackRate = 1, durationSec, durationInFrames,
37
+ }) => {
38
+ const frame = useCurrentFrame();
39
+ const { fps, width, durationInFrames: total } = useVideoConfig();
40
+ const view = plateView(frame / Math.max(1, durationInFrames ?? total), { zoom, blurPx: blur, width });
41
+ const url = useMedia(src);
42
+ const layers = videoLayers(frame, { clipFrames: durationSec ? (durationSec * fps) / playbackRate : 1, loop: loop && Boolean(durationSec) });
43
+ const clipFrames = Math.max(1, Math.floor(durationSec ? (durationSec * fps) / playbackRate : 1));
44
+ return (
45
+ <AbsoluteFill style={{ overflow: "hidden" }}>
46
+ <AbsoluteFill style={{ transform: `scale(${view.scale})`, filter: blur > 0 ? `blur(${blur}px)` : undefined }}>
47
+ {layers.map((l) => (
48
+ <Sequence key={l.index} from={l.start} durationInFrames={durationSec && loop ? clipFrames : Infinity} layout="none">
49
+ <AbsoluteFill style={{ opacity: l.opacity }}>
50
+ <OffthreadVideo src={url} muted playbackRate={playbackRate} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
51
+ </AbsoluteFill>
52
+ </Sequence>
53
+ ))}
54
+ </AbsoluteFill>
55
+ <Finish dim={dim} tint={tint} />
56
+ </AbsoluteFill>
57
+ );
58
+ };
59
+
60
+ // One still picture as the ground. It always covers the frame: the pan and the blur never show an edge. zoom is a slow push over its time on screen
61
+ // (default 0.06), drift a slow pan of a few percent, and fit is "cover".
62
+ export const BgImage: React.FC<{ src: string; dim?: number; blur?: number; tint?: string; zoom?: number; drift?: Drift; fit?: "cover"; durationInFrames?: number }> = ({
63
+ src, dim = 0.35, blur = 0, tint, zoom = 0.06, drift = "none", durationInFrames,
64
+ }) => {
65
+ const frame = useCurrentFrame();
66
+ const { width, durationInFrames: total } = useVideoConfig();
67
+ const v = plateView(frame / Math.max(1, durationInFrames ?? total), { zoom, drift, blurPx: blur, width });
68
+ return (
69
+ <AbsoluteFill style={{ overflow: "hidden" }}>
70
+ <Plate src={src} scale={v.scale} tx={v.tx} ty={v.ty} blurPx={blur} />
71
+ <Finish dim={dim} tint={tint} />
72
+ </AbsoluteFill>
73
+ );
74
+ };
75
+
76
+ // Several grounds that change smoothly over the film. items are { src, from } (frames, in order, the first visible from frame 0; a video src is drawn too, played once from the start of the film and held on its last frame).
77
+ // The change is centred on `from` unless align is "start"; `transition` is "fade" (default), "blur" (a cross-fade through a blur peak), "wipe" (a soft-edged wipe)
78
+ // or "zoom" (the old one pushes in while the new one settles from slightly larger), over transitionFrames (default 18). Each item keeps its own slow zoom running
79
+ // through the change. dim, blur, tint, zoom and drift are shared, so the film keeps one look. bgPerScene builds items from the scenes.
80
+ export const BgSequence: React.FC<{ items: SequenceItem[]; transition?: SequenceTransition; transitionFrames?: number; align?: "center" | "start"; dim?: number; blur?: number; tint?: string; zoom?: number; drift?: Drift }> = ({
81
+ items, transition = "fade", transitionFrames = 18, align = "center", dim = 0.35, blur = 0, tint, zoom = 0.06, drift = "none",
82
+ }) => {
83
+ const frame = useCurrentFrame();
84
+ const { width } = useVideoConfig();
85
+ checkItems(items);
86
+ const layers = sequenceLayers(items, frame, { transition, transitionFrames, align });
87
+ const peak = transition === "blur" ? 24 : 0;
88
+ return (
89
+ <AbsoluteFill style={{ overflow: "hidden" }}>
90
+ {layers.map((l) => {
91
+ const v = plateView(l.progress, { zoom, drift, blurPx: blur + peak, width });
92
+ return <Plate key={l.index} src={items[l.index]!.src} scale={v.scale * l.scale} tx={v.tx} ty={v.ty} blurPx={blur + l.blur * peak} opacity={l.opacity} wipe={l.wipe} />;
93
+ })}
94
+ <Finish dim={dim} tint={tint} />
95
+ </AbsoluteFill>
96
+ );
97
+ };
98
+
99
+ // Three large colour fields on the base colour, in the hero colour and its two neighbours on the colour wheel, drifting and crossing: their edges and
100
+ // where they cross show, and they move plainly within two seconds. Radial gradients and one blend mode: cheap. intensity (0 to 1, default 0.6) is how
101
+ // strongly they light the base; Grade and Vignette on top darken a ground, so raise it (0.8) under them.
102
+ export const BgAurora: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
103
+ const frame = useCurrentFrame();
104
+ const { fps, width, height } = useVideoConfig();
105
+ const longer = Math.max(width, height), k = groundStrength(intensity);
106
+ const colors = [hero, hueShift(mixHex(hero, "#ffffff", 0.12), 42), hueShift(mixHex(hero, bg, 0.1), -46)];
107
+ const peak = [0.95, 0.8, 0.85];
108
+ return (
109
+ <AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
110
+ {auroraBlobs(frame, fps, speed).map((b, i) => (
111
+ <div key={i} style={{
112
+ position: "absolute", left: b.x * width - b.r * longer, top: b.y * height - b.r * longer, width: b.r * 2 * longer, height: b.r * 2 * longer, mixBlendMode: "screen",
113
+ background: `radial-gradient(circle closest-side, ${withAlpha(colors[i]!, peak[i]! * k)} 0%, ${withAlpha(colors[i]!, peak[i]! * k * 0.7)} 45%, ${withAlpha(colors[i]!, peak[i]! * k * 0.22)} 80%, ${withAlpha(colors[i]!, 0)} 100%)`,
114
+ }} />
115
+ ))}
116
+ </AbsoluteFill>
117
+ );
118
+ };
119
+
120
+ // A perspective grid receding to a horizon and sliding toward the viewer, with a glow where it meets the sky. A few dozen lines in one SVG. intensity (0 to 1,
121
+ // default 0.6) is how bright the lines and the glow are.
122
+ export const BgGrid: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
123
+ const frame = useCurrentFrame();
124
+ const { fps, width, height } = useVideoConfig();
125
+ const horizon = height * 0.42, vx = width / 2, rows = gridRows(gridOffset(frame, fps, speed), 16), cols = 15, k = groundStrength(intensity);
126
+ const line = mixHex(hero, "#ffffff", 0.25);
127
+ return (
128
+ <AbsoluteFill style={{ backgroundColor: bg }}>
129
+ <AbsoluteFill style={{ background: `radial-gradient(ellipse 80% 28% at 50% 42%, ${withAlpha(hero, 0.8 * k)} 0%, ${withAlpha(hero, 0)} 100%)` }} />
130
+ <svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }}>
131
+ <defs><linearGradient id="rk-grid-fade" gradientUnits="userSpaceOnUse" x1="0" y1={horizon} x2="0" y2={height}><stop offset="0" stopColor={line} stopOpacity="0" /><stop offset="0.3" stopColor={line} stopOpacity={0.55 * k} /><stop offset="1" stopColor={line} stopOpacity={Math.min(1, 1.1 * k)} /></linearGradient></defs>
132
+ <g stroke="url(#rk-grid-fade)" strokeWidth={Math.max(1.5, width / 420)} fill="none">
133
+ {rows.map((d, i) => <line key={`r${i}`} x1={0} x2={width} y1={horizon + (height - horizon) * d} y2={horizon + (height - horizon) * d} />)}
134
+ {Array.from({ length: cols }, (_, i) => { const x = (i / (cols - 1) - 0.5) * width * 3; return <line key={`c${i}`} x1={vx} y1={horizon} x2={vx + x} y2={height} />; })}
135
+ </g>
136
+ </svg>
137
+ </AbsoluteFill>
138
+ );
139
+ };
140
+
141
+ // Four wide diagonal beams of light sweeping across the base colour, brightening and dimming, with a glow where they leave the top. They are soft but plainly
142
+ // there: the strongest is about as bright as the hero colour at half strength. intensity (0 to 1, default 0.6) is how bright; Grade and Vignette on top
143
+ // darken a ground, so raise it (0.8) under them.
144
+ export const BgBeams: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
145
+ const frame = useCurrentFrame();
146
+ const { fps, width, height } = useVideoConfig();
147
+ const k = groundStrength(intensity), light = mixHex(hero, "#ffffff", 0.2);
148
+ return (
149
+ <AbsoluteFill style={{ backgroundColor: bg, overflow: "hidden" }}>
150
+ <AbsoluteFill style={{ background: `radial-gradient(ellipse 90% 40% at 50% -5%, ${withAlpha(hero, 0.7 * k)} 0%, ${withAlpha(hero, 0)} 100%)` }} />
151
+ {beamStates(frame, fps, speed).map((b, i) => (
152
+ <div key={i} style={{
153
+ position: "absolute", left: (0.3 + b.slide) * width - width * 0.2, top: -height * 0.25, width: width * 0.4, height: height * 1.5, transform: `rotate(${b.angle}deg)`, mixBlendMode: "screen",
154
+ background: `linear-gradient(90deg, ${withAlpha(light, 0)} 0%, ${withAlpha(light, 0.55 * k * b.strength)} 50%, ${withAlpha(light, 0)} 100%)`,
155
+ }} />
156
+ ))}
157
+ </AbsoluteFill>
158
+ );
159
+ };
160
+
161
+ // A still ground with a breathing vignette, a soft glow of the hero colour and film grain. The grain steps every few frames by the frame number, so a
162
+ // frame is always the same. intensity (0 to 1, default 0.6) is how strong the glow and the grain are.
163
+ export const BgGrain: React.FC<{ bg: string; hero: string; speed?: number; intensity?: number }> = ({ bg, hero, speed = 1, intensity }) => {
164
+ const frame = useCurrentFrame();
165
+ const { fps, width, height } = useVideoConfig();
166
+ const b = breath(frame, fps, speed), k = groundStrength(intensity);
167
+ return (
168
+ <AbsoluteFill style={{ backgroundColor: bg }}>
169
+ <AbsoluteFill style={{ background: `radial-gradient(ellipse at ${50 + 6 * Math.sin((frame / fps) * speed * 0.5)}% ${38 + 4 * b}%, ${withAlpha(hero, 0.55 * k)} 0%, ${withAlpha(hero, 0)} 62%)` }} />
170
+ <AbsoluteFill style={{ background: `radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(0,0,0,${0.28 + 0.16 * b}) 100%)` }} />
171
+ <svg width={width} height={height} style={{ position: "absolute", left: 0, top: 0, opacity: 0.06 + 0.1 * k, mixBlendMode: "overlay" }}>
172
+ <filter id="rk-grain"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" seed={grainSeed(frame)} stitchTiles="stitch" /><feColorMatrix type="saturate" values="0" /></filter>
173
+ <rect width="100%" height="100%" filter="url(#rk-grain)" />
174
+ </svg>
175
+ </AbsoluteFill>
176
+ );
177
+ };
@@ -0,0 +1,97 @@
1
+ import React from "react";
2
+ import { continueRender, delayRender, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { Place } from "./Place";
4
+ import { fonts, springs } from "./theme";
5
+ import { inkOn } from "./three-math";
6
+ import { clamp01, headlineFrames, headlineLines, headlineWords, keywordIndex, outCubic } from "./ui-math";
7
+ import { DEFAULT_HERO } from "./ui-theme";
8
+ import { TextInkContext } from "./ImageLayers";
9
+
10
+ // A headline set heavy: two to five words that come in one by one, with ONE keyword emphasised. `emphasis` is "color" (the keyword in the hero colour),
11
+ // "highlight" (a block of the hero colour sweeps in behind it) or "underline" (a bar draws on under it). `keyword` must be one of the words (case and
12
+ // punctuation ignored) or it throws and lists the words. The text breaks into the fewest lines of about 12 characters and is sized to fill `width` (a fraction
13
+ // of the frame's width, default 0.86): it is measured after layout, so nothing leaves the frame, and at most `maxSize` (a fraction of the width, default
14
+ // 0.2) is used. `delay` and `per` are frames from the component's own mount (0 and 4 frames a word); headlineFrames(text, { delay, per }) gives each word's
15
+ // frame for sound. `ink` is the text colour (default white, or the ink a TextOnImage chose for its zone), `y` the height of its middle as a fraction of the frame (0.5).
16
+ export const Headline: React.FC<{
17
+ text: string;
18
+ keyword?: string;
19
+ emphasis?: "color" | "highlight" | "underline";
20
+ hero?: string;
21
+ ink?: string;
22
+ font?: string;
23
+ width?: number;
24
+ maxSize?: number;
25
+ delay?: number;
26
+ per?: number;
27
+ align?: "center" | "left";
28
+ y?: number;
29
+ }> = ({ text, keyword, emphasis = "color", hero = DEFAULT_HERO, ink: given, font = fonts.display, width: fraction = 0.86, maxSize = 0.2, delay = 0, per = 4, align = "center", y = 0.5 }) => {
30
+ const frame = useCurrentFrame();
31
+ const fromText = React.useContext(TextInkContext);
32
+ const ink = given ?? fromText ?? "#f4f4f5";
33
+ const { fps, width: W } = useVideoConfig();
34
+ const words = headlineWords(text);
35
+ const key = keyword === undefined ? -1 : keywordIndex(text, keyword);
36
+ const lines = React.useMemo(() => headlineLines(words), [text]);
37
+ const longest = Math.max(...lines.map((l) => l.map((i) => words[i]!).join(" ").length));
38
+ const target = W * fraction;
39
+ const estimate = Math.min(W * maxSize, target / (longest * 0.64));
40
+ const [fit, setFit] = React.useState(1);
41
+ const fitRef = React.useRef(1);
42
+ const refs = React.useRef<(HTMLSpanElement | null)[]>([]);
43
+ const [hold] = React.useState(() => delayRender("headline fit"));
44
+ // The estimate is corrected once the real letters are laid out (and again when the fonts have arrived, since a face that is not loaded yet measures
45
+ // narrower), so a face that is wider or narrower than expected still fills the width and never leaves it. The frame waits for the correction.
46
+ React.useLayoutEffect(() => {
47
+ let live = true, same = 0, tries = 0, last = -1;
48
+ const measure = (): number => {
49
+ const widest = Math.max(...refs.current.map((r) => r?.offsetWidth ?? 0));
50
+ if (widest > 0 && live) {
51
+ const next = Math.max(0.3, Math.min((W * maxSize) / estimate, target / (widest / fitRef.current)));
52
+ fitRef.current = next;
53
+ setFit(next);
54
+ }
55
+ return widest / fitRef.current;
56
+ };
57
+ // The face arrives a moment after the first layout (it is added to the page while this runs), so the width is read again until it has stopped changing.
58
+ const poll = () => {
59
+ if (!live) return;
60
+ const natural = Math.round(measure() * 10) / 10;
61
+ same = natural === last ? same + 1 : 0;
62
+ last = natural;
63
+ tries++;
64
+ if ((same >= 3 && (typeof document === "undefined" || document.fonts.status === "loaded")) || tries > 60) continueRender(hold); else setTimeout(poll, 40);
65
+ };
66
+ poll();
67
+ return () => { live = false; continueRender(hold); };
68
+ }, [text, font, W, fraction, maxSize, estimate]);
69
+ const size = estimate * fit;
70
+ const ev = headlineFrames(text, { delay, per });
71
+ return (
72
+ <Place y={y}>
73
+ <div style={{ width: target, textAlign: align, fontFamily: font, fontWeight: 800, fontSize: size, lineHeight: 1.04, letterSpacing: "-0.025em", color: ink }}>
74
+ {lines.map((line, li) => (
75
+ <div key={li} style={{ display: "flex", justifyContent: align === "center" ? "center" : "flex-start" }}>
76
+ <span ref={(el) => { refs.current[li] = el; }} style={{ display: "inline-flex", whiteSpace: "nowrap", gap: "0.24em" }}>
77
+ {line.map((wi) => {
78
+ const land = ev.words[wi]!;
79
+ const p = spring({ frame: frame - land + 6, fps, config: springs.snappy });
80
+ const hot = wi === key;
81
+ const sweep = outCubic(clamp01((frame - land - 3) / 12));
82
+ const wordInk = hot && emphasis === "color" ? hero : hot && emphasis === "highlight" ? inkOn(hero) : ink;
83
+ return (
84
+ <span key={wi} style={{ position: "relative", display: "inline-block", opacity: clamp01(p * 1.6), transform: `translateY(${(1 - p) * 0.35}em)`, filter: p < 1 ? `blur(${(1 - p) * size * 0.06}px)` : undefined, color: wordInk }}>
85
+ {hot && emphasis === "highlight" ? <span style={{ position: "absolute", left: "-0.1em", right: "-0.1em", top: "0.06em", bottom: "0.0em", background: hero, borderRadius: "0.12em", transformOrigin: "0 50%", transform: `scaleX(${sweep})`, zIndex: -1 }} /> : null}
86
+ <span style={{ position: "relative" }}>{words[wi]}</span>
87
+ {hot && emphasis === "underline" ? <span style={{ position: "absolute", left: 0, bottom: "-0.02em", height: "0.1em", width: `${sweep * 100}%`, background: hero, borderRadius: "0.1em" }} /> : null}
88
+ </span>
89
+ );
90
+ })}
91
+ </span>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ </Place>
96
+ );
97
+ };
@@ -0,0 +1,197 @@
1
+ import React from "react";
2
+ import { continueRender, delayRender, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { useThree } from "@react-three/fiber";
4
+ import { CanvasTexture, DoubleSide, ExtrudeGeometry, Shape, ShapeGeometry, SRGBColorSpace, TextureLoader, type BufferGeometry, type Texture } from "three";
5
+ import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
6
+ import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
7
+ import { useMedia } from "./media";
8
+ import { CameraKeysContext } from "./scene3d-context";
9
+ import { typeface } from "./Text3D";
10
+ import { poseAt, type PoseKey } from "./three-fx-math";
11
+ import { degToRad, mixHex, nearestVisible, type Vec3 } from "./three-math";
12
+
13
+ const roundRect = (w: number, h: number, r: number): Shape => {
14
+ const s = new Shape(), x = -w / 2, y = -h / 2, k = Math.min(r, w / 2, h / 2);
15
+ s.moveTo(x + k, y); s.lineTo(x + w - k, y); s.quadraticCurveTo(x + w, y, x + w, y + k); s.lineTo(x + w, y + h - k); s.quadraticCurveTo(x + w, y + h, x + w - k, y + h);
16
+ s.lineTo(x + k, y + h); s.quadraticCurveTo(x, y + h, x, y + h - k); s.lineTo(x, y + k); s.quadraticCurveTo(x, y, x + k, y);
17
+ return s;
18
+ };
19
+ const uvs = (g: ShapeGeometry, w: number, h: number) => { const p = g.attributes.position!, uv = g.attributes.uv!; for (let i = 0; i < p.count; i++) uv.setXY(i, (p.getX(i) + w / 2) / w, (p.getY(i) + h / 2) / h); return g; };
20
+
21
+ // A soft dark ellipse for the contact shadow, drawn once.
22
+ const shadowTex = (): CanvasTexture => {
23
+ const c = document.createElement("canvas");
24
+ c.width = c.height = 256;
25
+ const g = c.getContext("2d")!, r = g.createRadialGradient(128, 128, 0, 128, 128, 128);
26
+ r.addColorStop(0, "rgba(0,0,0,0.75)"); r.addColorStop(0.55, "rgba(0,0,0,0.3)"); r.addColorStop(1, "rgba(0,0,0,0)");
27
+ g.fillStyle = r; g.fillRect(0, 0, 256, 256);
28
+ const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace; return t;
29
+ };
30
+
31
+ // A screen face when there is no screenshot: a gradient of the hero colour and a few soft bars, so a device is never an empty slab.
32
+ const mockTex = (hero: string, aspect: number): CanvasTexture => {
33
+ const W = 512, H = Math.round(512 / aspect), c = document.createElement("canvas");
34
+ c.width = W; c.height = H;
35
+ const g = c.getContext("2d")!, grad = g.createLinearGradient(0, 0, W, H);
36
+ grad.addColorStop(0, mixHex(hero, "#ffffff", 0.25)); grad.addColorStop(1, mixHex(hero, "#000000", 0.45));
37
+ g.fillStyle = grad; g.fillRect(0, 0, W, H);
38
+ g.fillStyle = "rgba(255,255,255,0.9)"; g.beginPath(); g.roundRect(W * 0.1, H * 0.12, W * 0.45, H * 0.04, 12); g.fill();
39
+ for (let i = 0; i < 5; i++) { g.fillStyle = `rgba(255,255,255,${0.2 - i * 0.02})`; g.beginPath(); g.roundRect(W * 0.1, H * (0.24 + i * 0.13), W * 0.8, H * 0.1, 18); g.fill(); }
40
+ const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace; return t;
41
+ };
42
+
43
+ export type HeroKind = "device" | "logo" | "box" | "coin";
44
+
45
+ type Built = { geometry: BufferGeometry; glyph?: BufferGeometry; w: number; h: number; d: number; screen?: { w: number; h: number; z: number } };
46
+ // The body of each kind, centred, in its own units, with its size.
47
+ function build(kind: HeroKind, text: string, aspect: number): Built {
48
+ if (kind === "device") {
49
+ const w = 1, h = w / aspect, depth = 0.07, g = new ExtrudeGeometry(roundRect(w, h, 0.09 * w), { depth, bevelEnabled: true, bevelThickness: 0.018, bevelSize: 0.018, bevelSegments: 5, curveSegments: 14 });
50
+ g.translate(0, 0, -depth / 2);
51
+ return { geometry: g, w: w + 0.04, h: h + 0.04, d: depth + 0.04, screen: { w: w * 0.92, h: h * 0.94, z: depth / 2 + 0.019 } };
52
+ }
53
+ if (kind === "logo") {
54
+ const g = new TextGeometry(text, { font: typeface(), size: 1, depth: 0.28, curveSegments: 8, bevelEnabled: true, bevelThickness: 0.025, bevelSize: 0.02, bevelSegments: 3 });
55
+ g.computeBoundingBox();
56
+ const b = g.boundingBox!;
57
+ g.translate(-(b.max.x + b.min.x) / 2, -(b.max.y + b.min.y) / 2, -0.14);
58
+ return { geometry: g, w: b.max.x - b.min.x, h: b.max.y - b.min.y, d: 0.34 };
59
+ }
60
+ if (kind === "coin") {
61
+ // A true disc with a raised rim, and the first letter of `text` standing on its face.
62
+ const disc = new Shape();
63
+ disc.absarc(0, 0, 0.5, 0, Math.PI * 2, false);
64
+ const g = new ExtrudeGeometry(disc, { depth: 0.1, bevelEnabled: true, bevelThickness: 0.04, bevelSize: 0.03, bevelSegments: 8, curveSegments: 64 });
65
+ g.translate(0, 0, -0.05);
66
+ const glyph = new TextGeometry(text.slice(0, 1) || "R", { font: typeface(), size: 0.5, depth: 0.04, curveSegments: 8, bevelEnabled: true, bevelThickness: 0.008, bevelSize: 0.008, bevelSegments: 2 });
67
+ glyph.computeBoundingBox();
68
+ const b = glyph.boundingBox!;
69
+ glyph.translate(-(b.max.x + b.min.x) / 2, -(b.max.y + b.min.y) / 2, 0.095);
70
+ return { geometry: g, w: 1.06, h: 1.06, d: 0.2, glyph };
71
+ }
72
+ return { geometry: new RoundedBoxGeometry(1, 1, 1, 6, 0.12), w: 1, h: 1, d: 1 };
73
+ }
74
+
75
+ // The screenshot as a texture, loaded once; the frame waits for it (and for its size, which sets the screen's shape) so it is never drawn empty.
76
+ function useScreenshot(url: string): { texture: Texture | null; aspect: number | undefined } {
77
+ const [state, setState] = React.useState<{ texture: Texture | null; aspect: number | undefined; url: string }>({ texture: null, aspect: undefined, url: "" });
78
+ const hold = React.useRef<number | undefined>(undefined);
79
+ const release = () => { if (hold.current !== undefined) { continueRender(hold.current); hold.current = undefined; } };
80
+ React.useLayoutEffect(() => {
81
+ if (!url) { setState({ texture: null, aspect: undefined, url: "" }); return; }
82
+ if (hold.current === undefined) hold.current = delayRender(`screenshot ${url.slice(0, 40)}`);
83
+ let live = true;
84
+ new TextureLoader().load(url, (t) => { t.colorSpace = SRGBColorSpace; const img = t.image as { width: number; height: number }; if (live) setState({ texture: t, aspect: img.width / Math.max(1, img.height), url }); else release(); }, undefined, () => release());
85
+ return () => { live = false; };
86
+ }, [url]);
87
+ // The frame is released only after the texture has been drawn into the scene: two animation frames after it arrived.
88
+ // While rendering, the canvas draws only when the frame changes, so a texture that arrives later has to be drawn by asking for a frame.
89
+ const advance = useThree((t) => t.advance);
90
+ React.useEffect(() => {
91
+ if (state.texture && state.url === url) { advance(performance.now()); release(); }
92
+ }, [state, url, advance]);
93
+ React.useEffect(() => release, []);
94
+ return { texture: state.texture, aspect: state.aspect };
95
+ }
96
+
97
+ const Picture: React.FC<{ texture: Texture; w: number; h: number; opacity?: number }> = ({ texture, w, h, opacity = 1 }) => {
98
+ const g = React.useMemo(() => uvs(new ShapeGeometry(roundRect(w, h, 0.05), 12), w, h), [w, h]);
99
+ return <mesh geometry={g}><meshBasicMaterial map={texture} toneMapped={false} transparent={opacity < 1} opacity={opacity} /></mesh>;
100
+ };
101
+
102
+ // A studio-lit hero: one object, lit like a product shot. `kind` is "device" (a rounded slab with a screen that shows `src`, a screenshot; without it a designed
103
+ // screen), "logo" (`text`, a short word or a single letter, extruded in the kit's typeface), "box" or "coin". Physical material with a clearcoat in `hero`, a key light
104
+ // and two coloured rim lights (`rim1`, `rim2`, by default the hero colour turned toward blue and toward pink). `floor` is "soft" (a contact shadow, default), "mirror"
105
+ // (a flipped, faded copy below) or "none". `spin` is how many degrees a second it turns about the vertical (12 for a logo, box or coin; a device sways by that many
106
+ // degrees instead) and `hover` how much it bobs, 0 to 1 (0.5). It fits the frame by default (`fit`, 0.7, at the closest the camera comes). `poses` are keys {
107
+ // frame, rotation (degrees), position, scale} that re-pose it, the same timing as Camera: one hero carried across scenes. `aspect` is a device's width over height
108
+ // (the screenshot's own when `src` is given, else 0.5).
109
+ export const Hero3D: React.FC<{
110
+ kind?: HeroKind;
111
+ src?: string;
112
+ text?: string;
113
+ hero?: string;
114
+ rim1?: string;
115
+ rim2?: string;
116
+ floor?: "soft" | "mirror" | "none";
117
+ spin?: number;
118
+ hover?: number;
119
+ fit?: number;
120
+ aspect?: number;
121
+ poses?: PoseKey[];
122
+ lights?: boolean;
123
+ position?: Vec3;
124
+ }> = ({ kind = "box", src, text = "R", hero = "#7C3AED", rim1, rim2, floor = "soft", spin, hover = 0.5, fit = 0.7, aspect, poses, lights = true, position = [0, 0, 0] }) => {
125
+ const frame = useCurrentFrame();
126
+ const { fps, width, height } = useVideoConfig();
127
+ const keys = React.useContext(CameraKeysContext);
128
+ const url = useMedia(src ?? "");
129
+ const shot = useScreenshot(src ? url : "");
130
+ const natural = shot.aspect;
131
+ const ratio = aspect ?? natural ?? 0.5;
132
+ const built = React.useMemo(() => build(kind, text.slice(0, 10), ratio), [kind, text, ratio]);
133
+ React.useEffect(() => () => built.geometry.dispose(), [built]);
134
+ const view = nearestVisible(keys, width / height, position[2]);
135
+ // Fit with room for the turn: a thing that spins is as wide as its width and its depth.
136
+ const reach = Math.max(built.w, kind === "device" ? built.w : built.d * 0.6 + built.w * 0.6);
137
+ const base = Math.min((fit * view.width) / reach, (fit * view.height) / built.h);
138
+ const pose = poseAt(poses, frame, fps);
139
+ const t = frame / fps;
140
+ const turn = spin ?? (kind === "device" ? 8 : 12);
141
+ const yaw = kind === "device" ? Math.sin(t * 0.9) * turn : t * turn;
142
+ const bob = Math.sin(t * 1.7) * 0.035 * hover * base * built.h;
143
+ const r1 = rim1 ?? mixHex(hero, "#2a6bff", 0.55), r2 = rim2 ?? mixHex(hero, "#ff3aa8", 0.55);
144
+ const shadow = React.useMemo(shadowTex, []);
145
+ const mock = React.useMemo(() => (kind === "device" && !src ? mockTex(hero, ratio) : undefined), [kind, src, hero, ratio]);
146
+ const metal = kind === "coin" ? 0.85 : kind === "logo" ? 0.35 : 0.2;
147
+ const body = (opacity: number) => (
148
+ <group>
149
+ <mesh geometry={built.geometry}>
150
+ <meshPhysicalMaterial color={kind === "device" ? mixHex(hero, "#101018", 0.8) : hero} roughness={kind === "coin" ? 0.28 : 0.34} metalness={metal} clearcoat={1} clearcoatRoughness={0.12} transparent={opacity < 1} opacity={opacity} />
151
+ </mesh>
152
+ {built.glyph ? (
153
+ <mesh geometry={built.glyph}>
154
+ <meshPhysicalMaterial color={mixHex(hero, "#ffffff", 0.45)} roughness={0.25} metalness={0.9} clearcoat={1} clearcoatRoughness={0.1} transparent={opacity < 1} opacity={opacity} />
155
+ </mesh>
156
+ ) : null}
157
+ {kind === "device" && built.screen ? (
158
+ <group position={[0, 0, built.screen.z]}>
159
+ {src ? (shot.texture ? <Picture texture={shot.texture} w={built.screen.w} h={built.screen.h} opacity={opacity} /> : null) : mock ? <mesh geometry={uvs(new ShapeGeometry(roundRect(built.screen.w, built.screen.h, 0.05), 12), built.screen.w, built.screen.h)}><meshBasicMaterial map={mock} toneMapped={false} transparent={opacity < 1} opacity={opacity} /></mesh> : null}
160
+ {/* The sheen: a pale band that crosses the glass once, early, and stays inside it. */}
161
+ <mesh position={[(Math.min(1, Math.max(0, (frame - 10) / 40)) - 0.5) * built.screen.w * 0.9, 0, 0.002]} scale={[built.screen.w * 0.16, built.screen.h * 0.98, 1]}>
162
+ <planeGeometry args={[1, 1]} />
163
+ <meshBasicMaterial color="#ffffff" transparent opacity={0.14 * opacity * (frame < 10 || frame > 50 ? 0 : Math.sin(Math.PI * ((frame - 10) / 40)))} depthWrite={false} side={DoubleSide} />
164
+ </mesh>
165
+ </group>
166
+ ) : null}
167
+ </group>
168
+ );
169
+ const floorY = -(built.h / 2) * base - 0.06 * base;
170
+ return (
171
+ <group position={[position[0] + pose.position[0], position[1] + pose.position[1] + bob, position[2] + pose.position[2]]} scale={pose.scale}>
172
+ {lights ? (
173
+ <>
174
+ <directionalLight position={[3, 5, 6]} intensity={2.2} />
175
+ <pointLight position={[-3.5, 1, -3]} color={r1} intensity={55} distance={14} />
176
+ <pointLight position={[3.5, 1.5, -3]} color={r2} intensity={55} distance={14} />
177
+ </>
178
+ ) : null}
179
+ <group rotation={[degToRad(pose.rotation[0]), degToRad(pose.rotation[1] + yaw), degToRad(pose.rotation[2])]} scale={base}>{body(1)}</group>
180
+ {floor === "soft" ? (
181
+ <mesh position={[0, floorY, 0]} rotation={[-Math.PI / 2, 0, 0]} scale={[base * built.w * 1.8, base * Math.max(built.d, built.w * 0.5) * 1.6, 1]}>
182
+ <planeGeometry args={[1, 1]} />
183
+ <meshBasicMaterial map={shadow} transparent depthWrite={false} toneMapped={false} />
184
+ </mesh>
185
+ ) : null}
186
+ {floor === "mirror" ? (
187
+ <group position={[0, floorY * 2 - 0.0, 0]} scale={[1, -1, 1]}>
188
+ <group rotation={[degToRad(pose.rotation[0]), degToRad(pose.rotation[1] + yaw), degToRad(pose.rotation[2])]} scale={base}>{body(0.2)}</group>
189
+ </group>
190
+ ) : null}
191
+ </group>
192
+ );
193
+ };
194
+
195
+ // A tilted screen in 3D carrying a screenshot (`src`) or a designed mock, with a bezel, a shadow and a sheen: the interface carried across scenes. It is Hero3D kind="device"
196
+ // with no spin (a gentle sway of 5 degrees), so give it `poses` to re-pose the one screen at each scene. Same props as Hero3D otherwise.
197
+ export const Screen3D: React.FC<Omit<React.ComponentProps<typeof Hero3D>, "kind" | "text">> = (props) => <Hero3D kind="device" spin={5} {...props} />;
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { font as loadFont } from "./theme";
4
+ import { rgba } from "./ui-theme";
5
+ import { timecode } from "./ui-math";
6
+
7
+ // A thin layer of instrument decoration that sits over a scene: corner brackets, two to four small monospace labels in the corners (your own words: a
8
+ // version, a place, a status; they fill top left, top right, bottom left, bottom right in that order), a running timecode from the frame (it takes the next
9
+ // free corner; `timecode={false}` for none) and faint scan lines with a slow band. Labels are never smaller than 0.022 of the frame's width (24 px at 1080
10
+ // wide). `ink` is the colour of everything (default white), `hero` the small recording dot. Cheap: no images, no filters.
11
+ export const HudOverlay: React.FC<{
12
+ labels?: string[];
13
+ timecode?: boolean;
14
+ scanlines?: boolean;
15
+ hero?: string;
16
+ ink?: string;
17
+ font?: string;
18
+ inset?: number;
19
+ }> = ({ labels = [], timecode: showCode = true, scanlines = true, hero = "#ff4d4d", ink = "#ffffff", font, inset = 0.05 }) => {
20
+ const frame = useCurrentFrame();
21
+ const { fps, width: W, height: H } = useVideoConfig();
22
+ const mono = font ?? loadFont("jetbrainsMono");
23
+ const m = W * inset, arm = W * 0.07, t = Math.max(3, W * 0.0035);
24
+ const items = labels.slice(0, 4);
25
+ if (showCode && items.length < 4) items.push(timecode(frame, fps));
26
+ const fs = Math.max(W * 0.024, 24);
27
+ const corner = (i: number): React.CSSProperties => [
28
+ { left: m, top: m }, { right: m, top: m, textAlign: "right" as const }, { left: m, bottom: m }, { right: m, bottom: m, textAlign: "right" as const },
29
+ ][i]!;
30
+ const bracket = (i: number): React.CSSProperties => {
31
+ const left = i % 2 === 0, top = i < 2;
32
+ return { position: "absolute", [left ? "left" : "right"]: m * 0.45, [top ? "top" : "bottom"]: m * 0.45, width: arm, height: arm, [left ? "borderLeft" : "borderRight"]: `${t}px solid ${rgba(ink, 0.85)}`, [top ? "borderTop" : "borderBottom"]: `${t}px solid ${rgba(ink, 0.85)}` };
33
+ };
34
+ const band = ((frame * 5) % (H * 1.4)) - H * 0.2;
35
+ return (
36
+ <AbsoluteFill style={{ pointerEvents: "none", fontFamily: mono, color: ink }}>
37
+ {scanlines ? (
38
+ <>
39
+ <AbsoluteFill style={{ backgroundImage: `repeating-linear-gradient(0deg, ${rgba(ink, 0.045)} 0px, ${rgba(ink, 0.045)} 2px, transparent 2px, transparent 5px)` }} />
40
+ <div style={{ position: "absolute", left: 0, right: 0, top: band, height: H * 0.12, background: `linear-gradient(180deg, transparent, ${rgba(ink, 0.05)}, transparent)` }} />
41
+ </>
42
+ ) : null}
43
+ {[0, 1, 2, 3].map((i) => <div key={i} style={bracket(i)} />)}
44
+ {items.map((label, i) => (
45
+ <div key={i} style={{ position: "absolute", ...corner(i), margin: i < 2 ? `${W * 0.012}px ${W * 0.03}px` : `${W * 0.012}px ${W * 0.03}px`, fontSize: fs, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", opacity: 0.88, display: "flex", alignItems: "center", gap: fs * 0.5, flexDirection: i % 2 ? "row-reverse" : "row" }}>
46
+ {i === 0 ? <span style={{ width: fs * 0.55, height: fs * 0.55, borderRadius: 999, background: hero, opacity: Math.floor(frame / 15) % 2 ? 0.25 : 1 }} /> : null}
47
+ <span style={{ fontVariantNumeric: "tabular-nums" }}>{label}</span>
48
+ </div>
49
+ ))}
50
+ </AbsoluteFill>
51
+ );
52
+ };
@@ -0,0 +1,48 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { useMedia } from "./media";
4
+ import { layerMoves, type ImageLayersRecord } from "./image-layers-math";
5
+
6
+ // What ImageLayers shares with the TextOnImage inside it: the picture's layers, and the element the words go into when they must sit in front of the subject.
7
+ export const ImageLayersContext = React.createContext<{ layers?: ImageLayersRecord; frontRoot: HTMLElement | null } | undefined>(undefined);
8
+ // The ink a TextOnImage chose for its zone, which a Headline inside it takes unless it is given one.
9
+ export const TextInkContext = React.createContext<string | undefined>(undefined);
10
+
11
+ const fill = (scale: number, tx: number, ty: number, w: number, h: number): React.CSSProperties => ({ transform: `translate(${tx * w}px, ${ty * h}px) scale(${scale})` });
12
+
13
+ // A picture in layers, with the words as a layer OF the picture. It draws the back picture, then `children` (the middle layer: your words), then the subject cut out
14
+ // of the same picture, then `front`, so a headline can pass BEHIND the subject. Pass `layers` (the manifest scene's imageLayers, from `reelkit assets layers`) or
15
+ // `back` and `subject` yourself; without a subject it is one flat picture with the words over it, and nothing fails. Depth comes from the frame: the back drifts and
16
+ // grows slowly, the subject moves more and the other way (`depth`, 0 to 1, default 0.4; 0 moves them together), and the words between them at their own rate.
17
+ // `focus` softly blurs the other layer ("subject" blurs the back, "back" blurs the subject). Every layer covers the frame at every frame, so no edge is shown.
18
+ // Put a TextOnImage in it for words that sit where the picture is calm.
19
+ export const ImageLayers: React.FC<{
20
+ layers?: ImageLayersRecord;
21
+ back?: string;
22
+ subject?: string;
23
+ depth?: number;
24
+ focus?: "subject" | "back";
25
+ front?: React.ReactNode;
26
+ children?: React.ReactNode;
27
+ }> = ({ layers, back, subject, depth = 0.4, focus, front, children }) => {
28
+ const frame = useCurrentFrame();
29
+ const { fps, width, height } = useVideoConfig();
30
+ const backSrc = useMedia(back ?? layers?.back ?? ""), subjectSrc = useMedia(subject ?? layers?.subject ?? "");
31
+ const hasSubject = Boolean(subject ?? layers?.subject);
32
+ const m = layerMoves(frame, fps, depth);
33
+ const blur = Math.round(width * 0.006 * 10) / 10;
34
+ const [frontRoot, setFrontRoot] = React.useState<HTMLElement | null>(null);
35
+ const img: React.CSSProperties = { width: "100%", height: "100%", objectFit: "cover", display: "block" };
36
+ const ctx = React.useMemo(() => ({ layers: layers ?? (back ? { back, subject, textZone: { region: "bottom" as const, luminance: 0.3, busy: 0.5 } } : undefined), frontRoot }), [layers, back, subject, frontRoot]);
37
+ return (
38
+ <ImageLayersContext.Provider value={ctx}>
39
+ <AbsoluteFill style={{ overflow: "hidden" }}>
40
+ {backSrc ? <AbsoluteFill style={{ ...fill(m.back.scale, m.back.tx, m.back.ty, width, height), filter: focus === "subject" ? `blur(${blur}px)` : undefined }}><img src={backSrc} style={img} /></AbsoluteFill> : null}
41
+ <AbsoluteFill style={{ ...fill(m.text.scale, m.text.tx, m.text.ty, width, height) }}>{children}</AbsoluteFill>
42
+ {hasSubject && subjectSrc ? <AbsoluteFill style={{ ...fill(m.subject.scale, m.subject.tx, m.subject.ty, width, height), filter: focus === "back" ? `blur(${blur}px)` : undefined }}><img src={subjectSrc} style={img} /></AbsoluteFill> : null}
43
+ <AbsoluteFill ref={setFrontRoot as never} />
44
+ {front ? <AbsoluteFill>{front}</AbsoluteFill> : null}
45
+ </AbsoluteFill>
46
+ </ImageLayersContext.Provider>
47
+ );
48
+ };
@@ -1,11 +1,11 @@
1
1
  import React from "react";
2
2
  import { Audio, useVideoConfig } from "remotion";
3
- import { musicVolume, speechSpans, type SpeechScene } from "./music-math";
3
+ import { DEFAULT_DUCK_TO, DEFAULT_MUSIC_VOLUME, musicVolume, speechSpans, type MusicDip, type SpeechScene } from "./music-math";
4
4
  import { useGain, useManifest, useMedia } from "./media";
5
5
 
6
6
  // The video's music track. Place it once, outside the scenes. It sits at `duckTo` while the voiceover is heard and comes up to `volume`
7
- // in the gaps, loops when the video is longer than the track, and fades out over the last second. `scenes` defaults to the manifest's.
8
- export const Music: React.FC<{ src: string; volume?: number; duckTo?: number; scenes?: SpeechScene[] }> = ({ src, volume = 0.5, duckTo = 0.12, scenes }) => {
7
+ // in real pauses (a gap of 1.2 s or more: it rises over 0.4 s and is back down 0.25 s before the next word), drops to a dip's volume inside each of `dips` (absolute frames: a rest before the big moment), loops when the video is longer than the track, and fades out over the last second. `scenes` defaults to the manifest's.
8
+ export const Music: React.FC<{ src: string; volume?: number; duckTo?: number; scenes?: SpeechScene[]; dips?: MusicDip[] }> = ({ src, volume = DEFAULT_MUSIC_VOLUME, duckTo = DEFAULT_DUCK_TO, scenes, dips }) => {
9
9
  const { fps, durationInFrames } = useVideoConfig();
10
10
  const manifest = useManifest();
11
11
  const gain = useGain(src);
@@ -13,7 +13,7 @@ export const Music: React.FC<{ src: string; volume?: number; duckTo?: number; sc
13
13
  return (
14
14
  <Audio
15
15
  src={useMedia(src)} loop loopVolumeCurveBehavior="extend"
16
- volume={(f) => musicVolume(f, { fps, totalFrames: durationInFrames, volume, duckTo, spans, gain })}
16
+ volume={(f) => musicVolume(f, { fps, totalFrames: durationInFrames, volume, duckTo, spans, gain, dips })}
17
17
  />
18
18
  );
19
19
  };