Arslan1997 commited on
Commit
bfe94d3
·
1 Parent(s): 19e319e

adding new demo + landing page improvements

Browse files
renderer/public/demo-vo/durations.json CHANGED
@@ -1,10 +1,7 @@
1
  {
2
- "warp": 3.92,
3
- "paste": 6.95,
4
- "orbit": 6.61,
5
- "research": 3.74,
6
- "audio": 2.67,
7
- "launchkit": 3.32,
8
- "launchpad": 4.05,
9
- "outro": 2.48
10
  }
 
1
  {
2
+ "warp": 3.61,
3
+ "sync": 2.25,
4
+ "map": 5.17,
5
+ "kit": 2.95,
6
+ "outro": 2.8
 
 
 
7
  }
renderer/scripts/make_demo_music.py CHANGED
@@ -1,4 +1,4 @@
1
- """Ambient space score for the landing demo: warm pads, a bell arpeggio, a soft sub, long reverb.
2
  Pure additive synthesis, no noise sources. Writes renderer/public/demo-vo/music.wav (stereo, 44.1 kHz)."""
3
 
4
  import wave
@@ -61,7 +61,7 @@ def bell(freq: float, start: float, gain: float, pan: float) -> None:
61
  return
62
  length = min(n - i0, int(2.4 * SR))
63
  tt = np.arange(length) / SR
64
- decay = np.exp(-tt * 2.2)
65
  tone = np.sin(2 * np.pi * freq * tt) + 0.35 * np.sin(2 * np.pi * freq * 2.76 * tt) * np.exp(-tt * 5)
66
  tone += 0.12 * np.sin(2 * np.pi * freq * 5.4 * tt) * np.exp(-tt * 9)
67
  attack = np.clip(tt / 0.004, 0, 1)
@@ -70,21 +70,21 @@ def bell(freq: float, start: float, gain: float, pan: float) -> None:
70
  right[i0:i0 + length] += tone * (1 + pan)
71
 
72
 
73
- # Progression in D (lydian colour), ~5.2 s per chord, ending on a wide resolved chord for the outro.
74
  CHORDS = [
75
- (0.0, [50, 57, 61, 64, 69]), # Dmaj9 (no 3rd low) D A C# E A
76
- (5.2, [47, 54, 57, 61, 66]), # Bm11 feel B F# A C# F#
77
- (10.4, [43, 50, 54, 57, 62]), # Gmaj7 add9 G D F# A D
78
- (15.6, [45, 52, 56, 59, 64]), # A6/9 feel A E G# B E
79
- (20.8, [50, 57, 61, 64, 68]), # Dmaj7#11 D A C# E G#
80
- (26.0, [47, 54, 57, 62, 66]), # Bm9 B F# A D F#
81
- (31.2, [43, 50, 55, 59, 64]), # G6/9 G D G B E
82
- (36.4, [50, 57, 62, 64, 69]), # Dsus2 add6 outro D A D E A
83
  ]
84
  CHORD_LEN = 5.2
85
 
86
  # Filter brightness rises slowly through the piece and blooms at the outro.
87
- brightness = 0.28 + 0.14 * np.clip(t / TOTAL, 0, 1) + 0.1 * np.clip((t - 36) / 4, 0, 1)
88
  brightness *= 1 + 0.08 * np.sin(2 * np.pi * 0.05 * t)
89
 
90
  for start, notes in CHORDS:
@@ -97,14 +97,14 @@ for start, notes in CHORDS:
97
  root = hz(notes[0] - 12)
98
  idx = np.nonzero(e)[0]
99
  sl = slice(idx[0], idx[-1] + 1)
100
- sub = np.sin(2 * np.pi * root * t[sl]) * e[sl] * 0.11
101
  left[sl] += sub
102
  right[sl] += sub
103
 
104
- # Bell arpeggio from the second scene on: eighth notes at 88 bpm, up and down the chord an octave up.
105
- step = 60 / 88 / 2
106
  rng = np.random.default_rng(3)
107
- beat = 3.4
108
  k = 0
109
  while beat < TOTAL - 3.5:
110
  chord = [c for c in CHORDS if c[0] <= beat][-1][1]
@@ -112,8 +112,8 @@ while beat < TOTAL - 3.5:
112
  midi = pattern[k % len(pattern)] + 12
113
  accent = 1.0 if k % 4 == 0 else 0.7
114
  thin = 0.55 if 20 < beat < 26 else 1.0 # breathe during the audio overview scene
115
- if rng.random() > 0.12: # a few rests so it does not feel mechanical
116
- bell(hz(midi), beat, gain=0.045 * accent * thin, pan=0.5 * np.sin(k * 0.9))
117
  beat += step
118
  k += 1
119
 
@@ -132,13 +132,13 @@ right += dr * 0.5
132
 
133
  # Long reverb: convolution with a smooth decaying impulse (built from low passed, decaying partials,
134
  # not white noise, so it stays silky).
135
- ir_len = int(SR * 3.2)
136
  it = np.arange(ir_len) / SR
137
  ir_l = np.zeros(ir_len)
138
  ir_r = np.zeros(ir_len)
139
- for f in rng.uniform(180, 2400, 90):
140
  ph = rng.uniform(0, 2 * np.pi, 2)
141
- d = np.exp(-it * rng.uniform(1.1, 2.2))
142
  ir_l += np.sin(2 * np.pi * f * it + ph[0]) * d
143
  ir_r += np.sin(2 * np.pi * f * it + ph[1]) * d
144
  ir_l /= np.max(np.abs(ir_l))
 
1
+ """Sombre space score for the landing demo: dark minor pads, slow sparse bells, a deep drone, long dim reverb.
2
  Pure additive synthesis, no noise sources. Writes renderer/public/demo-vo/music.wav (stereo, 44.1 kHz)."""
3
 
4
  import wave
 
61
  return
62
  length = min(n - i0, int(2.4 * SR))
63
  tt = np.arange(length) / SR
64
+ decay = np.exp(-tt * 1.5)
65
  tone = np.sin(2 * np.pi * freq * tt) + 0.35 * np.sin(2 * np.pi * freq * 2.76 * tt) * np.exp(-tt * 5)
66
  tone += 0.12 * np.sin(2 * np.pi * freq * 5.4 * tt) * np.exp(-tt * 9)
67
  attack = np.clip(tt / 0.004, 0, 1)
 
70
  right[i0:i0 + length] += tone * (1 + pan)
71
 
72
 
73
+ # Progression in D minor (aeolian), ~5.2 s per chord, falling to a bare resolved chord for the outro.
74
  CHORDS = [
75
+ (0.0, [50, 57, 60, 64, 65]), # Dm9 D A C E F
76
+ (5.2, [46, 53, 57, 60, 65]), # Bbmaj7 Bb F A C F
77
+ (10.4, [43, 50, 53, 58, 62]), # Gm7 G D F Bb D
78
+ (15.6, [45, 52, 55, 60, 64]), # A7sus feel A E G C E
79
+ (20.8, [50, 57, 60, 65, 69]), # Dm add11 D A C F A
80
+ (26.0, [46, 53, 57, 60, 64]), # Bbmaj7#11 Bb F A C E
81
+ (31.2, [43, 50, 55, 58, 62]), # Gm6 G D G Bb D
82
+ (36.4, [50, 57, 60, 64, 65]), # Dm9 outro D A C E F
83
  ]
84
  CHORD_LEN = 5.2
85
 
86
  # Filter brightness rises slowly through the piece and blooms at the outro.
87
+ brightness = 0.17 + 0.08 * np.clip(t / TOTAL, 0, 1) + 0.04 * np.clip((t - 36) / 4, 0, 1)
88
  brightness *= 1 + 0.08 * np.sin(2 * np.pi * 0.05 * t)
89
 
90
  for start, notes in CHORDS:
 
97
  root = hz(notes[0] - 12)
98
  idx = np.nonzero(e)[0]
99
  sl = slice(idx[0], idx[-1] + 1)
100
+ sub = np.sin(2 * np.pi * root * t[sl]) * e[sl] * 0.15
101
  left[sl] += sub
102
  right[sl] += sub
103
 
104
+ # Bell arpeggio from the second scene on: slow quarter notes at 56 bpm, up and down the chord an octave up.
105
+ step = 60 / 56
106
  rng = np.random.default_rng(3)
107
+ beat = 4.0
108
  k = 0
109
  while beat < TOTAL - 3.5:
110
  chord = [c for c in CHORDS if c[0] <= beat][-1][1]
 
112
  midi = pattern[k % len(pattern)] + 12
113
  accent = 1.0 if k % 4 == 0 else 0.7
114
  thin = 0.55 if 20 < beat < 26 else 1.0 # breathe during the audio overview scene
115
+ if rng.random() > 0.3: # many rests so it does not feel mechanical
116
+ bell(hz(midi), beat, gain=0.036 * accent * thin, pan=0.5 * np.sin(k * 0.9))
117
  beat += step
118
  k += 1
119
 
 
132
 
133
  # Long reverb: convolution with a smooth decaying impulse (built from low passed, decaying partials,
134
  # not white noise, so it stays silky).
135
+ ir_len = int(SR * 4.2)
136
  it = np.arange(ir_len) / SR
137
  ir_l = np.zeros(ir_len)
138
  ir_r = np.zeros(ir_len)
139
+ for f in rng.uniform(120, 1300, 90):
140
  ph = rng.uniform(0, 2 * np.pi, 2)
141
+ d = np.exp(-it * rng.uniform(0.8, 1.6))
142
  ir_l += np.sin(2 * np.pi * f * it + ph[0]) * d
143
  ir_r += np.sin(2 * np.pi * f * it + ph[1]) * d
144
  ir_l /= np.max(np.abs(ir_l))
