Humera Raheel commited on
Commit
bdc4e48
·
2 Parent(s): 23b36b7219ba5a

Merge pull request #10 from FireBird-Technologies/feat/new_promo_vid

Browse files
frontend/src/components/FeatureShowcase.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
2
  import { Link } from "react-router-dom";
3
 
4
  /** Landing page feature tour: one full section per feature, alternating sides, each with a small
@@ -33,36 +33,180 @@ function ChatVisual() {
33
  );
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  function MapVisual() {
37
- const nodes = [
38
- { x: 50, y: 48, r: 14, label: "Pricing" },
39
- { x: 22, y: 30, r: 9, label: "Habits" },
40
- { x: 78, y: 26, r: 10, label: "Audience" },
41
- { x: 30, y: 76, r: 8, label: "Craft" },
42
- { x: 74, y: 74, r: 11, label: "Paid tier" },
43
- ];
44
- const edges = [
 
 
 
 
 
45
  [0, 1],
46
  [0, 2],
 
47
  [0, 4],
48
- [1, 3],
49
- [2, 4],
 
 
 
 
 
50
  ];
51
  return (
52
- <div className="fx fx-map">
53
- <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
54
- {edges.map(([a, b], i) => (
55
- <line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={nodes[a].x} y1={nodes[a].y} x2={nodes[b].x} y2={nodes[b].y} />
 
 
56
  ))}
57
- {nodes.map((n, i) => (
58
- <g key={n.label} className="fx-node" style={{ ["--d" as string]: `${0.3 + i * 0.12}s` }}>
59
- <circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" />
60
- <circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
61
- <text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
62
- {n.label}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </text>
64
  </g>
65
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </svg>
67
  </div>
68
  );
@@ -89,19 +233,57 @@ function AudioVisual() {
89
  }
90
 
91
  function VideoVisual() {
 
92
  return (
93
  <div className="fx fx-video">
94
- <div className="fx-frame v916">
95
- <span className="mono">9:16</span>
96
- <b>I doubled my price.</b>
 
 
 
 
97
  </div>
98
  <div className="fx-frame v169">
99
- <span className="mono">16:9</span>
100
- <b>Why price is a signal</b>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </div>
102
- <div className="fx-frame v11">
103
- <span className="mono">1:1</span>
104
- <i className="fx-mini-wave" />
 
 
 
 
 
 
 
 
 
105
  </div>
106
  </div>
107
  );
@@ -178,23 +360,31 @@ const FEATURES: Feature[] = [
178
  eyebrow: "Research notebook",
179
  title: "Ask your archive anything. It answers with receipts.",
180
  body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
181
- points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "Summaries of whole notebooks, cited"],
182
  visual: <ChatVisual />,
183
  },
 
 
 
 
 
 
 
 
184
  {
185
  id: "map",
186
- eyebrow: "Topic map",
187
- title: "See the constellations in your writing.",
188
- body: "Every post is tagged with what it is really about, then linked to the topics it travels with. Zoom through years of work and spin any topic into a notebook.",
189
- points: ["Topics merge automatically as you publish", "Spot the themes you keep coming back to", "One click from a topic to a notebook"],
190
  visual: <MapVisual />,
191
  },
192
  {
193
  id: "audio",
194
  eyebrow: "Audio overview",
195
  title: "Two hosts talk through your ideas.",
196
- body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick from a library of commercial voices, or clone your own with consent.",
197
- points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Your cloned voice as host, if you want"],
198
  visual: <AudioVisual />,
199
  },
200
  {
@@ -205,6 +395,14 @@ const FEATURES: Feature[] = [
205
  points: ["Landscape for YouTube, vertical for Reels and TikTok", "Templates, stock footage, captions and 16 languages", "Square audiograms from any audio overview"],
206
  visual: <VideoVisual />,
207
  },
 
 
 
 
 
 
 
 
208
  {
209
  id: "kit",
210
  eyebrow: "Launch Kit",
@@ -214,12 +412,12 @@ const FEATURES: Feature[] = [
214
  visual: <KitVisual />,
215
  },
216
  {
217
- id: "voice",
218
- eyebrow: "Voice profile",
219
- title: "Sounds like you, on the page and out loud.",
220
- body: "Notestack learns your tone from your own posts, so drafts read like you wrote them. For audio, tune two hosts or record a couple of minutes to clone your voice.",
221
- points: ["Editable writing voice", "Commercially licensed voice library", "Consented cloning you can revoke anytime"],
222
- visual: <VoiceVisual />,
223
  },
224
  {
225
  id: "pad",
 
1
+ import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2
  import { Link } from "react-router-dom";
3
 
4
  /** Landing page feature tour: one full section per feature, alternating sides, each with a small
 
33
  );
34
  }
35
 
36
+ const SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
37
+ const SHAPE_MS = 3500; // how long each shape is shown on its own
38
+ const MORPH_MS = 1300;
39
+
40
+ /** Where the four outer topics sit in each shape (the hub, Gravity, stays put so the dive lines up). */
41
+ const LAYOUTS: [number, number][][] = [
42
+ [[74, 30], [20, 24], [26, 82], [78, 76]], // orbit: around the hub
43
+ [[55, 56], [42, 74], [16, 62], [18, 30]], // spiral: winding out
44
+ [[56, 36], [72, 48], [66, 68], [84, 78]], // figure: one long chain of stars
45
+ [[72, 28], [84, 36], [20, 76], [30, 86]], // cluster: two tight groups
46
+ ];
47
+
48
+ /** Cycles the shape on a timer while the visual is on screen. Clicking a pill jumps to that shape and pauses
49
+ * the cycle for a while. With reduced motion the shape only changes when the reader clicks. */
50
+ function useShapeCycle(ref: RefObject<HTMLElement>) {
51
+ const [view, setView] = useState({ shape: 0, prev: 0, p: 1, t: 0 });
52
+ const go = useRef<(i: number) => void>(() => undefined);
53
+ useEffect(() => {
54
+ const node = ref.current;
55
+ if (!node) return;
56
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
57
+ let raf = 0;
58
+ let last = 0;
59
+ let shape = 0;
60
+ let prev = 0;
61
+ let t0 = -MORPH_MS;
62
+ let nextAuto = 0;
63
+ const show = (now: number) => setView({ shape, prev, p: Math.min(1, (now - t0) / MORPH_MS), t: now / 1000 });
64
+ const jump = (i: number, now: number) => {
65
+ if (i === shape) return;
66
+ prev = shape;
67
+ shape = i;
68
+ t0 = now;
69
+ };
70
+ go.current = (i) => {
71
+ const now = performance.now();
72
+ jump(i, now);
73
+ nextAuto = now + 9000;
74
+ show(now);
75
+ if (still) setTimeout(() => show(performance.now() + MORPH_MS), MORPH_MS);
76
+ };
77
+ const tick = (now: number) => {
78
+ if (now >= nextAuto) {
79
+ jump((shape + 1) % SHAPES.length, now);
80
+ nextAuto = now + SHAPE_MS;
81
+ }
82
+ if (now - last > 33) {
83
+ last = now;
84
+ show(now);
85
+ }
86
+ raf = requestAnimationFrame(tick);
87
+ };
88
+ const io = new IntersectionObserver(([entry]) => {
89
+ cancelAnimationFrame(raf);
90
+ if (entry.isIntersecting && !still) {
91
+ nextAuto = performance.now() + SHAPE_MS;
92
+ raf = requestAnimationFrame(tick);
93
+ }
94
+ });
95
+ io.observe(node);
96
+ return () => {
97
+ io.disconnect();
98
+ cancelAnimationFrame(raf);
99
+ };
100
+ }, [ref]);
101
+ return { view, go };
102
+ }
103
+
104
  function MapVisual() {
105
+ const ref = useRef<HTMLDivElement>(null);
106
+ const { view, go } = useShapeCycle(ref);
107
+ const e = view.p < 0.5 ? 2 * view.p * view.p : 1 - (-2 * view.p + 2) ** 2 / 2;
108
+ // Three levels of depth: topics, the sub-topics inside Gravity, and the posts behind each sub-topic.
109
+ const hub = { x: 38, y: 52, r: 9, label: "Gravity" };
110
+ const names = ["Quantum", "Entropy", "Optics", "Relativity"];
111
+ const outer = names.map((label, i) => {
112
+ const [fx, fy] = LAYOUTS[view.prev][i];
113
+ const [tx, ty] = LAYOUTS[view.shape][i];
114
+ return { label, r: [7, 6, 6, 7][i], x: fx + (tx - fx) * e, y: fy + (ty - fy) * e };
115
+ });
116
+ const topics = [hub, ...outer];
117
+ const links = [
118
  [0, 1],
119
  [0, 2],
120
+ [0, 3],
121
  [0, 4],
122
+ [1, 4],
123
+ [2, 3],
124
+ ];
125
+ const subs = [
126
+ { x: 52, y: 40, label: "Spacetime", posts: [[59, 35], [58, 45], [47, 35]] },
127
+ { x: 24, y: 62, label: "Black holes", posts: [[17, 66], [22, 70], [17, 57]] },
128
+ { x: 50, y: 67, label: "Orbits", posts: [[58, 70], [46, 73], [56, 62]] },
129
  ];
130
  return (
131
+ <div className="fx fx-map" ref={ref}>
132
+ <div className="fx-pills mono" role="group" aria-label="Shape of the map">
133
+ {SHAPES.map((name, i) => (
134
+ <button key={name} type="button" className={i === view.shape ? "on" : ""} onClick={() => go.current(i)}>
135
+ {name}
136
+ </button>
137
  ))}
138
+ </div>
139
+ <div className="fx-crumb mono">
140
+ <span>Archive</span>
141
+ <span className="fx-crumb-deep"> › Gravity › Spacetime</span>
142
+ </div>
143
+ <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
144
+ <g className="fx-cam">
145
+ <g className="fx-dim">
146
+ {links.map(([a, b], i) => (
147
+ <line key={i} className="fx-edge" style={{ ["--d" as string]: `${i * 0.15}s` }} x1={topics[a].x} y1={topics[a].y} x2={topics[b].x} y2={topics[b].y} />
148
+ ))}
149
+ {links.map(([a, b], i) => {
150
+ const u = (view.t * 0.3 + i * 0.37) % 1; // a pulse of light travelling along the link
151
+ return <circle key={`p${i}`} className="fx-pulse" r="1" fill="#fff" fillOpacity={Math.sin(u * Math.PI)} cx={topics[b].x + (topics[a].x - topics[b].x) * u} cy={topics[b].y + (topics[a].y - topics[b].y) * u} />;
152
+ })}
153
+ {outer.map((n, i) => (
154
+ <g key={n.label}>
155
+ {[0, 1, 2].map((k) => {
156
+ const angle = i * 1.7 + k * 2.1;
157
+ return <circle key={k} className="fx-sat" cx={n.x + Math.cos(angle) * (n.r + 6)} cy={n.y + Math.sin(angle) * (n.r + 6)} r="1.1" />;
158
+ })}
159
+ <g className="fx-node" style={{ ["--d" as string]: `${0.5 + i * 0.12}s` }}>
160
+ <circle cx={n.x} cy={n.y} r={n.r * 0.9} className="fx-halo" style={{ ["--t" as string]: `${(i + 1) * 0.7}s` }} />
161
+ <circle cx={n.x} cy={n.y} r={n.r * 0.3} fill="#fff" />
162
+ <text x={n.x} y={n.y + n.r * 0.9 + 5} textAnchor="middle">
163
+ {n.label}
164
+ </text>
165
+ </g>
166
+ </g>
167
+ ))}
168
+ </g>
169
+ <g className="fx-deep">
170
+ <ellipse className="fx-ring" cx={hub.x} cy={hub.y} rx="15" ry="12.5" />
171
+ {subs.map((sub) => (
172
+ <g key={sub.label}>
173
+ <line className="fx-deep-edge" x1={hub.x} y1={hub.y} x2={sub.x} y2={sub.y} />
174
+ {sub.posts.map(([px, py]) => (
175
+ <g key={`${px}-${py}`}>
176
+ <line className="fx-deep-edge" x1={sub.x} y1={sub.y} x2={px} y2={py} />
177
+ <circle className="fx-post" cx={px} cy={py} r="0.9" />
178
+ </g>
179
+ ))}
180
+ <circle className="fx-halo" cx={sub.x} cy={sub.y} r="3.4" />
181
+ <circle cx={sub.x} cy={sub.y} r="1.5" fill="#fff" />
182
+ <text className="fx-sub" x={sub.x} y={sub.y + 5.4} textAnchor="middle">
183
+ {sub.label}
184
+ </text>
185
+ </g>
186
+ ))}
187
+ </g>
188
+ <g className="fx-node" style={{ ["--d" as string]: "0.3s" }}>
189
+ <circle cx={hub.x} cy={hub.y} r={hub.r * 0.9} className="fx-halo" />
190
+ <circle cx={hub.x} cy={hub.y} r={hub.r * 0.3} fill="#fff" />
191
+ <text x={hub.x} y={hub.y - hub.r * 0.9 - 2} textAnchor="middle">
192
+ {hub.label}
193
  </text>
194
  </g>
195
+ </g>
196
+ <g className="fx-panel">
197
+ <rect x="68" y="12" width="30" height="34" rx="2" />
198
+ <text className="fx-panel-h" x="71" y="19">
199
+ Gravity
200
+ </text>
201
+ <text className="fx-panel-k" x="71" y="26">
202
+ FROM YOUR POSTS
203
+ </text>
204
+ {["Why Things Fall", "Curved Space", "Notes on Orbits"].map((title, i) => (
205
+ <text key={title} className="fx-panel-t" x="71" y={32 + i * 5}>
206
+ {title}
207
+ </text>
208
+ ))}
209
+ </g>
210
  </svg>
211
  </div>
212
  );
 
233
  }
