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,54 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
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 "remotion";
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 "remotion";
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 "remotion";
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 "remotion";
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 "remotion";
3
+ import { ThreeCanvas } from "@remotion/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
+ };
@@ -1,19 +1,96 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, interpolate, Sequence, useCurrentFrame } from "remotion";
2
+ import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { transitionAt, transitionCss, type TransitionKind } from "./transition-math";
3
4
 
4
- const Fade: React.FC<{ durationInFrames: number; children: React.ReactNode }> = ({ durationInFrames, children }) => {
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 }) => {
5
50
  const frame = useCurrentFrame();
6
- const edge = Math.min(8, Math.floor(durationInFrames / 2));
7
- const opacity = interpolate(frame, [0, edge, durationInFrames - edge, durationInFrames], [0, 1, 1, 0], {
8
- extrapolateLeft: "clamp", extrapolateRight: "clamp",
9
- });
10
- return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
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
+ );
11
88
  };
12
89
 
13
- export const SceneFrame: React.FC<{ from: number; durationInFrames: number; children: React.ReactNode }> = ({
14
- from, durationInFrames, children,
15
- }) => (
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 }) => (
16
93
  <Sequence from={from} durationInFrames={durationInFrames}>
17
- <Fade durationInFrames={durationInFrames}>{children}</Fade>
94
+ <Moving durationInFrames={durationInFrames} {...transition}>{children}</Moving>
18
95
  </Sequence>
19
96
  );