renderer/scripts/make_demo_vo.py CHANGED
@@ -1,7 +1,7 @@
1
  """Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
2
 
3
- The screens show the product; the voice never explains it, it only says how it feels: exploring,
4
- excited, mesmerized. Also writes src/compositions/demoVo.ts; scenes stretch to fit their lines.
5
  make_demo_music.py writes a 47.5 s score; raise its TOTAL and rerun it if the demo grows past that.
6
 
7
  cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
@@ -17,18 +17,15 @@ OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo"
17
  OUT.mkdir(parents=True, exist_ok=True)
18
 
19
  LINES = {
20
- "warp": "Captain's log. Something wonderful is on the horizon.",
21
- "paste": "Take a breath. That flutter of excitement? That's how every great expedition begins.",
22
- "orbit": "And there it is. Everything you've made, shimmering... impossible to look away.",
23
- "research": "A question sparks, and suddenly you're wide awake.",
24
- "audio": "Lean back. Let it wash over you.",
25
- "launchkit": "Your pulse quickens. You can't wait to share this.",
26
- "launchpad": "Three, two, one... and you're flying.",
27
- "outro": "Notestack. Clear skies ahead.",
28
  }
29
 
30
  voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
31
- vs = tts.VoiceSettings(stability=0.55, similarity_boost=0.8, style=0.3, speed=0.93)
32
  ws = uuid.UUID(int=0)
33
  keys = list(LINES)
34
  durations = {}
 
1
  """Narration for the landing page demo (Nora Vale, Mission Control). Writes public/demo-vo/*.mp3 + durations.json.
2
 
3
+ The screens show the product, one feature at a time; the voice stays sparse and frames the film as a
4
+ journey of knowledge and discovery. Also writes src/compositions/demoVo.ts; scenes stretch to fit their lines.
5
  make_demo_music.py writes a 47.5 s score; raise its TOTAL and rerun it if the demo grows past that.
6
 
7
  cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_demo_vo.py
 
17
  OUT.mkdir(parents=True, exist_ok=True)
18
 
19
  LINES = {
20
+ "warp": "Every great journey begins with a single question.",
21
+ "sync": "Gather everything you know.",
22
+ "map": "Somewhere in all you have written, a whole world is waiting to be discovered.",
23
+ "kit": "Knowledge was always meant to travel further.",
24
+ "outro": "Notestack. Begin your journey.",
 
 
 
25
  }
26
 
27
  voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
28
+ vs = tts.VoiceSettings(stability=0.6, similarity_boost=0.8, style=0.2, speed=0.9)
29
  ws = uuid.UUID(int=0)
30
  keys = list(LINES)
31
  durations = {}
renderer/src/compositions/LandingDemo.tsx CHANGED
@@ -4,41 +4,53 @@ import { theme } from "../theme";
4
  import { DEMO_VO_SECONDS } from "./demoVo";
5
  import { Lockup } from "./Lockup";
6
 
7
- /** Landing page product demo: a trip through the Notestack galaxy. 1920x1080, 30 fps, ~32 s.
8
- * Scenes: warp in, paste URL, posts enter orbit, cited research, audio overview, Launch Kit,
9
- * Launchpad, outro. Strict palette: black, #217cff, white. */
 
10
 
11
  export const DEMO_FPS = 30;
12
  const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
13
 
14
- // Narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"): each line starts `vo`
15
- // seconds into its scene. Scenes keep their minimum length for the animation and stretch when a line
16
- // needs more room, so it always ends with a breath before the next scene's line.
17
  const BASE_SCENES = [
18
  { id: "warp", len: 5.2, vo: 0.6 },
19
- { id: "paste", len: 9.4, vo: 0.4 },
20
- { id: "orbit", len: 7.6, vo: 0.4 },
21
- { id: "research", len: 5.6, vo: 0.4 },
22
- { id: "audio", len: 5.1, vo: 0.4 },
23
- { id: "launchkit", len: 7.8, vo: 0.4 },
24
- { id: "launchpad", len: 4.6, vo: 0.4 },
 
25
  { id: "outro", len: 4.2, vo: 0.5 },
26
  ] as const;
 
27
  const XFADE = 0.5; // seconds of overlap between scenes
28
- const BREATH = 0.8; // gap between the end of one line and the next scene's line (includes the crossfade)
29
 
30
  // Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them.
31
- const VO_SECONDS: Record<(typeof BASE_SCENES)[number]["id"], number> = DEMO_VO_SECONDS;
32
- const SCENES = BASE_SCENES.map((s) => ({ ...s, len: Math.max(s.len, s.vo + VO_SECONDS[s.id] + BREATH) }));
 
 
 
 
33
 
34
  /** Frame ranges where the narrator is speaking, in composition time. */
35
  const VO_WINDOWS: [number, number][] = (() => {
36
  let cursor = 0;
37
- return SCENES.map((scene) => {
38
- const start = cursor + scene.vo;
 
 
 
 
 
39
  cursor += scene.len - XFADE;
40
- return [Math.round(start * 30), Math.round((start + VO_SECONDS[scene.id]) * 30)] as [number, number];
41
- });
42
  })();
43
 
44
  const MUSIC = 0.5;
@@ -158,9 +170,9 @@ function Caption({ eyebrow, title, delay = 0 }: { eyebrow: string; title: string
158
  const { fps } = useVideoConfig();
159
  const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
160
  return (
161
- <div style={{ position: "absolute", left: 140, top: 110, opacity: s, transform: `translateY(${(1 - s) * 24}px)` }}>
162
  <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
163
- <h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth: 900 }}>{title}</h2>
164
  </div>
165
  );
166
  }
@@ -177,6 +189,83 @@ function typed(text: string, frame: number, start: number, cps = 26): string {
177
  return text.slice(0, n);
178
  }
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  // Scenes
181
 
182
  function Warp() {
@@ -204,207 +293,126 @@ function Warp() {
204
  );
205
  }
206
 
207
- const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "Any RSS feed", "Your site", "Markdown files"];
208
 