234
 
235
  function VideoVisual() {
236
+ const scenes = ["Price is a signal", "The numbers", "Why it worked"];
237
  return (
238
  <div className="fx fx-video">
239
+ <div className="fx-scenes">
240
+ {scenes.map((title, i) => (
241
+ <span key={title} className={i === 1 ? "on" : ""}>
242
+ <i className="mono">{i + 1}</i>
243
+ {title}
244
+ </span>
245
+ ))}
246
  </div>
247
  <div className="fx-frame v169">
248
+ <span className="mono">Live preview</span>
249
+ <b>Paid readers, doubled</b>
250
+ </div>
251
+ <div className="fx-render mono">
252
+ <i /> Rendering MP4
253
+ </div>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ function MemoryVisual() {
259
+ const notes = [
260
+ ["audience", "I write for indie founders"],
261
+ ["tone", "Short sentences, no jargon"],
262
+ ["avoid", "Never name competitors"],
263
+ ];
264
+ return (
265
+ <div className="fx fx-memory">
266
+ {notes.map(([key, value]) => (
267
+ <div key={key} className="fx-profile">
268
+ <span className="mono">{key}</span>
269
+ <p>{value}</p>
270
+ </div>
271
+ ))}
272
+ <div className="fx-clone mono">
273
+ <span className="fx-rec" /> Saved from your last chat
274
  </div>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ function HelpVisual() {
280
+ return (
281
+ <div className="fx fx-help">
282
+ <div className="fx-bubble">How do I make a video from one post?</div>
283
+ <p className="fx-answer">Open Create, choose Video, then pick the post, a template and a voice. You can edit every scene before you render.</p>
284
+ <div className="fx-source">
285
+ <span className="mono">Still stuck?</span>
286
+ <span className="fx-line">Send it to the team. We will email you back.</span>
287
  </div>
288
  </div>
289
  );
 
360
  eyebrow: "Research notebook",
361
  title: "Ask your archive anything. It answers with receipts.",
362
  body: "Notestack reads your posts the way a careful researcher would: it searches, opens the right post, and cites the exact lines. If you never wrote it, it tells you so instead of guessing.",
363
+ points: ["Every sentence links to the lines it came from", "Chats are saved per notebook", "Thumbs up or down on any answer"],
364
  visual: <ChatVisual />,
365
  },
366
+ {
367
+ id: "memory",
368
+ eyebrow: "Memory",
369
+ title: "It remembers who you write for.",
370
+ body: "Notestack keeps short notes about your audience, tone and preferences, learned as you chat, and uses them in every answer and draft. Read, edit or delete any note in Settings.",
371
+ points: ["Notes saved from your conversations", "Edit or delete any note, anytime", "Shared across your notebooks"],
372
+ visual: <MemoryVisual />,
373
+ },
374
  {
375
  id: "map",
376
+ eyebrow: "Mind Constellation",
377
+ title: "Fly through the galaxy of your ideas.",
378
+ body: "Open a notebook as a constellation: topics orbit the ideas they share, and every star lists the posts it comes from. Switch the shape, dive in and see what you keep coming back to.",
379
+ points: ["Orbit, spiral, figure or cluster layouts", "Scroll to dive in, click a star to fly to it", "Every star lists the posts behind it"],
380
  visual: <MapVisual />,
381
  },
382
  {
383
  id: "audio",
384
  eyebrow: "Audio overview",
385
  title: "Two hosts talk through your ideas.",
386
+ body: "Turn a post or a whole notebook into a natural conversation, voiced by ElevenLabs. Pick two hosts from a library of voices, or use a voice you made yourself.",
387
+ points: ["Deep dive, brief or debate formats", "Every line grounded in your posts", "Square audiograms from any overview"],
388
  visual: <AudioVisual />,
389
  },
390
  {
 
395
  points: ["Landscape for YouTube, vertical for Reels and TikTok", "Templates, stock footage, captions and 16 languages", "Square audiograms from any audio overview"],
396
  visual: <VideoVisual />,
397
  },
398
+ {
399
+ id: "voice",
400
+ eyebrow: "Voices",
401
+ title: "Sounds like you, on the page and out loud.",
402
+ body: "Notestack learns your writing voice from your own posts. For narration, describe a voice, pick its options, or record a couple of minutes to clone your own, then reuse it in every audio overview and video.",
403
+ points: ["Describe it, pick options or clone yours", "Fine tune stability, similarity and style", "Consented cloning you can revoke anytime"],
404
+ visual: <VoiceVisual />,
405
+ },
406
  {
407
  id: "kit",
408
  eyebrow: "Launch Kit",
 
412
  visual: <KitVisual />,
413
  },
414
  {
415
+ id: "help",
416
+ eyebrow: "Help",
417
+ title: "Stuck? Ask Notestack.",
418
+ body: "A help assistant in the app answers how-to questions about every screen. If it can't, send the question to the team and we will email you back.",
419
+ points: ["Answers about any feature, in plain words", "Hand off to a person in one click", "Always one click away"],
420
+ visual: <HelpVisual />,
421
  },
422
  {
423
  id: "pad",
frontend/src/pages/Landing.tsx CHANGED
@@ -9,9 +9,11 @@ import { blogPosts } from "../content/blogPosts";
9
  import { useAuth } from "../hooks/useAuth";
10
  import {
11
  ArrowRightIcon,
 
12
  HelmetIcon,
13
  LaunchWindowIcon,
14
  LaunchpadIcon,
 
15
  RocketIcon,
16
  SignalIcon,
17
  TelescopeIcon,
@@ -19,7 +21,7 @@ import {
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 = 4;
23
 
24
  const FEATURES = [
25
  {
@@ -28,6 +30,12 @@ const FEATURES = [
28
  title: "Ask your archive anything",
29
  body: "Every answer cites the exact passage it came from. If you never wrote it, Notestack says so.",
30
  },
 
 
 
 
 
 
31
  {
32
  icon: SignalIcon,
33
  label: "Audio overview",
@@ -48,9 +56,15 @@ const FEATURES = [
48
  },
49
  {
50
  icon: HelmetIcon,
51
- label: "Voice profile",
52
  title: "Sounds like you",
53
- body: "Notestack learns your tone from your own posts and scores every draft against it.",
 
 
 
 
 
 
54
  },
55
  {
56
  icon: LaunchpadIcon,
 
9
  import { useAuth } from "../hooks/useAuth";
10
  import {
11
  ArrowRightIcon,
12
+ ConstellationIcon,
13
  HelmetIcon,
14
  LaunchWindowIcon,
15
  LaunchpadIcon,
16
+ OrbitIcon,
17
  RocketIcon,
18
  SignalIcon,
19
  TelescopeIcon,
 
21
 
22
  export const PENDING_SOURCE_KEY = "ns_pending_source";
23
  // Bump after re-rendering public/demo.mp4 so browsers and the CDN fetch the new file.
24
+ const DEMO_VERSION = 5;
25
 
26
  const FEATURES = [
27
  {
 
30
  title: "Ask your archive anything",
31
  body: "Every answer cites the exact passage it came from. If you never wrote it, Notestack says so.",
32
  },
33
+ {
34
+ icon: ConstellationIcon,
35
+ label: "Mind Constellation",
36
+ title: "Fly through your ideas",
37
+ body: "Open a notebook as a galaxy of topics. Switch the shape, dive in and see the posts behind every star.",
38
+ },
39
  {
40
  icon: SignalIcon,
41
  label: "Audio overview",
 
56
  },
57
  {
58
  icon: HelmetIcon,
59
+ label: "Voices",
60
  title: "Sounds like you",
61
+ body: "Notestack learns your tone from your posts, and you can describe, pick or clone a voice for narration.",
62
+ },
63
+ {
64
+ icon: OrbitIcon,
65
+ label: "Memory",
66
+ title: "Remembers who you write for",
67
+ body: "Short notes about your audience and style, learned from your chats and yours to edit anytime.",
68
  },
69
  {
70
  icon: LaunchpadIcon,
frontend/src/styles/showcase.css CHANGED
@@ -185,8 +185,8 @@
185
  stroke: var(--white);
186
  stroke-opacity: 0.3;
187
  stroke-width: 0.4;
188
- stroke-dasharray: 60;
189
- stroke-dashoffset: 60;
190
  transition-delay: var(--d);
191
  }
192
 
@@ -216,6 +216,141 @@
216
 
217
  .showcase-row.arrived .fx-halo {
218
  animation: pulse-halo 4s ease-in-out infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
 
221
  @keyframes pulse-halo {
@@ -290,10 +425,31 @@
290
 
291
  /* Video */
292
  .fx-video {
293
- grid-template-columns: 0.55fr 1.2fr 0.8fr;
294
  align-items: center;
295
  }
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  .fx-frame {
298
  display: grid;
299
  gap: 6px;
@@ -311,22 +467,51 @@
311
  line-height: 1.2;
312
  }
313
 
314
- .v916 {
315
- aspect-ratio: 9 / 16;
316
- }
317
-
318
  .v169 {
319
  aspect-ratio: 16 / 9;
320
  }
321
 
322
- .v11 {
323
- aspect-ratio: 1;
 
 
324
  }
325
 
326
- .fx-mini-wave {
327
  display: block;
328
- height: 18px;
329
- background: repeating-linear-gradient(90deg, var(--blue) 0 3px, transparent 3px 6px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  }
331
 
332
  /* Launch Kit */
@@ -469,9 +654,19 @@
469
 
470
  .fx-wave span,
471
  .fx-halo,
 
 
 
 
 
472
  .fx-rec {
473
  animation: none !important;
474
  }
 
 
 
 
 
475
  }
476
 
477
  /* Demo video sound controls */
 
185
  stroke: var(--white);
186
  stroke-opacity: 0.3;
187
  stroke-width: 0.4;
188
+ stroke-dasharray: 150;
189
+ stroke-dashoffset: 150;
190
  transition-delay: var(--d);
191
  }
192
 
 
216
 
217
  .showcase-row.arrived .fx-halo {
218
  animation: pulse-halo 4s ease-in-out infinite;
219
+ animation-delay: var(--t, 0s);
220
+ }
221
+
222
+ /* Depth: the camera dives into Gravity, the other topics fall back, its sub-topics and posts light up,
223
+ then it pulls back out. One 14 s loop, started once the row has arrived. */
224
+ .fx-cam {
225
+ transform-origin: 38px 52px;
226
+ transform-box: view-box;
227
+ }
228
+
229
+ .fx-deep,
230
+ .fx-panel,
231
+ .fx-crumb-deep {
232
+ opacity: 0;
233
+ }
234
+
235
+ .showcase-row.arrived .fx-cam {
236
+ animation: fx-dive 14s ease-in-out 2.5s infinite;
237
+ }
238
+
239
+ .showcase-row.arrived .fx-dim {
240
+ animation: fx-dim 14s ease-in-out 2.5s infinite;
241
+ }
242
+
243
+ .showcase-row.arrived .fx-deep,
244
+ .showcase-row.arrived .fx-panel,
245
+ .showcase-row.arrived .fx-crumb-deep {
246
+ animation: fx-in 14s ease-in-out 2.5s infinite;
247
+ }
248
+
249
+ @keyframes fx-dive {
250
+ 0%,
251
+ 26%,
252
+ 86%,
253
+ 100% {
254
+ transform: none;
255
+ }
256
+ 40%,
257
+ 72% {
258
+ transform: translate(-4px, -2px) scale(2.1);
259
+ }
260
+ }
261
+
262
+ @keyframes fx-dim {
263
+ 0%,
264
+ 26%,
265
+ 86%,
266
+ 100% {
267
+ opacity: 1;
268
+ }
269
+ 40%,
270
+ 72% {
271
+ opacity: 0.1;
272
+ }
273
+ }
274
+
275
+ @keyframes fx-in {
276
+ 0%,
277
+ 32%,
278
+ 80%,
279
+ 100% {
280
+ opacity: 0;
281
+ }
282
+ 42%,
283
+ 70% {
284
+ opacity: 1;
285
+ }
286
+ }
287
+
288
+ .fx-sat {
289
+ fill: var(--white);
290
+ opacity: 0.5;
291
+ }
292
+
293
+ .fx-ring {
294
+ fill: none;
295
+ stroke: var(--blue);
296
+ stroke-opacity: 0.5;
297
+ stroke-width: 0.25;
298
+ stroke-dasharray: 1.2 1.6;
299
+ }
300
+
301
+ .fx-deep-edge {
302
+ stroke: var(--white);
303
+ stroke-opacity: 0.35;
304
+ stroke-width: 0.2;
305
+ }
306
+
307
+ .fx-post {
308
+ fill: var(--white);
309
+ }
310
+
311
+ .fx-sub {
312
+ fill: var(--white);
313
+ font: 500 2.4px var(--font-body);
314
+ }
315
+
316
+ .fx-panel rect {
317
+ fill: rgba(0, 0, 0, 0.88);
318
+ stroke: var(--blue);
319
+ stroke-opacity: 0.5;
320
+ stroke-width: 0.25;
321
+ }
322
+
323
+ .fx-panel text {
324
+ fill: var(--white);
325
+ }
326
+
327
+ .fx-panel-h {
328
+ font: 600 3.8px var(--font-display);
329
+ }
330
+
331
+ .fx-panel-k {
332
+ fill: var(--blue) !important;
333
+ font: 500 1.9px var(--font-mono);
334
+ letter-spacing: 0.2px;
335
+ }
336
+
337
+ .fx-panel-t {
338
+ font: 500 2.7px var(--font-body);
339
+ }
340
+
341
+ .fx-crumb {
342
+ display: flex;
343
+ min-height: 1.2em;
344
+ }
345
+
346
+ .fx-pulse {
347
+ opacity: 0;
348
+ filter: drop-shadow(0 0 1.5px var(--blue));
349
+ transition: opacity 1s ease 1.5s;
350
+ }
351
+
352
+ .showcase-row.arrived .fx-pulse {
353
+ opacity: 0.9;
354
  }
355
 
356
  @keyframes pulse-halo {
 
425
 
426
  /* Video */
427
  .fx-video {
428
+ grid-template-columns: 0.8fr 1.3fr;
429
  align-items: center;
430
  }
431
 
432
+ .fx-scenes {
433
+ display: grid;
434
+ gap: 8px;
435
+ }
436
+
437
+ .fx-scenes span {
438
+ display: flex;
439
+ align-items: center;
440
+ gap: 10px;
441
+ padding: 9px 12px;
442
+ border-radius: 10px;
443
+ border: 1px solid var(--line-strong);
444
+ font-size: 0.8rem;
445
+ }
446
+
447
+ .fx-scenes span.on {
448
+ border-color: var(--blue);
449
+ background: var(--blue-10);
450
+ box-shadow: 0 0 18px var(--blue-20);
451
+ }
452
+
453
  .fx-frame {
454
  display: grid;
455
  gap: 6px;
 
467
  line-height: 1.2;
468
  }
469
 
 
 
 
 
470
  .v169 {
471
  aspect-ratio: 16 / 9;
472
  }
473
 
474
+ .fx-render {
475
+ grid-column: 1 / -1;
476
+ display: grid;
477
+ gap: 6px;
478
  }
479
 
480
+ .fx-render i {
481
  display: block;
482
+ height: 4px;
483
+ border-radius: 99px;
484
+ background: linear-gradient(90deg, var(--blue) 70%, var(--surface-2) 70%);
485
+ }
486
+
487
+ /* Mind Constellation shapes */
488
+ .fx-pills {
489
+ display: flex;
490
+ gap: 6px;
491
+ }
492
+
493
+ .fx-pills button {
494
+ padding: 3px 10px;
495
+ border-radius: 99px;
496
+ border: 1px solid var(--line-strong);
497
+ background: transparent;
498
+ color: var(--text-2);
499
+ font: inherit;
500
+ cursor: pointer;
501
+ transition:
502
+ border-color 0.3s ease,
503
+ background 0.3s ease,
504
+ color 0.3s ease;
505
+ }
506
+
507
+ .fx-pills button:hover {
508
+ color: var(--white);
509
+ }
510
+
511
+ .fx-pills button.on {
512
+ border-color: var(--blue);
513
+ background: var(--blue-10);
514
+ color: var(--white);
515
  }
516
 
517
  /* Launch Kit */
 
654
 
655
  .fx-wave span,
656
  .fx-halo,
657
+ .fx-cam,
658
+ .fx-dim,
659
+ .fx-deep,
660
+ .fx-panel,
661
+ .fx-crumb-deep,
662
  .fx-rec {
663
  animation: none !important;
664
  }
665
+
666
+ .fx-pulse {
667
+ display: none;
668
+ }
669
+
670
  }
671
 
672
  /* Demo video sound controls */
marketing/notestack-brief.md ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notestack AI: content brief for Blog2Video and BlogHub
2
+
3
+ ## One line
4
+
5
+ **Notestack** ([notestack.ai](https://notestack.ai)) is the ultimate NotebookLM alternative for writers, bloggers and newsletter authors: it turns everything you have published into a research notebook that answers from your own words, then helps you turn those words into audio, video and posts.
6
+
7
+ ## Short description (about 50 words)
8
+
9
+ Notestack connects to your blog, newsletter or website and pulls your whole archive into orbit. Ask it anything and it answers from what you actually wrote, citing the exact lines. Then turn any post into a two host audio overview, short videos, quote cards and a ready to post Launch Kit, all in your own voice.
10
+
11
+ ## Long description (about 150 words)
12
+
13
+ Most writers have years of ideas scattered across hundreds of posts, and almost none of it gets used again. Notestack fixes that. Paste the address of your Substack, Ghost, WordPress or Medium publication, any RSS feed, or any website (even ones without a feed), or upload markdown and PDF files. Notestack reads every post and keeps them in sync as you publish.
14
+
15
+ From there, your archive becomes a research notebook. Ask a question and get an answer built only from your writing, with citations to the exact lines, so nothing is made up. Explore a map of the topics you keep returning to and the ones that went quiet. Turn any post into a two host audio overview, a 9:16 short, a 16:9 explainer or an audiogram. Generate a Launch Kit with an X thread, LinkedIn post, Substack Note, Bluesky post, SEO pack and carousel, then schedule it all from one calendar.
16
+
17
+ ## Why it is the NotebookLM alternative for writers
18
+
19
+ | | Notestack | NotebookLM |
20
+ | --- | --- | --- |
21
+ | Getting your writing in | Syncs a whole blog, newsletter or site, plus md, txt, html and pdf | Add sources one at a time |
22
+ | Staying current | New posts arrive automatically | Re-add sources by hand |
23
+ | Citations | Exact line ranges, checked before you see them | Passage level citations |
24
+ | Publishing | Launch Kits and auto posting to X, LinkedIn and Bluesky | Not a publishing tool |
25
+ | Your voice | Learns your writing voice from your posts | General purpose writing |
26
+
27
+ Full comparison: [Notestack vs NotebookLM](https://notestack.ai/notebooklm-alternative)
28
+
29
+ ## Key features
30
+
31
+ - **Connect any source:** Substack (full archive), Ghost, WordPress, Medium, any RSS feed, whole websites, single URLs, and markdown, text, HTML or PDF uploads.
32
+ - **Grounded research notebooks:** answers come only from your posts, with citations to the exact lines. If your archive does not cover a question, it says so.
33
+ - **Topic map:** a constellation of your ideas, showing which topics are rising and which have gone dormant.
34
+ - **Voice profile:** learns how you write, so every draft sounds like you. Optional consented voice cloning for audio.
35
+ - **Audio overviews:** two hosts talk through any notebook or post.
36
+ - **Video studio:** 9:16 shorts, 16:9 explainers, 1:1 audiograms and quote cards in your brand colors.
37
+ - **Launch Kit:** hooks, X thread, LinkedIn post, Substack Notes, Bluesky post, SEO pack and carousel from one post.
38
+ - **Launchpad:** a calendar that auto posts to X, LinkedIn and Bluesky, with email reminders for everything else.
39
+ - **Resurfacing:** finds evergreen posts worth sharing again.
40
+
41
+ ## Pricing
42
+
43
+ | Plan | Price | Includes |
44
+ | --- | --- | --- |
45
+ | Free | $0 | 1 source, first 5 posts indexed, 3 audio minutes, 1 video minute, 2 Launch Kits a month |
46
+ | Writer | $24.99 a month ($18.99 a month billed annually) | 3 sources, 500 posts, 60 audio minutes, 30 video minutes, 50 Launch Kits |
47
+ | Studio | $48.99 a month ($36.99 a month billed annually) | 10 sources, 5,000 posts, 240 audio minutes, 120 video minutes, unlimited Launch Kits |
48
+
49
+ Annual billing saves 24%. Current pricing: [notestack.ai/pricing](https://notestack.ai/pricing)
50
+
51
+ ## Who it is for
52
+
53
+ - Newsletter writers on Substack, Ghost or beehiiv who want to reuse their archive
54
+ - Bloggers and content marketers with years of posts
55
+ - Researchers and consultants who publish long form thinking
56
+ - Anyone who has searched for a NotebookLM alternative built around their own writing
57
+
58
+ ## How it fits with Blog2Video, PDF2Video and BlogHub
59
+
60
+ All four are made by Firebird Technologies and work together:
61
+
62
+ - **[Notestack](https://notestack.ai):** research and repurpose your whole archive (the thinking layer).
63
+ - **[Blog2Video](https://blog2video.app):** turn a single blog post or URL into a video.
64
+ - **[PDF2Video](https://pdf2vid.com):** turn a PDF, paper or deck into a narrated video.
65
+ - **[BlogHub](https://bloghub.app):** a free directory where readers discover blogs and newsletters.
66
+
67
+ A natural flow: write and publish, list your publication on BlogHub so readers can find it, use Notestack to research and repurpose the archive, and use Blog2Video for standalone videos of single posts.
68
+
69
+ **Positioning rule:** Notestack is the NotebookLM alternative. BlogHub is a blog and newsletter directory, and Blog2Video and PDF2Video are video tools. Do not describe BlogHub, Blog2Video or PDF2Video as NotebookLM alternatives.
70
+
71
+ ## Links to use
72
+
73
+ | Anchor text | URL |
74
+ | --- | --- |
75
+ | Notestack | https://notestack.ai |
76
+ | the ultimate NotebookLM alternative for writers | https://notestack.ai/notebooklm-alternative |
77
+ | Notestack vs NotebookLM | https://notestack.ai/notebooklm-alternative |
78
+ | best NotebookLM alternatives | https://notestack.ai/blogs/notebooklm-alternatives |
79
+ | NotebookLM vs ChatGPT | https://notestack.ai/blogs/notebooklm-vs-chatgpt |
80
+ | best AI for research | https://notestack.ai/blogs/best-ai-for-research |
81
+ | AI tools for writers | https://notestack.ai/blogs/ai-tools-for-writers |
82
+ | AI second brain for writers | https://notestack.ai/blogs/ai-second-brain-for-writers |
83
+ | Notestack pricing | https://notestack.ai/pricing |
84
+
85
+ Vary the anchor text between posts, and link where it helps the reader (for example, when a post talks about reusing old posts, researching your own writing, or NotebookLM).
86
+
87
+ ## Ready to paste blurbs
88
+
89
+ **Blog2Video (end of a post):**
90
+
91
+ > Want to go beyond one video? [Notestack](https://notestack.ai) reads your entire archive, answers questions from your own posts with exact citations, and turns them into audio overviews, shorts and ready to post Launch Kits. It is the [NotebookLM alternative built for writers](https://notestack.ai/notebooklm-alternative).
92
+
93
+ **BlogHub (listing page or newsletter):**
94
+
95
+ > Listed your blog here? Put the archive to work with [Notestack](https://notestack.ai). Connect your blog or newsletter and ask it anything, with every answer cited to your own posts, then repurpose your best writing into audio, video and social posts.
96
+
97
+ **Short social or sidebar version:**
98
+
99
+ > Your knowledge, in orbit. [Notestack](https://notestack.ai): the NotebookLM alternative for writers.
100
+
101
+ ## Tone and style
102
+
103
+ - Warm, curious, a little space themed ("your knowledge, in orbit", "explore", "launch"), never hypey.
104
+ - Lead with the feeling (rediscovering your own ideas), then the proof (answers cite your exact lines).
105
+ - No em dashes. Use a colon, comma or a new sentence instead.
106
+ - Brand name: **Notestack** in text; "Notestack AI" is fine in headlines and video outros.
107
+ - Tagline: **Your knowledge, in orbit.**
108
+
109
+ ## Media
110
+
111
+ - Launch trailer (82 s): `marketing/notestack-launch.mp4` in the Notestack repo
112
+ - Product demo (47 s): https://notestack.ai/demo.mp4
113
+ - Logo files: `marketing/logo/` (mark, and lockups for dark and light backgrounds)
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,410 @@ 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 +856,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 +873,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 +882,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
+ "Why Things Fall", "Curved Space", "Notes on Orbits", "What Is Entropy?",
300
+ "The Quantum Leap", "Light as a Wave", "Time Is Strange", "Black Hole Basics",
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 gravity?", 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 gravity|spacetime", "Reading Why Things Fall, lines 12 to 20", "Reading Curved Space, lines 4 to 9"];
369
+ const context = ["Newton called it a force pulling things down.", "Einstein saw something different: gravity is", "the curvature of spacetime itself.", "Mass tells space how to bend.", "Objects follow the straightest path."];
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 wrote that <b>gravity is the curvature of spacetime</b> <Marker n={1} glow={cite} /> and that orbits are objects falling around that curve <Marker n={2} glow={cite} />.
 
 
388
  </p>
389
  </div>
390
+ {[
391
+ { n: 1, label: "Why Things Fall · L12 to 14" },
392
+ { n: 2, label: "Curved Space · 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] WHY THINGS FALL · L12 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
+ "Gravity", "Spacetime", "Black holes", "Orbits", "Relativity", "Quantum", "Entropy", "Optics",
430
+ "Waves", "Energy", "Chaos", "Cosmology", "Fields", "Symmetry", "Time",
431
+ ];
432
+ const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
433
+ type Shape = "orbit" | "spiral" | "figure" | "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.4 + 0.4;
449
+ const r = 110 + t * 260;
450
+ return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
451
+ }
452
+ if (shape === "figure") {
453
+ // A constellation figure: one long wave of stars across the sky.
454
+ return { x: 140 + i * 78, y: cy + (i % 2 ? -1 : 1) * (90 + (i % 3) * 30) };
455
+ }
456
+ const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }];
457
+ const c = centers[i % 3];
458
+ const k = Math.floor(i / 3);
459
+ const a = k * 1.25 + (i % 3);
460
+ const r = 36 + k * 24;
461
+ return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r };
462
+ }
463
+
464
+ /** Frame windows in which the layout morphs to the next shape (after each pill click). */
465
+ const MORPHS: [Shape, number, number][] = [["spiral", 58, 82], ["figure", 94, 118], ["cluster", 126, 150]];
466
+
467
+ function mapPos(i: number, frame: number): { x: number; y: number } {
468
+ let { x, y } = place("orbit", i);
469
+ for (const [shape, from, to] of MORPHS) {
470
+ const t = ease(clamp01(frame, from, to));
471
+ const next = place(shape, i);
472
+ x += (next.x - x) * t;
473
+ y += (next.y - y) * t;
474
+ }
475
+ return { x, y };
476
+ }
477
+
478
+ /** 03: the Mind Constellation, switching shapes and diving into a topic. */
479
+ function MindMap() {
480
+ const frame = useCurrentFrame();
481
+ const { fps } = useVideoConfig();
482
+ const activeShape = frame < 56 ? 0 : frame < 92 ? 1 : frame < 124 ? 2 : 3;
483
+ const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } });
484
+ const hubWin = place("cluster", 0);
485
+ const hub = at(hubWin.x, hubWin.y);
486
+ const selected = frame >= 158;
487
+ const ring = clamp01(frame, 4, 40);
488
+ const dive = 1 + 0.14 * ease(clamp01(frame, 158, 195)); // the camera drifts in on the picked star
489
+ const burst = ease(clamp01(frame, 158, 182));
490
+ return (
491
+ <AbsoluteFill>
492
+ <WarpField speed={0.6} count={260} />
493
+ <Caption eyebrow="03 · Map" title="See your whole archive." />
494
+ <Win>
495
+ <svg width={1640} height={668} style={{ position: "absolute", inset: 0 }}>
496
+ <g transform={`translate(${hubWin.x} ${hubWin.y}) scale(${dive}) translate(${-hubWin.x} ${-hubWin.y})`}>
497
+ {/* Orbit rings draw in, then fade as the stars leave them. */}
498
+ {[170, 270, 360].map((r, i) => (
499
+ <ellipse key={r} cx={620} cy={350} rx={r * 1.5} ry={r * 0.58} fill="none" stroke={i % 2 ? "#ffffff" : BLUE} strokeOpacity={0.22 * ring * (1 - ease(clamp01(frame, 58, 82)))} strokeWidth={1.5} strokeDasharray="6 10" />
500
+ ))}
501
+ {TOPICS.map((_, i) => {
502
+ if (i === 0) return null;
503
+ const parent = i < 4 ? 0 : i - 3;
504
+ const from = mapPos(i, frame);
505
+ const to = mapPos(parent, frame);
506
+ const on = clamp01(frame, 6 + i * 3, 24 + i * 3);
507
+ const lit = !selected || parent === 0;
508
+ const t = (frame * 0.018 + i * 0.37) % 1; // a pulse of light travelling toward the hub
509
+ return (
510
+ <g key={i} opacity={lit ? 1 : 0.25}>
511
+ <line x1={from.x} y1={from.y} x2={to.x} y2={to.y} pathLength={1} stroke={i % 2 ? BLUE : "#ffffff"} strokeOpacity={selected && parent === 0 ? 0.8 : 0.3} strokeWidth={selected && parent === 0 ? 2.5 : 1.5} strokeDasharray={1} strokeDashoffset={1 - on} />
512
+ {on >= 1 && <circle cx={from.x + (to.x - from.x) * t} cy={from.y + (to.y - from.y) * t} r={3.5} fill="#ffffff" opacity={Math.sin(t * Math.PI)} style={{ filter: `drop-shadow(0 0 6px ${BLUE})` }} />}
513
+ </g>
514
+ );
515
+ })}
516
+ {TOPICS.map((name, i) => {
517
+ const base = mapPos(i, frame);
518
+ const p = { x: base.x + Math.sin(frame * 0.03 + i * 1.9) * 5, y: base.y + Math.cos(frame * 0.025 + i * 1.3) * 5 };
519
+ const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } });
520
+ const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born;
521
+ const twinkle = 1 + 0.25 * Math.sin(frame * 0.14 + i * 1.7);
522
+ const near = i === 0 || (i >= 1 && i <= 3);
523
+ const dim = selected && !near ? 0.3 : 1;
524
+ return (
525
+ <g key={name} opacity={dim}>
526
+ <circle cx={p.x} cy={p.y} r={r * 2.6 * twinkle} fill={BLUE} opacity={0.18 * born} style={{ filter: "blur(8px)" }} />
527
+ <circle cx={p.x} cy={p.y} r={r} fill="#ffffff" style={{ filter: `drop-shadow(0 0 10px ${BLUE})` }} />
528
+ {i === 0 && selected && <circle cx={p.x} cy={p.y} r={r + 14 + 22 * burst} fill="none" stroke={BLUE} strokeWidth={3} opacity={1 - 0.6 * burst} />}
529
+ <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}>
530
+ {name}
531
+ </text>
532
+ </g>
533
+ );
534
+ })}
535
+ </g>
536
+ </svg>
537
+ <div style={{ position: "absolute", left: 40, top: 26, display: "flex", gap: 10 }}>
538
+ {MAP_SHAPES.map((name, i) => (
539
+ <Chip key={name} on={i === activeShape} press={i === 0 ? 0 : pulse(frame, [0, 56, 92, 124][i])} style={{ width: 120, height: 48, fontSize: 22 }}>
540
+ {name}
541
+ </Chip>
542
+ ))}
543
+ </div>
544
+ <p style={{ position: "absolute", left: 40, bottom: 20, margin: 0, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", opacity: 1 - clamp01(frame, 150, 165) }}>Scroll to dive in. Drag to move. Click a star to fly to it.</p>
545
+ <div style={{ ...panel, position: "absolute", right: 40, top: 26, width: 380, padding: 28, opacity: panelIn, transform: `translateX(${(1 - panelIn) * 60}px)` }}>
546
+ <p style={{ margin: 0, fontFamily: theme.display, fontSize: 40, color: theme.white }}>Gravity</p>
547
+ <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>Orbiting here</Label>
548
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
549
+ {["Spacetime", "Black holes", "Orbits"].map((t) => (
550
+ <Chip key={t} on style={{ padding: "4px 16px", fontSize: 20 }}>{t}</Chip>
551
+ ))}
552
+ </div>
553
+ <Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>From your posts</Label>
554
+ {["Why Things Fall", "Curved Space", "Notes on Orbits"].map((t) => (
555
+ <p key={t} style={{ margin: "10px 0 0", fontFamily: theme.body, fontSize: 24, color: theme.white }}>{t}</p>
556
+ ))}
557
+ </div>
558
+ </Win>
559
+ <Cursor
560
+ keys={[
561
+ { f: 0, x: 1560, y: 880 },
562
+ { f: 42, ...at(230, 50) },
563
+ { f: 58, ...at(230, 50) },
564
+ { f: 84, ...at(360, 50) },
565
+ { f: 94, ...at(360, 50) },
566
+ { f: 114, ...at(490, 50) },
567
+ { f: 126, ...at(490, 50) },
568
+ { f: 152, x: hub.x, y: hub.y },
569
+ { f: 172, x: hub.x + 30, y: hub.y + 120 },
570
+ ]}
571
+ clicks={[56, 92, 124, 156]}
572
+ />
573
+ </AbsoluteFill>
574
+ );
575
+ }
576
+
577
+ /** 04: pick a format, create, play. */
578
  function Audio() {
579
  const frame = useCurrentFrame();
580
  const { fps } = useVideoConfig();
581
+ const formats = ["Deep dive", "Brief", "Debate"];
582
+ const picked = frame >= 22 ? 2 : 0;
583
+ const bar = clamp01(frame, 52, 84);
584
+ const playing = clamp01(frame, 84, 96);
585
  const lines = [
586
+ { who: "A", text: "So the big idea here is that gravity is curved spacetime." },
587
+ { who: "B", text: "Right, and orbits are just objects falling around the curve." },
588
+ { who: "A", text: "Which she wrote about back in March, in Curved Space." },
589
  ];
590
+ const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24));
591
  return (
592
  <AbsoluteFill>
593
  <WarpField speed={0.7} count={260} />
594
+ <Caption eyebrow="04 · Listen" title="Turn it into a conversation." />
595
+ <Win>
596
+ <Label style={{ left: 80, top: 36 }}>Format</Label>
597
+ {formats.map((f, i) => (
598
+ <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 }}>
599
+ {f}
600
+ </Chip>
601
+ ))}
602
+ <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}` }}>
603
+ {bar > 0 && bar < 1 ? "Recording..." : "Create audio"}
604
+ </div>
605
+ <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 }} />
606
+ <div style={{ position: "absolute", left: 80, top: 290, width: 1480, height: 170, display: "flex", alignItems: "center", justifyContent: "center", gap: 10, opacity: playing }}>
607
  {Array.from({ length: 64 }).map((_, i) => {
608
  const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
609
+ const env = spring({ frame: frame - 90, fps, config: { damping: 20 } });
610
+ 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}` }} />;
611
  })}
612
  </div>
613
+ <div style={{ position: "absolute", left: 80, top: 480, display: "grid", gap: 12 }}>
614
  {lines.map((l, i) => {
615
  const on = i === active;
 
616
  return (
617
+ <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 }}>
618
+ <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>
 
 
619
  {l.text}
620
  </div>
621
  );
622
  })}
623
  </div>
624
+ </Win>
625
+ <Cursor
626
+ 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) }]}
627
+ clicks={[22, 50]}
628
+ />
629
  </AbsoluteFill>
630
  );
631
  }