@@ -0,0 +1,22 @@
1
+ import React, { useContext } from "react";
2
+ import { Audio, Sequence } from "remotion";
3
+ import { gainFor, ManifestContext, UrlsContext } from "./media";
4
+ import { resolveCues, type SoundCue } from "./sound-cues";
5
+
6
+ // Plays many sound effects from one place: a list of cues, each a frame of the whole video and a name from `sounds`. Place it once, outside
7
+ // the scenes, when a video is scored with sound (a video with no voice has dozens of cues). Like Sfx it applies the manifest's levelling gain,
8
+ // so the same volume is equally loud for every file. A cue without a volume plays at 0.35.
9
+ export const SoundCues: React.FC<{ cues: SoundCue[]; sounds: Record<string, string>; volume?: number }> = ({ cues, sounds, volume = 1 }) => {
10
+ const urls = useContext(UrlsContext);
11
+ const gains = useContext(ManifestContext)?.soundGain;
12
+ const plan = React.useMemo(() => resolveCues({ cues, sounds, volume, urls, gainFor: (src) => gainFor(gains, urls, src) }), [cues, sounds, volume, urls, gains]);
13
+ return (
14
+ <>
15
+ {plan.map((c, i) => (
16
+ <Sequence key={`${c.from}-${i}`} from={c.from} layout="none">
17
+ <Audio src={c.src} volume={c.volume} />
18
+ </Sequence>
19
+ ))}
20
+ </>
21
+ );
22
+ };
@@ -0,0 +1,98 @@
1
+ import React from "react";
2
+ import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { Place } from "./Place";
4
+ import { font as loadFont, fonts, springs } from "./theme";
5
+ import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
6
+ import { clock, terminalFrames, type TerminalLine } from "./ui-math";
7
+
8
+ // A terminal or agent window: a title bar, lines that stream in (`cmd` a typed command, `out` plain output, `ok` a success, `warn` a warning, `edit` a file
9
+ // changed with `add` and `del` counts), an optional checklist whose items get struck through, and a status line with an elapsed counter that turns to
10
+ // "Done" at the end. `rate` is lines a second (default 3); the window shows the last `maxLines` (default 8) and the newest is at the bottom. Times are frames
11
+ // from the component's own mount; terminalFrames(props, fps) gives each line's frame and each tick's for sound. Text is monospace (jetbrainsMono) unless
12
+ // `font` is given. `width` is a fraction of the frame (0.86); `theme` "dark" (default), "light" or "glass".
13
+ export const TerminalLog: React.FC<{
14
+ lines: TerminalLine[];
15
+ rate?: number;
16
+ title?: string;
17
+ status?: string;
18
+ checklist?: string[];
19
+ checkEvery?: number;
20
+ maxLines?: number;
21
+ speed?: number;
22
+ hero?: string;
23
+ theme?: UiTheme;
24
+ font?: string;
25
+ width?: number;
26
+ delay?: number;
27
+ y?: number;
28
+ }> = ({ lines, rate = 3, title = "agent", status = "Working", checklist = [], checkEvery, maxLines = 8, speed = 8, hero = DEFAULT_HERO, theme = "dark", font, width: fraction = 0.86, delay = 6, 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 mono = font ?? loadFont("jetbrainsMono");
34
+ const ev = terminalFrames({ lines, rate, delay, checklist, checkEvery }, fps);
35
+ const shown = lines.map((_, i) => i).filter((i) => ev.lines[i]! <= frame);
36
+ const visible = shown.slice(-maxLines);
37
+ const fs = P * 0.034, row = fs * 1.62;
38
+ const enter = spring({ frame, fps, config: springs.smooth });
39
+ const done = frame >= ev.done;
40
+ const seconds = Math.max(0, ((done ? ev.done : frame) - delay) / fps * speed);
41
+ const spin = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][Math.floor(frame / 2) % 10];
42
+ const colorOf = (k: TerminalLine["kind"]) => (k === "cmd" ? c.ink : k === "ok" ? c.ok : k === "warn" ? c.warn : k === "edit" ? c.ink : c.dim);
43
+ return (
44
+ <Place y={y}>
45
+ <div style={{ width: P, opacity: enter, transform: `translateY(${(1 - enter) * P * 0.04}px)`, fontFamily: mono }}>
46
+ <div style={{ background: c.panel, border: `${Math.max(2, P * 0.0025)}px solid ${c.stroke}`, borderRadius: P * 0.04, overflow: "hidden", boxShadow: `0 ${P * 0.03}px ${P * 0.08}px ${c.shadow}` }}>
47
+ <div style={{ display: "flex", alignItems: "center", gap: P * 0.018, padding: `${P * 0.032}px ${P * 0.04}px`, background: c.field, borderBottom: `1px solid ${c.stroke}` }}>
48
+ {["#ff5f57", "#febc2e", "#28c840"].map((d) => <div key={d} style={{ width: P * 0.03, height: P * 0.03, borderRadius: 999, background: d }} />)}
49
+ <div style={{ flex: 1, textAlign: "center", fontSize: fs * 0.9, color: c.dim, fontFamily: font ?? fonts.body, fontWeight: 600, marginRight: P * 0.1 }}>{title}</div>
50
+ </div>
51
+ <div style={{ padding: `${P * 0.04}px ${P * 0.045}px`, height: row * maxLines + P * 0.08, display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
52
+ {visible.map((i) => {
53
+ const l = lines[i]!, age = frame - ev.lines[i]!;
54
+ const o = interpolate(age, [0, 5], [0, 1], { extrapolateRight: "clamp" });
55
+ const mark = l.kind === "cmd" ? "$" : l.kind === "ok" ? "✓" : l.kind === "warn" ? "!" : l.kind === "edit" ? "✎" : " ";
56
+ return (
57
+ <div key={i} style={{ display: "flex", gap: fs * 0.7, height: row, alignItems: "center", fontSize: fs, color: colorOf(l.kind), opacity: o, transform: `translateY(${(1 - o) * fs * 0.4}px)`, whiteSpace: "nowrap" }}>
58
+ <span style={{ width: fs * 0.9, color: l.kind === "cmd" ? hero : colorOf(l.kind), fontWeight: 700, flex: "none" }}>{mark}</span>
59
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", flex: 1, fontWeight: l.kind === "cmd" ? 700 : 400 }}>{l.text}</span>
60
+ {l.kind === "edit" ? (
61
+ <span style={{ flex: "none", fontWeight: 700 }}>
62
+ {l.add !== undefined ? <span style={{ color: c.ok }}>+{l.add} </span> : null}
63
+ {l.del !== undefined ? <span style={{ color: c.bad }}>-{l.del}</span> : null}
64
+ </span>
65
+ ) : null}
66
+ </div>
67
+ );
68
+ })}
69
+ </div>
70
+ {checklist.length ? (
71
+ <div style={{ padding: `${P * 0.03}px ${P * 0.045}px`, borderTop: `1px solid ${c.stroke}`, display: "flex", flexDirection: "column", gap: P * 0.012 }}>
72
+ {checklist.map((item, i) => {
73
+ const at = ev.checks[i]!, k = interpolate(frame, [at, at + 6], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
74
+ return (
75
+ <div key={i} style={{ display: "flex", alignItems: "center", gap: fs * 0.7, fontSize: fs, color: k > 0.5 ? c.dim : c.ink }}>
76
+ <div style={{ width: fs, height: fs, borderRadius: fs * 0.25, border: `2px solid ${k > 0 ? hero : c.dim}`, background: k > 0.5 ? hero : "transparent", display: "flex", alignItems: "center", justifyContent: "center", flex: "none" }}>
77
+ {k > 0.5 ? <svg width={fs * 0.7} height={fs * 0.7} viewBox="0 0 24 24" fill="none" stroke={c.heroInk} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg> : null}
78
+ </div>
79
+ <span style={{ position: "relative", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
80
+ {item}
81
+ <span style={{ position: "absolute", left: 0, top: "52%", height: Math.max(2, fs * 0.08), width: `${k * 100}%`, background: c.dim }} />
82
+ </span>
83
+ </div>
84
+ );
85
+ })}
86
+ </div>
87
+ ) : null}
88
+ <div style={{ display: "flex", alignItems: "center", gap: fs * 0.6, padding: `${P * 0.03}px ${P * 0.045}px`, background: c.field, borderTop: `1px solid ${c.stroke}`, fontSize: fs * 0.92, color: done ? c.ok : hero, fontWeight: 700 }}>
89
+ <span style={{ width: fs, textAlign: "center" }}>{done ? "✓" : spin}</span>
90
+ <span>{done ? "Done" : `${status}...`}</span>
91
+ <span style={{ marginLeft: "auto", color: c.dim, fontWeight: 400 }}>{clock(seconds)}</span>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ <div style={{ position: "absolute", inset: 0, pointerEvents: "none", boxShadow: `inset 0 0 0 0 ${rgba(hero, 0)}` }} />
96
+ </Place>
97
+ );
98
+ };