209
- /** Step one: any blog, newsletter or site by URL, or markdown files dropped straight in. */
210
- function Paste() {
 
 
 
 
 
 
211
  const frame = useCurrentFrame();
212
  const { fps } = useVideoConfig();
213
- const url = typed("yourblog.com", frame, 14, 12);
214
- const drop = spring({ frame: frame - S(5.4), fps, config: { damping: 12 } });
215
- const press = spring({ frame: frame - S(7.6), fps, config: { damping: 10, mass: 0.5 } });
216
- const ring = interpolate(frame, [S(7.7), S(8.9)], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
217
  return (
218
  <AbsoluteFill>
219
- <WarpField speed={1.2} />
220
- <Caption eyebrow="Step 01" title="Feel the pull of something new" />
221
- <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", flexDirection: "column", gap: 44, paddingTop: 120 }}>
222
- <div style={{ ...panel, display: "flex", alignItems: "center", gap: 18, padding: 14, width: 1100, borderRadius: 999 }}>
223
- <div style={{ flex: 1, fontFamily: theme.body, fontSize: 40, color: url ? theme.white : "rgba(255,255,255,0.4)", paddingLeft: 30 }}>
224
- {url || "yourblog.com"}
225
- <span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
226
- </div>
227
- <div
228
- style={{
229
- position: "relative",
230
- padding: "24px 44px",
231
- borderRadius: 999,
232
- background: BLUE,
233
- color: theme.white,
234
- fontFamily: theme.body,
235
- fontWeight: 600,
236
- fontSize: 32,
237
- transform: `scale(${1 - 0.06 * Math.sin(press * Math.PI)})`,
238
- boxShadow: `0 0 ${30 + press * 40}px ${BLUE}`,
239
- }}
240
- >
241
- Start Exploring
242
- <div style={{ position: "absolute", inset: -ring * 60, borderRadius: 999, border: `2px solid ${BLUE}`, opacity: 1 - ring }} />
243
- </div>
244
  </div>
245
-
246
- <div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: 14, maxWidth: 1300 }}>
 
 
 
247
  {SOURCES.map((name, i) => {
248
- const on = spring({ frame: frame - S(1.8) - i * 7, fps, config: { damping: 16 } });
249
  return (
250
- <span
251
- key={name}
252
- style={{
253
- padding: "12px 24px",
254
- borderRadius: 999,
255
- border: `1px solid ${on > 0.5 ? BLUE : "rgba(255,255,255,0.18)"}`,
256
- background: on > 0.5 ? `${BLUE}26` : "rgba(0,0,0,0.5)",
257
- boxShadow: on > 0.5 ? `0 0 18px ${BLUE}66` : "none",
258
- fontFamily: theme.mono,
259
- fontSize: 24,
260
- letterSpacing: 1,
261
- color: theme.white,
262
- opacity: 0.25 + on * 0.75,
263
- transform: `translateY(${(1 - on) * 16}px)`,
264
- }}
265
- >
266
  {name}
267
- </span>
268
  );
269
  })}
270
  </div>
271
-
272
- <div
273
- style={{
274
- ...panel,
275
- display: "flex",
276
- alignItems: "center",
277
- gap: 22,
278
- padding: "18px 28px",
279
- borderRadius: 16,
280
- opacity: drop,
281
- transform: `translateY(${(1 - drop) * -120}px) rotate(${(1 - drop) * -6}deg)`,
282
- }}
283
- >
284
- <div style={{ width: 54, height: 66, borderRadius: 8, border: `2px solid ${BLUE}`, display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 18, color: BLUE }}>
285
- .md
286
- </div>
287
- <div style={{ display: "grid", gap: 4 }}>
288
- <span style={{ fontFamily: theme.body, fontSize: 30, color: theme.white }}>notes.md, essays.md, drafts.md</span>
289
- <span style={{ fontFamily: theme.mono, fontSize: 20, color: "rgba(255,255,255,0.6)" }}>No feed? Drop in markdown, text, HTML or PDF</span>
290
- </div>
291
  </div>
292
- </AbsoluteFill>
293
- </AbsoluteFill>
294
- );
295
- }
296
-
297
- const POSTS = [
298
- "On Pricing", "Writing Every Day", "The Paid Tier", "Why I Quit Twitter", "Notes on Craft", "Letters to Readers",
299
- "The Long Game", "Small Audiences", "Habits Beat Talent", "Editing Myself", "A Year of Essays", "What Readers Want",
300
- ];
301
-
302
- function Orbit() {
303
- const frame = useCurrentFrame();
304
- const { width, height, fps } = useVideoConfig();
305
- const cx = width / 2 + 180;
306
- const cy = height / 2 + 60;
307
- const count = Math.floor(interpolate(frame, [20, 110], [0, 142], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
308
- const planet = spring({ frame, fps, config: { damping: 20 } });
309
- return (
310
- <AbsoluteFill>
311
- <WarpField speed={0.8} />
312
- <Caption eyebrow="Step 02" title="Shimmering. Impossible to look away." />
313
- <svg width={width} height={height} style={{ position: "absolute" }}>
314
- <defs>
315
- <radialGradient id="planet" cx="40%" cy="35%" r="70%">
316
- <stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
317
- <stop offset="35%" stopColor={BLUE} />
318
- <stop offset="100%" stopColor="#000000" />
319
- </radialGradient>
320
- </defs>
321
- <circle cx={cx} cy={cy} r={150 * planet} fill="url(#planet)" style={{ filter: `drop-shadow(0 0 60px ${BLUE})` }} />
322
- {[260, 360].map((r, i) => (
323
- <ellipse key={r} cx={cx} cy={cy} rx={r * 1.6} ry={r * 0.45} fill="none" stroke={i ? "#ffffff" : BLUE} strokeOpacity={0.25} strokeWidth={2} />
324
- ))}
325
- </svg>
326
- {POSTS.map((title, i) => {
327
- const start = 8 + i * 6;
328
- const p = interpolate(frame, [start, start + 36], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
329
- const ring = i % 2 ? 360 : 260;
330
- const angle = (i / POSTS.length) * Math.PI * 2 + frame * 0.012 * (i % 2 ? 1 : -1);
331
- const tx = cx + Math.cos(angle) * ring * 1.6;
332
- const ty = cy + Math.sin(angle) * ring * 0.45;
333
- const fromX = width + 200;
334
- const fromY = random(`py${i}`) * height;
335
- const x = interpolate(p, [0, 1], [fromX, tx]);
336
- const y = interpolate(p, [0, 1], [fromY, ty]);
337
- const front = Math.sin(angle) > 0;
338
- return (
339
- <div
340
- key={title}
341
- style={{
342
- ...panel,
343
- position: "absolute",
344
- left: x,
345
- top: y,
346
- transform: `translate(-50%, -50%) scale(${front ? 1 : 0.8})`,
347
- padding: "12px 20px",
348
- borderRadius: 12,
349
- fontFamily: theme.body,
350
- fontSize: 22,
351
- color: theme.white,
352
- opacity: p * (front ? 1 : 0.55),
353
- zIndex: front ? 2 : 0,
354
- whiteSpace: "nowrap",
355
- }}
356
- >
357
- {title}
358
- </div>
359
- );
360
- })}
361
- <div style={{ position: "absolute", left: 140, bottom: 120, fontFamily: theme.mono, color: theme.white }}>
362
- <span style={{ fontSize: 96, fontFamily: theme.display, textShadow: `0 0 30px ${BLUE}` }}>{count}</span>
363
- <span style={{ fontSize: 28, marginLeft: 20, color: "rgba(255,255,255,0.7)", letterSpacing: 3 }}>POSTS IN ORBIT</span>
364
- </div>
365
  </AbsoluteFill>
366
  );
367
  }
368
 
369
- function Research() {
 
370
  const frame = useCurrentFrame();
371
  const { fps } = useVideoConfig();
372
- const q = typed("What have I written about pricing?", frame, 10, 30);
373
- const answer = spring({ frame: frame - 52, fps, config: { damping: 18 } });
374
  const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
 
375
  const steps = ["Searching for pric(e|ing)|paid tier", "Reading On Pricing, lines 12 to 20", "Reading The Paid Tier, lines 4 to 9"];
 
376
  return (
377
  <AbsoluteFill>
378
- <WarpField speed={0.6} count={260} />
379
- <Caption eyebrow="Research" title="A spark. Suddenly, wide awake." />
380
- <div style={{ position: "absolute", left: 140, right: 140, top: 360, display: "grid", gridTemplateColumns: "1.25fr 1fr", gap: 40 }}>
381
- <div style={{ ...panel, padding: 36, display: "grid", gap: 22, alignContent: "start" }}>
382
- <div style={{ justifySelf: "end", padding: "14px 22px", borderRadius: "18px 18px 4px 18px", background: `${BLUE}22`, border: `1px solid ${BLUE}66`, fontFamily: theme.body, fontSize: 30, color: theme.white }}>
383
  {q || " "}
384
  </div>
385
  <div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
386
  {steps.map((s, i) => (
387
- <span key={s} style={{ opacity: interpolate(frame, [26 + i * 8, 34 + i * 8], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
388
  {s}
389
  </span>
390
  ))}
391
  </div>
392
- <p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
393
- You raised prices twice and the <b>paid tier doubled</b>{" "}
394
- <Marker n={1} glow={cite} /> because readers took the work more seriously{" "}
395
- <Marker n={2} glow={cite} />.
396
  </p>
397
  </div>
398
- <div style={{ ...panel, padding: 30, opacity: cite, transform: `translateX(${(1 - cite) * 60}px)`, alignSelf: "start" }}>
 
 
 
 
 
 
 
 
399
  <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
400
- {["The paid tier doubled after I", "raised the price to ten dollars.", "Readers took the work seriously."].map((l, i) => (
401
- <div key={l} style={{ display: "flex", gap: 18, marginTop: 14, fontFamily: theme.body, fontSize: 26, color: theme.white, background: `${BLUE}26`, boxShadow: `inset 4px 0 0 ${BLUE}`, padding: "6px 12px", borderRadius: 8 }}>
402
- <span style={{ fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", paddingTop: 5 }}>{12 + i}</span>
403
- {l}
404
- </div>
405
- ))}
 
 
 
 
406
  </div>
407
- </div>
 
 
 
 
408
  </AbsoluteFill>
409
  );
410
  }
@@ -417,142 +425,377 @@ function Marker({ n, glow }: { n: number; glow: number }) {
417
  );
418
  }
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  function Audio() {
421
  const frame = useCurrentFrame();
422
  const { fps } = useVideoConfig();
 
 
 
 
423
  const lines = [
424
  { who: "A", text: "So the big idea here is that price is a signal." },
425
  { who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
426
  { who: "A", text: "Which she wrote about back in March, in On Pricing." },
427
  ];
428
- const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 20) / 34));
429
  return (
430
  <AbsoluteFill>
431
  <WarpField speed={0.7} count={260} />
432
- <Caption eyebrow="Audio overview" title="Lean back. Let it wash over you." />
433
- <div style={{ position: "absolute", left: 140, right: 140, top: 380, display: "grid", gap: 40 }}>
434
- <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, height: 220 }}>
 
 
 
 
 
 
 
 
 
 
435
  {Array.from({ length: 64 }).map((_, i) => {
436
  const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
437
- const env = spring({ frame: frame - 6, fps, config: { damping: 20 } });
438
- return <div key={i} style={{ width: 14, height: 12 + v * 200 * env, borderRadius: 7, background: i % 2 ? theme.white : BLUE, boxShadow: `0 0 18px ${BLUE}` }} />;
439
  })}
440
  </div>
441
- <div style={{ display: "grid", gap: 14 }}>
442
  {lines.map((l, i) => {
443
  const on = i === active;
444
- const seen = i <= active;
445
  return (
446
- <div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity: seen ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 34, color: theme.white }}>
447
- <span style={{ width: 52, height: 52, borderRadius: "50%", display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 22, background: l.who === "A" ? BLUE : theme.white, color: l.who === "A" ? theme.white : theme.black, boxShadow: on ? `0 0 26px ${BLUE}` : "none" }}>
448
- {l.who}
449
- </span>
450
  {l.text}
451
  </div>
452
  );
453
  })}
454
  </div>
455
- </div>
 
 
 
 
456
  </AbsoluteFill>
457
  );
458
  }
459
 
460
- function LaunchKit() {
 
 
 
 
 
 
 
 
 
 
 
461
  const frame = useCurrentFrame();
462
  const { fps } = useVideoConfig();
463
- const cards = [
464
- { label: "X THREAD", body: "I doubled my price. My paid tier doubled too. Here is what I learned." },
465
- { label: "LINKEDIN", body: "Most writers underprice. I did for two years. Then I ran an experiment." },
466
- { label: "SUBSTACK NOTES", body: "Price is a signal. Readers told me so, with their wallets." },
467
- { label: "BLUESKY", body: "Raised my price to $10. Paid readers went up, not down." },
468
- ];
469
  return (
470
  <AbsoluteFill>
471
- <WarpField speed={0.9} count={260} />
472
- <Caption eyebrow="Launch Kit" title="Your pulse quickens." />
473
- <div style={{ position: "absolute", left: 140, right: 140, top: 380, display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 28 }}>
474
- {cards.map((c, i) => {
475
- const s = spring({ frame: frame - 10 - i * 7, fps, config: { damping: 14 } });
476
- const rot = interpolate(s, [0, 1], [(i - 1.5) * 14, 0]);
 
 
 
 
 
 
 
477
  return (
478
- <div key={c.label} style={{ ...panel, padding: 28, minHeight: 300, opacity: s, transform: `translateY(${(1 - s) * 180}px) rotate(${rot}deg)`, display: "grid", alignContent: "start", gap: 18 }}>
479
- <span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
480
- <span style={{ fontFamily: theme.body, fontSize: 28, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
 
481
  </div>
482
  );
483
  })}
484
- </div>
485
- <div style={{ position: "absolute", left: 140, bottom: 90, display: "flex", gap: 16, fontFamily: theme.mono, fontSize: 22, color: "rgba(255,255,255,0.7)", letterSpacing: 2 }}>
486
- {["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
487
- <span key={t} style={{ padding: "10px 18px", border: `1px solid ${BLUE}88`, borderRadius: 999, opacity: interpolate(frame, [50 + i * 6, 60 + i * 6], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  {t}
489
  </span>
490
  ))}
491
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  </AbsoluteFill>
493
  );
494
  }
495
 
496
- function Launchpad() {
 
497
  const frame = useCurrentFrame();
498
- const { width, height } = useVideoConfig();
499
- const lit = [2, 5, 9, 12, 16, 19, 23, 26];
500
- const rocket = interpolate(frame, [70, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.in(Easing.cubic) });
 
 
 
 
 
 
501
  return (
502
  <AbsoluteFill>
503
- <WarpField speed={0.8} count={260} />
504
- <Caption eyebrow="Launchpad" title="Three, two, one. Flying." />
505
- <div style={{ position: "absolute", left: 140, top: 360, display: "grid", gridTemplateColumns: "repeat(7, 120px)", gap: 12 }}>
506
- {Array.from({ length: 28 }).map((_, i) => {
507
- const on = lit.includes(i) && frame > 8 + lit.indexOf(i) * 6;
508
  return (
509
- <div key={i} style={{ height: 88, borderRadius: 12, border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.14)"}`, background: on ? `${BLUE}33` : "rgba(0,0,0,0.5)", boxShadow: on ? `0 0 22px ${BLUE}88` : "none", padding: 10, fontFamily: theme.mono, fontSize: 16, color: "rgba(255,255,255,0.6)" }}>
510
- {i + 1}
511
- {on && <div style={{ marginTop: 18, height: 10, borderRadius: 5, background: theme.white, boxShadow: `0 0 12px ${BLUE}` }} />}
512
  </div>
513
  );
514
  })}
515
- </div>
516
- <svg width={width} height={height} style={{ position: "absolute" }}>
517
- <defs>
518
- <linearGradient id="trail" x1="0" y1="1" x2="0" y2="0">
519
- <stop offset="0%" stopColor={BLUE} stopOpacity="0" />
520
- <stop offset="70%" stopColor={BLUE} stopOpacity="0.8" />
521
- <stop offset="100%" stopColor="#ffffff" stopOpacity="1" />
522
- </linearGradient>
523
- </defs>
524
- {(() => {
525
- // A launch arc from the lower right: curved trail, hot head, exhaust sparks.
526
- const pt = (u: number) => ({ x: 1360 + 300 * u + 120 * u * u, y: 1000 - 1050 * u + 120 * u * (1 - u) });
527
- const head = pt(rocket);
528
- const tail = pt(Math.max(0, rocket - 0.35));
529
- const path = Array.from({ length: 24 }, (_, i) => pt(Math.max(0, rocket - 0.35) + ((rocket - Math.max(0, rocket - 0.35)) * i) / 23));
530
  return (
531
- <>
532
- <circle cx={1360} cy={1000} r={140 * Math.min(1, rocket * 4)} fill={BLUE} opacity={0.25 * (1 - rocket)} style={{ filter: "blur(30px)" }} />
533
- {rocket > 0 && (
534
- <polyline points={path.map((q) => `${q.x},${q.y}`).join(" ")} fill="none" stroke="url(#trail)" strokeWidth={10} strokeLinecap="round" style={{ filter: `drop-shadow(0 0 18px ${BLUE})` }} />
535
  )}
536
- {rocket > 0 &&
537
- Array.from({ length: 16 }).map((_, i) => {
538
- const u = Math.max(0, rocket - random(`s${i}`) * 0.3);
539
- const q = pt(u);
540
- const jitter = (random(`j${i}`) - 0.5) * 60;
541
- return <circle key={i} cx={q.x + jitter} cy={q.y + 20} r={2 + random(`k${i}`) * 3} fill="#ffffff" opacity={0.3 + 0.5 * random(`o${i}`)} />;
542
- })}
543
- {rocket > 0 && <circle cx={head.x} cy={head.y} r={16} fill="#ffffff" style={{ filter: `drop-shadow(0 0 28px ${BLUE}) drop-shadow(0 0 8px #ffffff)` }} />}
544
- {rocket === 0 && <circle cx={tail.x} cy={tail.y} r={10} fill="#ffffff" opacity={interpolate(frame, [30, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })} style={{ filter: `drop-shadow(0 0 16px ${BLUE})` }} />}
545
- </>
546
  );
547
- })()}
548
- </svg>
549
- <div style={{ position: "absolute", left: 1180, top: 400, fontFamily: theme.mono, color: theme.white, opacity: interpolate(frame, [20, 40], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) }}>
550
- <p style={{ margin: 0, fontSize: 22, letterSpacing: 4, color: BLUE }}>NEXT LAUNCH</p>
551
- <p style={{ margin: "10px 0 0", fontFamily: theme.display, fontSize: 88, textShadow: `0 0 30px ${BLUE}` }}>
552
- T-{String(Math.max(0, 10 - Math.floor(frame / 7))).padStart(2, "0")}
553
- </p>
554
- <p style={{ margin: "6px 0 0", fontSize: 22, color: "rgba(255,255,255,0.7)", letterSpacing: 2 }}>X · LINKEDIN · BLUESKY</p>
555
- </div>
 
 
 
 
556
  </AbsoluteFill>
557
  );
558
  }
@@ -580,14 +823,15 @@ function Outro() {
580
  );
581
  }
582
 
583
- const RENDER: Record<(typeof SCENES)[number]["id"], () => JSX.Element> = {
584
  warp: Warp,
585
- paste: Paste,
586
- orbit: Orbit,
587
- research: Research,
588
  audio: Audio,
589
- launchkit: LaunchKit,
590
- launchpad: Launchpad,
 
591
  outro: Outro,
592
  };
593
 
@@ -596,7 +840,7 @@ export function LandingDemo() {
596
  return (
597
  <AbsoluteFill style={{ backgroundColor: theme.black }}>
598
  <SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
599
- {SCENES.map((scene, i) => {
600
  const from = cursor;
601
  cursor += S(scene.len - XFADE);
602
  const Scene = RENDER[scene.id];
@@ -605,10 +849,11 @@ export function LandingDemo() {
605
  <FadeScene len={scene.len}>
606
  <Scene />
607
  </FadeScene>
608
- <Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
609
- <SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
610
- </Sequence>
611
- {i === 0 && null}
 
612
  </Sequence>
613
  );
614
  })}
 
4
  import { DEMO_VO_SECONDS } from "./demoVo";
5
  import { Lockup } from "./Lockup";
6
 
7
+ /** Landing page product demo: the real app, one feature at a time. 1920x1080, 30 fps, ~47 s.
8
+ * Scenes: intro, sync, ask, map, audio, video, edit, launch kit, outro. Each product scene is a
9
+ * window with a cursor that does the thing; the narrator adds a sparse journey-of-discovery line here and there.
10
+ * Strict palette: black, #217cff, white. */
11
 
12
  export const DEMO_FPS = 30;
13
  const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
14
 
15
+ // Narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"): a few scenes (intro, sync, map,
16
+ // launch kit, outro) speak, starting `vo` seconds in. Scenes keep their minimum length for the animation and stretch when a
17
+ // line needs more room, so it always ends with a breath before the scene changes.
18
  const BASE_SCENES = [
19
  { id: "warp", len: 5.2, vo: 0.6 },
20
+ { id: "sync", len: 5.0, vo: 0.5 },
21
+ { id: "ask", len: 6.0 },
22
+ { id: "map", len: 6.5, vo: 0.6 },
23
+ { id: "audio", len: 5.5 },
24
+ { id: "video", len: 6.5 },
25
+ { id: "edit", len: 6.0 },
26
+ { id: "kit", len: 5.5, vo: 0.5 },
27
  { id: "outro", len: 4.2, vo: 0.5 },
28
  ] as const;
29
+ type SceneId = (typeof BASE_SCENES)[number]["id"];
30
  const XFADE = 0.5; // seconds of overlap between scenes
31
+ const BREATH = 0.8; // gap between the end of a line and the scene change (includes the crossfade)
32
 
33
  // Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them.
34
+ const VO_SECONDS: Partial<Record<SceneId, number>> = DEMO_VO_SECONDS;
35
+ const SCENES = BASE_SCENES.map((s) => {
36
+ const vo = "vo" in s ? s.vo : undefined;
37
+ const line = VO_SECONDS[s.id];
38
+ return { id: s.id, vo, len: vo !== undefined && line ? Math.max(s.len, vo + line + BREATH) : s.len };
39
+ });
40
 
41
  /** Frame ranges where the narrator is speaking, in composition time. */
42
  const VO_WINDOWS: [number, number][] = (() => {
43
  let cursor = 0;
44
+ const windows: [number, number][] = [];
45
+ for (const scene of SCENES) {
46
+ const line = VO_SECONDS[scene.id];
47
+ if (scene.vo !== undefined && line) {
48
+ const start = cursor + scene.vo;
49
+ windows.push([Math.round(start * DEMO_FPS), Math.round((start + line) * DEMO_FPS)]);
50
+ }
51
  cursor += scene.len - XFADE;
52
+ }
53
+ return windows;
54
  })();
55
 
56
  const MUSIC = 0.5;
 
170
  const { fps } = useVideoConfig();
171
  const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
172
  return (
173
+ <div style={{ position: "absolute", left: 140, top: 96, opacity: s, transform: `translateY(${(1 - s) * 24}px)` }}>
174
  <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
175
+ <h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth: 1400 }}>{title}</h2>
176
  </div>
177
  );
178
  }
 
189
  return text.slice(0, n);
190
  }
191
 
192
+ const clamp01 = (frame: number, a: number, b: number) => interpolate(frame, [a, b], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
193
+ /** 0 to 1 and back over 10 frames from `at`: the press of a clicked control. */
194
+ const pulse = (frame: number, at: number) => Math.sin(clamp01(frame, at, at + 10) * Math.PI);
195
+
196
+ // App window and cursor
197
+
198
+ /** Where a window's content area starts in scene coordinates, so cursor targets can be written in window space. */
199
+ const WX = 141;
200
+ const WY = 303;
201
+ const at = (x: number, y: number) => ({ x: WX + x, y: WY + y });
202
+
203
+ /** A browser-style app window: 1640 x 668 of content under a 52 px title bar. */
204
+ function Win({ children }: { children: ReactNode }) {
205
+ return (
206
+ <div style={{ ...panel, position: "absolute", left: 140, top: 250, width: 1640, height: 720, overflow: "hidden", background: "rgba(3,7,16,0.9)" }}>
207
+ <div style={{ height: 52, display: "flex", alignItems: "center", gap: 10, padding: "0 22px", borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
208
+ {[0, 1, 2].map((i) => (
209
+ <span key={i} style={{ width: 13, height: 13, borderRadius: "50%", background: "rgba(255,255,255,0.22)" }} />
210
+ ))}
211
+ <span style={{ marginLeft: 22, padding: "5px 22px", borderRadius: 999, background: "rgba(255,255,255,0.08)", fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.6)" }}>notestack.ai/app</span>
212
+ </div>
213
+ <div style={{ position: "absolute", left: 0, top: 52, right: 0, bottom: 0 }}>{children}</div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ function Chip({ children, on = false, press = 0, style }: { children: ReactNode; on?: boolean; press?: number; style?: CSSProperties }) {
219
+ return (
220
+ <span
221
+ style={{
222
+ display: "inline-flex",
223
+ alignItems: "center",
224
+ justifyContent: "center",
225
+ borderRadius: 999,
226
+ border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.2)"}`,
227
+ background: on ? `${BLUE}33` : "rgba(255,255,255,0.04)",
228
+ boxShadow: on ? `0 0 22px ${BLUE}77` : "none",
229
+ fontFamily: theme.body,
230
+ fontSize: 26,
231
+ color: theme.white,
232
+ transform: `scale(${1 - 0.06 * press})`,
233
+ ...style,
234
+ }}
235
+ >
236
+ {children}
237
+ </span>
238
+ );
239
+ }
240
+
241
+ function Label({ children, style }: { children: ReactNode; style?: CSSProperties }) {
242
+ return <span style={{ position: "absolute", fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, textTransform: "uppercase", color: "rgba(255,255,255,0.55)", ...style }}>{children}</span>;
243
+ }
244
+
245
+ type Key = { f: number; x: number; y: number };
246
+
247
+ /** A mouse cursor that glides through `keys` (scene coordinates) and ripples on each frame in `clicks`. */
248
+ function Cursor({ keys, clicks = [] }: { keys: Key[]; clicks?: number[] }) {
249
+ const frame = useCurrentFrame();
250
+ const fs = keys.map((k) => k.f);
251
+ const opts = { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease } as const;
252
+ const x = interpolate(frame, fs, keys.map((k) => k.x), opts);
253
+ const y = interpolate(frame, fs, keys.map((k) => k.y), opts);
254
+ const down = clicks.reduce((m, c) => Math.max(m, pulse(frame, c)), 0);
255
+ return (
256
+ <>
257
+ {clicks.map((c) => {
258
+ const p = clamp01(frame, c, c + 16);
259
+ if (p <= 0 || p >= 1) return null;
260
+ return <div key={c} style={{ position: "absolute", left: x - 36 * p, top: y - 36 * p, width: 72 * p, height: 72 * p, borderRadius: "50%", border: `3px solid ${BLUE}`, opacity: 1 - p }} />;
261
+ })}
262
+ <svg width={40} height={44} viewBox="0 0 24 34" style={{ position: "absolute", left: x, top: y, transform: `scale(${1 - 0.12 * down})`, transformOrigin: "0 0", filter: `drop-shadow(0 0 10px ${BLUE})`, zIndex: 50 }}>
263
+ <path d="M1 1 L1 26 L7.5 20.5 L12 31 L16.5 29 L12 18.5 L21 18.5 Z" fill="#ffffff" stroke="#000000" strokeWidth={1.6} strokeLinejoin="round" />
264
+ </svg>
265
+ </>
266
+ );
267
+ }
268
+
269
  // Scenes
270
 
271
  function Warp() {
 
293
  );
294
  }
295
 
296
+ const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "RSS", "Markdown"];
297
 
298
+ const POSTS = [
299
+ "On Pricing", "Writing Every Day", "The Paid Tier", "Why I Quit Twitter",
300
+ "Notes on Craft", "Letters to Readers", "The Long Game", "Small Audiences",
301
+ ];
302
+ const POST_DATES = ["Mar 12", "Mar 04", "Feb 21", "Feb 09", "Jan 30", "Jan 18", "Jan 03", "Dec 20"];
303
+
304
+ /** 01: paste a link, the archive syncs in. */
305
+ function Sync() {
306
  const frame = useCurrentFrame();
307
  const { fps } = useVideoConfig();
308
+ const url = typed("yourblog.com", frame, 8, 14);
309
+ const press = pulse(frame, 52);
310
+ const count = Math.round(142 * ease(clamp01(frame, 58, 120)));
311
+ const bar = clamp01(frame, 54, 84);
312
  return (
313
  <AbsoluteFill>
314
+ <WarpField speed={1} />
315
+ <Caption eyebrow="01 · Sync" title="Point it at your writing." />
316
+ <Win>
317
+ <div style={{ position: "absolute", left: 80, top: 56, width: 1060, height: 84, borderRadius: 999, border: `1px solid ${BLUE}88`, background: "rgba(255,255,255,0.05)", display: "flex", alignItems: "center", paddingLeft: 38, fontFamily: theme.body, fontSize: 38, color: url ? theme.white : "rgba(255,255,255,0.4)" }}>
318
+ {url || "yourblog.com"}
319
+ <span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  </div>
321
+ <div style={{ position: "absolute", left: 1160, top: 56, width: 220, height: 84, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 32, color: theme.white, transform: `scale(${1 - 0.06 * press})`, boxShadow: `0 0 ${24 + press * 40}px ${BLUE}` }}>
322
+ Sync
323
+ </div>
324
+ <div style={{ position: "absolute", left: 80, top: 158, width: 1060 * bar, height: 4, borderRadius: 2, background: BLUE, boxShadow: `0 0 14px ${BLUE}`, opacity: bar > 0 && bar < 1 ? 1 : 0 }} />
325
+ <div style={{ position: "absolute", left: 80, top: 186, display: "flex", gap: 12 }}>
326
  {SOURCES.map((name, i) => {
327
+ const on = spring({ frame: frame - 14 - i * 5, fps, config: { damping: 16 } });
328
  return (
329
+ <Chip key={name} on={on > 0.5} style={{ padding: "8px 20px", fontSize: 21, fontFamily: theme.mono, opacity: 0.3 + on * 0.7 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  {name}
331
+ </Chip>
332
  );
333
  })}
334
  </div>
335
+ <div style={{ position: "absolute", right: 80, top: 176, fontFamily: theme.mono, color: theme.white, display: "flex", alignItems: "baseline", gap: 14 }}>
336
+ <span style={{ fontFamily: theme.display, fontSize: 56, textShadow: `0 0 24px ${BLUE}` }}>{count}</span>
337
+ <span style={{ fontSize: 20, letterSpacing: 3, color: "rgba(255,255,255,0.6)" }}>POSTS SYNCED</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  </div>
339
+ <div style={{ position: "absolute", left: 80, top: 280, width: 1480, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18 }}>
340
+ {POSTS.map((title, i) => {
341
+ const s = spring({ frame: frame - 60 - i * 5, fps, config: { damping: 14 } });
342
+ return (
343
+ <div key={title} style={{ display: "flex", alignItems: "center", gap: 20, height: 74, padding: "0 26px", borderRadius: 14, border: "1px solid rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.04)", opacity: s, transform: `translateY(${(1 - s) * 30}px)`, fontFamily: theme.body, fontSize: 28, color: theme.white }}>
344
+ <span style={{ width: 30, height: 30, borderRadius: "50%", background: BLUE, display: "grid", placeItems: "center", fontSize: 18, boxShadow: `0 0 14px ${BLUE}` }}>✓</span>
345
+ <span style={{ flex: 1 }}>{title}</span>
346
+ <span style={{ fontFamily: theme.mono, fontSize: 20, color: "rgba(255,255,255,0.5)" }}>{POST_DATES[i]}</span>
347
+ </div>
348
+ );
349
+ })}
350
+ </div>
351
+ </Win>
352
+ <Cursor
353
+ keys={[{ f: 0, x: 1560, y: 880 }, { f: 42, ...at(1270, 98) }, { f: 74, ...at(1270, 98) }, { f: 100, ...at(1100, 470) }]}
354
+ clicks={[52]}
355
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  </AbsoluteFill>
357
  );
358
  }
359
 
360
+ /** 02: ask a question, click the citation, see the exact lines. */
361
+ function Ask() {
362
  const frame = useCurrentFrame();
363
  const { fps } = useVideoConfig();
364
+ const q = typed("What have I written about pricing?", frame, 8, 30);
365
+ const answer = spring({ frame: frame - 66, fps, config: { damping: 18 } });
366
  const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
367
+ const open = spring({ frame: frame - 114, fps, config: { damping: 18 } });
368
  const steps = ["Searching for pric(e|ing)|paid tier", "Reading On Pricing, lines 12 to 20", "Reading The Paid Tier, lines 4 to 9"];
369
+ const context = ["I tried a lot of numbers before this one.", "The paid tier doubled after I", "raised the price to ten dollars.", "Readers took the work seriously.", "Nobody left. That surprised me."];
370
  return (
371
  <AbsoluteFill>
372
+ <WarpField speed={0.7} count={300} />
373
+ <Caption eyebrow="02 · Ask" title="Every answer, cited to the line." />
374
+ <Win>
375
+ <div style={{ position: "absolute", left: 80, top: 36, width: 880, display: "grid", gap: 20 }}>
376
+ <div style={{ justifySelf: "end", padding: "14px 24px", borderRadius: "18px 18px 4px 18px", background: `${BLUE}22`, border: `1px solid ${BLUE}66`, fontFamily: theme.body, fontSize: 30, color: theme.white, minWidth: 40, minHeight: 46 }}>
377
  {q || " "}
378
  </div>
379
  <div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
380
  {steps.map((s, i) => (
381
+ <span key={s} style={{ opacity: clamp01(frame, 40 + i * 8, 48 + i * 8) }}>
382
  {s}
383
  </span>
384
  ))}
385
  </div>
386
+ <p style={{ margin: 0, fontFamily: theme.body, fontSize: 32, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
387
+ You raised prices twice and the <b>paid tier doubled</b> <Marker n={1} glow={cite} /> because readers took the work more seriously <Marker n={2} glow={cite} />.
 
 
388
  </p>
389
  </div>
390
+ {[
391
+ { n: 1, label: "On Pricing · L12 to 14" },
392
+ { n: 2, label: "The Paid Tier · L4 to 9" },
393
+ ].map((c, i) => (
394
+ <Chip key={c.n} on={cite > 0.5 && (i === 0 ? frame >= 112 : false)} press={i === 0 ? pulse(frame, 112) : 0} style={{ position: "absolute", left: 80 + i * 450, top: 460, width: 420, height: 60, fontFamily: theme.mono, fontSize: 21, opacity: cite, gap: 10 }}>
395
+ <span style={{ color: BLUE }}>[{c.n}]</span> {c.label}
396
+ </Chip>
397
+ ))}
398
+ <div style={{ ...panel, position: "absolute", left: 1010, top: 36, width: 560, padding: 30, opacity: open, transform: `translateX(${(1 - open) * 60}px)` }}>
399
  <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
400
+ {context.map((l, i) => {
401
+ const hit = i >= 1 && i <= 3;
402
+ return (
403
+ <div key={l} style={{ display: "flex", gap: 18, marginTop: 12, fontFamily: theme.body, fontSize: 25, color: hit ? theme.white : "rgba(255,255,255,0.45)", background: hit ? `${BLUE}26` : "transparent", boxShadow: hit ? `inset 4px 0 0 ${BLUE}` : "none", padding: "6px 12px", borderRadius: 8 }}>
404
+ <span style={{ fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", paddingTop: 4 }}>{11 + i}</span>
405
+ {l}
406
+ </div>
407
+ );
408
+ })}
409
+ <p style={{ margin: "22px 0 0", fontFamily: theme.mono, fontSize: 19, color: BLUE }}>✓ Line range verified</p>
410
  </div>
411
+ </Win>
412
+ <Cursor
413
+ keys={[{ f: 0, x: 1560, y: 880 }, { f: 100, ...at(260, 490) }, { f: 126, ...at(260, 490) }, { f: 160, ...at(1250, 560) }]}
414
+ clicks={[112]}
415
+ />
416
  </AbsoluteFill>
417
  );
418
  }
 
425
  );
426
  }
427
 
428
+ const TOPICS = [
429
+ "Pricing", "Paid tier", "Audience", "Craft", "Habits", "Newsletters", "Growth", "Writing",
430
+ "Focus", "Trust", "Launches", "Editing", "Community", "Ideas", "Archive",
431
+ ];
432
+ const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
433
+ type Shape = "orbit" | "spiral" | "cluster";
434
+
435
+ /** Where topic `i` sits in each shape of the Mind Constellation (window coordinates). */
436
+ function place(shape: Shape, i: number): { x: number; y: number } {
437
+ const cx = 620;
438
+ const cy = 350;
439
+ const n = TOPICS.length;
440
+ if (shape === "orbit") {
441
+ if (i === 0) return { x: cx, y: cy };
442
+ const r = [170, 270, 360][(i - 1) % 3];
443
+ const a = i * 2.4;
444
+ return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
445
+ }
446
+ if (shape === "spiral") {
447
+ const t = i / (n - 1);
448
+ const a = t * Math.PI * 2.6 + 0.4;
449
+ const r = 30 + t * 340;
450
+ return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
451
+ }
452
+ const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }];
453
+ const c = centers[i % 3];
454
+ const k = Math.floor(i / 3);
455
+ const a = k * 1.25 + (i % 3);
456
+ const r = 36 + k * 24;
457
+ return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r };
458
+ }
459
+
460
+ function mapPos(i: number, frame: number): { x: number; y: number } {
461
+ const a = place("orbit", i);
462
+ const b = place("spiral", i);
463
+ const c = place("cluster", i);
464
+ const s1 = ease(clamp01(frame, 64, 96));
465
+ const s2 = ease(clamp01(frame, 112, 144));
466
+ return { x: a.x + (b.x - a.x) * s1 + (c.x - (a.x + (b.x - a.x) * s1)) * s2, y: a.y + (b.y - a.y) * s1 + (c.y - (a.y + (b.y - a.y) * s1)) * s2 };
467
+ }
468
+
469
+ /** 03: the Mind Constellation, switching shapes and diving into a topic. */
470
+ function MindMap() {
471
+ const frame = useCurrentFrame();
472
+ const { fps } = useVideoConfig();
473
+ const activeShape = frame < 62 ? 0 : frame < 110 ? 1 : 3;
474
+ const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } });
475
+ const hub = at(place("cluster", 0).x, place("cluster", 0).y);
476
+ return (
477
+ <AbsoluteFill>
478
+ <WarpField speed={0.6} count={260} />
479
+ <Caption eyebrow="03 · Map" title="See your whole archive." />
480
+ <Win>
481
+ <svg width={1640} height={668} style={{ position: "absolute", inset: 0 }}>
482
+ {TOPICS.map((_, i) => {
483
+ if (i === 0) return null;
484
+ const from = mapPos(i, frame);
485
+ const to = mapPos(i < 4 ? 0 : i - 3, frame);
486
+ const on = clamp01(frame, 6 + i * 3, 20 + i * 3);
487
+ return <line key={i} x1={from.x} y1={from.y} x2={to.x} y2={to.y} stroke={i % 2 ? BLUE : "#ffffff"} strokeOpacity={0.3 * on} strokeWidth={1.5} />;
488
+ })}
489
+ {TOPICS.map((name, i) => {
490
+ const p = mapPos(i, frame);
491
+ const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } });
492
+ const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born;
493
+ const picked = i === 0 && frame >= 158;
494
+ return (
495
+ <g key={name}>
496
+ <circle cx={p.x} cy={p.y} r={r * 2.6} fill={BLUE} opacity={0.18 * born} style={{ filter: "blur(8px)" }} />
497
+ <circle cx={p.x} cy={p.y} r={r} fill="#ffffff" style={{ filter: `drop-shadow(0 0 10px ${BLUE})` }} />
498
+ {picked && <circle cx={p.x} cy={p.y} r={r + 14} fill="none" stroke={BLUE} strokeWidth={3} />}
499
+ <text x={p.x} y={p.y + r + 26} textAnchor="middle" fontFamily={theme.body} fontSize={i === 0 ? 26 : 21} fill="#ffffff" opacity={0.85 * born}>
500
+ {name}
501
+ </text>
502
+ </g>
503
+ );
504
+ })}
505
+ </svg>
506
+ <div style={{ position: "absolute", left: 40, top: 26, display: "flex", gap: 10 }}>
507
+ {MAP_SHAPES.map((name, i) => (
508
+ <Chip key={name} on={i === activeShape} press={i === 1 ? pulse(frame, 62) : i === 3 ? pulse(frame, 110) : 0} style={{ width: 120, height: 48, fontSize: 22 }}>
509
+ {name}
510
+ </Chip>
511
+ ))}
512
+ </div>
513
+ <p style={{ position: "absolute", left: 40, bottom: 20, margin: 0, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)" }}>Scroll to dive in. Drag to move. Click a star to fly to it.</p>
514
+ <div style={{ ...panel, position: "absolute", right: 40, top: 26, width: 380, padding: 28, opacity: panelIn, transform: `translateX(${(1 - panelIn) * 60}px)` }}>
515
+ <p style={{ margin: 0, fontFamily: theme.display, fontSize: 40, color: theme.white }}>Pricing</p>
516
+ <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>Orbiting here</Label>
517
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
518
+ {["Paid tier", "Audience", "Trust"].map((t) => (
519
+ <Chip key={t} on style={{ padding: "4px 16px", fontSize: 20 }}>{t}</Chip>
520
+ ))}
521
+ </div>
522
+ <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>From your posts</Label>
523
+ {["On Pricing", "The Paid Tier", "Small Audiences"].map((t) => (
524
+ <p key={t} style={{ margin: "10px 0 0", fontFamily: theme.body, fontSize: 24, color: theme.white }}>{t}</p>
525
+ ))}
526
+ </div>
527
+ </Win>
528
+ <Cursor
529
+ keys={[
530
+ { f: 0, x: 1560, y: 880 },
531
+ { f: 50, ...at(220, 50) },
532
+ { f: 64, ...at(220, 50) },
533
+ { f: 98, ...at(470, 50) },
534
+ { f: 112, ...at(470, 50) },
535
+ { f: 150, x: hub.x, y: hub.y },
536
+ { f: 170, x: hub.x + 30, y: hub.y + 120 },
537
+ ]}
538
+ clicks={[62, 110, 156]}
539
+ />
540
+ </AbsoluteFill>
541
+ );
542
+ }
543
+
544
+ /** 04: pick a format, create, play. */
545
  function Audio() {
546
  const frame = useCurrentFrame();
547
  const { fps } = useVideoConfig();
548
+ const formats = ["Deep dive", "Brief", "Debate"];
549
+ const picked = frame >= 22 ? 2 : 0;
550
+ const bar = clamp01(frame, 52, 84);
551
+ const playing = clamp01(frame, 84, 96);
552
  const lines = [
553
  { who: "A", text: "So the big idea here is that price is a signal." },
554
  { who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
555
  { who: "A", text: "Which she wrote about back in March, in On Pricing." },
556
  ];
557
+ const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24));
558
  return (
559
  <AbsoluteFill>
560
  <WarpField speed={0.7} count={260} />
561
+ <Caption eyebrow="04 · Listen" title="Turn it into a conversation." />
562
+ <Win>
563
+ <Label style={{ left: 80, top: 36 }}>Format</Label>
564
+ {formats.map((f, i) => (
565
+ <Chip key={f} on={i === picked} press={i === 2 ? pulse(frame, 22) : 0} style={{ position: "absolute", left: 80 + i * 210, top: 80, width: 190, height: 56 }}>
566
+ {f}
567
+ </Chip>
568
+ ))}
569
+ <div style={{ position: "absolute", left: 80, top: 170, width: 300, height: 72, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 30, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 50)})`, boxShadow: `0 0 ${24 + pulse(frame, 50) * 40}px ${BLUE}` }}>
570
+ {bar > 0 && bar < 1 ? "Recording..." : "Create audio"}
571
+ </div>
572
+ <div style={{ position: "absolute", left: 420, top: 202, width: 900 * bar, height: 6, borderRadius: 3, background: BLUE, boxShadow: `0 0 14px ${BLUE}`, opacity: bar > 0 && bar < 1 ? 1 : 0 }} />
573
+ <div style={{ position: "absolute", left: 80, top: 290, width: 1480, height: 170, display: "flex", alignItems: "center", justifyContent: "center", gap: 10, opacity: playing }}>
574
  {Array.from({ length: 64 }).map((_, i) => {
575
  const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
576
+ const env = spring({ frame: frame - 90, fps, config: { damping: 20 } });
577
+ return <div key={i} style={{ width: 14, height: 12 + v * 150 * env, borderRadius: 7, background: i % 2 ? theme.white : BLUE, boxShadow: `0 0 18px ${BLUE}` }} />;
578
  })}
579
  </div>
580
+ <div style={{ position: "absolute", left: 80, top: 480, display: "grid", gap: 12 }}>
581
  {lines.map((l, i) => {
582
  const on = i === active;
 
583
  return (
584
+ <div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity: frame >= 92 + i * 24 ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 32, color: theme.white }}>
585
+ <span style={{ width: 46, height: 46, borderRadius: "50%", display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 20, background: l.who === "A" ? BLUE : theme.white, color: l.who === "A" ? theme.white : theme.black, boxShadow: on ? `0 0 26px ${BLUE}` : "none" }}>{l.who}</span>
 
 
586
  {l.text}
587
  </div>
588
  );
589
  })}
590
  </div>
591
+ </Win>
592
+ <Cursor
593
+ keys={[{ f: 0, x: 1560, y: 880 }, { f: 18, ...at(595, 108) }, { f: 30, ...at(595, 108) }, { f: 46, ...at(230, 206) }, { f: 60, ...at(230, 206) }, { f: 100, ...at(900, 560) }]}
594
+ clicks={[22, 50]}
595
+ />
596
  </AbsoluteFill>
597
  );
598
  }
599
 
600
+ const VIDEO_STYLES = ["16:9 Explainer", "9:16 Short", "1:1 Square"];
601
+ const TEMPLATE_LOOKS: CSSProperties[] = [
602
+ { background: "linear-gradient(135deg, #000, #0b1730)" },
603
+ { background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` },
604
+ { background: "linear-gradient(135deg, #ffffff, #cfe0ff)" },
605
+ { background: "rgba(255,255,255,0.04)", border: `2px dashed ${BLUE}88` },
606
+ ];
607
+ const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "From your website"];
608
+ const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"];
609
+
610
+ /** 05: wizard: pick a style, a template and a voice, create. */
611
+ function VideoCreate() {
612
  const frame = useCurrentFrame();
613
  const { fps } = useVideoConfig();
614
+ const style = frame >= 34 ? 1 : 0;
615
+ const tpl = frame >= 74 ? 1 : 0;
616
+ const voice = frame >= 114 ? 1 : 0;
617
+ const go = pulse(frame, 162);
618
+ const busy = clamp01(frame, 164, 194);
 
619
  return (
620
  <AbsoluteFill>
621
+ <WarpField speed={0.8} count={260} />
622
+ <Caption eyebrow="05 · Make" title="Pick a look. Pick a voice." />
623
+ <Win>
624
+ <Label style={{ left: 80, top: 30 }}>Video style</Label>
625
+ {VIDEO_STYLES.map((t, i) => (
626
+ <Chip key={t} on={i === style} press={i === 1 ? pulse(frame, 34) : 0} style={{ position: "absolute", left: 80 + i * 250, top: 70, width: 230, height: 56 }}>
627
+ {t}
628
+ </Chip>
629
+ ))}
630
+ <Label style={{ left: 80, top: 160 }}>Template</Label>
631
+ {TEMPLATE_NAMES.map((name, i) => {
632
+ const s = spring({ frame: frame - 6 - i * 5, fps, config: { damping: 16 } });
633
+ const sel = i === tpl;
634
  return (
635
+ <div key={name} style={{ position: "absolute", left: 80 + i * 300, top: 200, width: 270, height: 190, borderRadius: 16, border: `2px solid ${sel ? BLUE : "rgba(255,255,255,0.14)"}`, boxShadow: sel ? `0 0 30px ${BLUE}88` : "none", overflow: "hidden", opacity: s, transform: `scale(${(0.9 + 0.1 * s) * (i === 1 ? 1 - 0.04 * pulse(frame, 74) : 1)})`, ...TEMPLATE_LOOKS[i] }}>
636
+ <div style={{ position: "absolute", left: 20, top: 24, width: 120, height: 12, borderRadius: 6, background: i === 2 ? "#000" : "#fff", opacity: 0.9 }} />
637
+ <div style={{ position: "absolute", left: 20, top: 52, width: 190, height: 8, borderRadius: 4, background: i === 2 ? "#000" : "#fff", opacity: 0.4 }} />
638
+ <div style={{ position: "absolute", left: 20, bottom: 16, fontFamily: theme.body, fontSize: 22, color: i === 2 ? "#000" : "#fff" }}>{name}</div>
639
  </div>
640
  );
641
  })}
642
+ <Label style={{ left: 80, top: 430 }}>Narration voice</Label>
643
+ {VOICES.map((v, i) => (
644
+ <Chip key={v} on={i === voice} press={i === 1 ? pulse(frame, 114) : 0} style={{ position: "absolute", left: 80 + i * 370, top: 470, width: 340, height: 64 }}>
645
+ {i < 2 && <span style={{ marginRight: 12, color: BLUE }}>▶</span>}
646
+ {v}
647
+ </Chip>
648
+ ))}
649
+ <div style={{ position: "absolute", left: 1240, top: 560, width: 340, height: 76, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 30, color: theme.white, overflow: "hidden", transform: `scale(${1 - 0.06 * go})`, boxShadow: `0 0 ${24 + go * 40}px ${BLUE}` }}>
650
+ <div style={{ position: "absolute", left: 0, bottom: 0, height: 6, width: `${busy * 100}%`, background: "#fff" }} />
651
+ {busy > 0 ? "Making your video..." : "Create video"}
652
+ </div>
653
+ </Win>
654
+ <Cursor
655
+ keys={[
656
+ { f: 0, x: 1560, y: 880 },
657
+ { f: 30, ...at(450, 98) },
658
+ { f: 38, ...at(450, 98) },
659
+ { f: 70, ...at(515, 295) },
660
+ { f: 78, ...at(515, 295) },
661
+ { f: 110, ...at(630, 502) },
662
+ { f: 118, ...at(630, 502) },
663
+ { f: 158, ...at(1410, 598) },
664
+ { f: 190, ...at(1410, 598) },
665
+ ]}
666
+ clicks={[34, 74, 114, 162]}
667
+ />
668
+ </AbsoluteFill>
669
+ );
670
+ }
671
+
672
+ const SCENE_TITLES = ["Price is a signal", "The numbers", "Why it worked", "Try it yourself"];
673
+
674
+ /** 06: the scene editor, then render the MP4. */
675
+ function VideoEdit() {
676
+ const frame = useCurrentFrame();
677
+ const { fps } = useVideoConfig();
678
+ const active = frame >= 32 ? 1 : 0;
679
+ const since = frame >= 32 ? frame - 32 : frame;
680
+ const pop = spring({ frame: since, fps, config: { damping: 16 } });
681
+ const render = clamp01(frame, 110, 164);
682
+ const ready = frame >= 166;
683
+ const tabs = ["Edit Scenes", "Images/footage", "Audio", "Settings"];
684
+ return (
685
+ <AbsoluteFill>
686
+ <WarpField speed={0.7} count={260} />
687
+ <Caption eyebrow="06 · Edit" title="Tweak any scene, then render." />
688
+ <Win>
689
+ {tabs.map((t, i) => (
690
+ <span key={t} style={{ position: "absolute", left: 80 + i * 210, top: 12, width: 190, height: 48, display: "grid", placeItems: "center", borderRadius: 12, fontFamily: theme.body, fontSize: 23, color: theme.white, background: i === 0 ? `${BLUE}33` : "transparent", border: `1px solid ${i === 0 ? BLUE : "rgba(255,255,255,0.14)"}` }}>
691
  {t}
692
  </span>
693
  ))}
694
+ <div style={{ position: "absolute", left: 1340, top: 10, width: 220, height: 52, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 24, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 108)})`, boxShadow: `0 0 ${20 + pulse(frame, 108) * 40}px ${BLUE}` }}>
695
+ Download MP4
696
+ </div>
697
+ {SCENE_TITLES.map((t, i) => {
698
+ const s = spring({ frame: frame - 4 - i * 5, fps, config: { damping: 16 } });
699
+ const on = i === active;
700
+ return (
701
+ <div key={t} style={{ position: "absolute", left: 80, top: 80 + i * 120, width: 400, height: 106, borderRadius: 14, border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.14)"}`, background: on ? `${BLUE}26` : "rgba(255,255,255,0.04)", boxShadow: on ? `0 0 24px ${BLUE}66` : "none", display: "flex", alignItems: "center", gap: 18, padding: "0 20px", opacity: s, transform: `translateX(${(1 - s) * -40}px) scale(${i === 1 ? 1 - 0.03 * pulse(frame, 32) : 1})` }}>
702
+ <span style={{ fontFamily: theme.mono, fontSize: 22, color: BLUE }}>{i + 1}</span>
703
+ <div style={{ width: 96, height: 60, borderRadius: 8, background: i % 2 ? `linear-gradient(135deg, ${BLUE}, #0a2a66)` : "linear-gradient(135deg, #000, #0b1730)", border: "1px solid rgba(255,255,255,0.2)" }} />
704
+ <span style={{ fontFamily: theme.body, fontSize: 25, color: theme.white }}>{t}</span>
705
+ </div>
706
+ );
707
+ })}
708
+ <div style={{ position: "absolute", left: 520, top: 80, width: 1040, height: 560, borderRadius: 18, overflow: "hidden", border: "1px solid rgba(255,255,255,0.18)", background: `radial-gradient(70% 70% at 30% 20%, ${BLUE}33, #000 70%)`, boxShadow: `0 0 50px ${BLUE}33` }}>
709
+ {active === 0 ? (
710
+ <div style={{ position: "absolute", left: 70, top: 160, opacity: pop, transform: `translateY(${(1 - pop) * 30}px)` }}>
711
+ <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 22, letterSpacing: 5, color: BLUE }}>SCENE 1</p>
712
+ <p style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 86, lineHeight: 1.05, color: theme.white, textShadow: `0 0 30px ${BLUE}` }}>Price is<br />a signal.</p>
713
+ </div>
714
+ ) : (
715
+ <>
716
+ <p style={{ position: "absolute", left: 70, top: 56, margin: 0, fontFamily: theme.display, fontSize: 52, color: theme.white, opacity: pop }}>Paid readers, doubled</p>
717
+ {[0.28, 0.5, 1].map((h, i) => (
718
+ <div key={i} style={{ position: "absolute", left: 90 + i * 250, bottom: 90, width: 170, height: 340 * h * ease(clamp01(since, 8 + i * 7, 34 + i * 7)), borderRadius: "12px 12px 0 0", background: i === 2 ? "#ffffff" : BLUE, boxShadow: `0 0 30px ${BLUE}` }} />
719
+ ))}
720
+ <p style={{ position: "absolute", left: 70, bottom: 30, margin: 0, fontFamily: theme.body, fontSize: 26, color: "rgba(255,255,255,0.85)", opacity: pop }}>You raised prices twice and the paid tier doubled.</p>
721
+ </>
722
+ )}
723
+ {render > 0 && (
724
+ <div style={{ ...panel, position: "absolute", left: 270, top: 190, width: 500, padding: "28px 32px", background: "rgba(0,0,0,0.92)", opacity: clamp01(frame, 110, 118) }}>
725
+ <p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, color: theme.white }}>{ready ? "✓ Your MP4 is ready" : "Rendering MP4"}</p>
726
+ <div style={{ marginTop: 20, height: 10, borderRadius: 5, background: "rgba(255,255,255,0.14)" }}>
727
+ <div style={{ width: `${(ready ? 1 : render) * 100}%`, height: "100%", borderRadius: 5, background: BLUE, boxShadow: `0 0 16px ${BLUE}` }} />
728
+ </div>
729
+ </div>
730
+ )}
731
+ </div>
732
+ </Win>
733
+ <Cursor
734
+ keys={[
735
+ { f: 0, x: 1560, y: 880 },
736
+ { f: 26, ...at(280, 373) },
737
+ { f: 38, ...at(280, 373) },
738
+ { f: 100, ...at(1450, 36) },
739
+ { f: 124, ...at(1450, 36) },
740
+ { f: 150, ...at(1250, 480) },
741
+ ]}
742
+ clicks={[32, 108]}
743
+ />
744
  </AbsoluteFill>