632
 
633
+ const VIDEO_STYLES = ["16:9 Explainer", "9:16 Short", "1:1 Square"];
634
+ const TEMPLATE_LOOKS: CSSProperties[] = [
635
+ { background: "linear-gradient(135deg, #000, #0b1730)" },
636
+ { background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` },
637
+ { background: "linear-gradient(135deg, #ffffff, #cfe0ff)" },
638
+ { background: "linear-gradient(135deg, #0b1730, #217cff55)" },
639
+ ];
640
+ const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "Studio"];
641
+ const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"];
642
+
643
+ /** 05: wizard: pick a style, a template and a voice, create. */
644
+ function VideoCreate() {
645
  const frame = useCurrentFrame();
646
  const { fps } = useVideoConfig();
647
+ const style = frame >= 34 ? 1 : 0;
648
+ const tpl = frame >= 74 ? 1 : 0;
649
+ const voice = frame >= 114 ? 1 : 0;
650
+ const go = pulse(frame, 162);
651
+ const busy = clamp01(frame, 164, 194);
 
652
  return (
653
  <AbsoluteFill>
654
+ <WarpField speed={0.8} count={260} />
655
+ <Caption eyebrow="05 · Make" title="Pick a look. Pick a voice." />
656
+ <Win>
657
+ <Label style={{ left: 80, top: 30 }}>Video style</Label>
658
+ {VIDEO_STYLES.map((t, i) => (
659
+ <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 }}>
660
+ {t}
661
+ </Chip>
662
+ ))}
663
+ <Label style={{ left: 80, top: 160 }}>Template</Label>
664
+ {TEMPLATE_NAMES.map((name, i) => {
665
+ const s = spring({ frame: frame - 6 - i * 5, fps, config: { damping: 16 } });
666
+ const sel = i === tpl;
667
  return (
668
+ <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] }}>
669
+ <div style={{ position: "absolute", left: 20, top: 24, width: 120, height: 12, borderRadius: 6, background: i === 2 ? "#000" : "#fff", opacity: 0.9 }} />
670
+ <div style={{ position: "absolute", left: 20, top: 52, width: 190, height: 8, borderRadius: 4, background: i === 2 ? "#000" : "#fff", opacity: 0.4 }} />
671
+ <div style={{ position: "absolute", left: 20, bottom: 16, fontFamily: theme.body, fontSize: 22, color: i === 2 ? "#000" : "#fff" }}>{name}</div>
672
  </div>
673
  );
674
  })}
675
+ <Label style={{ left: 80, top: 430 }}>Narration voice</Label>
676
+ {VOICES.map((v, i) => (
677
+ <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 }}>
678
+ {i < 2 && <span style={{ marginRight: 12, color: BLUE }}>▶</span>}
679
+ {v}
680
+ </Chip>
681
+ ))}
682
+ <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}` }}>
683
+ <div style={{ position: "absolute", left: 0, bottom: 0, height: 6, width: `${busy * 100}%`, background: "#fff" }} />
684
+ {busy > 0 ? "Making your video..." : "Create video"}
685
+ </div>
686
+ </Win>
687
+ <Cursor
688
+ keys={[
689
+ { f: 0, x: 1560, y: 880 },
690
+ { f: 30, ...at(450, 98) },
691
+ { f: 38, ...at(450, 98) },
692
+ { f: 70, ...at(515, 295) },
693
+ { f: 78, ...at(515, 295) },
694
+ { f: 110, ...at(630, 502) },
695
+ { f: 118, ...at(630, 502) },
696
+ { f: 158, ...at(1410, 598) },
697
+ { f: 190, ...at(1410, 598) },
698
+ ]}
699
+ clicks={[34, 74, 114, 162]}
700
+ />
701
+ </AbsoluteFill>
702
+ );
703
+ }
704
+
705
+ const SCENE_TITLES = ["Gravity is geometry", "Inverse square", "Orbits", "Try it yourself"];
706
+
707
+ /** 06: the scene editor, then render the MP4. */
708
+ function VideoEdit() {
709
+ const frame = useCurrentFrame();
710
+ const { fps } = useVideoConfig();
711
+ const active = frame >= 32 ? 1 : 0;
712
+ const since = frame >= 32 ? frame - 32 : frame;
713
+ const pop = spring({ frame: since, fps, config: { damping: 16 } });
714
+ const render = clamp01(frame, 110, 164);
715
+ const ready = frame >= 166;
716
+ const tabs = ["Edit Scenes", "Images/footage", "Audio", "Settings"];
717
+ return (
718
+ <AbsoluteFill>
719
+ <WarpField speed={0.7} count={260} />
720
+ <Caption eyebrow="06 · Edit" title="Tweak any scene, then render." />
721
+ <Win>
722
+ {tabs.map((t, i) => (
723
+ <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)"}` }}>
724
  {t}
725
  </span>
726
  ))}
727
+ <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}` }}>
728
+ Download MP4
729
+ </div>
730
+ {SCENE_TITLES.map((t, i) => {
731
+ const s = spring({ frame: frame - 4 - i * 5, fps, config: { damping: 16 } });
732
+ const on = i === active;
733
+ return (
734
+ <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})` }}>
735
+ <span style={{ fontFamily: theme.mono, fontSize: 22, color: BLUE }}>{i + 1}</span>
736
+ <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)" }} />
737
+ <span style={{ fontFamily: theme.body, fontSize: 25, color: theme.white }}>{t}</span>
738
+ </div>
739
+ );
740
+ })}
741
+ <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` }}>
742
+ {active === 0 ? (
743
+ <div style={{ position: "absolute", left: 70, top: 160, opacity: pop, transform: `translateY(${(1 - pop) * 30}px)` }}>
744
+ <p style={{ margin: 0, fontFamily: theme.mono, fontSize: 22, letterSpacing: 5, color: BLUE }}>SCENE 1</p>
745
+ <p style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 86, lineHeight: 1.05, color: theme.white, textShadow: `0 0 30px ${BLUE}` }}>Gravity is<br />a curve.</p>
746
+ </div>
747
+ ) : (
748
+ <>
749
+ <p style={{ position: "absolute", left: 70, top: 56, margin: 0, fontFamily: theme.display, fontSize: 52, color: theme.white, opacity: pop }}>Gravity fades with distance</p>
750
+ {[1, 0.25, 0.11].map((h, i) => (
751
+ <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 === 0 ? "#ffffff" : BLUE, boxShadow: `0 0 30px ${BLUE}` }} />
752
+ ))}
753
+ <p style={{ position: "absolute", left: 70, bottom: 30, margin: 0, fontFamily: theme.body, fontSize: 26, color: "rgba(255,255,255,0.85)", opacity: pop }}>Double the distance and gravity drops to a quarter.</p>
754
+ </>
755
+ )}
756
+ {render > 0 && (
757
+ <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) }}>
758
+ <p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, color: theme.white }}>{ready ? "✓ Your MP4 is ready" : "Rendering MP4"}</p>
759
+ <div style={{ marginTop: 20, height: 10, borderRadius: 5, background: "rgba(255,255,255,0.14)" }}>
760
+ <div style={{ width: `${(ready ? 1 : render) * 100}%`, height: "100%", borderRadius: 5, background: BLUE, boxShadow: `0 0 16px ${BLUE}` }} />
761
+ </div>
762
+ </div>
763
+ )}
764
+ </div>
765
+ </Win>
766
+ <Cursor
767
+ keys={[
768
+ { f: 0, x: 1560, y: 880 },
769
+ { f: 26, ...at(280, 373) },
770
+ { f: 38, ...at(280, 373) },
771
+ { f: 100, ...at(1450, 36) },
772
+ { f: 124, ...at(1450, 36) },
773
+ { f: 150, ...at(1250, 480) },
774
+ ]}
775
+ clicks={[32, 108]}
776
+ />
777
  </AbsoluteFill>
778
  );
779
  }
780
 
781
+ /** 07: the Launch Kit appears, then Launchpad schedules it across the week. */
782
+ function LaunchKit() {
783
  const frame = useCurrentFrame();
784
+ const { fps } = useVideoConfig();
785
+ const cards = [
786
+ { label: "X THREAD", body: "Gravity is not a force. It is geometry. A thread on how Einstein changed everything." },
787
+ { label: "LINKEDIN", body: "Most of us learned gravity wrong. Here is the picture physicists actually use." },
788
+ { label: "SUBSTACK NOTES", body: "Mass tells space how to bend. Space tells mass how to move." },
789
+ { label: "BLUESKY", body: "Orbits are just falling, forever, around a curve." },
790
+ ];
791
+ const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
792
+ const slots: Record<number, string> = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" };
793
  return (
794
  <AbsoluteFill>
795
+ <WarpField speed={0.9} count={260} />
796
+ <Caption eyebrow="07 · Launch" title="A week of posts, scheduled." />
797
+ <Win>
798
+ {cards.map((c, i) => {
799
+ const s = spring({ frame: frame - 8 - i * 7, fps, config: { damping: 14 } });
800
  return (
801
+ <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 }}>
802
+ <span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
803
+ <span style={{ fontFamily: theme.body, fontSize: 26, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
804
  </div>
805
  );
806
  })}
807
+ <Label style={{ left: 80, top: 300 }}>Launchpad</Label>
808
+ {days.map((d, i) => {
809
+ const lit = frame >= 76 + i * 6;
810
+ const s = spring({ frame: frame - 76 - i * 6, fps, config: { damping: 12 } });
 
 
 
 
 
 
 
 
 
 
 
811
  return (
812
+ <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)" }}>
813
+ {d}
814
+ {lit && (
815
+ <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>
816
  )}
817
+ </div>
 
 
 
 
 
 
 
 
 
818
  );
819
+ })}
820
+ <div style={{ position: "absolute", left: 80, top: 540, display: "flex", gap: 14 }}>
821
+ {["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
822
+ <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) }}>
823
+ {t}
824
+ </span>
825
+ ))}
826
+ </div>
827
+ <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}` }}>
828
+ Schedule all
829
+ </div>
830
+ </Win>
831
+ <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]} />
832
  </AbsoluteFill>
833
  );
834
  }
 
856
  );
857
  }
858
 
859
+ const RENDER: Record<SceneId, () => JSX.Element> = {
860
  warp: Warp,
861
+ sync: Sync,
862
+ ask: Ask,
863
+ map: MindMap,
864
  audio: Audio,
865
+ video: VideoCreate,
866
+ edit: VideoEdit,
867
+ kit: LaunchKit,
868
  outro: Outro,
869
  };
870
 
 
873
  return (
874
  <AbsoluteFill style={{ backgroundColor: theme.black }}>
875
  <SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
876
+ {SCENES.map((scene) => {
877
  const from = cursor;
878
  cursor += S(scene.len - XFADE);
879
  const Scene = RENDER[scene.id];
 
882
  <FadeScene len={scene.len}>
883
  <Scene />
884
  </FadeScene>
885
+ {scene.vo !== undefined && (
886
+ <Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
887
+ <SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
888
+ </Sequence>
889
+ )}
890
  </Sequence>
891
  );
892
  })}
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;