reelme 0.4.0 → 0.5.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 (50) hide show
  1. package/assets/audio/PROVENANCE.md +18 -11
  2. package/assets/audio/generate-sfx.mjs +128 -0
  3. package/assets/audio/light-steps.mp3 +0 -0
  4. package/assets/audio/lofi-dusk.mp3 +0 -0
  5. package/assets/audio/manifest.json +56 -63
  6. package/assets/audio/neon-pulse.mp3 +0 -0
  7. package/assets/audio/night-drive.mp3 +0 -0
  8. package/assets/audio/sfx/SOURCES.md +16 -0
  9. package/assets/audio/sfx/pop.mp3 +0 -0
  10. package/assets/audio/sfx/rise.mp3 +0 -0
  11. package/assets/audio/sfx/whoosh.mp3 +0 -0
  12. package/assets/audio/sunny-bounce.mp3 +0 -0
  13. package/assets/audio/warm-memories.mp3 +0 -0
  14. package/package.json +1 -1
  15. package/src/cache.mjs +92 -2
  16. package/src/render.mjs +7 -0
  17. package/template/src/Root.tsx +75 -7
  18. package/template/src/brief.ts +26 -3
  19. package/template/src/cinematic/Atmosphere.tsx +176 -30
  20. package/template/src/cinematic/Camera.tsx +21 -9
  21. package/template/src/cinematic/look.ts +28 -4
  22. package/template/src/cinematic/transitions.tsx +4 -2
  23. package/template/src/components/primitives/Arrow.tsx +8 -5
  24. package/template/src/components/primitives/CodeBlock.tsx +21 -7
  25. package/template/src/components/primitives/RevealText.tsx +8 -2
  26. package/template/src/components/primitives/Terminal.tsx +24 -7
  27. package/template/src/components/scenes/BrowserFrame.tsx +12 -5
  28. package/template/src/components/scenes/CTA.tsx +4 -2
  29. package/template/src/components/scenes/Clip.tsx +27 -14
  30. package/template/src/components/scenes/CodeReveal.tsx +8 -10
  31. package/template/src/components/scenes/DataFlow.tsx +46 -18
  32. package/template/src/components/scenes/FileTree.tsx +22 -6
  33. package/template/src/components/scenes/Hook.tsx +4 -3
  34. package/template/src/components/scenes/MobileScreen.tsx +10 -18
  35. package/template/src/components/scenes/OSWindowScene.tsx +1 -1
  36. package/template/src/components/scenes/SplitComparison.tsx +41 -14
  37. package/template/src/custom-scenes.ts +19 -0
  38. package/template/src/duration.ts +43 -4
  39. package/template/src/index.ts +9 -5
  40. package/template/src/theme.ts +10 -1
  41. package/assets/audio/bright-sparks.mp3 +0 -0
  42. package/assets/audio/calm-keys.mp3 +0 -0
  43. package/assets/audio/circuit-pulse.mp3 +0 -0
  44. package/assets/audio/clean-horizon.mp3 +0 -0
  45. package/assets/audio/generate-tracks.mjs +0 -218
  46. package/assets/audio/midnight-protocol.mp3 +0 -0
  47. package/assets/audio/pixel-bounce.mp3 +0 -0
  48. package/assets/audio/steady-launch.mp3 +0 -0
  49. package/assets/audio/sunny-loop.mp3 +0 -0
  50. package/assets/audio/vector-grid.mp3 +0 -0
