Arslan1997 commited on
Commit
0d81e80
·
1 Parent(s): 23cef78

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
frontend/src/pages/Landing.tsx CHANGED
@@ -18,6 +18,8 @@ import {
18
  } from "../components/icons/Icons";
19
 
20
  export const PENDING_SOURCE_KEY = "ns_pending_source";
 
 
21
 
22
  const FEATURES = [
23
  {
@@ -92,8 +94,8 @@ function DemoVideo() {
92
  <video
93
  ref={ref}
94
  className="demo-video"
95
- src="/demo.mp4"
96
- poster="/demo-poster.jpg"
97
  autoPlay
98
  muted
99
  loop
 
18
  } from "../components/icons/Icons";
19
 
20
  export const PENDING_SOURCE_KEY = "ns_pending_source";
21
+ // Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
22
+ const DEMO_VERSION = 2;
23
 
24
  const FEATURES = [
25
  {
 
94
  <video
95
  ref={ref}
96
  className="demo-video"
97
+ src={`/demo.mp4?v=${DEMO_VERSION}`}
98
+ poster={`/demo-poster.jpg?v=${DEMO_VERSION}`}
99
  autoPlay
100
  muted
101
  loop
renderer/public/launch-vo/durations.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "intro": 5.36,
3
+ "launch": 2.85,
4
+ "sources": 8.0,
5
+ "research": 4.94,
6
+ "map": 5.96,
7
+ "voice": 4.52,
8
+ "audio": 3.45,
9
+ "video": 6.61,
10
+ "launchkit": 6.38,
11
+ "launchpad": 5.65,
12
+ "resurface": 3.82,
13
+ "outro": 5.07
14
+ }
renderer/scripts/make_launch_music.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Launch trailer score + timeline.
2
+
3
+ Reads renderer/public/launch-vo/durations.json, lays scenes out on bar lines at 120 bpm, writes
4
+ renderer/src/compositions/launchTimeline.ts and renderer/public/launch-vo/music.wav.
5
+
6
+ Shape: uncharted intro (pads, heartbeat) -> launch impact -> driving groove through the feature
7
+ galaxies (four on the floor, sidechained pads, plucked arp, bass) -> two bar build -> huge hit on
8
+ "Coming soon" and a long shimmering tail. Pure synthesis, no noise sources.
9
+ """
10
+
11
+ import json
12
+ import math
13
+ import wave
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ ROOT = Path(__file__).resolve().parents[1]
19
+ DUR = json.loads((ROOT / "public/launch-vo/durations.json").read_text())
20
+
21
+ BPM = 120
22
+ BEAT = 60 / BPM
23
+ BAR = BEAT * 4
24
+ VO_START = {"intro": 1.0, "outro": 1.2}
25
+ MIN_BARS = {"intro": 4, "outro": 5}
26
+
27
+ order = ["intro", "launch", "sources", "research", "map", "voice", "audio", "video", "launchkit",
28
+ "launchpad", "resurface", "montage", "outro"]
29
+ scenes = []
30
+ cursor = 0.0
31
+ for sid in order:
32
+ if sid == "montage":
33
+ bars, vo, vo_len = 2, None, None
34
+ else:
35
+ vo = VO_START.get(sid, 0.35)
36
+ vo_len = DUR[sid]
37
+ bars = max(MIN_BARS.get(sid, 2), math.ceil((vo + vo_len + 0.45) / BAR))
38
+ scenes.append({"id": sid, "start": cursor, "len": bars * BAR, "vo": vo, "voLen": vo_len})
39
+ cursor += bars * BAR
40
+ TOTAL = cursor
41
+ bar_of = {s["id"]: round(s["start"] / BAR) for s in scenes}
42
+ print("total", TOTAL, bar_of)
43
+
44
+ ts = ["// Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.",
45
+ f"// {BPM} bpm; every scene starts on a bar line so cuts land on the beat.",
46
+ f"export const LAUNCH_BPM = {BPM};",
47
+ f"export const LAUNCH_SECONDS = {TOTAL};",
48
+ "export const LAUNCH_SCENES = ["]
49
+ for s in scenes:
50
+ vo = "null" if s["vo"] is None else s["vo"]
51
+ vl = "null" if s["voLen"] is None else s["voLen"]
52
+ ts.append(f' {{ id: "{s["id"]}", start: {s["start"]}, len: {s["len"]}, vo: {vo}, voLen: {vl} }},')
53
+ ts.append("] as const;")
54
+ ts.append("export type LaunchSceneId = (typeof LAUNCH_SCENES)[number][\"id\"];")
55
+ (ROOT / "src/compositions/launchTimeline.ts").write_text("\n".join(ts) + "\n")
56
+
57
+ # ---------------------------------------------------------------- synthesis
58
+
59
+ SR = 44100
60
+ n = int(SR * (TOTAL + 0.2))
61
+ t = np.arange(n) / SR
62
+ L = {k: np.zeros(n) for k in ("pad", "bass", "arp", "drums", "fx", "bell")}
63
+ R = {k: np.zeros(n) for k in L}
64
+ rng = np.random.default_rng(11)
65
+
66
+
67
+ def hz(m: float) -> float:
68
+ return 440.0 * 2 ** ((m - 69) / 12)
69
+
70
+
71
+ def add(bus: str, i0: int, sig: np.ndarray, pan: float = 0.0) -> None:
72
+ if i0 >= n:
73
+ return
74
+ sig = sig[: n - i0]
75
+ L[bus][i0:i0 + len(sig)] += sig * (1 - pan)
76
+ R[bus][i0:i0 + len(sig)] += sig * (1 + pan)
77
+
78
+
79
+ def at(seconds: float) -> int:
80
+ return int(seconds * SR)
81
+
82
+
83
+ def bar_t(b: float) -> float:
84
+ return b * BAR
85
+
86
+
87
+ INTRO_END = bar_of["launch"]
88
+ GROOVE_START = bar_of["sources"]
89
+ BUILD = bar_of["montage"]
90
+ OUTRO = bar_of["outro"]
91
+ END_BAR = TOTAL / BAR
92
+
93
+ # I vi IV V in D, two bars each; outro resolves on a wide Dadd9.
94
+ PROG = [[50, 57, 62, 66, 69], [47, 54, 59, 62, 66], [43, 50, 55, 59, 62], [45, 52, 57, 61, 64]]
95
+ INTRO_PROG = [[50, 57, 64, 66, 69], [47, 54, 61, 62, 66]]
96
+ OUTRO_CHORD = [38, 50, 57, 62, 64, 66, 69, 74]
97
+
98
+
99
+ def chord_at(b: float) -> list[int]:
100
+ if b < INTRO_END:
101
+ return INTRO_PROG[int(b // 2) % 2]
102
+ if b >= OUTRO:
103
+ return OUTRO_CHORD[1:6]
104
+ return PROG[int((b - INTRO_END) // 2) % 4]
105
+
106
+
107
+ # Pads: detuned soft saws per two bar chord, slow attack.
108
+ def pad(notes, start, dur, gain, bright):
109
+ i0 = at(start)
110
+ length = at(dur + 2.5)
111
+ tt = np.arange(length) / SR
112
+ a = np.clip(tt / 1.2, 0, 1)
113
+ r = np.clip((dur + 2.5 - tt) / 2.5, 0, 1)
114
+ e = (0.5 - 0.5 * np.cos(np.pi * a)) * (0.5 - 0.5 * np.cos(np.pi * r))
115
+ for k, m in enumerate(notes):
116
+ tone = np.zeros(length)
117
+ for cents, ph in ((-8, 0.0), (8, 1.7)):
118
+ f = hz(m) * 2 ** (cents / 1200)
119
+ for h in range(1, 8):
120
+ tone += np.sin(2 * np.pi * f * h * tt + ph * h) * bright ** (h - 1) / h ** 1.3
121
+ add("pad", i0, tone * e * gain, pan=(k - 2) * 0.25)
122
+
123
+
124
+ b = 0.0
125
+ while b < END_BAR:
126
+ notes = chord_at(b)
127
+ if b >= OUTRO:
128
+ pad(OUTRO_CHORD, bar_t(b), END_BAR * BAR - bar_t(b) - 3.0, 0.05, 0.5)
129
+ break
130
+ bright = 0.3 if b < INTRO_END else 0.42 + 0.1 * (b - GROOVE_START) / max(1, BUILD - GROOVE_START)
131
+ pad(notes, bar_t(b), BAR * 2, 0.05 if b < INTRO_END else 0.042, bright)
132
+ b += 2
133
+
134
+
135
+ # Kick: pitched sine sweep + tiny click.
136
+ def kick(start, gain=1.0):
137
+ length = at(0.45)
138
+ tt = np.arange(length) / SR
139
+ f = 44 + 90 * np.exp(-tt * 28)
140
+ body = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 7.5)
141
+ click = np.sin(2 * np.pi * 1800 * tt) * np.exp(-tt * 400) * 0.25
142
+ add("drums", at(start), (body + click) * 0.5 * gain)
143
+
144
+
145
+ # Hat: inharmonic metallic partials, very short.
146
+ def hat(start, gain=1.0, pan=0.25):
147
+ length = at(0.07)
148
+ tt = np.arange(length) / SR
149
+ sig = sum(np.sin(2 * np.pi * f * tt + p) for f, p in ((6230, 0.1), (7310, 1.2), (8420, 2.3), (9870, 0.7), (11200, 1.9)))
150
+ add("drums", at(start), sig * np.exp(-tt * 70) * 0.018 * gain, pan)
151
+
152
+
153
+ # Snare/tom tick for the build: short tone burst with a pitch drop.
154
+ def tom(start, pitch, gain):
155
+ length = at(0.16)
156
+ tt = np.arange(length) / SR
157
+ f = pitch * (1 + 0.6 * np.exp(-tt * 40))
158
+ sig = np.sin(2 * np.pi * np.cumsum(f) / SR) + 0.4 * np.sin(2 * np.pi * np.cumsum(f * 2.3) / SR)
159
+ add("drums", at(start), sig * np.exp(-tt * 22) * gain * 0.2, pan=rng.uniform(-0.3, 0.3))
160
+
161
+
162
+ kicks: list[float] = []
163
+ # Heartbeat in the intro's second half.
164
+ for bb in range(2, INTRO_END):
165
+ for off in (0, BEAT * 0.75):
166
+ kick(bar_t(bb) + off, 0.35 if off else 0.5)
167
+ kicks.append(bar_t(bb) + off)
168
+ # Launch: kick on beat one of bar 1, quarters on bar 2.
169
+ kick(bar_t(INTRO_END), 1.2)
170
+ kicks.append(bar_t(INTRO_END))
171
+ for q in range(4):
172
+ kick(bar_t(INTRO_END + 1) + q * BEAT, 0.7)
173
+ kicks.append(bar_t(INTRO_END + 1) + q * BEAT)
174
+ # Groove: four on the floor with offbeat hats and 16th ghost hats.
175
+ for bb in range(GROOVE_START, BUILD):
176
+ for q in range(4):
177
+ s = bar_t(bb) + q * BEAT
178
+ kick(s, 0.85)
179
+ kicks.append(s)
180
+ hat(s + BEAT / 2, 1.0)
181
+ hat(s + BEAT / 4, 0.35, -0.25)
182
+ hat(s + BEAT * 3 / 4, 0.35, -0.25)
183
+ # Build: toms accelerating and rising, no kick.
184
+ bs = bar_t(BUILD)
185
+ steps = [(0, 8, 8), (1, 16, 16)]
186
+ for bar_i, per_bar, _ in steps:
187
+ for k in range(per_bar):
188
+ s = bs + bar_i * BAR + k * BAR / per_bar
189
+ prog = (s - bs) / (2 * BAR)
190
+ tom(s, 140 + 160 * prog, 0.4 + 0.8 * prog)
191
+ # Outro hit.
192
+ kick(bar_t(OUTRO), 1.5)
193
+ kicks.append(bar_t(OUTRO))
194
+
195
+ # Sidechain envelope: pads, bass and arp duck briefly after each kick.
196
+ side = np.ones(n)
197
+ for k in kicks:
198
+ i0 = at(k)
199
+ length = min(n - i0, at(0.4))
200
+ if length <= 0:
201
+ continue
202
+ tt = np.arange(length) / SR
203
+ side[i0:i0 + length] = np.minimum(side[i0:i0 + length], 1 - 0.55 * np.exp(-tt * 9))
204
+
205
+ # Bass: offbeat 8ths on the chord root through the groove, long root in the launch bars.
206
+ def bass_note(m, start, dur, gain):
207
+ length = at(dur + 0.05)
208
+ tt = np.arange(length) / SR
209
+ f = hz(m)
210
+ sig = np.sin(2 * np.pi * f * tt) + 0.35 * np.sin(2 * np.pi * 2 * f * tt) + 0.12 * np.sin(2 * np.pi * 3 * f * tt)
211
+ e = np.clip(tt / 0.008, 0, 1) * np.clip((dur + 0.05 - tt) / 0.05, 0, 1) * np.exp(-tt * 1.5)
212
+ add("bass", at(start), sig * e * gain)
213
+
214
+
215
+ bass_note(chord_at(INTRO_END)[0] - 12, bar_t(INTRO_END), BAR * 2 - 0.1, 0.22)
216
+ for bb in range(GROOVE_START, BUILD):
217
+ root = chord_at(bb)[0] - 12
218
+ for e8 in range(8):
219
+ if e8 % 2 == 1 or e8 == 0:
220
+ bass_note(root + (12 if e8 == 7 else 0), bar_t(bb) + e8 * BEAT / 2, BEAT / 2 - 0.03, 0.2)
221
+ # Build: sustained root, rising an octave by sliding in steps.
222
+ for k in range(8):
223
+ bass_note(chord_at(BUILD)[0] - 12 + k, bs + k * BAR / 4, BAR / 4 - 0.02, 0.12 + 0.02 * k)
224
+ # Outro: deep sustained sub.
225
+ bass_note(38 - 12, bar_t(OUTRO), 6.0, 0.3)
226
+
227
+
228
+ # Plucked arp: 16ths from the launch on, pattern up the chord, brightness grows.
229
+ def pluck(m, start, gain, bright, pan):
230
+ length = at(0.6)
231
+ tt = np.arange(length) / SR
232
+ f = hz(m)
233
+ sig = np.zeros(length)
234
+ for h in range(1, 7):
235
+ sig += np.sin(2 * np.pi * f * h * tt) * np.exp(-tt * (6 + h * h * 2.5 / bright)) / h
236
+ sig *= np.clip(tt / 0.003, 0, 1)
237
+ add("arp", at(start), sig * gain, pan)
238
+
239
+
240
+ k = 0
241
+ s = bar_t(INTRO_END)
242
+ end_arp = bar_t(OUTRO) + BAR * 2
243
+ while s < end_arp:
244
+ b_now = s / BAR
245
+ notes = chord_at(b_now)
246
+ pattern = [notes[1], notes[2], notes[3], notes[4], notes[3] + 12, notes[4], notes[2] + 12, notes[3]]
247
+ m = pattern[k % len(pattern)] + 12
248
+ if b_now < GROOVE_START:
249
+ gain, bright, every = 0.05, 0.7, 2
250
+ elif b_now < BUILD:
251
+ gain, bright, every = 0.06, 0.8 + 0.6 * (b_now - GROOVE_START) / max(1, BUILD - GROOVE_START), 1
252
+ elif b_now < OUTRO:
253
+ gain, bright, every = 0.07, 1.6 + (b_now - BUILD), 1
254
+ else:
255
+ gain, bright, every = 0.05 * max(0, 1 - (b_now - OUTRO) / 2), 1.4, 2
256
+ if k % every == 0:
257
+ pluck(m, s, gain * (1.0 if k % 4 == 0 else 0.75), bright, 0.45 * math.sin(k * 0.8))
258
+ s += BEAT / 4
259
+ k += 1
260
+
261
+
262
+ # Bells: sparse in the intro, a cascade on the outro hit.
263
+ def bell(m, start, gain, pan=0.0, decay=1.6):
264
+ length = at(3.0)
265
+ tt = np.arange(length) / SR
266
+ f = hz(m)
267
+ sig = np.sin(2 * np.pi * f * tt) + 0.35 * np.sin(2 * np.pi * f * 2.76 * tt) * np.exp(-tt * 4)
268
+ sig += 0.12 * np.sin(2 * np.pi * f * 5.4 * tt) * np.exp(-tt * 8)
269
+ add("bell", at(start), sig * np.exp(-tt * decay) * np.clip(tt / 0.004, 0, 1) * gain, pan)
270
+
271
+
272
+ for i, m in enumerate([81, 76, 78, 74, 81, 83]):
273
+ bell(m, 0.8 + i * 1.2, 0.05, 0.4 * math.sin(i * 1.3))
274
+ for i, m in enumerate([74, 78, 81, 86, 90, 93, 98]):
275
+ bell(m, bar_t(OUTRO) + i * BEAT / 2, 0.06, 0.5 * math.sin(i), decay=1.1)
276
+
277
+
278
+ # FX: risers into the launch and the outro, soft swells into each feature scene, impacts.
279
+ def riser(end, dur, gain, top=2400):
280
+ length = at(dur)
281
+ tt = np.arange(length) / SR
282
+ p = tt / dur
283
+ f = 180 * (top / 180) ** p
284
+ sig = np.zeros(length)
285
+ for d in (-5, 0, 7):
286
+ sig += np.sin(2 * np.pi * np.cumsum(f * 2 ** (d / 1200)) / SR)
287
+ sig *= p ** 2 * gain
288
+ add("fx", at(end - dur), sig)
289
+
290
+
291
+ def impact(start, gain):
292
+ length = at(3.5)
293
+ tt = np.arange(length) / SR
294
+ boom = np.sin(2 * np.pi * np.cumsum(30 + 60 * np.exp(-tt * 6)) / SR) * np.exp(-tt * 1.4)
295
+ shimmer = sum(np.sin(2 * np.pi * f * tt) * np.exp(-tt * 2.2) for f in (1174.7, 1480, 1760, 2349)) * 0.05
296
+ add("fx", at(start), (boom * 0.6 + shimmer) * gain)
297
+
298
+
299
+ riser(bar_t(INTRO_END), BAR * 1.5, 0.06)
300
+ impact(bar_t(INTRO_END), 0.9)
301
+ for sc in scenes:
302
+ sb = round(sc["start"] / BAR)
303
+ if GROOVE_START < sb < BUILD:
304
+ riser(sc["start"], BEAT * 2, 0.025, top=1600)
305
+ riser(bar_t(OUTRO), BAR * 2, 0.09, top=3200)
306
+ impact(bar_t(OUTRO), 1.3)
307
+
308
+ # ---------------------------------------------------------------- mix
309
+
310
+ for bus in ("pad", "bass", "arp"):
311
+ L[bus] *= side
312
+ R[bus] *= side
313
+
314
+ # Ping pong delay on the arp and bells (dotted eighth).
315
+ delay = at(BEAT * 0.75)
316
+ for bus in ("arp", "bell"):
317
+ dl, dr = np.zeros(n), np.zeros(n)
318
+ for i in range(4):
319
+ shift = delay * (i + 1)
320
+ g = 0.35 ** (i + 1)
321
+ src_l, src_r = (R[bus], L[bus]) if i % 2 == 0 else (L[bus], R[bus])
322
+ dl[shift:] += src_l[:-shift] * g
323
+ dr[shift:] += src_r[:-shift] * g
324
+ L[bus] += dl * 0.6
325
+ R[bus] += dr * 0.6
326
+
327
+ # Reverb (smooth partial-based IR, no noise).
328
+ ir_len = at(3.4)
329
+ it = np.arange(ir_len) / SR
330
+ irl, irr = np.zeros(ir_len), np.zeros(ir_len)
331
+ for f in rng.uniform(200, 2600, 100):
332
+ ph = rng.uniform(0, 2 * np.pi, 2)
333
+ d = np.exp(-it * rng.uniform(1.0, 2.0))
334
+ irl += np.sin(2 * np.pi * f * it + ph[0]) * d
335
+ irr += np.sin(2 * np.pi * f * it + ph[1]) * d
336
+ irl /= np.max(np.abs(irl))
337
+ irr /= np.max(np.abs(irr))
338
+
339
+
340
+ def convolve(x, ir):
341
+ size = 1 << int(np.ceil(np.log2(len(x) + len(ir))))
342
+ return np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(ir, size), size)[: len(x)]
343
+
344
+
345
+ def norm(x):
346
+ return x / (np.max(np.abs(x)) + 1e-9)
347
+
348
+
349
+ LEVEL = {"pad": 0.55, "bass": 0.7, "arp": 0.55, "drums": 0.8, "fx": 0.6, "bell": 0.5}
350
+ SEND = {"pad": 0.5, "arp": 0.35, "bell": 0.7, "fx": 0.5, "drums": 0.05, "bass": 0.0}
351
+ ml, mr = np.zeros(n), np.zeros(n)
352
+ send_l, send_r = np.zeros(n), np.zeros(n)
353
+ peak = max(np.max(np.abs(L[b])) + np.max(np.abs(R[b])) for b in L) / 2
354
+ for bus in L:
355
+ gl, gr = L[bus] / peak * LEVEL[bus], R[bus] / peak * LEVEL[bus]
356
+ ml += gl
357
+ mr += gr
358
+ send_l += gl * SEND[bus]
359
+ send_r += gr * SEND[bus]
360
+ wet_l = norm(convolve(send_l, irl)) * np.max(np.abs(send_l)) * 0.9
361
+ wet_r = norm(convolve(send_r, irr)) * np.max(np.abs(send_r)) * 0.9
362
+ ml += wet_l
363
+ mr += wet_r
364
+
365
+ master = np.minimum(1, t / 1.5) * np.clip((TOTAL + 0.2 - t) / 3.5, 0, 1)
366
+ ml *= master
367
+ mr *= master
368
+ pk = max(np.max(np.abs(ml)), np.max(np.abs(mr)))
369
+ ml = np.tanh(ml / pk * 1.4) * 0.85
370
+ mr = np.tanh(mr / pk * 1.4) * 0.85
371
+ out = ROOT / "public/launch-vo/music.wav"
372
+ with wave.open(str(out), "wb") as w:
373
+ w.setnchannels(2)
374
+ w.setsampwidth(2)
375
+ w.setframerate(SR)
376
+ w.writeframes((np.stack([ml, mr], axis=1) * 32767).astype("<i2").tobytes())
377
+ print("music ok", out, round(TOTAL, 2), "s")
renderer/scripts/make_launch_vo.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Narration for the launch trailer (Nora Vale, Mission Control). Writes public/launch-vo/*.mp3 + durations.json.
2
+
3
+ Run from backend/ (uses its ElevenLabs client and .env), then run make_launch_music.py:
4
+ cd backend && PYTHONPATH=. .venv/Scripts/python ../renderer/scripts/make_launch_vo.py
5
+ """
6
+
7
+ import json
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from app.services import tts
12
+
13
+ OUT = Path(__file__).resolve().parents[1] / "public" / "launch-vo"
14
+ OUT.mkdir(parents=True, exist_ok=True)
15
+
16
+ LINES = {
17
+ "intro": "Every writer carries a universe of ideas. Most of it... still uncharted.",
18
+ "launch": "So we built a ship. Let's go exploring.",
19
+ "sources": "First stop: your sources. Any blog, newsletter, website, or markdown file, pulled into orbit.",
20
+ "research": "Ask your archive anything. Every answer is traced to the exact lines you wrote.",
21
+ "map": "Chart the constellations in your thinking. See what's rising, and what has gone dark.",
22
+ "voice": "Teach it your voice, so everything it makes still sounds like you.",
23
+ "audio": "Turn any notebook into a two host audio overview.",
24
+ "video": "Shorts, explainers, audiograms and quote cards, all rendered from your own words.",
25
+ "launchkit": "One post becomes a thread, a LinkedIn post, notes, an SEO pack and a carousel.",
26
+ "launchpad": "Schedule everything, and post straight to X, LinkedIn and Bluesky.",
27
+ "resurface": "And your best old posts? We bring them back into orbit.",
28
+ "outro": "Notestack A.I. The galaxies of your untapped potential. Coming soon.",
29
+ }
30
+
31
+ voice_id = next(v["voice_id"] for v in tts.list_voices() if v["name"].startswith("Nora Vale"))
32
+ # A touch more energy than the landing demo: this one is a trailer.
33
+ vs = tts.VoiceSettings(stability=0.5, similarity_boost=0.8, style=0.35, speed=0.95)
34
+ ws = uuid.UUID(int=0)
35
+ keys = list(LINES)
36
+ durations = {}
37
+ for i, key in enumerate(keys):
38
+ prev_text = LINES[keys[i - 1]] if i else None
39
+ next_text = LINES[keys[i + 1]] if i + 1 < len(keys) else None
40
+ clip = tts.synthesize(ws, LINES[key], voice_id, vs, prev_text, next_text)
41
+ (OUT / f"{key}.mp3").write_bytes(clip)
42
+ durations[key] = round(tts.mp3_seconds(clip), 2)
43
+ (OUT / "durations.json").write_text(json.dumps(durations, indent=2))
44
+ print(json.dumps(durations))
renderer/src/Root.tsx CHANGED
@@ -3,6 +3,7 @@ import { AudiogramSquare, audiogramDuration } from "./compositions/AudiogramSqua
3
  import { CarouselSlide } from "./compositions/CarouselSlide";
4
  import { ExplainerLong, explainerDuration } from "./compositions/ExplainerLong";
5
  import { DEMO_DURATION, DEMO_FPS, LandingDemo } from "./compositions/LandingDemo";
 
6
  import { QuoteCard } from "./compositions/QuoteCard";
7
  import { ShortVertical, shortVerticalDuration } from "./compositions/ShortVertical";
8
  import {
@@ -43,6 +44,7 @@ const sampleAudiogram: AudiogramSquareProps = {
43
  export function Root() {
44
  return (
45
  <>
 
46
  <Composition id="LandingDemo" component={LandingDemo} width={1920} height={1080} fps={DEMO_FPS} durationInFrames={DEMO_DURATION} />
47
  <Composition
48
  id="ShortVertical"
 
3
  import { CarouselSlide } from "./compositions/CarouselSlide";
4
  import { ExplainerLong, explainerDuration } from "./compositions/ExplainerLong";
5
  import { DEMO_DURATION, DEMO_FPS, LandingDemo } from "./compositions/LandingDemo";
6
+ import { LAUNCH_DURATION, LAUNCH_FPS, LaunchTrailer } from "./compositions/LaunchTrailer";
7
  import { QuoteCard } from "./compositions/QuoteCard";
8
  import { ShortVertical, shortVerticalDuration } from "./compositions/ShortVertical";
9
  import {
 
44
  export function Root() {
45
  return (
46
  <>
47
+ <Composition id="LaunchTrailer" component={LaunchTrailer} width={1920} height={1080} fps={LAUNCH_FPS} durationInFrames={LAUNCH_DURATION} />
48
  <Composition id="LandingDemo" component={LandingDemo} width={1920} height={1080} fps={DEMO_FPS} durationInFrames={DEMO_DURATION} />
49
  <Composition
50
  id="ShortVertical"
renderer/src/compositions/LandingDemo.tsx CHANGED
@@ -62,7 +62,7 @@ const ease = Easing.bezier(0.22, 1, 0.36, 1);
62
  // Shared pieces
63
 
64
  /** 3D starfield flying toward the camera. `speed` can ramp for warp streaks. */
65
- function WarpField({ speed, count = 420, streak = 0 }: { speed: number; count?: number; streak?: number }) {
66
  const frame = useCurrentFrame();
67
  const { width, height } = useVideoConfig();
68
  const cx = width / 2;
@@ -103,7 +103,7 @@ function WarpField({ speed, count = 420, streak = 0 }: { speed: number; count?:
103
  }
104
 
105
  /** A diagonal light flare that sweeps across, with a hot core and anamorphic streak. */
106
- function Flare({ at, duration = 24, y = 50 }: { at: number; duration?: number; y?: number }) {
107
  const frame = useCurrentFrame();
108
  const p = interpolate(frame, [at, at + duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
109
  if (p <= 0 || p >= 1) return null;
 
62
  // Shared pieces
63
 
64
  /** 3D starfield flying toward the camera. `speed` can ramp for warp streaks. */
65
+ export function WarpField({ speed, count = 420, streak = 0 }: { speed: number; count?: number; streak?: number }) {
66
  const frame = useCurrentFrame();
67
  const { width, height } = useVideoConfig();
68
  const cx = width / 2;
 
103
  }
104
 
105
  /** A diagonal light flare that sweeps across, with a hot core and anamorphic streak. */
106
+ export function Flare({ at, duration = 24, y = 50 }: { at: number; duration?: number; y?: number }) {
107
  const frame = useCurrentFrame();
108
  const p = interpolate(frame, [at, at + duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease });
109
  if (p <= 0 || p >= 1) return null;
renderer/src/compositions/LaunchTrailer.tsx ADDED
@@ -0,0 +1,706 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import { theme } from "../theme";
4
+ import { Flare, WarpField } from "./LandingDemo";
5
+ import { LAUNCH_BPM, LAUNCH_SCENES, LAUNCH_SECONDS, type LaunchSceneId } from "./launchTimeline";
6
+
7
+ /** Launch trailer: a journey of knowledge through the galaxies of untapped potential.
8
+ * One galaxy per feature, cut on the beat (see launchTimeline.ts, generated with the score),
9
+ * narrated by Nora Vale, ending on "Notestack AI. Coming soon." 1920x1080, 30 fps. */
10
+
11
+ export const LAUNCH_FPS = 30;
12
+ const S = (seconds: number) => Math.round(seconds * LAUNCH_FPS);
13
+ export const LAUNCH_DURATION = S(LAUNCH_SECONDS);
14
+
15
+ const BLUE = theme.blue;
16
+ const WHITE = theme.white;
17
+ const BEAT = 60 / LAUNCH_BPM;
18
+ const XF = 0.3; // each scene fades in over the tail of the previous one
19
+ const ease = Easing.bezier(0.22, 1, 0.36, 1);
20
+ const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
21
+
22
+ // ---------------------------------------------------------------- audio
23
+
24
+ const VO_WINDOWS: [number, number][] = LAUNCH_SCENES.filter((s) => s.vo !== null).map((s) => [
25
+ S(s.start + (s.vo ?? 0)),
26
+ S(s.start + (s.vo ?? 0) + (s.voLen ?? 0)),
27
+ ]);
28
+ const MUSIC = 0.62;
29
+ const DUCKED = 0.24;
30
+
31
+ function musicVolume(frame: number): number {
32
+ const ramp = 8;
33
+ let level = MUSIC;
34
+ for (const [a, b] of VO_WINDOWS) {
35
+ if (frame >= a - ramp && frame <= b + ramp) {
36
+ const into = interpolate(frame, [a - ramp, a, b, b + ramp], [0, 1, 1, 0], clamp);
37
+ level = Math.min(level, MUSIC - (MUSIC - DUCKED) * into);
38
+ }
39
+ }
40
+ return level;
41
+ }
42
+
43
+ // ---------------------------------------------------------------- shared pieces
44
+
45
+ /** 1 on each beat, decaying quickly. Scenes start on bar lines, so local time is beat aligned. */
46
+ function usePulse(sharp = 8) {
47
+ const frame = useCurrentFrame();
48
+ const t = frame / LAUNCH_FPS;
49
+ return Math.exp(-(t % BEAT) * sharp);
50
+ }
51
+
52
+ /** A spiral galaxy with a white core; rotates slowly, drifts toward the camera over the scene. */
53
+ function Galaxy({ seed, x, y, size, tilt = 0.55, arms = 2, spin = 1, coreBoost = 0 }: {
54
+ seed: string; x: number; y: number; size: number; tilt?: number; arms?: number; spin?: number; coreBoost?: number;
55
+ }) {
56
+ const frame = useCurrentFrame();
57
+ const rot = frame * 0.0035 * spin + random(`${seed}-rot`) * 6;
58
+ const count = 520;
59
+ const stars = [];
60
+ for (let i = 0; i < count; i++) {
61
+ const arm = i % arms;
62
+ const u = Math.sqrt(random(`${seed}-u${i}`));
63
+ const r = u * size;
64
+ const theta = u * 5.2 + (arm * Math.PI * 2) / arms + rot + (random(`${seed}-j${i}`) - 0.5) * 0.7 * (1 - u * 0.4);
65
+ const spread = (random(`${seed}-s${i}`) - 0.5) * size * 0.12;
66
+ const px = Math.cos(theta) * (r + spread);
67
+ const py = Math.sin(theta) * (r + spread) * tilt;
68
+ const near = 1 - u;
69
+ const fill = near > 0.55 ? WHITE : random(`${seed}-c${i}`) < 0.55 ? BLUE : "#9cc4ff";
70
+ stars.push(<circle key={i} cx={px} cy={py} r={0.6 + random(`${seed}-r${i}`) * 1.8 * (0.5 + near)} fill={fill} opacity={0.35 + 0.65 * random(`${seed}-o${i}`)} />);
71
+ }
72
+ const core = 0.85 + coreBoost;
73
+ return (
74
+ <svg style={{ position: "absolute", left: x - size * 1.3, top: y - size * 1.3, overflow: "visible" }} width={size * 2.6} height={size * 2.6}>
75
+ <defs>
76
+ <radialGradient id={`${seed}-halo`}>
77
+ <stop offset="0%" stopColor={BLUE} stopOpacity={0.35} />
78
+ <stop offset="100%" stopColor={BLUE} stopOpacity={0} />
79
+ </radialGradient>
80
+ <radialGradient id={`${seed}-core`}>
81
+ <stop offset="0%" stopColor={WHITE} stopOpacity={Math.min(1, core)} />
82
+ <stop offset="18%" stopColor={WHITE} stopOpacity={0.45 * core} />
83
+ <stop offset="45%" stopColor="#9cc4ff" stopOpacity={0.12 * core} />
84
+ <stop offset="100%" stopColor={BLUE} stopOpacity={0} />
85
+ </radialGradient>
86
+ </defs>
87
+ <g transform={`translate(${size * 1.3} ${size * 1.3})`}>
88
+ <ellipse rx={size * 1.25} ry={size * 1.25 * tilt} fill={`url(#${seed}-halo)`} />
89
+ {stars}
90
+ <ellipse rx={size * 0.42} ry={size * 0.42 * Math.max(tilt, 0.7)} fill={`url(#${seed}-core)`} />
91
+ <circle r={size * 0.035} fill={WHITE} style={{ filter: `drop-shadow(0 0 ${size * 0.06}px ${WHITE}) drop-shadow(0 0 ${size * 0.15}px ${BLUE})` }} />
92
+ </g>
93
+ </svg>
94
+ );
95
+ }
96
+
97
+ /** Deep space backdrop: drifting stars and a faint far galaxy. */
98
+ function Space({ drift = 1, children }: { drift?: number; children?: ReactNode }) {
99
+ const frame = useCurrentFrame();
100
+ const { width, height } = useVideoConfig();
101
+ const t = frame / LAUNCH_FPS;
102
+ return (
103
+ <AbsoluteFill style={{ backgroundColor: theme.black, overflow: "hidden" }}>
104
+ <AbsoluteFill style={{ background: `radial-gradient(50% 40% at 70% 50%, ${BLUE}1f, transparent 70%), radial-gradient(35% 30% at 15% 85%, ${BLUE}14, transparent 70%)` }} />
105
+ <svg width={width} height={height} style={{ position: "absolute" }}>
106
+ {Array.from({ length: 260 }).map((_, i) => {
107
+ const depth = 0.3 + random(`d${i}`) * 0.7;
108
+ const x = (((random(`x${i}`) * width - t * 22 * depth * drift) % width) + width) % width;
109
+ const y = random(`y${i}`) * height;
110
+ const tw = 0.4 + 0.6 * Math.abs(Math.sin(t * (0.5 + random(`p${i}`)) + i));
111
+ return <circle key={i} cx={x} cy={y} r={0.4 + depth * 1.4} fill={random(`b${i}`) < 0.2 ? "#9cc4ff" : WHITE} opacity={tw * depth} />;
112
+ })}
113
+ </svg>
114
+ {children}
115
+ </AbsoluteFill>
116
+ );
117
+ }
118
+
119
+ const panel: CSSProperties = {
120
+ border: "1px solid rgba(255,255,255,0.16)",
121
+ borderRadius: 24,
122
+ background: "rgba(0,0,0,0.7)",
123
+ boxShadow: `0 0 60px ${BLUE}33, 0 0 2px ${BLUE}`,
124
+ backdropFilter: "blur(2px)",
125
+ };
126
+
127
+ function useIn(delay: number, damping = 16) {
128
+ const frame = useCurrentFrame();
129
+ const { fps } = useVideoConfig();
130
+ return spring({ frame: frame - delay, fps, config: { damping } });
131
+ }
132
+
133
+ function typed(text: string, frame: number, start: number, cps = 30): string {
134
+ return text.slice(0, Math.max(0, Math.floor(((frame - start) / LAUNCH_FPS) * cps)));
135
+ }
136
+
137
+ // ---------------------------------------------------------------- journey HUD
138
+
139
+ const STOPS: { id: LaunchSceneId; short: string; name: string; line: string }[] = [
140
+ { id: "sources", short: "Sources", name: "The Source Nebula", line: "Any blog, newsletter, website or markdown, pulled into orbit." },
141
+ { id: "research", short: "Research", name: "The Research Cluster", line: "Answers cited to the exact lines you wrote." },
142
+ { id: "map", short: "Topic map", name: "The Constellation Field", line: "See which ideas are rising, and which went dark." },
143
+ { id: "voice", short: "Voice", name: "The Voice Pulsar", line: "Everything it makes still sounds like you." },
144
+ { id: "audio", short: "Audio", name: "The Audio Belt", line: "Two host audio overviews of any notebook." },
145
+ { id: "video", short: "Studio", name: "The Studio Galaxy", line: "Shorts, explainers, audiograms and quote cards." },
146
+ { id: "launchkit", short: "Launch Kit", name: "The Launch Kit Spiral", line: "Threads, LinkedIn, Notes, SEO and carousels." },
147
+ { id: "launchpad", short: "Launchpad", name: "The Launchpad", line: "Schedule and auto post to X, LinkedIn and Bluesky." },
148
+ { id: "resurface", short: "Resurface", name: "The Evergreen Belt", line: "Your best old posts, back in orbit." },
149
+ ];
150
+
151
+ function Hud({ index }: { index: number }) {
152
+ const frame = useCurrentFrame();
153
+ const pulse = usePulse(10);
154
+ const inn = useIn(2, 20);
155
+ const travel = interpolate(frame, [0, 24], [index - 1, index], { ...clamp, easing: ease });
156
+ const x0 = 360;
157
+ const x1 = 1560;
158
+ const px = (i: number) => x0 + ((x1 - x0) * i) / (STOPS.length - 1);
159
+ return (
160
+ <AbsoluteFill style={{ opacity: inn }}>
161
+ <div style={{ position: "absolute", left: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: "rgba(255,255,255,0.6)" }}>
162
+ NOTESTACK EXPEDITION
163
+ </div>
164
+ <div style={{ position: "absolute", right: 80, top: 56, fontFamily: theme.mono, fontSize: 20, letterSpacing: 5, color: BLUE }}>
165
+ GALAXY {String(index + 1).padStart(2, "0")} / {String(STOPS.length).padStart(2, "0")}
166
+ </div>
167
+ <svg width={1920} height={1080} style={{ position: "absolute" }}>
168
+ <line x1={x0} y1={990} x2={x1} y2={990} stroke="rgba(255,255,255,0.18)" strokeWidth={2} strokeDasharray="6 8" />
169
+ <line x1={x0} y1={990} x2={px(travel)} y2={990} stroke={BLUE} strokeWidth={3} style={{ filter: `drop-shadow(0 0 6px ${BLUE})` }} />
170
+ {STOPS.map((s, i) => {
171
+ const done = i <= index;
172
+ const here = i === index;
173
+ return (
174
+ <g key={s.id}>
175
+ <circle cx={px(i)} cy={990} r={here ? 9 + pulse * 3 : 6} fill={done ? (here ? WHITE : BLUE) : "#000"} stroke={done ? BLUE : "rgba(255,255,255,0.35)"} strokeWidth={2}
176
+ style={here ? { filter: `drop-shadow(0 0 10px ${WHITE}) drop-shadow(0 0 18px ${BLUE})` } : undefined} />
177
+ <text x={px(i)} y={1030} textAnchor="middle" fontFamily={theme.mono} fontSize={15} letterSpacing={2} fill={here ? WHITE : done ? "rgba(156,196,255,0.8)" : "rgba(255,255,255,0.35)"}>
178
+ {s.short.toUpperCase()}
179
+ </text>
180
+ </g>
181
+ );
182
+ })}
183
+ <circle cx={px(travel)} cy={990} r={4} fill={WHITE} />
184
+ </svg>
185
+ </AbsoluteFill>
186
+ );
187
+ }
188
+
189
+ /** Frame for every feature stop: galaxy flying in on the right, name and line on the left, the visual on top. */
190
+ function Stop({ id, children }: { id: LaunchSceneId; children: ReactNode }) {
191
+ const frame = useCurrentFrame();
192
+ const { durationInFrames } = useVideoConfig();
193
+ const index = STOPS.findIndex((s) => s.id === id);
194
+ const stop = STOPS[index];
195
+ const pulse = usePulse(9);
196
+ const approach = interpolate(frame, [0, durationInFrames], [0.55, 1.08], { easing: Easing.out(Easing.quad) });
197
+ const eyebrow = useIn(4, 18);
198
+ const name = useIn(8, 15);
199
+ const line = useIn(16, 20);
200
+ const visual = useIn(10, 17);
201
+ return (
202
+ <Space drift={1.4}>
203
+ <div style={{ position: "absolute", inset: 0, transform: `scale(${approach})`, transformOrigin: "1260px 520px" }}>
204
+ <Galaxy seed={`g-${id}`} x={1260} y={520} size={430} tilt={0.42 + random(`t-${id}`) * 0.3} arms={2 + (index % 2)} coreBoost={pulse * 0.25} />
205
+ </div>
206
+ <AbsoluteFill style={{ background: "linear-gradient(90deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.55) 38%, transparent 62%)" }} />
207
+ <div style={{ position: "absolute", left: 120, top: 250, width: 700 }}>
208
+ <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 7, color: BLUE, opacity: eyebrow, transform: `translateX(${(1 - eyebrow) * -30}px)` }}>
209
+ GALAXY {String(index + 1).padStart(2, "0")}
210
+ </p>
211
+ <h2 style={{ margin: "18px 0 0", fontFamily: theme.display, fontWeight: 700, fontSize: 92, lineHeight: 1.0, color: WHITE, opacity: name, transform: `translateY(${(1 - name) * 30}px)`, textShadow: `0 0 40px ${BLUE}88` }}>
212
+ {stop.name}
213
+ </h2>
214
+ <p style={{ margin: "28px 0 0", fontFamily: theme.body, fontSize: 36, lineHeight: 1.35, color: "rgba(255,255,255,0.82)", opacity: line, transform: `translateY(${(1 - line) * 20}px)` }}>
215
+ {stop.line}
216
+ </p>
217
+ </div>
218
+ <div style={{ position: "absolute", left: 900, top: 190, width: 900, height: 660, opacity: visual, transform: `translateY(${(1 - visual) * 40}px) scale(${0.94 + visual * 0.06})` }}>
219
+ {children}
220
+ </div>
221
+ <Hud index={index} />
222
+ </Space>
223
+ );
224
+ }
225
+
226
+ // ---------------------------------------------------------------- feature visuals (900 x 660 box)
227
+
228
+ function SourcesVisual() {
229
+ const frame = useCurrentFrame();
230
+ const kinds = ["Blog", "Newsletter", "Website", "Markdown", "PDF", "Substack", "Ghost", "WordPress"];
231
+ const cx = 450;
232
+ const cy = 330;
233
+ const count = Math.round(interpolate(frame, [40, 240], [0, 248], { ...clamp, easing: ease }));
234
+ return (
235
+ <div style={{ position: "absolute", inset: 0 }}>
236
+ <svg width={900} height={660} style={{ position: "absolute", overflow: "visible" }}>
237
+ <ellipse cx={cx} cy={cy} rx={330} ry={130} fill="none" stroke={`${BLUE}88`} strokeWidth={2} strokeDasharray="4 10" />
238
+ <ellipse cx={cx} cy={cy} rx={220} ry={86} fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth={1.5} />
239
+ <circle cx={cx} cy={cy} r={74} fill="#000" stroke={BLUE} strokeWidth={3} style={{ filter: `drop-shadow(0 0 30px ${BLUE})` }} />
240
+ <circle cx={cx} cy={cy} r={40} fill={WHITE} opacity={0.9} style={{ filter: `drop-shadow(0 0 24px ${WHITE})` }} />
241
+ </svg>
242
+ {kinds.map((k, i) => {
243
+ const arrive = interpolate(frame, [12 + i * 9, 50 + i * 9], [0, 1], { ...clamp, easing: ease });
244
+ const ring = i % 2 ? 220 : 330;
245
+ const ringY = i % 2 ? 86 : 130;
246
+ const a = (i / kinds.length) * Math.PI * 2 + frame * 0.012;
247
+ const tx = cx + Math.cos(a) * ring;
248
+ const ty = cy + Math.sin(a) * ringY;
249
+ const sx = cx + Math.cos(a) * 1100;
250
+ const sy = cy + Math.sin(a) * 700;
251
+ const x = sx + (tx - sx) * arrive;
252
+ const y = sy + (ty - sy) * arrive;
253
+ const front = Math.sin(a) > 0;
254
+ return (
255
+ <div key={k} style={{ position: "absolute", left: x, top: y, transform: `translate(-50%, -50%) scale(${front ? 1 : 0.82})`, opacity: arrive * (front ? 1 : 0.7), zIndex: front ? 2 : 0, padding: "12px 22px", borderRadius: 999, background: front ? "rgba(0,0,0,0.85)" : "rgba(0,0,0,0.6)", border: `1.5px solid ${front ? BLUE : "rgba(255,255,255,0.3)"}`, color: WHITE, fontFamily: theme.body, fontWeight: 600, fontSize: 24, whiteSpace: "nowrap", boxShadow: front ? `0 0 24px ${BLUE}66` : undefined }}>
256
+ {k}
257
+ </div>
258
+ );
259
+ })}
260
+ <div style={{ position: "absolute", left: 0, right: 0, bottom: 0, textAlign: "center", fontFamily: theme.mono, fontSize: 28, letterSpacing: 3, color: WHITE }}>
261
+ <span style={{ color: BLUE }}>{count}</span> POSTS IN ORBIT
262
+ </div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ function ResearchVisual() {
268
+ const frame = useCurrentFrame();
269
+ const q = "What have I said about pricing?";
270
+ const answer = "You argue for charging early and raising prices with each new tier";
271
+ const cite1 = useIn(62, 14);
272
+ const cite2 = useIn(72, 14);
273
+ const lines = useIn(80, 18);
274
+ return (
275
+ <div style={{ ...panel, position: "absolute", left: 40, right: 0, top: 20, padding: 36, display: "grid", gap: 22, alignContent: "start" }}>
276
+ <div style={{ justifySelf: "end", padding: "14px 22px", borderRadius: 18, background: BLUE, color: WHITE, fontFamily: theme.body, fontSize: 28 }}>
277
+ {typed(q, frame, 4, 34)}
278
+ <span style={{ opacity: frame % 20 < 10 ? 1 : 0 }}>|</span>
279
+ </div>
280
+ <div style={{ fontFamily: theme.body, fontSize: 28, lineHeight: 1.5, color: WHITE, minHeight: 90 }}>
281
+ {typed(answer, frame, 30, 40)}
282
+ <span style={{ display: "inline-block", marginLeft: 10, padding: "0 10px", borderRadius: 8, background: BLUE, fontFamily: theme.mono, fontSize: 20, transform: `scale(${cite1})` }}>1</span>
283
+ <span style={{ display: "inline-block", marginLeft: 8, padding: "0 10px", borderRadius: 8, background: BLUE, fontFamily: theme.mono, fontSize: 20, transform: `scale(${cite2})` }}>2</span>
284
+ </div>
285
+ <div style={{ opacity: lines, transform: `translateY(${(1 - lines) * 20}px)`, border: "1px solid rgba(255,255,255,0.14)", borderRadius: 16, padding: 20, fontFamily: theme.mono, fontSize: 19, lineHeight: 1.7 }}>
286
+ <div style={{ color: BLUE, marginBottom: 6 }}>sources/blog/2024-03-11-price-early.md</div>
287
+ {["12 Charge from day one. Free teaches", "13 people your work is worth nothing.", "14 Raise the price with every tier you", "15 add, not every year."].map((l, i) => (
288
+ <div key={l} style={{ color: i < 3 ? WHITE : "rgba(255,255,255,0.55)", background: i < 3 ? `${BLUE}33` : undefined, borderLeft: i < 3 ? `3px solid ${BLUE}` : "3px solid transparent", paddingLeft: 12 }}>
289
+ {l}
290
+ </div>
291
+ ))}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ function MapVisual() {
298
+ const frame = useCurrentFrame();
299
+ const pulse = usePulse(6);
300
+ const nodes = [
301
+ { x: 450, y: 320, r: 26, name: "Writing" },
302
+ { x: 250, y: 200, r: 16, name: "Pricing" },
303
+ { x: 660, y: 190, r: 20, name: "AI tools", rising: true },
304
+ { x: 700, y: 430, r: 14, name: "Newsletters" },
305
+ { x: 230, y: 470, r: 12, name: "Habits", dormant: true },
306
+ { x: 470, y: 560, r: 15, name: "Audience" },
307
+ { x: 120, y: 330, r: 9, name: "Design" },
308
+ { x: 820, y: 300, r: 10, name: "Research" },
309
+ ];
310
+ const edges = [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [1, 6], [2, 7], [3, 5], [2, 3], [1, 4]];
311
+ return (
312
+ <svg width={900} height={660} style={{ position: "absolute", overflow: "visible" }}>
313
+ <defs>
314
+ <radialGradient id="map-core">
315
+ <stop offset="0%" stopColor={WHITE} stopOpacity={0.75} />
316
+ <stop offset="20%" stopColor={WHITE} stopOpacity={0.3} />
317
+ <stop offset="55%" stopColor={BLUE} stopOpacity={0.1} />
318
+ <stop offset="100%" stopColor={BLUE} stopOpacity={0} />
319
+ </radialGradient>
320
+ </defs>
321
+ <circle cx={450} cy={330} r={260 + pulse * 10} fill="url(#map-core)" />
322
+ {edges.map(([a, b], i) => {
323
+ const p = interpolate(frame, [18 + i * 5, 40 + i * 5], [0, 1], clamp);
324
+ const A = nodes[a];
325
+ const B = nodes[b];
326
+ return <line key={i} x1={A.x} y1={A.y} x2={A.x + (B.x - A.x) * p} y2={A.y + (B.y - A.y) * p} stroke={WHITE} strokeOpacity={0.45} strokeWidth={2} />;
327
+ })}
328
+ {nodes.map((n, i) => {
329
+ const s = spring({ frame: frame - 6 - i * 4, fps: LAUNCH_FPS, config: { damping: 12 } });
330
+ return (
331
+ <g key={n.name} opacity={Math.min(1, s)}>
332
+ <circle cx={n.x} cy={n.y} r={n.r * 2.4 * s} fill={BLUE} opacity={0.25} />
333
+ <circle cx={n.x} cy={n.y} r={n.r * 0.6 * s} fill={n.rising ? WHITE : n.dormant ? "rgba(255,255,255,0.45)" : "#9cc4ff"} style={n.rising ? { filter: `drop-shadow(0 0 12px ${WHITE})` } : undefined} />
334
+ {n.dormant && <circle cx={n.x} cy={n.y} r={n.r + 8} fill="none" stroke={WHITE} strokeOpacity={0.6} strokeDasharray="3 5" strokeWidth={1.5} />}
335
+ <text x={n.x} y={n.y + n.r + 30} textAnchor="middle" fontFamily={theme.body} fontWeight={600} fontSize={22} fill={WHITE} stroke="#000" strokeWidth={5} paintOrder="stroke">
336
+ {n.name}
337
+ </text>
338
+ </g>
339
+ );
340
+ })}
341
+ <g opacity={interpolate(frame, [80, 96], [0, 1], clamp)}>
342
+ <rect x={700} y={100} width={190} height={46} rx={23} fill={WHITE} />
343
+ <text x={795} y={131} textAnchor="middle" fontFamily={theme.mono} fontSize={20} fill="#000">RISING 2.4x</text>
344
+ </g>
345
+ <g opacity={interpolate(frame, [110, 126], [0, 1], clamp)}>
346
+ <rect x={80} y={520} width={170} height={46} rx={23} fill="#000" stroke="rgba(255,255,255,0.6)" strokeDasharray="4 5" />
347
+ <text x={165} y={551} textAnchor="middle" fontFamily={theme.mono} fontSize={20} fill={WHITE}>DORMANT</text>
348
+ </g>
349
+ </svg>
350
+ );
351
+ }
352
+
353
+ function VoiceVisual() {
354
+ const frame = useCurrentFrame();
355
+ const traits = [["Tone", "warm, direct"], ["Openers", "a sharp question"], ["Rhythm", "short, punchy lines"], ["Avoids", "jargon, hype"]];
356
+ const match = Math.round(interpolate(frame, [70, 140], [0, 96], { ...clamp, easing: ease }));
357
+ return (
358
+ <div style={{ ...panel, position: "absolute", left: 40, right: 0, top: 30, padding: 40, display: "grid", gap: 26, alignContent: "start" }}>
359
+ <svg width={780} height={150}>
360
+ {Array.from({ length: 60 }).map((_, i) => {
361
+ const h = 16 + Math.abs(Math.sin(i * 0.55 + frame * 0.22) * Math.sin(i * 0.13 + frame * 0.07)) * 120;
362
+ return <rect key={i} x={i * 13} y={75 - h / 2} width={7} height={h} rx={3.5} fill={i % 7 === 0 ? WHITE : BLUE} opacity={0.5 + 0.5 * (h / 136)} />;
363
+ })}
364
+ </svg>
365
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
366
+ {traits.map(([k, v], i) => {
367
+ const s = spring({ frame: frame - 20 - i * 10, fps: LAUNCH_FPS, config: { damping: 15 } });
368
+ return (
369
+ <div key={k} style={{ opacity: s, transform: `translateY(${(1 - s) * 16}px)`, border: `1px solid ${BLUE}88`, borderRadius: 14, padding: "14px 18px", fontFamily: theme.body, fontSize: 24, color: WHITE }}>
370
+ <span style={{ fontFamily: theme.mono, fontSize: 18, color: BLUE, letterSpacing: 2 }}>{k.toUpperCase()}</span>
371
+ <br />
372
+ {v}
373
+ </div>
374
+ );
375
+ })}
376
+ </div>
377
+ <div style={{ fontFamily: theme.mono, fontSize: 28, color: WHITE, letterSpacing: 2 }}>
378
+ VOICE MATCH <span style={{ color: BLUE }}>{match}%</span>
379
+ </div>
380
+ </div>
381
+ );
382
+ }
383
+
384
+ function AudioVisual() {
385
+ const frame = useCurrentFrame();
386
+ const speaker = Math.floor(frame / 36) % 2;
387
+ const lines = ["So the big idea here is simple.", "Charge early. It changes who shows up.", "And it is all from their own posts.", "Every line, cited."];
388
+ const host = (label: string, active: boolean, x: number) => (
389
+ <div style={{ position: "absolute", left: x, top: 120, width: 170, height: 170, borderRadius: "50%", display: "grid", placeItems: "center", background: active ? WHITE : "#000", color: active ? "#000" : WHITE, border: `3px solid ${BLUE}`, fontFamily: theme.display, fontWeight: 700, fontSize: 64, boxShadow: active ? `0 0 0 ${10 + Math.sin(frame * 0.5) * 6}px ${BLUE}55, 0 0 60px ${BLUE}` : undefined }}>
390
+ {label}
391
+ </div>
392
+ );
393
+ return (
394
+ <div style={{ position: "absolute", inset: 0 }}>
395
+ {host("A", speaker === 0, 60)}
396
+ {host("B", speaker === 1, 670)}
397
+ <svg width={900} height={660} style={{ position: "absolute" }}>
398
+ {Array.from({ length: 36 }).map((_, i) => {
399
+ const h = 10 + Math.abs(Math.sin(i * 0.8 + frame * 0.35) * Math.cos(i * 0.21 - frame * 0.1)) * 150;
400
+ return <rect key={i} x={265 + i * 10.5} y={205 - h / 2} width={6} height={h} rx={3} fill={BLUE} opacity={0.85} />;
401
+ })}
402
+ </svg>
403
+ <div style={{ ...panel, position: "absolute", left: 60, right: 50, top: 400, padding: "26px 32px", fontFamily: theme.body, fontSize: 32, color: WHITE }}>
404
+ <span style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 3 }}>HOST {speaker ? "B" : "A"}</span>
405
+ <br />
406
+ {lines[Math.floor(frame / 36) % lines.length]}
407
+ </div>
408
+ </div>
409
+ );
410
+ }
411
+
412
+ function VideoVisual() {
413
+ const frame = useCurrentFrame();
414
+ const card = (delay: number, style: CSSProperties, label: string, body: ReactNode) => {
415
+ const s = spring({ frame: frame - delay, fps: LAUNCH_FPS, config: { damping: 14 } });
416
+ return (
417
+ <div style={{ ...panel, position: "absolute", overflow: "hidden", opacity: s, transform: `translateY(${(1 - s) * 80}px) rotate(${(1 - s) * 4}deg)`, ...style }}>
418
+ <div style={{ position: "absolute", inset: 0, background: `radial-gradient(80% 60% at 50% 110%, ${BLUE}55, transparent 70%)` }} />
419
+ <div style={{ position: "absolute", left: 18, top: 14, fontFamily: theme.mono, fontSize: 16, letterSpacing: 3, color: BLUE }}>{label}</div>
420
+ <div style={{ position: "absolute", inset: "50px 22px 22px", display: "grid", placeItems: "center", textAlign: "center", color: WHITE, fontFamily: theme.display, fontWeight: 600 }}>{body}</div>
421
+ </div>
422
+ );
423
+ };
424
+ const play = <div style={{ width: 0, height: 0, borderLeft: `34px solid ${WHITE}`, borderTop: "20px solid transparent", borderBottom: "20px solid transparent" }} />;
425
+ return (
426
+ <div style={{ position: "absolute", inset: 0 }}>
427
+ {card(4, { left: 40, top: 60, width: 250, height: 444 }, "SHORT 9:16", <div style={{ fontSize: 34, lineHeight: 1.15 }}>Your archive already is a podcast</div>)}
428
+ {card(14, { left: 320, top: 40, width: 520, height: 292 }, "EXPLAINER 16:9", <div style={{ display: "grid", gap: 16, placeItems: "center" }}>{play}<span style={{ fontSize: 30 }}>Why pricing early works</span></div>)}
429
+ {card(24, { left: 320, top: 360, width: 250, height: 250 }, "AUDIOGRAM 1:1",
430
+ <svg width={200} height={120}>{Array.from({ length: 16 }).map((_, i) => { const h = 12 + Math.abs(Math.sin(i + frame * 0.3)) * 90; return <rect key={i} x={i * 12.5} y={60 - h / 2} width={7} height={h} rx={3} fill={i % 4 ? BLUE : WHITE} />; })}</svg>)}
431
+ {card(34, { left: 590, top: 360, width: 250, height: 250 }, "QUOTE CARD", <div style={{ fontSize: 26, lineHeight: 1.25 }}>"Free teaches people your work is worth nothing."</div>)}
432
+ </div>
433
+ );
434
+ }
435
+
436
+ function LaunchKitVisual() {
437
+ const frame = useCurrentFrame();
438
+ const items = [["X THREAD", "1/7 Most writers undercharge. Here is why."], ["LINKEDIN", "I raised my prices three times last year."], ["SUBSTACK NOTES", "Charge from day one. Seriously."], ["BLUESKY", "Pricing is a signal, not a number."], ["SEO PACK", "Title, meta, slug, keywords, links"], ["CAROUSEL", "8 slides: How to price your writing"]];
439
+ return (
440
+ <div style={{ position: "absolute", inset: "20px 0 0 30px", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18 }}>
441
+ {items.map(([k, v], i) => {
442
+ const s = spring({ frame: frame - 8 - i * S(BEAT), fps: LAUNCH_FPS, config: { damping: 13 } });
443
+ return (
444
+ <div key={k} style={{ ...panel, opacity: Math.min(1, s), transform: `translateY(${(1 - s) * 60}px) scale(${0.9 + 0.1 * s})`, padding: "22px 24px", minHeight: 160 }}>
445
+ <div style={{ fontFamily: theme.mono, fontSize: 18, letterSpacing: 3, color: BLUE }}>{k}</div>
446
+ <div style={{ marginTop: 12, fontFamily: theme.body, fontSize: 26, lineHeight: 1.35, color: WHITE }}>{v}</div>
447
+ </div>
448
+ );
449
+ })}
450
+ </div>
451
+ );
452
+ }
453
+
454
+ function LaunchpadVisual() {
455
+ const frame = useCurrentFrame();
456
+ const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
457
+ const posts: [number, number, string][] = [[0, 0, "X"], [1, 1, "LinkedIn"], [2, 0, "Bluesky"], [3, 2, "X"], [4, 1, "Notes"], [5, 0, "LinkedIn"], [6, 2, "X"]];
458
+ const lift = interpolate(frame, [120, 200], [0, 1], { ...clamp, easing: Easing.in(Easing.cubic) });
459
+ return (
460
+ <div style={{ position: "absolute", inset: 0 }}>
461
+ <div style={{ ...panel, position: "absolute", left: 30, top: 20, width: 620, padding: 26 }}>
462
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 10 }}>
463
+ {days.map((d) => (
464
+ <div key={d} style={{ fontFamily: theme.mono, fontSize: 16, color: "rgba(255,255,255,0.6)", textAlign: "center" }}>{d}</div>
465
+ ))}
466
+ {Array.from({ length: 21 }).map((_, cell) => {
467
+ const col = cell % 7;
468
+ const row = Math.floor(cell / 7);
469
+ const post = posts.find(([c, r]) => c === col && r === row);
470
+ const s = post ? spring({ frame: frame - 10 - posts.indexOf(post) * 8, fps: LAUNCH_FPS, config: { damping: 12 } }) : 0;
471
+ const sent = post && frame > 90 + posts.indexOf(post) * 5;
472
+ return (
473
+ <div key={cell} style={{ height: 96, borderRadius: 12, border: "1px solid rgba(255,255,255,0.12)", display: "grid", placeItems: "center" }}>
474
+ {post && (
475
+ <div style={{ transform: `scale(${s})`, padding: "6px 8px", borderRadius: 8, background: sent ? WHITE : BLUE, color: sent ? "#000" : WHITE, fontFamily: theme.body, fontWeight: 600, fontSize: 15 }}>
476
+ {post[2]}
477
+ </div>
478
+ )}
479
+ </div>
480
+ );
481
+ })}
482
+ </div>
483
+ <div style={{ marginTop: 18, fontFamily: theme.mono, fontSize: 20, letterSpacing: 2, color: frame > 100 ? WHITE : "rgba(255,255,255,0.6)" }}>
484
+ {frame > 100 ? "7 POSTS LIVE" : "7 POSTS SCHEDULED"}
485
+ </div>
486
+ </div>
487
+ <svg width={260} height={660} style={{ position: "absolute", left: 660, top: 0, overflow: "visible" }}>
488
+ <g transform={`translate(130 ${470 - lift * 700})`}>
489
+ <path d={`M -14 60 Q 0 ${120 + lift * 260} 14 60 Z`} fill={WHITE} opacity={0.5 + lift * 0.5} style={{ filter: `drop-shadow(0 0 16px ${BLUE})` }} />
490
+ <path d="M 0 -80 C 34 -40 34 20 24 60 L -24 60 C -34 20 -34 -40 0 -80 Z" fill={WHITE} />
491
+ <circle cx={0} cy={-18} r={12} fill={BLUE} />
492
+ <path d="M -24 30 L -46 70 L -22 58 Z M 24 30 L 46 70 L 22 58 Z" fill={BLUE} />
493
+ </g>
494
+ <line x1={40} y1={560} x2={220} y2={560} stroke="rgba(255,255,255,0.4)" strokeWidth={3} />
495
+ </svg>
496
+ </div>
497
+ );
498
+ }
499
+
500
+ function ResurfaceVisual() {
501
+ const frame = useCurrentFrame();
502
+ const years = [2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026];
503
+ const rise = interpolate(frame, [30, 80], [0, 1], { ...clamp, easing: ease });
504
+ const score = Math.round(interpolate(frame, [60, 110], [0, 92], clamp));
505
+ const x = (i: number) => 60 + i * 110;
506
+ return (
507
+ <div style={{ position: "absolute", inset: 0 }}>
508
+ <svg width={900} height={660} style={{ position: "absolute" }}>
509
+ <line x1={40} y1={560} x2={860} y2={560} stroke="rgba(255,255,255,0.3)" strokeWidth={2} />
510
+ {years.map((y, i) => (
511
+ <g key={y}>
512
+ <circle cx={x(i)} cy={560} r={6} fill={i === 2 ? WHITE : BLUE} />
513
+ <text x={x(i)} y={600} textAnchor="middle" fontFamily={theme.mono} fontSize={18} fill="rgba(255,255,255,0.6)">{y}</text>
514
+ </g>
515
+ ))}
516
+ <path d={`M ${x(2)} 560 C ${x(2)} ${560 - 200 * rise}, ${x(4)} ${420 - 200 * rise}, ${x(4)} ${360 - 180 * rise}`} fill="none" stroke={WHITE} strokeOpacity={0.6} strokeWidth={2} strokeDasharray="5 7" />
517
+ </svg>
518
+ <div style={{ ...panel, position: "absolute", left: x(2) + 60 + rise * 140, top: 420 - rise * 330, width: 480, padding: 26, boxShadow: `0 0 ${40 + rise * 60}px ${BLUE}${rise > 0.5 ? "99" : "44"}` }}>
519
+ <div style={{ fontFamily: theme.mono, fontSize: 18, color: BLUE, letterSpacing: 2 }}>MARCH 2021</div>
520
+ <div style={{ marginTop: 10, fontFamily: theme.display, fontWeight: 600, fontSize: 34, color: WHITE, lineHeight: 1.15 }}>The quiet power of a small audience</div>
521
+ <div style={{ marginTop: 18, display: "flex", gap: 14, fontFamily: theme.mono, fontSize: 20 }}>
522
+ <span style={{ padding: "6px 14px", borderRadius: 999, background: WHITE, color: "#000" }}>EVERGREEN {score}</span>
523
+ <span style={{ padding: "6px 14px", borderRadius: 999, border: `1px solid ${BLUE}`, color: WHITE }}>RESHARE</span>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ // ---------------------------------------------------------------- bookend scenes
531
+
532
+ function Intro() {
533
+ const frame = useCurrentFrame();
534
+ const a = useIn(24, 22);
535
+ const b = useIn(S(3.4), 22);
536
+ const c = useIn(S(6.2), 20);
537
+ const push = interpolate(frame, [0, S(8)], [1, 1.12]);
538
+ return (
539
+ <Space drift={0.4}>
540
+ <div style={{ position: "absolute", inset: 0, transform: `scale(${push})`, opacity: interpolate(frame, [0, 40], [0, 1], clamp) }}>
541
+ <Galaxy seed="far-1" x={1500} y={260} size={150} tilt={0.35} />
542
+ <Galaxy seed="far-2" x={330} y={820} size={110} tilt={0.6} arms={3} />
543
+ <Galaxy seed="far-3" x={1650} y={860} size={70} tilt={0.5} />
544
+ <Galaxy seed="far-4" x={260} y={200} size={60} tilt={0.7} />
545
+ </div>
546
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 28 }}>
547
+ <h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 600, fontSize: 96, color: WHITE, opacity: a, transform: `translateY(${(1 - a) * 30}px)` }}>
548
+ Every writer carries a universe.
549
+ </h1>
550
+ <h2 style={{ margin: 0, fontFamily: theme.display, fontWeight: 500, fontSize: 64, color: "rgba(255,255,255,0.75)", opacity: b, transform: `translateY(${(1 - b) * 24}px)` }}>
551
+ Most of it is <span style={{ color: WHITE, textShadow: `0 0 30px ${BLUE}, 0 0 70px ${BLUE}` }}>still uncharted.</span>
552
+ </h2>
553
+ <p style={{ margin: "30px 0 0", fontFamily: theme.mono, fontSize: 24, letterSpacing: 8, color: BLUE, opacity: c }}>A JOURNEY OF KNOWLEDGE</p>
554
+ </AbsoluteFill>
555
+ </Space>
556
+ );
557
+ }
558
+
559
+ function Launch() {
560
+ const frame = useCurrentFrame();
561
+ const speed = interpolate(frame, [0, 20, 90, 120], [2, 22, 18, 8], clamp);
562
+ const streak = interpolate(frame, [0, 12, 95, 120], [0, 1, 1, 0.3], clamp);
563
+ const title = useIn(10, 14);
564
+ const sub = useIn(28, 18);
565
+ const shake = frame < 14 ? Math.sin(frame * 3.1) * (14 - frame) * 0.8 : 0;
566
+ return (
567
+ <AbsoluteFill style={{ transform: `translate(${shake}px, ${shake * 0.6}px)` }}>
568
+ <WarpField speed={speed} streak={streak} count={520} />
569
+ <AbsoluteFill style={{ background: `radial-gradient(circle at 50% 50%, rgba(255,255,255,${interpolate(frame, [0, 10], [0.8, 0], clamp)}), transparent 60%)` }} />
570
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center", gap: 26 }}>
571
+ <h1 style={{ margin: 0, fontFamily: theme.display, fontWeight: 700, fontSize: 132, lineHeight: 1, color: WHITE, opacity: title, transform: `scale(${0.85 + title * 0.15})`, textShadow: `0 0 50px ${BLUE}` }}>
572
+ A journey of knowledge
573
+ </h1>
574
+ <p style={{ margin: 0, fontFamily: theme.body, fontSize: 40, color: "rgba(255,255,255,0.85)", opacity: sub, transform: `translateY(${(1 - sub) * 20}px)` }}>
575
+ exploring the galaxies of untapped potential
576
+ </p>
577
+ </AbsoluteFill>
578
+ <Flare at={6} duration={30} y={47} />
579
+ </AbsoluteFill>
580
+ );
581
+ }
582
+
583
+ function Montage() {
584
+ const frame = useCurrentFrame();
585
+ const { durationInFrames } = useVideoConfig();
586
+ const speed = interpolate(frame, [0, durationInFrames], [4, 40], { easing: Easing.in(Easing.quad) });
587
+ const lit = Math.floor(frame / S(BEAT / 2)); // one galaxy per eighth note
588
+ const whiteout = interpolate(frame, [durationInFrames - 14, durationInFrames], [0, 1], clamp);
589
+ const go = useIn(S(2.6), 12);
590
+ return (
591
+ <AbsoluteFill>
592
+ <WarpField speed={speed} streak={interpolate(frame, [0, 60], [0.2, 1], clamp)} count={560} />
593
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
594
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 440px)", gap: 22 }}>
595
+ {STOPS.map((s, i) => {
596
+ const on = i < lit;
597
+ return (
598
+ <div key={s.id} style={{ ...panel, padding: "22px 26px", opacity: on ? 1 : 0.18, borderColor: on ? BLUE : "rgba(255,255,255,0.16)", background: on ? "rgba(0,0,0,0.8)" : "rgba(0,0,0,0.4)", boxShadow: on ? `0 0 40px ${BLUE}88` : "none", transform: `scale(${on ? 1 : 0.95})` }}>
599
+ <div style={{ fontFamily: theme.mono, fontSize: 18, letterSpacing: 3, color: BLUE }}>GALAXY {String(i + 1).padStart(2, "0")}</div>
600
+ <div style={{ marginTop: 6, fontFamily: theme.display, fontWeight: 600, fontSize: 32, color: WHITE }}>{s.short}</div>
601
+ </div>
602
+ );
603
+ })}
604
+ </div>
605
+ <p style={{ marginTop: 44, fontFamily: theme.mono, fontSize: 34, letterSpacing: 12, color: WHITE, opacity: go, textShadow: `0 0 24px ${BLUE}` }}>ALL SYSTEMS GO</p>
606
+ </AbsoluteFill>
607
+ <AbsoluteFill style={{ background: WHITE, opacity: whiteout }} />
608
+ </AbsoluteFill>
609
+ );
610
+ }
611
+
612
+ function Outro() {
613
+ const frame = useCurrentFrame();
614
+ const { durationInFrames } = useVideoConfig();
615
+ const flash = interpolate(frame, [0, 22], [1, 0], clamp);
616
+ const logo = useIn(4, 13);
617
+ const name = useIn(12, 15);
618
+ const soon = useIn(S(3.2), 12);
619
+ const tag = useIn(S(4.4), 20);
620
+ const pulse = usePulse(5);
621
+ const end = interpolate(frame, [durationInFrames - 30, durationInFrames], [1, 0], clamp);
622
+ const letters = "COMING SOON".split("");
623
+ return (
624
+ <AbsoluteFill style={{ opacity: end }}>
625
+ <Space drift={0.3}>
626
+ <div style={{ position: "absolute", inset: 0, transform: `scale(${interpolate(frame, [0, durationInFrames], [1.15, 1])})`, transformOrigin: "960px 540px" }}>
627
+ <Galaxy seed="home" x={960} y={560} size={620} tilt={0.3} arms={2} spin={0.6} coreBoost={0.15 + pulse * 0.1} />
628
+ </div>
629
+ <AbsoluteFill style={{ background: "radial-gradient(45% 45% at 50% 50%, rgba(0,0,0,0.55), transparent 80%)" }} />
630
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", textAlign: "center" }}>
631
+ <div style={{ width: 128, height: 128, borderRadius: 32, background: WHITE, display: "grid", placeItems: "center", opacity: logo, transform: `scale(${logo})`, boxShadow: `0 0 90px ${BLUE}, 0 0 30px ${WHITE}` }}>
632
+ <div style={{ width: 48, height: 48, borderRadius: "50%", background: "#000", boxShadow: "20px -20px 0 -11px #000" }} />
633
+ </div>
634
+ <h1 style={{ margin: "34px 0 0", fontFamily: theme.display, fontWeight: 700, fontSize: 150, lineHeight: 1, color: WHITE, opacity: name, transform: `translateY(${(1 - name) * 30}px)`, textShadow: `0 0 50px ${BLUE}` }}>
635
+ Notestack <span style={{ color: BLUE, textShadow: `0 0 30px ${BLUE}` }}>AI</span>
636
+ </h1>
637
+ <div style={{ marginTop: 44, display: "flex", gap: 6 }}>
638
+ {letters.map((ch, i) => {
639
+ const s = spring({ frame: frame - S(3.2) - i * 2, fps: LAUNCH_FPS, config: { damping: 11 } });
640
+ return (
641
+ <span key={i} style={{ display: "inline-block", minWidth: ch === " " ? 30 : undefined, fontFamily: theme.mono, fontWeight: 400, fontSize: 64, letterSpacing: 10, color: WHITE, opacity: Math.min(1, s) * soon, transform: `translateY(${(1 - s) * 40}px)`, textShadow: `0 0 ${20 + pulse * 20}px ${BLUE}, 0 0 60px ${BLUE}` }}>
642
+ {ch}
643
+ </span>
644
+ );
645
+ })}
646
+ </div>
647
+ <p style={{ margin: "40px 0 0", fontFamily: theme.body, fontSize: 34, color: "rgba(255,255,255,0.8)", opacity: tag }}>
648
+ Your knowledge, in orbit. <span style={{ color: BLUE }}>notestack.ai</span>
649
+ </p>
650
+ </AbsoluteFill>
651
+ <Flare at={S(3.1)} duration={34} y={64} />
652
+ </Space>
653
+ <AbsoluteFill style={{ background: WHITE, opacity: flash }} />
654
+ </AbsoluteFill>
655
+ );
656
+ }
657
+
658
+ // ---------------------------------------------------------------- composition
659
+
660
+ const RENDER: Record<LaunchSceneId, () => JSX.Element> = {
661
+ intro: Intro,
662
+ launch: Launch,
663
+ sources: () => <Stop id="sources"><SourcesVisual /></Stop>,
664
+ research: () => <Stop id="research"><ResearchVisual /></Stop>,
665
+ map: () => <Stop id="map"><MapVisual /></Stop>,
666
+ voice: () => <Stop id="voice"><VoiceVisual /></Stop>,
667
+ audio: () => <Stop id="audio"><AudioVisual /></Stop>,
668
+ video: () => <Stop id="video"><VideoVisual /></Stop>,
669
+ launchkit: () => <Stop id="launchkit"><LaunchKitVisual /></Stop>,
670
+ launchpad: () => <Stop id="launchpad"><LaunchpadVisual /></Stop>,
671
+ resurface: () => <Stop id="resurface"><ResurfaceVisual /></Stop>,
672
+ montage: Montage,
673
+ outro: Outro,
674
+ };
675
+
676
+ function FadeIn({ skip, children }: { skip: boolean; children: ReactNode }) {
677
+ const frame = useCurrentFrame();
678
+ const opacity = skip ? 1 : interpolate(frame, [0, S(XF)], [0, 1], clamp);
679
+ return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
680
+ }
681
+
682
+ export function LaunchTrailer() {
683
+ return (
684
+ <AbsoluteFill style={{ backgroundColor: theme.black }}>
685
+ <SoundTrack src={staticFile("launch-vo/music.wav")} volume={musicVolume} />
686
+ {LAUNCH_SCENES.map((scene, i) => {
687
+ const last = i === LAUNCH_SCENES.length - 1;
688
+ const Scene = RENDER[scene.id];
689
+ // Hard cut into the outro (the montage whites out); crossfade everywhere else.
690
+ const skipFade = i === 0 || scene.id === "outro" || scene.id === "launch";
691
+ return (
692
+ <Sequence key={scene.id} from={S(scene.start)} durationInFrames={S(scene.len + (last ? 0 : XF))} name={scene.id}>
693
+ <FadeIn skip={skipFade}>
694
+ <Scene />
695
+ </FadeIn>
696
+ {scene.vo !== null && (
697
+ <Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
698
+ <SoundTrack src={staticFile(`launch-vo/${scene.id}.mp3`)} volume={1} />
699
+ </Sequence>
700
+ )}
701
+ </Sequence>
702
+ );
703
+ })}
704
+ </AbsoluteFill>
705
+ );
706
+ }
renderer/src/compositions/launchTimeline.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.
2
+ // 120 bpm; every scene starts on a bar line so cuts land on the beat.
3
+ export const LAUNCH_BPM = 120;
4
+ export const LAUNCH_SECONDS = 92.0;
5
+ export const LAUNCH_SCENES = [
6
+ { id: "intro", start: 0.0, len: 8.0, vo: 1.0, voLen: 5.36 },
7
+ { id: "launch", start: 8.0, len: 4.0, vo: 0.35, voLen: 2.85 },
8
+ { id: "sources", start: 12.0, len: 10.0, vo: 0.35, voLen: 8.0 },
9
+ { id: "research", start: 22.0, len: 6.0, vo: 0.35, voLen: 4.94 },
10
+ { id: "map", start: 28.0, len: 8.0, vo: 0.35, voLen: 5.96 },
11
+ { id: "voice", start: 36.0, len: 6.0, vo: 0.35, voLen: 4.52 },
12
+ { id: "audio", start: 42.0, len: 6.0, vo: 0.35, voLen: 3.45 },
13
+ { id: "video", start: 48.0, len: 8.0, vo: 0.35, voLen: 6.61 },
14
+ { id: "launchkit", start: 56.0, len: 8.0, vo: 0.35, voLen: 6.38 },
15
+ { id: "launchpad", start: 64.0, len: 8.0, vo: 0.35, voLen: 5.65 },
16
+ { id: "resurface", start: 72.0, len: 6.0, vo: 0.35, voLen: 3.82 },
17
+ { id: "montage", start: 78.0, len: 4.0, vo: null, voLen: null },
18
+ { id: "outro", start: 82.0, len: 10.0, vo: 1.2, voLen: 5.07 },
19
+ ] as const;
20
+ export type LaunchSceneId = (typeof LAUNCH_SCENES)[number]["id"];