745
  );
746
  }
747
 
748
+ /** 07: the Launch Kit appears, then Launchpad schedules it across the week. */
749
+ function LaunchKit() {
750
  const frame = useCurrentFrame();
751
+ const { fps } = useVideoConfig();
752
+ const cards = [
753
+ { label: "X THREAD", body: "I doubled my price. My paid tier doubled too. Here is what I learned." },
754
+ { label: "LINKEDIN", body: "Most writers underprice. I did for two years. Then I ran an experiment." },
755
+ { label: "SUBSTACK NOTES", body: "Price is a signal. Readers told me so, with their wallets." },
756
+ { label: "BLUESKY", body: "Raised my price to $10. Paid readers went up, not down." },
757
+ ];
758
+ const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
759
+ const slots: Record<number, string> = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" };
760
  return (
761
  <AbsoluteFill>
762
+ <WarpField speed={0.9} count={260} />
763
+ <Caption eyebrow="07 · Launch" title="A week of posts, scheduled." />
764
+ <Win>
765
+ {cards.map((c, i) => {
766
+ const s = spring({ frame: frame - 8 - i * 7, fps, config: { damping: 14 } });
767
  return (
768
+ <div key={c.label} style={{ position: "absolute", left: 80 + i * 390, top: 30, width: 350, height: 230, padding: 26, borderRadius: 18, border: "1px solid rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.04)", opacity: s, transform: `translateY(${(1 - s) * 140}px) rotate(${interpolate(s, [0, 1], [(i - 1.5) * 10, 0])}deg)`, display: "grid", alignContent: "start", gap: 14 }}>
769
+ <span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
770
+ <span style={{ fontFamily: theme.body, fontSize: 26, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
771
  </div>
772
  );
773
  })}
774
+ <Label style={{ left: 80, top: 300 }}>Launchpad</Label>
775
+ {days.map((d, i) => {
776
+ const lit = frame >= 76 + i * 6;
777
+ const s = spring({ frame: frame - 76 - i * 6, fps, config: { damping: 12 } });
 
 
 
 
 
 
 
 
 
 
 
778
  return (
779
+ <div key={d} style={{ position: "absolute", left: 80 + i * 215, top: 345, width: 195, height: 130, borderRadius: 14, border: `1px solid ${lit ? BLUE : "rgba(255,255,255,0.14)"}`, background: lit ? `${BLUE}33` : "rgba(255,255,255,0.03)", boxShadow: lit ? `0 0 22px ${BLUE}88` : "none", padding: 14, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
780
+ {d}
781
+ {lit && (
782
+ <div style={{ marginTop: 22, padding: "8px 0", textAlign: "center", borderRadius: 999, background: theme.white, color: theme.black, fontFamily: theme.body, fontWeight: 600, fontSize: 21, transform: `scale(${0.6 + 0.4 * s})`, boxShadow: `0 0 14px ${BLUE}` }}>{slots[i]}</div>
783
  )}
784
+ </div>
 
 
 
 
 
 
 
 
 
785
  );
786
+ })}
787
+ <div style={{ position: "absolute", left: 80, top: 540, display: "flex", gap: 14 }}>
788
+ {["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
789
+ <span key={t} style={{ padding: "10px 18px", border: `1px solid ${BLUE}88`, borderRadius: 999, fontFamily: theme.mono, fontSize: 20, letterSpacing: 2, color: "rgba(255,255,255,0.75)", opacity: clamp01(frame, 40 + i * 6, 50 + i * 6) }}>
790
+ {t}
791
+ </span>
792
+ ))}
793
+ </div>
794
+ <div style={{ position: "absolute", left: 1300, top: 530, width: 280, height: 70, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 28, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 68)})`, boxShadow: `0 0 ${24 + pulse(frame, 68) * 40}px ${BLUE}` }}>
795
+ Schedule all
796
+ </div>
797
+ </Win>
798
+ <Cursor keys={[{ f: 0, x: 1560, y: 880 }, { f: 62, ...at(1440, 566) }, { f: 100, ...at(1440, 566) }, { f: 140, ...at(1100, 600) }]} clicks={[68]} />
799
  </AbsoluteFill>
800
  );
801
  }
 
823
  );
824
  }
825
 
826
+ const RENDER: Record<SceneId, () => JSX.Element> = {
827
  warp: Warp,
828
+ sync: Sync,
829
+ ask: Ask,
830
+ map: MindMap,
831
  audio: Audio,
832
+ video: VideoCreate,
833
+ edit: VideoEdit,
834
+ kit: LaunchKit,
835
  outro: Outro,
836
  };
837
 
 
840
  return (
841
  <AbsoluteFill style={{ backgroundColor: theme.black }}>
842
  <SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
843
+ {SCENES.map((scene) => {
844
  const from = cursor;
845
  cursor += S(scene.len - XFADE);
846
  const Scene = RENDER[scene.id];
 
849
  <FadeScene len={scene.len}>
850
  <Scene />
851
  </FadeScene>
852
+ {scene.vo !== undefined && (
853
+ <Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
854
+ <SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
855
+ </Sequence>
856
+ )}
857
  </Sequence>
858
  );
859
  })}
renderer/src/compositions/demoVo.ts CHANGED
@@ -1,2 +1,2 @@
1
  // Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
2
- export const DEMO_VO_SECONDS = {"warp": 3.92, "paste": 6.95, "orbit": 6.61, "research": 3.74, "audio": 2.67, "launchkit": 3.32, "launchpad": 4.05, "outro": 2.48} as const;
 
1
  // Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
2
+ export const DEMO_VO_SECONDS = {"warp": 3.61, "sync": 2.25, "map": 5.17, "kit": 2.95, "outro": 2.8} as const;