@@ -18,11 +18,21 @@ export interface ProjectMeta {
18
18
  installCommand: string;
19
19
  repoUrl: string;
20
20
  primaryColor: string;
21
+ /**
22
+ * Second brand hue for backdrops and gradients (taken from the project's
23
+ * logo or site). Without it one is derived from primaryColor — a real brand
24
+ * pair reads noticeably less generic than a derived one.
25
+ */
26
+ secondaryColor?: string;
21
27
  tone: "professional" | "playful" | "technical";
22
28
  /** Publishing targets; required, at least one. Presets derive dimensions. */
23
29
  platforms: PlatformId[];
24
- /** Bundled CC0 track selection; false disables audio. */
25
- audio?: { track: string; volume?: number } | false;
30
+ /**
31
+ * Bundled CC0 track selection; false disables audio (music, SFX, and beat
32
+ * quantization together). `bpm` is injected by the CLI from the audio
33
+ * manifest at stage time — briefs don't set it by hand.
34
+ */
35
+ audio?: { track: string; volume?: number; bpm?: number } | false;
26
36
  /** "made with reelme" credit in the CTA footer; default true. */
27
37
  watermark?: boolean;
28
38
  mode?: "intro" | "announcement";
@@ -198,6 +208,18 @@ export interface HotkeyScene {
198
208
  caption?: string;
199
209
  }
200
210
 
211
+ export interface CustomScene {
212
+ type: "custom";
213
+ /**
214
+ * Repo-relative path to a bespoke scene component (a .tsx default export),
215
+ * usually authored by the skill for THIS project's one differentiating
216
+ * moment. The CLI stages the file and registers it (custom-scenes.ts).
217
+ */
218
+ component: string;
219
+ durationInFrames: number;
220
+ caption?: string;
221
+ }
222
+
201
223
  export interface HookScene {
202
224
  type: "hook";
203
225
  text: string;
@@ -245,7 +267,8 @@ export type Scene =
245
267
  | HotkeyScene
246
268
  | HookScene
247
269
  | BenchmarkScene
248
- | ClipScene;
270
+ | ClipScene
271
+ | CustomScene;
249
272
 
250
273
  export interface Cuts {
251
274
  /** The full narrative arc. Always required. */
@@ -19,50 +19,31 @@ function drift(frame: number, period: number, phase: number) {
19
19
  /**
20
20
  * The continuous stage the reel is shot on. Rendered once, behind every scene,
21
21
  * so the background never resets between cuts — that single fact is what turns
22
- * a deck of slides into one film. Lights drift slowly over the whole runtime.
22
+ * a deck of slides into one film.
23
+ *
24
+ * Each look stages the film on a distinct backdrop SYSTEM (look.backdrop)
25
+ * instead of one shared orb recipe. The system covers the frame with intent —
26
+ * a full gradient field, a structural grid, an aimed light — so no region is
27
+ * ever a flat void with a detached glow floating in it.
23
28
  */
24
29
  export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
25
30
  const liveFrame = useCurrentFrame();
26
31
  const lite = quality === "lite";
27
- // Gif output freezes the lights: a frame that changes every tick defeats gif
32
+ // Gif output freezes the motion: a frame that changes every tick defeats gif
28
33
  // compression and balloons file size. The graded look stays; only motion goes.
29
34
  const frame = lite ? 0 : liveFrame;
30
35
 
31
- const accent = chroma(theme.accent);
32
- const cool = accent.set("hsl.h", "+150").desaturate(0.8).hex();
33
-
34
- // Primary key light drifts across the upper third.
35
- const keyX = 50 + drift(frame, 520, 0) * 14;
36
- const keyY = 32 + drift(frame, 680, 1.3) * 8;
37
- // Secondary fill light (only on two-tone looks) sweeps the opposite corner.
38
- const fillX = 24 + drift(frame, 600, 2.1) * 12;
39
- const fillY = 74 + drift(frame, 740, 0.6) * 10;
40
-
41
- const glowAlpha = look.glow * (lite ? 0.6 : 1);
42
36
  // Patterned overlays (1px grid/scanlines) are GIF-compression poison — every
43
37
  // frame becomes high-frequency detail. Drop them on the lite path.
44
38
  const showOverlay = !lite;
45
- // Gif compresses flat areas; smooth wide gradients (worst with bright accents)
46
- // explode its 256-color palette. On lite, the key light is a smaller, tighter
47
- // pool and the fill light is dropped, leaving most of the frame the flat base.
48
- const keyGradient = lite
49
- ? `radial-gradient(42% 38% at ${keyX}% ${keyY}%, ${accent.alpha(0.34 * glowAlpha).css()} 0%, transparent 62%)`
50
- : `radial-gradient(60% 55% at ${keyX}% ${keyY}%, ${accent.alpha(0.55 * glowAlpha).css()} 0%, ${accent.alpha(0.12 * glowAlpha).css()} 38%, transparent 70%)`;
51
39
 
52
40
  return (
53
41
  <AbsoluteFill style={{ background: theme.bg }}>
54
- {/* Key light */}
55
- <AbsoluteFill style={{ background: keyGradient }} />
56
- {/* Fill light */}
57
- {look.twoTone && !lite && (
58
- <AbsoluteFill
59
- style={{
60
- background: `radial-gradient(55% 50% at ${fillX}% ${fillY}%, ${chroma(cool).alpha(0.4 * glowAlpha).css()} 0%, transparent 68%)`,
61
- }}
62
- />
63
- )}
42
+ <Backdrop theme={theme} look={look} frame={frame} lite={lite} />
64
43
 
65
- {showOverlay && look.overlay === "grid" && <GridOverlay color={theme.border} lite={lite} />}
44
+ {showOverlay && look.overlay === "grid" && look.backdrop !== "grid" && (
45
+ <GridOverlay color={theme.border} lite={lite} />
46
+ )}
66
47
  {showOverlay && look.overlay === "scanlines" && <Scanlines lite={lite} />}
67
48
 
68
49
  {/* Grade pass: a subtle full-frame tint that unifies the palette. */}
@@ -85,6 +66,171 @@ export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
85
66
  );
86
67
  };
87
68
 
69
+ /** Dispatch to the look's backdrop system. */
70
+ const Backdrop: React.FC<{ theme: Theme; look: CinematicLook; frame: number; lite: boolean }> = ({
71
+ theme,
72
+ look,
73
+ frame,
74
+ lite,
75
+ }) => {
76
+ const accent = chroma(theme.accent);
77
+ const accent2 = chroma(theme.accent2);
78
+ // On light stages the color has to stay pastel or text contrast dies.
79
+ const lightBg = chroma(theme.bg).luminance() > 0.5;
80
+ // Lite keeps most of the backdrop's identity: at half strength the gif's
81
+ // stage read as a flat dark void, which is exactly the look this system
82
+ // replaced. Slightly larger gif files are the accepted price.
83
+ const k = look.glow * (lite ? 0.75 : 1) * (lightBg ? 0.5 : 1);
84
+
85
+ switch (look.backdrop) {
86
+ case "mesh": {
87
+ // Full-bleed gradient field: three large soft color poles (brand hue,
88
+ // second hue, and a deep counter-tone) drifting very slowly. The whole
89
+ // frame carries color variation — no dead flat region, no orb smudge.
90
+ const ax = 22 + drift(frame, 620, 0) * 10;
91
+ const ay = 24 + drift(frame, 760, 1.1) * 8;
92
+ const bx = 80 + drift(frame, 680, 2.3) * 10;
93
+ const by = 30 + drift(frame, 820, 0.5) * 10;
94
+ const cx = 55 + drift(frame, 720, 3.4) * 12;
95
+ const cy = 92 + drift(frame, 900, 1.9) * 6;
96
+ const deep = accent.set("hsl.h", "-30").darken(1.2);
97
+ return (
98
+ <>
99
+ <AbsoluteFill
100
+ style={{
101
+ background: `radial-gradient(70% 65% at ${ax}% ${ay}%, ${accent.alpha(0.5 * k).css()} 0%, transparent 72%)`,
102
+ }}
103
+ />
104
+ <AbsoluteFill
105
+ style={{
106
+ background: `radial-gradient(65% 60% at ${bx}% ${by}%, ${accent2.alpha(0.38 * k).css()} 0%, transparent 70%)`,
107
+ }}
108
+ />
109
+ <AbsoluteFill
110
+ style={{
111
+ background: `radial-gradient(80% 55% at ${cx}% ${cy}%, ${deep.alpha(0.4 * k).css()} 0%, transparent 75%)`,
112
+ }}
113
+ />
114
+ </>
115
+ );
116
+ }
117
+
118
+ case "spotlight": {
119
+ // One aimed light: an elliptical pool centered on the content zone
120
+ // (slightly above frame center), like a subject lit on a dark stage.
121
+ // The light falls ON the content instead of drifting in empty space.
122
+ const sway = drift(frame, 780, 0.4) * 3;
123
+ const warm = accent.brighten(0.4);
124
+ return (
125
+ <>
126
+ <AbsoluteFill
127
+ style={{
128
+ background: `radial-gradient(52% 44% at ${50 + sway}% 42%, ${warm.alpha(0.34 * k).css()} 0%, ${warm.alpha(0.08 * k).css()} 55%, transparent 74%)`,
129
+ }}
130
+ />
131
+ {/* Faint floor bounce so the lower frame isn't pure void. */}
132
+ <AbsoluteFill
133
+ style={{
134
+ background: `radial-gradient(90% 30% at 50% 104%, ${accent2.alpha(0.14 * k).css()} 0%, transparent 70%)`,
135
+ }}
136
+ />
137
+ </>
138
+ );
139
+ }
140
+
141
+ case "sweep": {
142
+ // Saturated full-frame diagonal wash between the two brand hues, with a
143
+ // slow-moving highlight band. Vibrant edge to edge — arcade energy comes
144
+ // from color, not from floating lights.
145
+ const shiftA = 12 + drift(frame, 560, 0.8) * 8;
146
+ const shiftB = 88 + drift(frame, 640, 2.6) * 8;
147
+ const bandY = 50 + drift(frame, 700, 1.5) * 16;
148
+ return (
149
+ <>
150
+ <AbsoluteFill
151
+ style={{
152
+ background: `linear-gradient(128deg, ${accent.alpha(0.42 * k).css()} 0%, transparent ${shiftA + 34}%), linear-gradient(308deg, ${accent2.alpha(0.36 * k).css()} 0%, transparent ${100 - shiftB + 42}%)`,
153
+ }}
154
+ />
155
+ <AbsoluteFill
156
+ style={{
157
+ background: `linear-gradient(180deg, transparent ${bandY - 24}%, ${accent.brighten(1).alpha(0.1 * k).css()} ${bandY}%, transparent ${bandY + 24}%)`,
158
+ }}
159
+ />
160
+ </>
161
+ );
162
+ }
163
+
164
+ case "grid": {
165
+ // The engineering grid IS the backdrop: structural lines with a soft
166
+ // cool wash rising from the baseline. Reads as a drafting table, not a
167
+ // dark room with a lamp.
168
+ return (
169
+ <>
170
+ <AbsoluteFill
171
+ style={{
172
+ background: `linear-gradient(180deg, transparent 55%, ${accent.alpha(0.16 * k).css()} 100%)`,
173
+ }}
174
+ />
175
+ <StructuralGrid color={theme.border} accent={theme.accent} frame={frame} lite={lite} />
176
+ </>
177
+ );
178
+ }
179
+
180
+ case "field":
181
+ default: {
182
+ // Editorial poster field: a rich duotone wash — second hue at the top,
183
+ // brand hue rising from below — over the deep base. Near-solid, carried
184
+ // by grain and grade rather than by lights.
185
+ return (
186
+ <AbsoluteFill
187
+ style={{
188
+ background: `linear-gradient(180deg, ${accent2.alpha(0.2 * k).css()} 0%, transparent 40%, transparent 62%, ${accent.alpha(0.3 * k).css()} 100%)`,
189
+ }}
190
+ />
191
+ );
192
+ }
193
+ }
194
+ };
195
+
196
+ /**
197
+ * Blueprint's structural grid: minor + major lines and a center crosshair
198
+ * tick, strong enough to read as the actual set design instead of a texture.
199
+ */
200
+ const StructuralGrid: React.FC<{ color: string; accent: string; frame: number; lite: boolean }> = ({
201
+ color,
202
+ accent,
203
+ frame,
204
+ lite,
205
+ }) => {
206
+ const shift = lite ? 0 : (frame * 0.12) % 72;
207
+ const minor = chroma(color).alpha(lite ? 0.08 : 0.13).css();
208
+ const major = chroma(color).alpha(lite ? 0.14 : 0.22).css();
209
+ const tick = chroma(accent).alpha(0.4).css();
210
+ return (
211
+ <>
212
+ <AbsoluteFill
213
+ style={{
214
+ backgroundImage: `linear-gradient(${minor} 1px, transparent 1px), linear-gradient(90deg, ${minor} 1px, transparent 1px), linear-gradient(${major} 1px, transparent 1px), linear-gradient(90deg, ${major} 1px, transparent 1px)`,
215
+ backgroundSize: "72px 72px, 72px 72px, 360px 360px, 360px 360px",
216
+ backgroundPosition: `${shift}px ${shift}px`,
217
+ maskImage: "radial-gradient(130% 110% at 50% 50%, black 35%, transparent 85%)",
218
+ WebkitMaskImage: "radial-gradient(130% 110% at 50% 50%, black 35%, transparent 85%)",
219
+ }}
220
+ />
221
+ {/* Center crosshair tick: the drafting-table registration mark. */}
222
+ {!lite && (
223
+ <AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
224
+ <div style={{ position: "relative", width: 28, height: 28, opacity: 0.5 }}>
225
+ <div style={{ position: "absolute", left: "50%", top: 0, bottom: 0, width: 1, background: tick }} />
226
+ <div style={{ position: "absolute", top: "50%", left: 0, right: 0, height: 1, background: tick }} />
227
+ </div>
228
+ </AbsoluteFill>
229
+ )}
230
+ </>
231
+ );
232
+ };
233
+
88
234
  const GridOverlay: React.FC<{ color: string; lite: boolean }> = ({ color, lite }) => {
89
235
  const frame = useCurrentFrame();
90
236
  const shift = lite ? 0 : (frame * 0.15) % 64;
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
2
+ import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
3
3
  import { CinematicLook } from "./look";
4
4
 
5
5
  interface Props {
@@ -12,34 +12,46 @@ interface Props {
12
12
  children: React.ReactNode;
13
13
  }
14
14
 
15
+ // The arrival window: how long the camera keeps settling after the cut before
16
+ // coming to a complete rest, as a fraction of the scene (capped in frames).
17
+ const SETTLE_FRACTION = 0.55;
18
+ const SETTLE_MAX_FRAMES = 90;
19
+
15
20
  /**
16
- * A virtual camera over a scene's content. Movement is pure zoom in, out, or a
17
- * gentle breathe anchored to the center so the composition never slides
18
- * sideways. Amplitudes are small so the frame breathes without softening text.
21
+ * A virtual camera over a scene's content. Each shot ARRIVES with a slow zoom
22
+ * that eases to a complete rest, then holds perfectly still. A zoom that runs
23
+ * for the whole scene re-rasterizes crisp text at a new sub-pixel scale every
24
+ * frame, which reads as a constant vibration on bold type — so all movement is
25
+ * front-loaded into the arrival while the eye is still adjusting to the cut,
26
+ * and the hold (where the viewer actually reads) stays rock solid. Zoom only,
27
+ * anchored to center, so the composition never slides sideways.
19
28
  */
20
29
  export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled, children }) => {
21
30
  const frame = useCurrentFrame();
22
31
  if (disabled) return <AbsoluteFill>{children}</AbsoluteFill>;
32
+ void seed;
23
33
 
24
- const p = interpolate(frame, [0, durationInFrames], [0, 1], {
34
+ const settleFrames = Math.min(Math.round(durationInFrames * SETTLE_FRACTION), SETTLE_MAX_FRAMES);
35
+ const p = interpolate(frame, [0, settleFrames], [0, 1], {
25
36
  extrapolateLeft: "clamp",
26
37
  extrapolateRight: "clamp",
38
+ easing: Easing.out(Easing.cubic),
27
39
  });
28
40
  const k = look.cameraIntensity;
29
41
 
30
42
  let scale = 1;
31
43
  switch (look.camera) {
32
44
  case "push":
33
- scale = interpolate(p, [0, 1], [1.0, 1 + 0.05 * k]); // slow zoom in
45
+ scale = interpolate(p, [0, 1], [1 - 0.045 * k, 1.0]); // slow zoom in to rest
34
46
  break;
35
47
  case "drift":
36
- scale = interpolate(p, [0, 1], [1.0, 1 + 0.03 * k]); // gentle zoom in
48
+ scale = interpolate(p, [0, 1], [1 - 0.03 * k, 1.0]); // gentle zoom in to rest
37
49
  break;
38
50
  case "pan":
39
- scale = interpolate(p, [0, 1], [1 + 0.05 * k, 1.0]); // slow zoom out
51
+ scale = interpolate(p, [0, 1], [1 + 0.03 * k, 1.0]); // slow zoom out to rest
40
52
  break;
41
53
  case "float":
42
- scale = 1 + (0.02 + 0.015 * Math.sin(frame / 42 + seed)) * k; // subtle breathe
54
+ scale = interpolate(p, [0, 1], [1 - 0.02 * k, 1.0]); // soft zoom in to rest
43
55
  break;
44
56
  case "still":
45
57
  default:
@@ -15,6 +15,21 @@ export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
15
15
 
16
16
  export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
17
17
 
18
+ /**
19
+ * The backdrop SYSTEM a look is staged on. This is the look's silhouette: two
20
+ * looks with different backdrops read as different films even before grading.
21
+ * The old single recipe (dark field + drifting radial orbs) was the template's
22
+ * fingerprint — every reel shared it, and an orb in empty space reads as a
23
+ * smudge, not a light. Each system covers the frame with intent instead:
24
+ *
25
+ * - mesh: full-bleed multi-hue gradient field (accent + accent2), slow drift
26
+ * - spotlight: one elliptical light pool aimed where the content sits
27
+ * - sweep: saturated full-frame diagonal gradient between the brand hues
28
+ * - grid: structural engineering grid — the graphic IS the backdrop
29
+ * - field: rich near-solid editorial color field with a soft top wash
30
+ */
31
+ export type BackdropSystem = "mesh" | "spotlight" | "sweep" | "grid" | "field";
32
+
18
33
  export interface Grade {
19
34
  /** Tint color layered over the whole frame. */
20
35
  color: string;
@@ -24,7 +39,9 @@ export interface Grade {
24
39
 
25
40
  export interface CinematicLook {
26
41
  id: LookId;
27
- /** Radial accent-light intensity behind the content (0..1). */
42
+ /** Which backdrop system stages the whole reel. */
43
+ backdrop: BackdropSystem;
44
+ /** Backdrop light/color intensity (0..1). */
28
45
  glow: number;
29
46
  /** Whether a second, cooler light source is added for depth. */
30
47
  twoTone: boolean;
@@ -33,7 +50,7 @@ export interface CinematicLook {
33
50
  /** Film-grain opacity on the final frame (0..1); skipped for gif output. */
34
51
  grain: number;
35
52
  overlay: AtmosphereOverlay;
36
- /** Base camera move applied to every scene's content. */
53
+ /** Arrival move for every scene's content: a zoom that eases to a rest. */
37
54
  camera: CameraMove;
38
55
  /** Camera amplitude multiplier (1 = default). */
39
56
  cameraIntensity: number;
@@ -53,6 +70,7 @@ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
53
70
  // Clean keynote stage — soft key light, gentle push, long dissolves.
54
71
  // Motion: settled, no bounce — elements arrive and hold.
55
72
  keynote: {
73
+ backdrop: "mesh",
56
74
  glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
57
75
  camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
58
76
  energy: 0.4,
@@ -61,22 +79,27 @@ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
61
79
  // Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
62
80
  // Motion: heavy and deliberate — weighty mass, slow to settle.
63
81
  noir: {
82
+ backdrop: "spotlight",
64
83
  glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
65
84
  camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
66
85
  energy: 0.35,
67
86
  motion: { damping: 28, stiffness: 78, mass: 1.4 },
68
87
  },
69
88
  // Saturated arcade — dual lights, scanlines, snappy whips and zooms.
70
- // Motion: snappy with a playful overshoot — light mass, low damping.
89
+ // Motion: snappy but settled — light mass, high stiffness, near-critical
90
+ // damping. Energy comes from speed, not from overshoot: a bouncing element
91
+ // reads as a toy, not a cut (gallery feedback).
71
92
  arcade: {
93
+ backdrop: "sweep",
72
94
  glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
73
95
  camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
74
96
  energy: 0.85,
75
- motion: { damping: 11, stiffness: 155, mass: 0.7 },
97
+ motion: { damping: 20, stiffness: 155, mass: 0.7 },
76
98
  },
77
99
  // Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
78
100
  // Motion: stiff and precise — quick, minimal bounce, machined.
79
101
  blueprint: {
102
+ backdrop: "grid",
80
103
  glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
81
104
  camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
82
105
  energy: 0.6,
@@ -85,6 +108,7 @@ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
85
108
  // Premium brand film — warm grade, big soft vignette, very slow elegant push.
86
109
  // Motion: the smoothest — elegant glide, no perceptible bounce.
87
110
  editorial: {
111
+ backdrop: "field",
88
112
  glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
89
113
  camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
90
114
  energy: 0.3,
@@ -8,7 +8,9 @@ import { CinematicLook, TransitionStyle } from "./look";
8
8
  const RHYTHM: Record<string, TransitionStyle[]> = {
9
9
  keynote: ["fade", "rise", "fade", "rise"],
10
10
  noir: ["dip", "fade", "dip", "rise"],
11
- arcade: ["whip", "flip", "zoom", "whip"],
11
+ // No flip: a hinged swing reads as a gimmick next to the rest of the edit
12
+ // (gallery feedback) — arcade's energy now comes from fast zooms and whips.
13
+ arcade: ["zoom", "whip", "zoom", "fade"],
12
14
  blueprint: ["cut", "wipe", "cut", "whip"],
13
15
  editorial: ["fade", "wipe", "rise", "fade"],
14
16
  };
@@ -66,7 +68,7 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
66
68
  break;
67
69
  case "punch":
68
70
  opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
69
- scale = String(interpolate(p, [0, 1], [1.12, 1]));
71
+ scale = String(interpolate(p, [0, 1], [1.07, 1]));
70
72
  break;
71
73
  case "zoom":
72
74
  opacity = p;
@@ -10,11 +10,14 @@ interface ArrowProps {
10
10
  label?: string;
11
11
  theme: Theme;
12
12
  startFrame?: number;
13
+ /** Line weight; supporting graphics need real presence at video size. */
14
+ strokeWidth?: number;
15
+ labelSize?: number;
13
16
  }
14
17
 
15
- const ARROW_HEAD_SIZE = 10;
18
+ const ARROW_HEAD_SIZE = 14;
16
19
 
17
- export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0 }) => {
20
+ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0, strokeWidth = 3.5, labelSize = 22 }) => {
18
21
  const frame = useCurrentFrame();
19
22
  const { fps } = useVideoConfig();
20
23
  const elapsed = frame - startFrame;
@@ -50,7 +53,7 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
50
53
  x2={x2short}
51
54
  y2={y2short}
52
55
  stroke={theme.accent}
53
- strokeWidth={2.5}
56
+ strokeWidth={strokeWidth}
54
57
  strokeDasharray={len}
55
58
  strokeDashoffset={dashOffset}
56
59
  strokeLinecap="round"
@@ -68,9 +71,9 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
68
71
  {label && progress > 0.5 && (
69
72
  <text
70
73
  x={midX}
71
- y={midY - 10}
74
+ y={midY - 14}
72
75
  fill={theme.textMuted}
73
- fontSize={15}
76
+ fontSize={labelSize}
74
77
  textAnchor="middle"
75
78
  fontFamily={theme.fontSans}
76
79
  opacity={interpolate(progress, [0.5, 1], [0, 1])}
@@ -61,19 +61,30 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
61
61
  framesPerLine = 9,
62
62
  }) => {
63
63
  const frame = useCurrentFrame();
64
- const { fps } = useVideoConfig();
64
+ const { fps, width: frameWidth } = useVideoConfig();
65
65
  const elapsed = frame - startFrame;
66
66
  const lines = code.split("\n");
67
+ // Scale type with the frame so the editor window commands wide canvases
68
+ // (ch-based width sizing inherits this automatically).
69
+ const fontSize = Math.max(22, Math.round(frameWidth * 0.0135));
67
70
  const visibleCount = Math.max(0, Math.min(lines.length, Math.floor(elapsed / framesPerLine) + 1));
68
71
 
72
+ // Size the window to the longest line of the FULL snippet (not the partial
73
+ // reveal) so it arrives at its final width and never stretches to fill the
74
+ // frame — an editor window several times wider than its code reads as empty
75
+ // chrome (gallery feedback). 83px covers gutter, line numbers, and padding.
76
+ const cols = Math.min(Math.max(lines.reduce((m, l) => Math.max(m, l.length), 0), 24), 88);
77
+
69
78
  return (
70
- <div style={{ fontFamily: theme.fontMono, fontSize: 20, lineHeight: 1.7 }}>
79
+ <div style={{ fontFamily: theme.fontMono, fontSize, lineHeight: 1.7, width: `calc(${cols}ch + 83px)`, maxWidth: "100%" }}>
71
80
  <div
72
81
  style={{
73
82
  background: "#0d1117",
74
- borderRadius: 10,
83
+ borderRadius: 12,
75
84
  overflow: "hidden",
76
- boxShadow: "0 8px 48px rgba(0,0,0,0.6)",
85
+ // Contact shadow + brand-light halo so the window sits in the stage.
86
+ boxShadow: `0 30px 90px -18px ${theme.accent}59, 0 24px 60px rgba(0,0,0,0.55)`,
87
+ outline: "1px solid rgba(255,255,255,0.09)",
77
88
  }}
78
89
  >
79
90
  {/* Title bar */}
@@ -95,9 +106,11 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
95
106
  </span>
96
107
  </div>
97
108
 
98
- {/* Code lines */}
109
+ {/* Code lines. All rows are laid out from frame 0 (unrevealed ones
110
+ invisible) so the window opens at its final height — the reveal
111
+ happens inside stable chrome instead of inflating it. */}
99
112
  <div style={{ padding: "20px 0" }}>
100
- {lines.slice(0, visibleCount).map((line, i) => {
113
+ {lines.map((line, i) => {
101
114
  const lineNum = i + 1;
102
115
  const isHighlight = highlightLine !== undefined && lineNum === highlightLine;
103
116
  const highlightOpacity =
@@ -115,6 +128,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
115
128
  style={{
116
129
  display: "flex",
117
130
  padding: "0 24px",
131
+ visibility: i < visibleCount ? "visible" : "hidden",
118
132
  background: isHighlight
119
133
  ? `rgba(${hexToRgb(theme.accent)}, ${0.15 * highlightOpacity})`
120
134
  : "transparent",
@@ -128,7 +142,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
128
142
  color: "#484f58",
129
143
  minWidth: 32,
130
144
  userSelect: "none",
131
- fontSize: 16,
145
+ fontSize: Math.round(fontSize * 0.8),
132
146
  paddingTop: 1,
133
147
  }}
134
148
  >
@@ -79,7 +79,6 @@ export const RevealText: React.FC<RevealTextProps> = ({
79
79
  lineHeight,
80
80
  letterSpacing,
81
81
  maxWidth,
82
- textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
83
82
  }}
84
83
  >
85
84
  {words.map((word, i) => {
@@ -92,12 +91,18 @@ export const RevealText: React.FC<RevealTextProps> = ({
92
91
  extrapolateRight: "clamp",
93
92
  });
94
93
  const isEmph = emphIndices.has(i);
94
+ // The clip mask exists only for the rise. Once the word is at rest the
95
+ // mask is released and the glow fades in — a soft 40px shadow inside an
96
+ // overflow-hidden span gets sheared into visible rectangles, so the two
97
+ // must never coexist (gallery feedback: "shadow looks cut off").
98
+ const settled = y < 1;
99
+ const shadowAlpha = glow && settled ? interpolate(p, [0.991, 1], [0, 0.45], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) : 0;
95
100
  return (
96
101
  <React.Fragment key={i}>
97
102
  <span
98
103
  style={{
99
104
  display: "inline-block",
100
- overflow: "hidden",
105
+ overflow: settled ? "visible" : "hidden",
101
106
  paddingBottom: "0.14em",
102
107
  marginBottom: "-0.14em",
103
108
  verticalAlign: "bottom",
@@ -109,6 +114,7 @@ export const RevealText: React.FC<RevealTextProps> = ({
109
114
  translate: `0 ${y}%`,
110
115
  opacity,
111
116
  color: isEmph ? theme.accent : undefined,
117
+ textShadow: shadowAlpha > 0 ? `0 6px 40px rgba(0,0,0,${shadowAlpha.toFixed(3)})` : undefined,
112
118
  }}
113
119
  >
114
120
  {word}