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,52 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "../frame";
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 "../frame";
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,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { useMedia } from "./media";
3
3
  import { ease } from "./theme";
4
- import { AbsoluteFill, Img, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
4
+ import { AbsoluteFill, Img, interpolate, useCurrentFrame, useVideoConfig } from "../frame";
5
5
 
6
6
  // direction "out" starts zoomed and pulls back; alternate between consecutive image scenes.
7
7
  export const KenBurnsImage: React.FC<{ src: string; zoom?: number; direction?: "in" | "out" }> = ({ src, zoom = 1.1, direction = "in" }) => {
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { useMedia } from "./media";
3
- import { AbsoluteFill, OffthreadVideo } from "remotion";
3
+ import { AbsoluteFill, OffthreadVideo } from "../frame";
4
4
 
5
5
  // A green-screen clip with its green keyed out (the transparent .webm that `--green` makes), laid over whatever is beneath it.
6
6
  // x and y are where the clip's bottom-centre sits, as fractions of the frame (x 0 is the left edge, y 1 the bottom edge); scale is the clip's
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "../frame";
3
3
 
4
4
  // The bottom layer of every scene: the base colour with two slow-drifting soft colour fields. Never a flat fill.
5
5
  export const BgMesh: React.FC<{ bg: string; hero: string; accent?: string }> = ({ bg, hero, accent = hero }) => {
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { fonts } from "./theme";
3
- import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "../frame";
4
4
 
5
5
  export const LowerThird: React.FC<{ title: string; subtitle?: string; accent?: string }> = ({ title, subtitle, accent = "#5b8cff" }) => {
6
6
  const frame = useCurrentFrame();
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import { Audio, useVideoConfig } from "../frame";
3
+ import { DEFAULT_DUCK_TO, DEFAULT_MUSIC_VOLUME, musicVolume, speechSpans, type MusicDip, type SpeechScene } from "./music-math";
4
+ import { useGain, useManifest, useMedia } from "./media";
5
+
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 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
+ const { fps, durationInFrames } = useVideoConfig();
10
+ const manifest = useManifest();
11
+ const gain = useGain(src);
12
+ const spans = React.useMemo(() => speechSpans(scenes ?? manifest?.scenes ?? [], fps), [scenes, manifest, fps]);
13
+ return (
14
+ <Audio
15
+ src={useMedia(src)} loop loopVolumeCurveBehavior="extend"
16
+ volume={(f) => musicVolume(f, { fps, totalFrames: durationInFrames, volume, duckTo, spans, gain, dips })}
17
+ />
18
+ );
19
+ };
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { fonts } from "./theme";
4
+ import { inkOn } from "./three-math";
5
+ import { DEFAULT_HERO, rgba } from "./ui-theme";
6
+ import { clickFrames, cursorAt, CURSOR_MOVE_FRAMES, type CursorPoint } from "./ui-math";
7
+
8
+ // A mouse arrow with a coloured name pill (a teammate, an agent), moving along `points` (x, y as fractions of the frame; `click: true` makes it click there,
9
+ // `hold` is the frames it rests, 10 on a click and 5 otherwise) on a smooth arc with ease, and clicking with a ripple. Place it over the scene, above the
10
+ // interface it points at. Times are frames from the component's own mount: it appears at `delay` (default 4) and each leg takes `moveFrames` (22);
11
+ // clickFrames(points, { delay, moveFrames }) gives the click frames for sound. `color` is the pill and the ripple (default the hero colour); `name` is the pill's
12
+ // text. The kit's own cursor: it does not depend on any library cursor.
13
+ export const NamedCursor: React.FC<{
14
+ points: CursorPoint[];
15
+ name?: string;
16
+ color?: string;
17
+ hero?: string;
18
+ font?: string;
19
+ delay?: number;
20
+ moveFrames?: number;
21
+ size?: number;
22
+ }> = ({ points, name = "Ava", color, hero = DEFAULT_HERO, font = fonts.body, delay = 4, moveFrames = CURSOR_MOVE_FRAMES, size = 0.055 }) => {
23
+ const frame = useCurrentFrame();
24
+ const { width: W, height: H } = useVideoConfig();
25
+ const tint = color ?? hero;
26
+ const o = { delay, moveFrames };
27
+ const pos = cursorAt(points, frame, o);
28
+ const clicks = clickFrames(points, o);
29
+ const appear = interpolate(frame, [delay - 4, delay + 2], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
30
+ const last = clicks.filter((c) => c <= frame).pop();
31
+ const since = last === undefined ? 99 : frame - last;
32
+ const press = since < 6 ? 0.88 : 1;
33
+ const a = W * size;
34
+ const x = pos.x * W, y = pos.y * H;
35
+ return (
36
+ <AbsoluteFill style={{ pointerEvents: "none" }}>
37
+ {clicks.map((c, i) => {
38
+ const t = (frame - c) / 18;
39
+ if (t < 0 || t > 1) return null;
40
+ const at = cursorAtClick(points, i);
41
+ return <div key={i} style={{ position: "absolute", left: at.x * W - a * 1.6 * t - a * 0.3, top: at.y * H - a * 1.6 * t - a * 0.3, width: (a * 1.6 * t + a * 0.3) * 2, height: (a * 1.6 * t + a * 0.3) * 2, borderRadius: 999, border: `${Math.max(3, W * 0.005)}px solid ${rgba(tint, 1 - t)}`, background: rgba(tint, 0.18 * (1 - t)) }} />;
42
+ })}
43
+ <div style={{ position: "absolute", left: x, top: y, opacity: appear, transform: `scale(${press})`, transformOrigin: "0 0" }}>
44
+ <svg width={a} height={a * 1.3} viewBox="0 0 24 31" style={{ display: "block", filter: `drop-shadow(0 ${a * 0.06}px ${a * 0.1}px rgba(0,0,0,0.4))` }}>
45
+ <path d="M2 1.5v24l6.2-5.6 4.1 9.3 4-1.8-4-9.2h8.4Z" fill={tint} stroke="#ffffff" strokeWidth="1.8" strokeLinejoin="round" />
46
+ </svg>
47
+ <div style={{ position: "absolute", left: a * 0.72, top: a * 1.0, background: tint, color: inkOn(tint), fontFamily: font, fontWeight: 700, fontSize: W * 0.032, padding: `${W * 0.008}px ${W * 0.026}px`, borderRadius: 999, whiteSpace: "nowrap", boxShadow: `0 ${W * 0.005}px ${W * 0.02}px rgba(0,0,0,0.35)` }}>{name}</div>
48
+ </div>
49
+ </AbsoluteFill>
50
+ );
51
+ };
52
+
53
+ // The point of the i-th click (clicks are the points marked click, in order).
54
+ const cursorAtClick = (points: readonly CursorPoint[], i: number) => points.filter((p) => p.click)[i] ?? { x: 0.5, y: 0.5 };
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { CameraKeysContext } from "./scene3d-context";
4
+ import { naturalRingRadius, ringFitScale, ringPosition, type Vec3 } from "./three-math";
5
+
6
+ // The width and height a child asks for (a Card3D's own props, default 3 by 2), to size the ring around it.
7
+ const itemSize = (child: React.ReactNode): { w: number; h: number } => {
8
+ const p = (React.isValidElement(child) ? child.props : {}) as { width?: number; height?: number };
9
+ const w = p.width ?? 3;
10
+ return { w, h: p.height ?? w * 0.66 };
11
+ };
12
+
13
+ // Arranges its children on a ring around a point and turns the ring slowly: for a set of cards or logos. Place it inside a Scene3D. Leave `radius` out
14
+ // and the ring sizes itself: the radius is the one at which the cards stand side by side, and the whole ring (radius and cards together) is scaled so
15
+ // that it lies inside `fit` of the frame (default 0.85) at the closest the Scene3D's camera comes, on any aspect and however far it has turned. A
16
+ // `radius` you give wins, in scene units, and then nothing is scaled. speed is degrees a frame (default 0.5, which is a full turn in 24 seconds), tiltDeg
17
+ // tips the ring toward the camera so that the front is lower and the back higher (default 0). Each child faces outward, so the one at the front faces the
18
+ // camera; set billboard to keep every child facing the camera instead. `position` moves the middle of the ring. Put a label or an icon on every card.
19
+ export const Orbit3D: React.FC<{
20
+ radius?: number;
21
+ fit?: number;
22
+ speed?: number;
23
+ tiltDeg?: number;
24
+ billboard?: boolean;
25
+ position?: Vec3;
26
+ children: React.ReactNode;
27
+ }> = ({ radius, fit = 0.85, speed = 0.5, tiltDeg = 0, billboard = false, position = [0, 0, 0], children }) => {
28
+ const frame = useCurrentFrame();
29
+ const { width, height } = useVideoConfig();
30
+ const keys = React.useContext(CameraKeysContext);
31
+ const items = React.useMemo(() => React.Children.toArray(children), [children]);
32
+ let r = radius ?? 3, scale = 1;
33
+ if (radius === undefined && items.length) {
34
+ const sizes = items.map(itemSize);
35
+ const w = Math.max(...sizes.map((s) => s.w)), h = Math.max(...sizes.map((s) => s.h));
36
+ r = naturalRingRadius(items.length, w);
37
+ scale = ringFitScale({ keys, aspect: width / height, fit, radius: r, itemW: w, itemH: h, tiltDeg, billboard, at: position });
38
+ }
39
+ return (
40
+ <group position={position}>
41
+ <group scale={scale}>
42
+ {items.map((child, i) => {
43
+ const p = ringPosition(i, items.length, frame, { radius: r, speed, tiltDeg });
44
+ return <group key={i} position={p.position} rotation={[0, billboard ? 0 : p.facing, 0]}>{child}</group>;
45
+ })}
46
+ </group>
47
+ </group>
48
+ );
49
+ };
@@ -0,0 +1,74 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { AdditiveBlending, BufferAttribute, CanvasTexture, Color, SRGBColorSpace } from "three";
4
+ import { CameraKeysContext } from "./scene3d-context";
5
+ import { textPoints } from "./sample-text";
6
+ import { MAX_POINTS, morphAt, morphPoints, particleShape, type ParticleShape } from "./three-fx-math";
7
+ import { nearestVisible, type Vec3 } from "./three-math";
8
+
9
+ // A soft round dot, drawn once: the glow of a point.
10
+ const dot = (): CanvasTexture => {
11
+ const c = document.createElement("canvas");
12
+ c.width = c.height = 64;
13
+ const g = c.getContext("2d")!;
14
+ const r = g.createRadialGradient(32, 32, 0, 32, 32, 32);
15
+ r.addColorStop(0, "rgba(255,255,255,1)"); r.addColorStop(0.35, "rgba(255,255,255,0.55)"); r.addColorStop(1, "rgba(255,255,255,0)");
16
+ g.fillStyle = r; g.fillRect(0, 0, 64, 64);
17
+ const t = new CanvasTexture(c);
18
+ t.colorSpace = SRGBColorSpace;
19
+ return t;
20
+ };
21
+
22
+ // A cloud of glowing points that morphs from one shape to another: an abstract idea made of light. `from` and `to` are "sphere", "torus", "knot", "galaxy", "plane" or
23
+ // "text" (the points fall inside the letters of `text`, a short Latin word). `count` points (12000 unless set, at most 60000). The morph is driven by the frame: it runs
24
+ // between the frames in `morphFrames` (default [20, 80]) and holds either side, or give `morph` (0 to 1) yourself. The points go from the first of `colors` to the second
25
+ // by height, additive and soft, with a gentle seeded drift (`drift`, 0.012) that breathes and is zero at the ends of the morph. `fit` is how much of the frame's width the
26
+ // shape fills at the closest the camera comes (0.8); `size` is the size of a point as a fraction of the shape's width (0.026). Done on the CPU with ordinary points, so it is the same on every machine.
27
+ export const Particles3D: React.FC<{
28
+ from?: ParticleShape;
29
+ to?: ParticleShape;
30
+ text?: string;
31
+ count?: number;
32
+ morph?: number;
33
+ morphFrames?: [number, number];
34
+ colors?: [string, string];
35
+ drift?: number;
36
+ fit?: number;
37
+ size?: number;
38
+ seed?: number;
39
+ spin?: number;
40
+ position?: Vec3;
41
+ }> = ({ from = "sphere", to = "torus", text = "Relay", count = 12000, morph, morphFrames = [20, 80], colors = ["#7C3AED", "#22D3EE"], drift = 0.012, fit = 0.8, size = 0.026, seed = 0, spin = 6, position = [0, 0, 0] }) => {
42
+ const frame = useCurrentFrame();
43
+ const { fps, width, height } = useVideoConfig();
44
+ const keys = React.useContext(CameraKeysContext);
45
+ const n = Math.max(1, Math.min(MAX_POINTS, Math.round(count)));
46
+ const A = React.useMemo(() => (from === "text" ? textPoints(text, n, seed) : particleShape(from, n, seed)), [from, text, n, seed]);
47
+ const B = React.useMemo(() => (to === "text" ? textPoints(text, n, seed + 7) : particleShape(to, n, seed + 7)), [to, text, n, seed]);
48
+ const view = nearestVisible(keys, width / height, position[2]);
49
+ const scale = fit * view.width;
50
+ const sprite = React.useMemo(dot, []);
51
+ const tint = React.useMemo(() => {
52
+ const a = new Color(colors[0]), b = new Color(colors[1]), c = new Color(), out = new Float32Array(n * 3);
53
+ for (let i = 0; i < n; i++) { const y = (A[i * 3 + 1]! + B[i * 3 + 1]!) / 2 + 0.5; c.copy(a).lerp(b, Math.max(0, Math.min(1, y * 1.1))); out[i * 3] = c.r; out[i * 3 + 1] = c.g; out[i * 3 + 2] = c.b; }
54
+ return out;
55
+ }, [colors, n, A, B]);
56
+ const m = morph ?? morphAt(frame, morphFrames);
57
+ const pts = React.useMemo(() => morphPoints(A, B, m, frame, fps, { drift, seed }), [A, B, m, frame, fps, drift, seed]);
58
+ const geometry = React.useRef<import("three").BufferGeometry>(null);
59
+ React.useLayoutEffect(() => {
60
+ const g = geometry.current;
61
+ if (!g) return;
62
+ g.setAttribute("position", new BufferAttribute(pts, 3));
63
+ g.setAttribute("color", new BufferAttribute(tint, 3));
64
+ g.computeBoundingSphere();
65
+ }, [pts, tint]);
66
+ return (
67
+ <group position={position} scale={scale} rotation={[0, (frame / fps) * (spin * Math.PI) / 180, 0]}>
68
+ <points frustumCulled={false}>
69
+ <bufferGeometry ref={geometry} />
70
+ <pointsMaterial map={sprite} size={size * scale} vertexColors transparent opacity={0.92} depthWrite={false} blending={AdditiveBlending} sizeAttenuation />
71
+ </points>
72
+ </group>
73
+ );
74
+ };
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useVideoConfig } from "../frame";
3
+
4
+ // Centres a product-surface component in the frame, at fraction `y` of the height (0.5 is the middle). Used inside the components themselves.
5
+ export const Place: React.FC<{ y?: number; children: React.ReactNode }> = ({ y = 0.5, children }) => {
6
+ const { height } = useVideoConfig();
7
+ return (
8
+ <AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center", transform: y === 0.5 ? undefined : `translateY(${(y - 0.5) * height}px)` }}>
9
+ {children}
10
+ </AbsoluteFill>
11
+ );
12
+ };
@@ -0,0 +1,84 @@
1
+ import React from "react";
2
+ import { interpolate, spring, useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { Place } from "./Place";
4
+ import { fonts, springs } from "./theme";
5
+ import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
6
+ import { PROMPT_DEFAULTS, promptFrames, typedCount } from "./ui-math";
7
+
8
+ // A rounded input with a question typed into it, the way an assistant or a tool is used: the text appears at `cps` characters a second with a caret, `chips`
9
+ // sit under it, a round send button presses when the typing is over, and an optional `reply` streams in below. Every time is a frame from the component's
10
+ // own mount (see ui-math): `delay` is when typing starts (default 6), and promptFrames(props, fps) gives the frame of every character, of the send and of
11
+ // the reply for sound. `width` is a fraction of the frame's width (default 0.86); `theme` is "dark", "light" or "glass" (default "dark"); `y` is the
12
+ // height of the box's middle as a fraction of the frame (default 0.5, the reply hangs below it).
13
+ export const PromptBox: React.FC<{
14
+ text: string;
15
+ cps?: number;
16
+ chips?: string[];
17
+ reply?: string;
18
+ replyCps?: number;
19
+ placeholder?: string;
20
+ hero?: string;
21
+ theme?: UiTheme;
22
+ font?: string;
23
+ width?: number;
24
+ delay?: number;
25
+ sendPause?: number;
26
+ replyDelay?: number;
27
+ y?: number;
28
+ }> = ({ text, cps = PROMPT_DEFAULTS.cps, chips = [], reply, replyCps = PROMPT_DEFAULTS.replyCps, placeholder = "Ask anything", hero = DEFAULT_HERO, theme = "dark", font = fonts.body, width: fraction = 0.86, delay = PROMPT_DEFAULTS.delay, sendPause, replyDelay, y = 0.5 }) => {
29
+ const frame = useCurrentFrame();
30
+ const { fps, width: W } = useVideoConfig();
31
+ const c = uiColors(theme, hero);
32
+ const P = W * fraction;
33
+ const ev = promptFrames({ text, cps, delay, reply, replyCps, sendPause, replyDelay }, fps);
34
+ const n = typedCount(frame, text, cps, fps, delay);
35
+ const typing = frame >= delay && frame < ev.typedEnd + 2;
36
+ const caretOn = typing || frame < delay || Math.floor((frame - ev.typedEnd) / 8) % 2 === 0;
37
+ const enter = spring({ frame: frame - 0, fps, config: springs.smooth });
38
+ const press = interpolate(frame, [ev.send, ev.send + 3, ev.send + 9], [1, 0.86, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
39
+ const sent = frame >= ev.send;
40
+ const glow = interpolate(frame, [ev.send, ev.send + 4, ev.send + 18], [0, 1, 0.35], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
41
+ const chars = [...text], rchars = [...(reply ?? "")];
42
+ const rn = reply ? ev.reply.filter((f) => f <= frame).length : 0;
43
+ const fs = P * 0.046;
44
+ const sparkle = (
45
+ <svg width={P * 0.06} height={P * 0.06} viewBox="0 0 24 24" style={{ flex: "none", marginTop: P * 0.008 }}>
46
+ <path d="M12 1.5 14.6 9.4 22.5 12 14.6 14.6 12 22.5 9.4 14.6 1.5 12 9.4 9.4Z" fill={hero} />
47
+ </svg>
48
+ );
49
+ return (
50
+ <Place y={y}>
51
+ <div style={{ width: P, opacity: enter, transform: `translateY(${(1 - enter) * P * 0.04}px)`, fontFamily: font }}>
52
+ <div style={{ background: c.panel, border: `${Math.max(2, P * 0.0025)}px solid ${c.stroke}`, borderRadius: P * 0.055, padding: `${P * 0.05}px ${P * 0.05}px ${P * 0.04}px`, boxShadow: `0 ${P * 0.03}px ${P * 0.08}px ${c.shadow}, 0 0 ${P * 0.12}px ${rgba(hero, 0.18 + 0.2 * glow)}` }}>
53
+ <div style={{ minHeight: fs * 1.4 * 3, fontSize: fs, lineHeight: 1.4, color: c.ink, fontWeight: 500, position: "relative", overflowWrap: "anywhere" }}>
54
+ {n === 0 && placeholder ? <span style={{ color: c.dim, position: "absolute", left: 0, top: 0, opacity: sent ? 0 : 1 }}>{placeholder}</span> : null}
55
+ <span style={{ color: sent ? c.dim : c.ink }}>{chars.slice(0, n).join("")}</span>
56
+ <span style={{ display: "inline-block", width: 0, height: fs * 1.05, verticalAlign: "text-bottom", position: "relative" }}>
57
+ <span style={{ position: "absolute", left: P * 0.004, top: fs * 0.02, width: Math.max(3, P * 0.0075), height: fs * 1.08, background: hero, opacity: caretOn && !sent ? 1 : 0, borderRadius: 2 }} />
58
+ </span>
59
+ <span style={{ color: "transparent" }}>{chars.slice(n).join("")}</span>
60
+ </div>
61
+ <div style={{ display: "flex", alignItems: "center", gap: P * 0.025, marginTop: P * 0.035 }}>
62
+ <div style={{ display: "flex", gap: P * 0.02, flex: 1, flexWrap: "wrap" }}>
63
+ {chips.map((chip, i) => (
64
+ <div key={i} style={{ fontSize: P * 0.033, fontWeight: 600, color: c.dim, background: c.chip, borderRadius: 999, padding: `${P * 0.012}px ${P * 0.03}px`, whiteSpace: "nowrap" }}>{chip}</div>
65
+ ))}
66
+ </div>
67
+ <div style={{ width: P * 0.125, height: P * 0.125, borderRadius: 999, background: hero, display: "flex", alignItems: "center", justifyContent: "center", transform: `scale(${press})`, boxShadow: `0 0 ${P * 0.07 * glow}px ${rgba(hero, 0.9)}`, opacity: n > 0 ? 1 : 0.45, flex: "none" }}>
68
+ <svg width={P * 0.06} height={P * 0.06} viewBox="0 0 24 24" fill="none" stroke={c.heroInk} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ {reply ? (
73
+ <div style={{ display: "flex", gap: P * 0.03, marginTop: P * 0.05, padding: `0 ${P * 0.02}px`, opacity: interpolate(frame, [ev.replyStart - 2, ev.replyStart + 4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
74
+ {sparkle}
75
+ <div style={{ fontSize: fs * 0.95, lineHeight: 1.42, color: c.ink, fontWeight: 500, overflowWrap: "anywhere" }}>
76
+ <span>{rchars.slice(0, rn).join("")}</span>
77
+ <span style={{ color: "transparent" }}>{rchars.slice(rn).join("")}</span>
78
+ </div>
79
+ </div>
80
+ ) : null}
81
+ </div>
82
+ </Place>
83
+ );
84
+ };
@@ -0,0 +1,70 @@
1
+ import React from "react";
2
+ import { useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { ThreeCanvas } from "../three";
4
+ import { useThree } from "@react-three/fiber";
5
+ import { ManifestContext, UrlsContext } from "./media";
6
+ import { camera3DAt, type Camera3DKey } from "./three-math";
7
+ import { CameraKeysContext } from "./scene3d-context";
8
+ import { moodLights, type Mood } from "./three-fx-math";
9
+ import "./quiet-three";
10
+
11
+ // Moves the three.js camera along the keys, at every frame, with the same timing rule as the 2D Camera. It writes the camera in a layout effect, which
12
+ // runs before the canvas draws the frame, and it keeps no state of its own, so any frame can be drawn on its own.
13
+ const Rig: React.FC<{ keys?: Camera3DKey[]; lead?: number; stiffness?: number; damping?: number }> = ({ keys, lead, stiffness, damping }) => {
14
+ const camera = useThree((s) => s.camera) as import("three").PerspectiveCamera;
15
+ const frame = useCurrentFrame();
16
+ const { fps } = useVideoConfig();
17
+ React.useLayoutEffect(() => {
18
+ const v = camera3DAt(keys, frame, fps, { lead, stiffness, damping });
19
+ camera.position.set(...v.position);
20
+ camera.fov = v.fov;
21
+ camera.lookAt(...v.lookAt);
22
+ camera.updateProjectionMatrix();
23
+ camera.updateMatrixWorld();
24
+ }, [camera, keys, frame, fps, lead, stiffness, damping]);
25
+ return null;
26
+ };
27
+
28
+ // A three.js scene at the size of the composition, for real depth: extruded type, cards in space, a ring of objects. The canvas is transparent unless
29
+ // `background` is set, so it lays over a 2D ground, and a soft three-point light is placed for you. Place it inside a SceneFrame, with captions and
30
+ // small labels in 2D on top of it. It draws nothing, and makes no GL context, until a Scene3D is on screen. `camera.keys` move the camera: x, y, z are its
31
+ // place, lookAt the point it faces (default the origin) and fov its field of view in degrees (default 50); a key that leaves a value out keeps the one before.
32
+ // The default camera sits at z 6 looking at the origin. What the camera sees: at fov 50 the visible height is about 0.93 times the distance to the object
33
+ // and the width that times the frame's aspect, so 0.52 times the distance on a 9:16 phone frame (about 3.1 units across at z 6, 4.9 at z 9.5) and 1.66
34
+ // times the distance on 16:9. `mood` ("studio", "night", "sunset" or "neon") chooses the light colours and intensities and the fog tint together, so lights are never hand-tuned; without it the
35
+ // default three-point light is used, as before. Text3D and Orbit3D read the camera keys, and size themselves to fit the frame at the key where it comes closest.
36
+ export const Scene3D: React.FC<{
37
+ camera?: { keys: Camera3DKey[]; lead?: number; stiffness?: number; damping?: number };
38
+ background?: string;
39
+ lights?: boolean;
40
+ mood?: Mood;
41
+ children?: React.ReactNode;
42
+ }> = ({ camera, background, lights = true, mood, children }) => {
43
+ const { width, height } = useVideoConfig();
44
+ // The canvas is a separate React tree, so the kit's own contexts are carried across by hand: without them a Card3D could not find its picture.
45
+ const urls = React.useContext(UrlsContext), manifest = React.useContext(ManifestContext);
46
+ return (
47
+ <ThreeCanvas width={width} height={height} style={{ position: "absolute", left: 0, top: 0 }} camera={{ fov: 50, position: [0, 0, 6], near: 0.1, far: 200 }}>
48
+ {background ? <color attach="background" args={[background]} /> : null}
49
+ <Rig keys={camera?.keys} lead={camera?.lead} stiffness={camera?.stiffness} damping={camera?.damping} />
50
+ {lights && mood ? (
51
+ <>
52
+ <ambientLight color={moodLights(mood).ambient.color} intensity={moodLights(mood).ambient.intensity} />
53
+ <directionalLight color={moodLights(mood).key.color} intensity={moodLights(mood).key.intensity} position={moodLights(mood).key.position} />
54
+ <directionalLight color={moodLights(mood).fill.color} intensity={moodLights(mood).fill.intensity} position={moodLights(mood).fill.position} />
55
+ <directionalLight color={moodLights(mood).rim.color} intensity={moodLights(mood).rim.intensity} position={moodLights(mood).rim.position} />
56
+ </>
57
+ ) : null}
58
+ {mood ? <fog attach="fog" args={[moodLights(mood).fog.color, moodLights(mood).fog.near, moodLights(mood).fog.far]} /> : null}
59
+ {lights && !mood ? (
60
+ <>
61
+ <ambientLight intensity={1.4} />
62
+ <directionalLight position={[4, 6, 8]} intensity={2.6} />
63
+ <directionalLight position={[-6, 2, 4]} intensity={1.1} />
64
+ <directionalLight position={[0, 3, -8]} intensity={1.4} />
65
+ </>
66
+ ) : null}
67
+ <UrlsContext.Provider value={urls}><ManifestContext.Provider value={manifest}><CameraKeysContext.Provider value={camera?.keys}>{children}</CameraKeysContext.Provider></ManifestContext.Provider></UrlsContext.Provider>
68
+ </ThreeCanvas>
69
+ );
70
+ };
@@ -0,0 +1,96 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "../frame";
3
+ import { transitionAt, transitionCss, type TransitionKind } from "./transition-math";
4
+
5
+ export type SceneTransition = {
6
+ // How the scene arrives and leaves: "fade" (the default), "cut", "zoom-in", "zoom-out", "zoom-through", "push-left", "push-right", "push-up",
7
+ // "push-down", "whip-left", "whip-right", "blur", "turn", "color-push" (a field of `color` covers the frame as a circle, a bar or a diagonal and uncovers the
8
+ // next scene), "rgb-whip" (a whip with the red and blue channels pulled apart), "flash" (2 to 3 frames of white or `color`), "ring" (the scene closes into a
9
+ // ring that sweeps in from `origin`, the next opens out of one) or "flip" (a one-frame inverted flash on a hard cut, for the beat). A scene's exit and the next
10
+ // scene's enter are meant to be the same kind.
11
+ enter?: TransitionKind;
12
+ exit?: TransitionKind;
13
+ // How many frames the transition takes: 8 unless set (color-push 10, rgb-whip 6, ring 12, flash 3, flip 1), at most 20, never more than half the scene.
14
+ transitionFrames?: number;
15
+ // The colour of a color-push's field, a flash and a ring (default "#7C3AED" for a field and a ring, "#ffffff" for a flash), and the shape of a color-push's field: "circle" (grows from `origin`), "bar" or "diagonal".
16
+ color?: string;
17
+ shape?: "circle" | "bar" | "diagonal";
18
+ // Where a zoom aims, as fractions of the frame (default the centre).
19
+ origin?: { x: number; y: number };
20
+ };
21
+
22
+ // The overlay and the clip a state with an `fx` needs. The exit makes the scene covered or closed, and the enter uncovers or opens it, from the same side, so the frame is
23
+ // whole on the boundary and the two edges of a cut read as one move.
24
+ const fxView = (s: NonNullable<ReturnType<typeof transitionAt>["fx"]>, w: number, h: number, origin: { x: number; y: number }, color: string | undefined, shape: "circle" | "bar" | "diagonal") => {
25
+ const a = s.amount, diag = Math.hypot(w, h);
26
+ const ox = origin.x * w, oy = origin.y * h;
27
+ let clip: string | undefined, overlay: React.CSSProperties | undefined, filter: string | undefined;
28
+ if (s.kind === "flash") overlay = { background: color ?? "#ffffff", opacity: a };
29
+ else if (s.kind === "flip") filter = "invert(1) hue-rotate(180deg)";
30
+ else if (s.kind === "color-push") {
31
+ const c = color ?? "#7C3AED";
32
+ if (shape === "circle") overlay = { background: c, clipPath: `circle(${a * Math.hypot(Math.max(ox, w - ox), Math.max(oy, h - oy))}px at ${ox}px ${oy}px)` };
33
+ else if (shape === "bar") overlay = { background: c, left: s.phase === "exit" ? 0 : "auto", right: s.phase === "enter" ? 0 : "auto", width: `${a * 100}%` };
34
+ else {
35
+ // A slanted edge that sweeps across: the field is the part of the frame on one side of a line leaning 20 percent of the width.
36
+ const lean = 0.2, x = a * (1 + lean) * 100;
37
+ overlay = { background: c, clipPath: s.phase === "exit" ? `polygon(0 0, ${x}% 0, ${x - lean * 100}% 100%, 0 100%)` : `polygon(${100 - x}% 0, 100% 0, 100% 100%, ${100 - x - lean * 100}% 100%)` };
38
+ }
39
+ } else if (s.kind === "ring") {
40
+ // The scene is only inside a circle that grows from the origin (or shrinks to it); the ring is drawn on the circle's edge.
41
+ const r = (1 - a) * Math.hypot(Math.max(ox, w - ox), Math.max(oy, h - oy));
42
+ clip = `circle(${r}px at ${ox}px ${oy}px)`;
43
+ const t = Math.max(6, w * 0.014);
44
+ overlay = { left: ox - r, top: oy - r, width: r * 2, height: r * 2, right: "auto", bottom: "auto", borderRadius: "50%", border: `${t}px solid ${color ?? "#7C3AED"}`, boxShadow: `0 0 ${t * 3}px ${color ?? "#7C3AED"}`, opacity: Math.min(1, Math.max(0, (r - t) / (t * 3))), boxSizing: "border-box" };
45
+ }
46
+ return { clip, overlay, filter };
47
+ };
48
+
49
+ const Moving: React.FC<SceneTransition & { durationInFrames: number; children: React.ReactNode }> = ({ durationInFrames, enter, exit, transitionFrames, origin, color, shape = "circle", children }) => {
50
+ const frame = useCurrentFrame();
51
+ const { width, height } = useVideoConfig();
52
+ const state = transitionAt(frame, durationInFrames, { enter, exit, transitionFrames });
53
+ const css = transitionCss(state, width, height, origin);
54
+ const { x, y } = css.blurPx;
55
+ const split = state.fx?.kind === "rgb-whip";
56
+ const directional = x !== y && (x > 0 || y > 0);
57
+ const id = `rk-blur-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;
58
+ const view = state.fx && !split ? fxView(state.fx, width, height, origin ?? { x: 0.5, y: 0.5 }, color, shape) : undefined;
59
+ const filter = split || directional ? `url(#${id})` : view?.filter ?? (x > 0 ? `blur(${x}px)` : undefined);
60
+ const k = split ? state.fx!.amount * width * 0.03 : 0;
61
+ return (
62
+ <>
63
+ <AbsoluteFill style={{ opacity: css.opacity, transform: css.transform === "none" ? undefined : css.transform, transformOrigin: css.transformOrigin, filter, clipPath: view?.clip }}>
64
+ {split ? (
65
+ // Red pulled one way, blue the other, green kept, then smeared along the move: the channels are separated with a colour matrix each and put back with a screen blend.
66
+ <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden>
67
+ <filter id={id} x="-20%" y="-20%" width="140%" height="140%" colorInterpolationFilters="sRGB">
68
+ <feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r" />
69
+ <feOffset in="r" dx={-k} result="r2" />
70
+ <feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g" />
71
+ <feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="b" />
72
+ <feOffset in="b" dx={k} result="b2" />
73
+ <feBlend in="r2" in2="g" mode="screen" result="rg" />
74
+ <feBlend in="rg" in2="b2" mode="screen" result="all" />
75
+ <feGaussianBlur in="all" stdDeviation={`${x} 0`} />
76
+ </filter>
77
+ </svg>
78
+ ) : directional ? (
79
+ <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden>
80
+ <filter id={id} x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation={`${x} ${y}`} /></filter>
81
+ </svg>
82
+ ) : null}
83
+ {children}
84
+ </AbsoluteFill>
85
+ {view?.overlay ? <AbsoluteFill style={{ pointerEvents: "none", ...view.overlay }} /> : null}
86
+ </>
87
+ );
88
+ };
89
+
90
+ // One scene on the timeline. By default it fades in and out over up to 8 frames. `enter` and `exit` choose other transitions, all complete
91
+ // exactly at the scene's edges so that the cuts stay on the beat.
92
+ export const SceneFrame: React.FC<{ from: number; durationInFrames: number; children: React.ReactNode } & SceneTransition> = ({ from, durationInFrames, children, ...transition }) => (
93
+ <Sequence from={from} durationInFrames={durationInFrames}>
94
+ <Moving durationInFrames={durationInFrames} {...transition}>{children}</Moving>
95
+ </Sequence>
96
+ );
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { useMedia } from "./media";
3
- import { AbsoluteFill, Loop, OffthreadVideo, useVideoConfig } from "remotion";
3
+ import { AbsoluteFill, Loop, OffthreadVideo, useVideoConfig } from "../frame";
4
4
 
5
5
  // Lays a white-on-black overlay clip (film marks, HUD frames) over the video. The "screen" blend drops the black.
6
6
  // Pass durationSec from the overlay's record to loop it for the whole video; without it the clip plays once.
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { useGain, useMedia } from "./media";
3
- import { Audio, Sequence } from "remotion";
3
+ import { Audio, Sequence } from "../frame";
4
4
 
5
5
  // Plays one sound effect starting at a frame. at is counted from the start of the enclosing scene, or of the video
6
6
  // when placed outside the scenes. Keep volume well under the voiceover.