Spaces:
Runtime error
Runtime error
Commit ·
bfe94d3
1
Parent(s): 19e319e
adding new demo + landing page improvements
Browse files
renderer/public/demo-vo/durations.json
CHANGED
|
@@ -1,10 +1,7 @@
|
|
| 1 |
{
|
| 2 |
-
"warp": 3.
|
| 3 |
-
"
|
| 4 |
-
"
|
| 5 |
-
"
|
| 6 |
-
"
|
| 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 |
-
"""
|
| 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 *
|
| 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
|
| 74 |
CHORDS = [
|
| 75 |
-
(0.0, [50, 57,
|
| 76 |
-
(5.2, [
|
| 77 |
-
(10.4, [43, 50,
|
| 78 |
-
(15.6, [45, 52,
|
| 79 |
-
(20.8, [50, 57,
|
| 80 |
-
(26.0, [
|
| 81 |
-
(31.2, [43, 50, 55,
|
| 82 |
-
(36.4, [50, 57,
|
| 83 |
]
|
| 84 |
CHORD_LEN = 5.2
|
| 85 |
|
| 86 |
# Filter brightness rises slowly through the piece and blooms at the outro.
|
| 87 |
-
brightness = 0.
|
| 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.
|
| 101 |
left[sl] += sub
|
| 102 |
right[sl] += sub
|
| 103 |
|
| 104 |
-
# Bell arpeggio from the second scene on:
|
| 105 |
-
step = 60 /
|
| 106 |
rng = np.random.default_rng(3)
|
| 107 |
-
beat =
|
| 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.
|
| 116 |
-
bell(hz(midi), beat, gain=0.
|
| 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 *
|
| 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(
|
| 140 |
ph = rng.uniform(0, 2 * np.pi, 2)
|
| 141 |
-
d = np.exp(-it * rng.uniform(
|
| 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
|
| 4 |
-
|
| 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": "
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
"
|
| 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.
|
| 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:
|
| 8 |
-
* Scenes:
|
| 9 |
-
*
|
|
|
|
| 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"):
|
| 15 |
-
//
|
| 16 |
-
// needs more room, so it always ends with a breath before the
|
| 17 |
const BASE_SCENES = [
|
| 18 |
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 19 |
-
{ id: "
|
| 20 |
-
{ id: "
|
| 21 |
-
{ id: "
|
| 22 |
-
{ id: "audio", len: 5.
|
| 23 |
-
{ id: "
|
| 24 |
-
{ id: "
|
|
|
|
| 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
|
| 29 |
|
| 30 |
// Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them.
|
| 31 |
-
const VO_SECONDS: Record<
|
| 32 |
-
const SCENES = BASE_SCENES.map((s) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
| 35 |
const VO_WINDOWS: [number, number][] = (() => {
|
| 36 |
let cursor = 0;
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
cursor += scene.len - XFADE;
|
| 40 |
-
|
| 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:
|
| 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:
|
| 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", "
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
const frame = useCurrentFrame();
|
| 212 |
const { fps } = useVideoConfig();
|
| 213 |
-
const url = typed("yourblog.com", frame,
|
| 214 |
-
const
|
| 215 |
-
const
|
| 216 |
-
const
|
| 217 |
return (
|
| 218 |
<AbsoluteFill>
|
| 219 |
-
<WarpField speed={1
|
| 220 |
-
<Caption eyebrow="
|
| 221 |
-
<
|
| 222 |
-
<div style={{ .
|
| 223 |
-
|
| 224 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 247 |
{SOURCES.map((name, i) => {
|
| 248 |
-
const on = spring({ frame: frame -
|
| 249 |
return (
|
| 250 |
-
<
|
| 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 |
-
</
|
| 268 |
);
|
| 269 |
})}
|
| 270 |
</div>
|
| 271 |
-
|
| 272 |
-
|
| 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 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 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 |
-
|
|
|
|
| 370 |
const frame = useCurrentFrame();
|
| 371 |
const { fps } = useVideoConfig();
|
| 372 |
-
const q = typed("What have I written about pricing?", frame,
|
| 373 |
-
const answer = spring({ frame: frame -
|
| 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.
|
| 379 |
-
<Caption eyebrow="
|
| 380 |
-
<
|
| 381 |
-
<div style={{
|
| 382 |
-
<div style={{ justifySelf: "end", padding: "14px
|
| 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:
|
| 388 |
{s}
|
| 389 |
</span>
|
| 390 |
))}
|
| 391 |
</div>
|
| 392 |
-
<p style={{ margin: 0, fontFamily: theme.body, fontSize:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
|
| 400 |
-
{
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
{l}
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
</div>
|
| 407 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
</AbsoluteFill>
|
| 409 |
);
|
| 410 |
}
|
|
@@ -417,142 +425,377 @@ function Marker({ n, glow }: { n: number; glow: number }) {
|
|
| 417 |
);
|
| 418 |
}
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
function Audio() {
|
| 421 |
const frame = useCurrentFrame();
|
| 422 |
const { fps } = useVideoConfig();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
const lines = [
|
| 424 |
{ who: "A", text: "So the big idea here is that price is a signal." },
|
| 425 |
{ who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
|
| 426 |
{ who: "A", text: "Which she wrote about back in March, in On Pricing." },
|
| 427 |
];
|
| 428 |
-
const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame -
|
| 429 |
return (
|
| 430 |
<AbsoluteFill>
|
| 431 |
<WarpField speed={0.7} count={260} />
|
| 432 |
-
<Caption eyebrow="
|
| 433 |
-
<
|
| 434 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 -
|
| 438 |
-
return <div key={i} style={{ width: 14, height: 12 + v *
|
| 439 |
})}
|
| 440 |
</div>
|
| 441 |
-
<div style={{ display: "grid", gap:
|
| 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:
|
| 447 |
-
<span style={{ width:
|
| 448 |
-
{l.who}
|
| 449 |
-
</span>
|
| 450 |
{l.text}
|
| 451 |
</div>
|
| 452 |
);
|
| 453 |
})}
|
| 454 |
</div>
|
| 455 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
</AbsoluteFill>
|
| 457 |
);
|
| 458 |
}
|
| 459 |
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
const frame = useCurrentFrame();
|
| 462 |
const { fps } = useVideoConfig();
|
| 463 |
-
const
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
];
|
| 469 |
return (
|
| 470 |
<AbsoluteFill>
|
| 471 |
-
<WarpField speed={0.
|
| 472 |
-
<Caption eyebrow="
|
| 473 |
-
<
|
| 474 |
-
{
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
return (
|
| 478 |
-
<div key={
|
| 479 |
-
<
|
| 480 |
-
<
|
|
|
|
| 481 |
</div>
|
| 482 |
);
|
| 483 |
})}
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
{t}
|
| 489 |
</span>
|
| 490 |
))}
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
</AbsoluteFill>
|
| 493 |
);
|
| 494 |
}
|
| 495 |
|
| 496 |
-
|
|
|
|
| 497 |
const frame = useCurrentFrame();
|
| 498 |
-
const {
|
| 499 |
-
const
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
return (
|
| 502 |
<AbsoluteFill>
|
| 503 |
-
<WarpField speed={0.
|
| 504 |
-
<Caption eyebrow="
|
| 505 |
-
<
|
| 506 |
-
{
|
| 507 |
-
const
|
| 508 |
return (
|
| 509 |
-
<div key={
|
| 510 |
-
{
|
| 511 |
-
|
| 512 |
</div>
|
| 513 |
);
|
| 514 |
})}
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 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 |
-
|
| 533 |
-
{
|
| 534 |
-
<
|
| 535 |
)}
|
| 536 |
-
|
| 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 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
<
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
</AbsoluteFill>
|
| 557 |
);
|
| 558 |
}
|
|
@@ -580,14 +823,15 @@ function Outro() {
|
|
| 580 |
);
|
| 581 |
}
|
| 582 |
|
| 583 |
-
const RENDER: Record<
|
| 584 |
warp: Warp,
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
audio: Audio,
|
| 589 |
-
|
| 590 |
-
|
|
|
|
| 591 |
outro: Outro,
|
| 592 |
};
|
| 593 |
|
|
@@ -596,7 +840,7 @@ export function LandingDemo() {
|
|
| 596 |
return (
|
| 597 |
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 598 |
<SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
|
| 599 |
-
{SCENES.map((scene
|
| 600 |
const from = cursor;
|
| 601 |
cursor += S(scene.len - XFADE);
|
| 602 |
const Scene = RENDER[scene.id];
|
|
@@ -605,10 +849,11 @@ export function LandingDemo() {
|
|
| 605 |
<FadeScene len={scene.len}>
|
| 606 |
<Scene />
|
| 607 |
</FadeScene>
|
| 608 |
-
|
| 609 |
-
<
|
| 610 |
-
|
| 611 |
-
|
|
|
|
| 612 |
</Sequence>
|
| 613 |
);
|
| 614 |
})}
|
|
|
|
| 4 |
import { DEMO_VO_SECONDS } from "./demoVo";
|
| 5 |
import { Lockup } from "./Lockup";
|
| 6 |
|
| 7 |
+
/** Landing page product demo: the real app, one feature at a time. 1920x1080, 30 fps, ~47 s.
|
| 8 |
+
* Scenes: intro, sync, ask, map, audio, video, edit, launch kit, outro. Each product scene is a
|
| 9 |
+
* window with a cursor that does the thing; the narrator adds a sparse journey-of-discovery line here and there.
|
| 10 |
+
* Strict palette: black, #217cff, white. */
|
| 11 |
|
| 12 |
export const DEMO_FPS = 30;
|
| 13 |
const S = (seconds: number) => Math.round(seconds * DEMO_FPS);
|
| 14 |
|
| 15 |
+
// Narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"): a few scenes (intro, sync, map,
|
| 16 |
+
// launch kit, outro) speak, starting `vo` seconds in. Scenes keep their minimum length for the animation and stretch when a
|
| 17 |
+
// line needs more room, so it always ends with a breath before the scene changes.
|
| 18 |
const BASE_SCENES = [
|
| 19 |
{ id: "warp", len: 5.2, vo: 0.6 },
|
| 20 |
+
{ id: "sync", len: 5.0, vo: 0.5 },
|
| 21 |
+
{ id: "ask", len: 6.0 },
|
| 22 |
+
{ id: "map", len: 6.5, vo: 0.6 },
|
| 23 |
+
{ id: "audio", len: 5.5 },
|
| 24 |
+
{ id: "video", len: 6.5 },
|
| 25 |
+
{ id: "edit", len: 6.0 },
|
| 26 |
+
{ id: "kit", len: 5.5, vo: 0.5 },
|
| 27 |
{ id: "outro", len: 4.2, vo: 0.5 },
|
| 28 |
] as const;
|
| 29 |
+
type SceneId = (typeof BASE_SCENES)[number]["id"];
|
| 30 |
const XFADE = 0.5; // seconds of overlap between scenes
|
| 31 |
+
const BREATH = 0.8; // gap between the end of a line and the scene change (includes the crossfade)
|
| 32 |
|
| 33 |
// Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them.
|
| 34 |
+
const VO_SECONDS: Partial<Record<SceneId, number>> = DEMO_VO_SECONDS;
|
| 35 |
+
const SCENES = BASE_SCENES.map((s) => {
|
| 36 |
+
const vo = "vo" in s ? s.vo : undefined;
|
| 37 |
+
const line = VO_SECONDS[s.id];
|
| 38 |
+
return { id: s.id, vo, len: vo !== undefined && line ? Math.max(s.len, vo + line + BREATH) : s.len };
|
| 39 |
+
});
|
| 40 |
|
| 41 |
/** Frame ranges where the narrator is speaking, in composition time. */
|
| 42 |
const VO_WINDOWS: [number, number][] = (() => {
|
| 43 |
let cursor = 0;
|
| 44 |
+
const windows: [number, number][] = [];
|
| 45 |
+
for (const scene of SCENES) {
|
| 46 |
+
const line = VO_SECONDS[scene.id];
|
| 47 |
+
if (scene.vo !== undefined && line) {
|
| 48 |
+
const start = cursor + scene.vo;
|
| 49 |
+
windows.push([Math.round(start * DEMO_FPS), Math.round((start + line) * DEMO_FPS)]);
|
| 50 |
+
}
|
| 51 |
cursor += scene.len - XFADE;
|
| 52 |
+
}
|
| 53 |
+
return windows;
|
| 54 |
})();
|
| 55 |
|
| 56 |
const MUSIC = 0.5;
|
|
|
|
| 170 |
const { fps } = useVideoConfig();
|
| 171 |
const s = spring({ frame: frame - delay, fps, config: { damping: 18 } });
|
| 172 |
return (
|
| 173 |
+
<div style={{ position: "absolute", left: 140, top: 96, opacity: s, transform: `translateY(${(1 - s) * 24}px)` }}>
|
| 174 |
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 24, letterSpacing: 6, textTransform: "uppercase", color: BLUE }}>{eyebrow}</p>
|
| 175 |
+
<h2 style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 64, lineHeight: 1.05, color: theme.white, maxWidth: 1400 }}>{title}</h2>
|
| 176 |
</div>
|
| 177 |
);
|
| 178 |
}
|
|
|
|
| 189 |
return text.slice(0, n);
|
| 190 |
}
|
| 191 |
|
| 192 |
+
const clamp01 = (frame: number, a: number, b: number) => interpolate(frame, [a, b], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
| 193 |
+
/** 0 to 1 and back over 10 frames from `at`: the press of a clicked control. */
|
| 194 |
+
const pulse = (frame: number, at: number) => Math.sin(clamp01(frame, at, at + 10) * Math.PI);
|
| 195 |
+
|
| 196 |
+
// App window and cursor
|
| 197 |
+
|
| 198 |
+
/** Where a window's content area starts in scene coordinates, so cursor targets can be written in window space. */
|
| 199 |
+
const WX = 141;
|
| 200 |
+
const WY = 303;
|
| 201 |
+
const at = (x: number, y: number) => ({ x: WX + x, y: WY + y });
|
| 202 |
+
|
| 203 |
+
/** A browser-style app window: 1640 x 668 of content under a 52 px title bar. */
|
| 204 |
+
function Win({ children }: { children: ReactNode }) {
|
| 205 |
+
return (
|
| 206 |
+
<div style={{ ...panel, position: "absolute", left: 140, top: 250, width: 1640, height: 720, overflow: "hidden", background: "rgba(3,7,16,0.9)" }}>
|
| 207 |
+
<div style={{ height: 52, display: "flex", alignItems: "center", gap: 10, padding: "0 22px", borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
|
| 208 |
+
{[0, 1, 2].map((i) => (
|
| 209 |
+
<span key={i} style={{ width: 13, height: 13, borderRadius: "50%", background: "rgba(255,255,255,0.22)" }} />
|
| 210 |
+
))}
|
| 211 |
+
<span style={{ marginLeft: 22, padding: "5px 22px", borderRadius: 999, background: "rgba(255,255,255,0.08)", fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.6)" }}>notestack.ai/app</span>
|
| 212 |
+
</div>
|
| 213 |
+
<div style={{ position: "absolute", left: 0, top: 52, right: 0, bottom: 0 }}>{children}</div>
|
| 214 |
+
</div>
|
| 215 |
+
);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function Chip({ children, on = false, press = 0, style }: { children: ReactNode; on?: boolean; press?: number; style?: CSSProperties }) {
|
| 219 |
+
return (
|
| 220 |
+
<span
|
| 221 |
+
style={{
|
| 222 |
+
display: "inline-flex",
|
| 223 |
+
alignItems: "center",
|
| 224 |
+
justifyContent: "center",
|
| 225 |
+
borderRadius: 999,
|
| 226 |
+
border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.2)"}`,
|
| 227 |
+
background: on ? `${BLUE}33` : "rgba(255,255,255,0.04)",
|
| 228 |
+
boxShadow: on ? `0 0 22px ${BLUE}77` : "none",
|
| 229 |
+
fontFamily: theme.body,
|
| 230 |
+
fontSize: 26,
|
| 231 |
+
color: theme.white,
|
| 232 |
+
transform: `scale(${1 - 0.06 * press})`,
|
| 233 |
+
...style,
|
| 234 |
+
}}
|
| 235 |
+
>
|
| 236 |
+
{children}
|
| 237 |
+
</span>
|
| 238 |
+
);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function Label({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
| 242 |
+
return <span style={{ position: "absolute", fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, textTransform: "uppercase", color: "rgba(255,255,255,0.55)", ...style }}>{children}</span>;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
type Key = { f: number; x: number; y: number };
|
| 246 |
+
|
| 247 |
+
/** A mouse cursor that glides through `keys` (scene coordinates) and ripples on each frame in `clicks`. */
|
| 248 |
+
function Cursor({ keys, clicks = [] }: { keys: Key[]; clicks?: number[] }) {
|
| 249 |
+
const frame = useCurrentFrame();
|
| 250 |
+
const fs = keys.map((k) => k.f);
|
| 251 |
+
const opts = { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease } as const;
|
| 252 |
+
const x = interpolate(frame, fs, keys.map((k) => k.x), opts);
|
| 253 |
+
const y = interpolate(frame, fs, keys.map((k) => k.y), opts);
|
| 254 |
+
const down = clicks.reduce((m, c) => Math.max(m, pulse(frame, c)), 0);
|
| 255 |
+
return (
|
| 256 |
+
<>
|
| 257 |
+
{clicks.map((c) => {
|
| 258 |
+
const p = clamp01(frame, c, c + 16);
|
| 259 |
+
if (p <= 0 || p >= 1) return null;
|
| 260 |
+
return <div key={c} style={{ position: "absolute", left: x - 36 * p, top: y - 36 * p, width: 72 * p, height: 72 * p, borderRadius: "50%", border: `3px solid ${BLUE}`, opacity: 1 - p }} />;
|
| 261 |
+
})}
|
| 262 |
+
<svg width={40} height={44} viewBox="0 0 24 34" style={{ position: "absolute", left: x, top: y, transform: `scale(${1 - 0.12 * down})`, transformOrigin: "0 0", filter: `drop-shadow(0 0 10px ${BLUE})`, zIndex: 50 }}>
|
| 263 |
+
<path d="M1 1 L1 26 L7.5 20.5 L12 31 L16.5 29 L12 18.5 L21 18.5 Z" fill="#ffffff" stroke="#000000" strokeWidth={1.6} strokeLinejoin="round" />
|
| 264 |
+
</svg>
|
| 265 |
+
</>
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
// Scenes
|
| 270 |
|
| 271 |
function Warp() {
|
|
|
|
| 293 |
);
|
| 294 |
}
|
| 295 |
|
| 296 |
+
const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "RSS", "Markdown"];
|
| 297 |
|
| 298 |
+
const POSTS = [
|
| 299 |
+
"On Pricing", "Writing Every Day", "The Paid Tier", "Why I Quit Twitter",
|
| 300 |
+
"Notes on Craft", "Letters to Readers", "The Long Game", "Small Audiences",
|
| 301 |
+
];
|
| 302 |
+
const POST_DATES = ["Mar 12", "Mar 04", "Feb 21", "Feb 09", "Jan 30", "Jan 18", "Jan 03", "Dec 20"];
|
| 303 |
+
|
| 304 |
+
/** 01: paste a link, the archive syncs in. */
|
| 305 |
+
function Sync() {
|
| 306 |
const frame = useCurrentFrame();
|
| 307 |
const { fps } = useVideoConfig();
|
| 308 |
+
const url = typed("yourblog.com", frame, 8, 14);
|
| 309 |
+
const press = pulse(frame, 52);
|
| 310 |
+
const count = Math.round(142 * ease(clamp01(frame, 58, 120)));
|
| 311 |
+
const bar = clamp01(frame, 54, 84);
|
| 312 |
return (
|
| 313 |
<AbsoluteFill>
|
| 314 |
+
<WarpField speed={1} />
|
| 315 |
+
<Caption eyebrow="01 · Sync" title="Point it at your writing." />
|
| 316 |
+
<Win>
|
| 317 |
+
<div style={{ position: "absolute", left: 80, top: 56, width: 1060, height: 84, borderRadius: 999, border: `1px solid ${BLUE}88`, background: "rgba(255,255,255,0.05)", display: "flex", alignItems: "center", paddingLeft: 38, fontFamily: theme.body, fontSize: 38, color: url ? theme.white : "rgba(255,255,255,0.4)" }}>
|
| 318 |
+
{url || "yourblog.com"}
|
| 319 |
+
<span style={{ opacity: frame % 30 < 15 ? 1 : 0, color: BLUE }}>|</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
</div>
|
| 321 |
+
<div style={{ position: "absolute", left: 1160, top: 56, width: 220, height: 84, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 32, color: theme.white, transform: `scale(${1 - 0.06 * press})`, boxShadow: `0 0 ${24 + press * 40}px ${BLUE}` }}>
|
| 322 |
+
Sync
|
| 323 |
+
</div>
|
| 324 |
+
<div style={{ position: "absolute", left: 80, top: 158, width: 1060 * bar, height: 4, borderRadius: 2, background: BLUE, boxShadow: `0 0 14px ${BLUE}`, opacity: bar > 0 && bar < 1 ? 1 : 0 }} />
|
| 325 |
+
<div style={{ position: "absolute", left: 80, top: 186, display: "flex", gap: 12 }}>
|
| 326 |
{SOURCES.map((name, i) => {
|
| 327 |
+
const on = spring({ frame: frame - 14 - i * 5, fps, config: { damping: 16 } });
|
| 328 |
return (
|
| 329 |
+
<Chip key={name} on={on > 0.5} style={{ padding: "8px 20px", fontSize: 21, fontFamily: theme.mono, opacity: 0.3 + on * 0.7 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
{name}
|
| 331 |
+
</Chip>
|
| 332 |
);
|
| 333 |
})}
|
| 334 |
</div>
|
| 335 |
+
<div style={{ position: "absolute", right: 80, top: 176, fontFamily: theme.mono, color: theme.white, display: "flex", alignItems: "baseline", gap: 14 }}>
|
| 336 |
+
<span style={{ fontFamily: theme.display, fontSize: 56, textShadow: `0 0 24px ${BLUE}` }}>{count}</span>
|
| 337 |
+
<span style={{ fontSize: 20, letterSpacing: 3, color: "rgba(255,255,255,0.6)" }}>POSTS SYNCED</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
+
<div style={{ position: "absolute", left: 80, top: 280, width: 1480, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18 }}>
|
| 340 |
+
{POSTS.map((title, i) => {
|
| 341 |
+
const s = spring({ frame: frame - 60 - i * 5, fps, config: { damping: 14 } });
|
| 342 |
+
return (
|
| 343 |
+
<div key={title} style={{ display: "flex", alignItems: "center", gap: 20, height: 74, padding: "0 26px", borderRadius: 14, border: "1px solid rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.04)", opacity: s, transform: `translateY(${(1 - s) * 30}px)`, fontFamily: theme.body, fontSize: 28, color: theme.white }}>
|
| 344 |
+
<span style={{ width: 30, height: 30, borderRadius: "50%", background: BLUE, display: "grid", placeItems: "center", fontSize: 18, boxShadow: `0 0 14px ${BLUE}` }}>✓</span>
|
| 345 |
+
<span style={{ flex: 1 }}>{title}</span>
|
| 346 |
+
<span style={{ fontFamily: theme.mono, fontSize: 20, color: "rgba(255,255,255,0.5)" }}>{POST_DATES[i]}</span>
|
| 347 |
+
</div>
|
| 348 |
+
);
|
| 349 |
+
})}
|
| 350 |
+
</div>
|
| 351 |
+
</Win>
|
| 352 |
+
<Cursor
|
| 353 |
+
keys={[{ f: 0, x: 1560, y: 880 }, { f: 42, ...at(1270, 98) }, { f: 74, ...at(1270, 98) }, { f: 100, ...at(1100, 470) }]}
|
| 354 |
+
clicks={[52]}
|
| 355 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
</AbsoluteFill>
|
| 357 |
);
|
| 358 |
}
|
| 359 |
|
| 360 |
+
/** 02: ask a question, click the citation, see the exact lines. */
|
| 361 |
+
function Ask() {
|
| 362 |
const frame = useCurrentFrame();
|
| 363 |
const { fps } = useVideoConfig();
|
| 364 |
+
const q = typed("What have I written about pricing?", frame, 8, 30);
|
| 365 |
+
const answer = spring({ frame: frame - 66, fps, config: { damping: 18 } });
|
| 366 |
const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } });
|
| 367 |
+
const open = spring({ frame: frame - 114, fps, config: { damping: 18 } });
|
| 368 |
const steps = ["Searching for pric(e|ing)|paid tier", "Reading On Pricing, lines 12 to 20", "Reading The Paid Tier, lines 4 to 9"];
|
| 369 |
+
const context = ["I tried a lot of numbers before this one.", "The paid tier doubled after I", "raised the price to ten dollars.", "Readers took the work seriously.", "Nobody left. That surprised me."];
|
| 370 |
return (
|
| 371 |
<AbsoluteFill>
|
| 372 |
+
<WarpField speed={0.7} count={300} />
|
| 373 |
+
<Caption eyebrow="02 · Ask" title="Every answer, cited to the line." />
|
| 374 |
+
<Win>
|
| 375 |
+
<div style={{ position: "absolute", left: 80, top: 36, width: 880, display: "grid", gap: 20 }}>
|
| 376 |
+
<div style={{ justifySelf: "end", padding: "14px 24px", borderRadius: "18px 18px 4px 18px", background: `${BLUE}22`, border: `1px solid ${BLUE}66`, fontFamily: theme.body, fontSize: 30, color: theme.white, minWidth: 40, minHeight: 46 }}>
|
| 377 |
{q || " "}
|
| 378 |
</div>
|
| 379 |
<div style={{ fontFamily: theme.mono, fontSize: 20, color: BLUE, display: "grid", gap: 6 }}>
|
| 380 |
{steps.map((s, i) => (
|
| 381 |
+
<span key={s} style={{ opacity: clamp01(frame, 40 + i * 8, 48 + i * 8) }}>
|
| 382 |
{s}
|
| 383 |
</span>
|
| 384 |
))}
|
| 385 |
</div>
|
| 386 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 32, lineHeight: 1.5, color: theme.white, opacity: answer, transform: `translateY(${(1 - answer) * 16}px)` }}>
|
| 387 |
+
You raised prices twice and the <b>paid tier doubled</b> <Marker n={1} glow={cite} /> because readers took the work more seriously <Marker n={2} glow={cite} />.
|
|
|
|
|
|
|
| 388 |
</p>
|
| 389 |
</div>
|
| 390 |
+
{[
|
| 391 |
+
{ n: 1, label: "On Pricing · L12 to 14" },
|
| 392 |
+
{ n: 2, label: "The Paid Tier · L4 to 9" },
|
| 393 |
+
].map((c, i) => (
|
| 394 |
+
<Chip key={c.n} on={cite > 0.5 && (i === 0 ? frame >= 112 : false)} press={i === 0 ? pulse(frame, 112) : 0} style={{ position: "absolute", left: 80 + i * 450, top: 460, width: 420, height: 60, fontFamily: theme.mono, fontSize: 21, opacity: cite, gap: 10 }}>
|
| 395 |
+
<span style={{ color: BLUE }}>[{c.n}]</span> {c.label}
|
| 396 |
+
</Chip>
|
| 397 |
+
))}
|
| 398 |
+
<div style={{ ...panel, position: "absolute", left: 1010, top: 36, width: 560, padding: 30, opacity: open, transform: `translateX(${(1 - open) * 60}px)` }}>
|
| 399 |
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 20, color: BLUE, letterSpacing: 2 }}>[1] ON PRICING · LINES 12 TO 14</p>
|
| 400 |
+
{context.map((l, i) => {
|
| 401 |
+
const hit = i >= 1 && i <= 3;
|
| 402 |
+
return (
|
| 403 |
+
<div key={l} style={{ display: "flex", gap: 18, marginTop: 12, fontFamily: theme.body, fontSize: 25, color: hit ? theme.white : "rgba(255,255,255,0.45)", background: hit ? `${BLUE}26` : "transparent", boxShadow: hit ? `inset 4px 0 0 ${BLUE}` : "none", padding: "6px 12px", borderRadius: 8 }}>
|
| 404 |
+
<span style={{ fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)", paddingTop: 4 }}>{11 + i}</span>
|
| 405 |
+
{l}
|
| 406 |
+
</div>
|
| 407 |
+
);
|
| 408 |
+
})}
|
| 409 |
+
<p style={{ margin: "22px 0 0", fontFamily: theme.mono, fontSize: 19, color: BLUE }}>✓ Line range verified</p>
|
| 410 |
</div>
|
| 411 |
+
</Win>
|
| 412 |
+
<Cursor
|
| 413 |
+
keys={[{ f: 0, x: 1560, y: 880 }, { f: 100, ...at(260, 490) }, { f: 126, ...at(260, 490) }, { f: 160, ...at(1250, 560) }]}
|
| 414 |
+
clicks={[112]}
|
| 415 |
+
/>
|
| 416 |
</AbsoluteFill>
|
| 417 |
);
|
| 418 |
}
|
|
|
|
| 425 |
);
|
| 426 |
}
|
| 427 |
|
| 428 |
+
const TOPICS = [
|
| 429 |
+
"Pricing", "Paid tier", "Audience", "Craft", "Habits", "Newsletters", "Growth", "Writing",
|
| 430 |
+
"Focus", "Trust", "Launches", "Editing", "Community", "Ideas", "Archive",
|
| 431 |
+
];
|
| 432 |
+
const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"];
|
| 433 |
+
type Shape = "orbit" | "spiral" | "cluster";
|
| 434 |
+
|
| 435 |
+
/** Where topic `i` sits in each shape of the Mind Constellation (window coordinates). */
|
| 436 |
+
function place(shape: Shape, i: number): { x: number; y: number } {
|
| 437 |
+
const cx = 620;
|
| 438 |
+
const cy = 350;
|
| 439 |
+
const n = TOPICS.length;
|
| 440 |
+
if (shape === "orbit") {
|
| 441 |
+
if (i === 0) return { x: cx, y: cy };
|
| 442 |
+
const r = [170, 270, 360][(i - 1) % 3];
|
| 443 |
+
const a = i * 2.4;
|
| 444 |
+
return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
|
| 445 |
+
}
|
| 446 |
+
if (shape === "spiral") {
|
| 447 |
+
const t = i / (n - 1);
|
| 448 |
+
const a = t * Math.PI * 2.6 + 0.4;
|
| 449 |
+
const r = 30 + t * 340;
|
| 450 |
+
return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 };
|
| 451 |
+
}
|
| 452 |
+
const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }];
|
| 453 |
+
const c = centers[i % 3];
|
| 454 |
+
const k = Math.floor(i / 3);
|
| 455 |
+
const a = k * 1.25 + (i % 3);
|
| 456 |
+
const r = 36 + k * 24;
|
| 457 |
+
return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r };
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function mapPos(i: number, frame: number): { x: number; y: number } {
|
| 461 |
+
const a = place("orbit", i);
|
| 462 |
+
const b = place("spiral", i);
|
| 463 |
+
const c = place("cluster", i);
|
| 464 |
+
const s1 = ease(clamp01(frame, 64, 96));
|
| 465 |
+
const s2 = ease(clamp01(frame, 112, 144));
|
| 466 |
+
return { x: a.x + (b.x - a.x) * s1 + (c.x - (a.x + (b.x - a.x) * s1)) * s2, y: a.y + (b.y - a.y) * s1 + (c.y - (a.y + (b.y - a.y) * s1)) * s2 };
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/** 03: the Mind Constellation, switching shapes and diving into a topic. */
|
| 470 |
+
function MindMap() {
|
| 471 |
+
const frame = useCurrentFrame();
|
| 472 |
+
const { fps } = useVideoConfig();
|
| 473 |
+
const activeShape = frame < 62 ? 0 : frame < 110 ? 1 : 3;
|
| 474 |
+
const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } });
|
| 475 |
+
const hub = at(place("cluster", 0).x, place("cluster", 0).y);
|
| 476 |
+
return (
|
| 477 |
+
<AbsoluteFill>
|
| 478 |
+
<WarpField speed={0.6} count={260} />
|
| 479 |
+
<Caption eyebrow="03 · Map" title="See your whole archive." />
|
| 480 |
+
<Win>
|
| 481 |
+
<svg width={1640} height={668} style={{ position: "absolute", inset: 0 }}>
|
| 482 |
+
{TOPICS.map((_, i) => {
|
| 483 |
+
if (i === 0) return null;
|
| 484 |
+
const from = mapPos(i, frame);
|
| 485 |
+
const to = mapPos(i < 4 ? 0 : i - 3, frame);
|
| 486 |
+
const on = clamp01(frame, 6 + i * 3, 20 + i * 3);
|
| 487 |
+
return <line key={i} x1={from.x} y1={from.y} x2={to.x} y2={to.y} stroke={i % 2 ? BLUE : "#ffffff"} strokeOpacity={0.3 * on} strokeWidth={1.5} />;
|
| 488 |
+
})}
|
| 489 |
+
{TOPICS.map((name, i) => {
|
| 490 |
+
const p = mapPos(i, frame);
|
| 491 |
+
const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } });
|
| 492 |
+
const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born;
|
| 493 |
+
const picked = i === 0 && frame >= 158;
|
| 494 |
+
return (
|
| 495 |
+
<g key={name}>
|
| 496 |
+
<circle cx={p.x} cy={p.y} r={r * 2.6} fill={BLUE} opacity={0.18 * born} style={{ filter: "blur(8px)" }} />
|
| 497 |
+
<circle cx={p.x} cy={p.y} r={r} fill="#ffffff" style={{ filter: `drop-shadow(0 0 10px ${BLUE})` }} />
|
| 498 |
+
{picked && <circle cx={p.x} cy={p.y} r={r + 14} fill="none" stroke={BLUE} strokeWidth={3} />}
|
| 499 |
+
<text x={p.x} y={p.y + r + 26} textAnchor="middle" fontFamily={theme.body} fontSize={i === 0 ? 26 : 21} fill="#ffffff" opacity={0.85 * born}>
|
| 500 |
+
{name}
|
| 501 |
+
</text>
|
| 502 |
+
</g>
|
| 503 |
+
);
|
| 504 |
+
})}
|
| 505 |
+
</svg>
|
| 506 |
+
<div style={{ position: "absolute", left: 40, top: 26, display: "flex", gap: 10 }}>
|
| 507 |
+
{MAP_SHAPES.map((name, i) => (
|
| 508 |
+
<Chip key={name} on={i === activeShape} press={i === 1 ? pulse(frame, 62) : i === 3 ? pulse(frame, 110) : 0} style={{ width: 120, height: 48, fontSize: 22 }}>
|
| 509 |
+
{name}
|
| 510 |
+
</Chip>
|
| 511 |
+
))}
|
| 512 |
+
</div>
|
| 513 |
+
<p style={{ position: "absolute", left: 40, bottom: 20, margin: 0, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.5)" }}>Scroll to dive in. Drag to move. Click a star to fly to it.</p>
|
| 514 |
+
<div style={{ ...panel, position: "absolute", right: 40, top: 26, width: 380, padding: 28, opacity: panelIn, transform: `translateX(${(1 - panelIn) * 60}px)` }}>
|
| 515 |
+
<p style={{ margin: 0, fontFamily: theme.display, fontSize: 40, color: theme.white }}>Pricing</p>
|
| 516 |
+
<Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>Orbiting here</Label>
|
| 517 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
|
| 518 |
+
{["Paid tier", "Audience", "Trust"].map((t) => (
|
| 519 |
+
<Chip key={t} on style={{ padding: "4px 16px", fontSize: 20 }}>{t}</Chip>
|
| 520 |
+
))}
|
| 521 |
+
</div>
|
| 522 |
+
<Label style={{ position: "static", display: "block", marginTop: 22, fontSize: 16 }}>From your posts</Label>
|
| 523 |
+
{["On Pricing", "The Paid Tier", "Small Audiences"].map((t) => (
|
| 524 |
+
<p key={t} style={{ margin: "10px 0 0", fontFamily: theme.body, fontSize: 24, color: theme.white }}>{t}</p>
|
| 525 |
+
))}
|
| 526 |
+
</div>
|
| 527 |
+
</Win>
|
| 528 |
+
<Cursor
|
| 529 |
+
keys={[
|
| 530 |
+
{ f: 0, x: 1560, y: 880 },
|
| 531 |
+
{ f: 50, ...at(220, 50) },
|
| 532 |
+
{ f: 64, ...at(220, 50) },
|
| 533 |
+
{ f: 98, ...at(470, 50) },
|
| 534 |
+
{ f: 112, ...at(470, 50) },
|
| 535 |
+
{ f: 150, x: hub.x, y: hub.y },
|
| 536 |
+
{ f: 170, x: hub.x + 30, y: hub.y + 120 },
|
| 537 |
+
]}
|
| 538 |
+
clicks={[62, 110, 156]}
|
| 539 |
+
/>
|
| 540 |
+
</AbsoluteFill>
|
| 541 |
+
);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
/** 04: pick a format, create, play. */
|
| 545 |
function Audio() {
|
| 546 |
const frame = useCurrentFrame();
|
| 547 |
const { fps } = useVideoConfig();
|
| 548 |
+
const formats = ["Deep dive", "Brief", "Debate"];
|
| 549 |
+
const picked = frame >= 22 ? 2 : 0;
|
| 550 |
+
const bar = clamp01(frame, 52, 84);
|
| 551 |
+
const playing = clamp01(frame, 84, 96);
|
| 552 |
const lines = [
|
| 553 |
{ who: "A", text: "So the big idea here is that price is a signal." },
|
| 554 |
{ who: "B", text: "Right, and she has the numbers: the paid tier doubled." },
|
| 555 |
{ who: "A", text: "Which she wrote about back in March, in On Pricing." },
|
| 556 |
];
|
| 557 |
+
const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24));
|
| 558 |
return (
|
| 559 |
<AbsoluteFill>
|
| 560 |
<WarpField speed={0.7} count={260} />
|
| 561 |
+
<Caption eyebrow="04 · Listen" title="Turn it into a conversation." />
|
| 562 |
+
<Win>
|
| 563 |
+
<Label style={{ left: 80, top: 36 }}>Format</Label>
|
| 564 |
+
{formats.map((f, i) => (
|
| 565 |
+
<Chip key={f} on={i === picked} press={i === 2 ? pulse(frame, 22) : 0} style={{ position: "absolute", left: 80 + i * 210, top: 80, width: 190, height: 56 }}>
|
| 566 |
+
{f}
|
| 567 |
+
</Chip>
|
| 568 |
+
))}
|
| 569 |
+
<div style={{ position: "absolute", left: 80, top: 170, width: 300, height: 72, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 30, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 50)})`, boxShadow: `0 0 ${24 + pulse(frame, 50) * 40}px ${BLUE}` }}>
|
| 570 |
+
{bar > 0 && bar < 1 ? "Recording..." : "Create audio"}
|
| 571 |
+
</div>
|
| 572 |
+
<div style={{ position: "absolute", left: 420, top: 202, width: 900 * bar, height: 6, borderRadius: 3, background: BLUE, boxShadow: `0 0 14px ${BLUE}`, opacity: bar > 0 && bar < 1 ? 1 : 0 }} />
|
| 573 |
+
<div style={{ position: "absolute", left: 80, top: 290, width: 1480, height: 170, display: "flex", alignItems: "center", justifyContent: "center", gap: 10, opacity: playing }}>
|
| 574 |
{Array.from({ length: 64 }).map((_, i) => {
|
| 575 |
const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21));
|
| 576 |
+
const env = spring({ frame: frame - 90, fps, config: { damping: 20 } });
|
| 577 |
+
return <div key={i} style={{ width: 14, height: 12 + v * 150 * env, borderRadius: 7, background: i % 2 ? theme.white : BLUE, boxShadow: `0 0 18px ${BLUE}` }} />;
|
| 578 |
})}
|
| 579 |
</div>
|
| 580 |
+
<div style={{ position: "absolute", left: 80, top: 480, display: "grid", gap: 12 }}>
|
| 581 |
{lines.map((l, i) => {
|
| 582 |
const on = i === active;
|
|
|
|
| 583 |
return (
|
| 584 |
+
<div key={i} style={{ display: "flex", gap: 22, alignItems: "center", opacity: frame >= 92 + i * 24 ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 32, color: theme.white }}>
|
| 585 |
+
<span style={{ width: 46, height: 46, borderRadius: "50%", display: "grid", placeItems: "center", fontFamily: theme.mono, fontSize: 20, background: l.who === "A" ? BLUE : theme.white, color: l.who === "A" ? theme.white : theme.black, boxShadow: on ? `0 0 26px ${BLUE}` : "none" }}>{l.who}</span>
|
|
|
|
|
|
|
| 586 |
{l.text}
|
| 587 |
</div>
|
| 588 |
);
|
| 589 |
})}
|
| 590 |
</div>
|
| 591 |
+
</Win>
|
| 592 |
+
<Cursor
|
| 593 |
+
keys={[{ f: 0, x: 1560, y: 880 }, { f: 18, ...at(595, 108) }, { f: 30, ...at(595, 108) }, { f: 46, ...at(230, 206) }, { f: 60, ...at(230, 206) }, { f: 100, ...at(900, 560) }]}
|
| 594 |
+
clicks={[22, 50]}
|
| 595 |
+
/>
|
| 596 |
</AbsoluteFill>
|
| 597 |
);
|
| 598 |
}
|
| 599 |
|
| 600 |
+
const VIDEO_STYLES = ["16:9 Explainer", "9:16 Short", "1:1 Square"];
|
| 601 |
+
const TEMPLATE_LOOKS: CSSProperties[] = [
|
| 602 |
+
{ background: "linear-gradient(135deg, #000, #0b1730)" },
|
| 603 |
+
{ background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` },
|
| 604 |
+
{ background: "linear-gradient(135deg, #ffffff, #cfe0ff)" },
|
| 605 |
+
{ background: "rgba(255,255,255,0.04)", border: `2px dashed ${BLUE}88` },
|
| 606 |
+
];
|
| 607 |
+
const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "From your website"];
|
| 608 |
+
const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"];
|
| 609 |
+
|
| 610 |
+
/** 05: wizard: pick a style, a template and a voice, create. */
|
| 611 |
+
function VideoCreate() {
|
| 612 |
const frame = useCurrentFrame();
|
| 613 |
const { fps } = useVideoConfig();
|
| 614 |
+
const style = frame >= 34 ? 1 : 0;
|
| 615 |
+
const tpl = frame >= 74 ? 1 : 0;
|
| 616 |
+
const voice = frame >= 114 ? 1 : 0;
|
| 617 |
+
const go = pulse(frame, 162);
|
| 618 |
+
const busy = clamp01(frame, 164, 194);
|
|
|
|
| 619 |
return (
|
| 620 |
<AbsoluteFill>
|
| 621 |
+
<WarpField speed={0.8} count={260} />
|
| 622 |
+
<Caption eyebrow="05 · Make" title="Pick a look. Pick a voice." />
|
| 623 |
+
<Win>
|
| 624 |
+
<Label style={{ left: 80, top: 30 }}>Video style</Label>
|
| 625 |
+
{VIDEO_STYLES.map((t, i) => (
|
| 626 |
+
<Chip key={t} on={i === style} press={i === 1 ? pulse(frame, 34) : 0} style={{ position: "absolute", left: 80 + i * 250, top: 70, width: 230, height: 56 }}>
|
| 627 |
+
{t}
|
| 628 |
+
</Chip>
|
| 629 |
+
))}
|
| 630 |
+
<Label style={{ left: 80, top: 160 }}>Template</Label>
|
| 631 |
+
{TEMPLATE_NAMES.map((name, i) => {
|
| 632 |
+
const s = spring({ frame: frame - 6 - i * 5, fps, config: { damping: 16 } });
|
| 633 |
+
const sel = i === tpl;
|
| 634 |
return (
|
| 635 |
+
<div key={name} style={{ position: "absolute", left: 80 + i * 300, top: 200, width: 270, height: 190, borderRadius: 16, border: `2px solid ${sel ? BLUE : "rgba(255,255,255,0.14)"}`, boxShadow: sel ? `0 0 30px ${BLUE}88` : "none", overflow: "hidden", opacity: s, transform: `scale(${(0.9 + 0.1 * s) * (i === 1 ? 1 - 0.04 * pulse(frame, 74) : 1)})`, ...TEMPLATE_LOOKS[i] }}>
|
| 636 |
+
<div style={{ position: "absolute", left: 20, top: 24, width: 120, height: 12, borderRadius: 6, background: i === 2 ? "#000" : "#fff", opacity: 0.9 }} />
|
| 637 |
+
<div style={{ position: "absolute", left: 20, top: 52, width: 190, height: 8, borderRadius: 4, background: i === 2 ? "#000" : "#fff", opacity: 0.4 }} />
|
| 638 |
+
<div style={{ position: "absolute", left: 20, bottom: 16, fontFamily: theme.body, fontSize: 22, color: i === 2 ? "#000" : "#fff" }}>{name}</div>
|
| 639 |
</div>
|
| 640 |
);
|
| 641 |
})}
|
| 642 |
+
<Label style={{ left: 80, top: 430 }}>Narration voice</Label>
|
| 643 |
+
{VOICES.map((v, i) => (
|
| 644 |
+
<Chip key={v} on={i === voice} press={i === 1 ? pulse(frame, 114) : 0} style={{ position: "absolute", left: 80 + i * 370, top: 470, width: 340, height: 64 }}>
|
| 645 |
+
{i < 2 && <span style={{ marginRight: 12, color: BLUE }}>▶</span>}
|
| 646 |
+
{v}
|
| 647 |
+
</Chip>
|
| 648 |
+
))}
|
| 649 |
+
<div style={{ position: "absolute", left: 1240, top: 560, width: 340, height: 76, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 30, color: theme.white, overflow: "hidden", transform: `scale(${1 - 0.06 * go})`, boxShadow: `0 0 ${24 + go * 40}px ${BLUE}` }}>
|
| 650 |
+
<div style={{ position: "absolute", left: 0, bottom: 0, height: 6, width: `${busy * 100}%`, background: "#fff" }} />
|
| 651 |
+
{busy > 0 ? "Making your video..." : "Create video"}
|
| 652 |
+
</div>
|
| 653 |
+
</Win>
|
| 654 |
+
<Cursor
|
| 655 |
+
keys={[
|
| 656 |
+
{ f: 0, x: 1560, y: 880 },
|
| 657 |
+
{ f: 30, ...at(450, 98) },
|
| 658 |
+
{ f: 38, ...at(450, 98) },
|
| 659 |
+
{ f: 70, ...at(515, 295) },
|
| 660 |
+
{ f: 78, ...at(515, 295) },
|
| 661 |
+
{ f: 110, ...at(630, 502) },
|
| 662 |
+
{ f: 118, ...at(630, 502) },
|
| 663 |
+
{ f: 158, ...at(1410, 598) },
|
| 664 |
+
{ f: 190, ...at(1410, 598) },
|
| 665 |
+
]}
|
| 666 |
+
clicks={[34, 74, 114, 162]}
|
| 667 |
+
/>
|
| 668 |
+
</AbsoluteFill>
|
| 669 |
+
);
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
const SCENE_TITLES = ["Price is a signal", "The numbers", "Why it worked", "Try it yourself"];
|
| 673 |
+
|
| 674 |
+
/** 06: the scene editor, then render the MP4. */
|
| 675 |
+
function VideoEdit() {
|
| 676 |
+
const frame = useCurrentFrame();
|
| 677 |
+
const { fps } = useVideoConfig();
|
| 678 |
+
const active = frame >= 32 ? 1 : 0;
|
| 679 |
+
const since = frame >= 32 ? frame - 32 : frame;
|
| 680 |
+
const pop = spring({ frame: since, fps, config: { damping: 16 } });
|
| 681 |
+
const render = clamp01(frame, 110, 164);
|
| 682 |
+
const ready = frame >= 166;
|
| 683 |
+
const tabs = ["Edit Scenes", "Images/footage", "Audio", "Settings"];
|
| 684 |
+
return (
|
| 685 |
+
<AbsoluteFill>
|
| 686 |
+
<WarpField speed={0.7} count={260} />
|
| 687 |
+
<Caption eyebrow="06 · Edit" title="Tweak any scene, then render." />
|
| 688 |
+
<Win>
|
| 689 |
+
{tabs.map((t, i) => (
|
| 690 |
+
<span key={t} style={{ position: "absolute", left: 80 + i * 210, top: 12, width: 190, height: 48, display: "grid", placeItems: "center", borderRadius: 12, fontFamily: theme.body, fontSize: 23, color: theme.white, background: i === 0 ? `${BLUE}33` : "transparent", border: `1px solid ${i === 0 ? BLUE : "rgba(255,255,255,0.14)"}` }}>
|
| 691 |
{t}
|
| 692 |
</span>
|
| 693 |
))}
|
| 694 |
+
<div style={{ position: "absolute", left: 1340, top: 10, width: 220, height: 52, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 24, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 108)})`, boxShadow: `0 0 ${20 + pulse(frame, 108) * 40}px ${BLUE}` }}>
|
| 695 |
+
Download MP4
|
| 696 |
+
</div>
|
| 697 |
+
{SCENE_TITLES.map((t, i) => {
|
| 698 |
+
const s = spring({ frame: frame - 4 - i * 5, fps, config: { damping: 16 } });
|
| 699 |
+
const on = i === active;
|
| 700 |
+
return (
|
| 701 |
+
<div key={t} style={{ position: "absolute", left: 80, top: 80 + i * 120, width: 400, height: 106, borderRadius: 14, border: `1px solid ${on ? BLUE : "rgba(255,255,255,0.14)"}`, background: on ? `${BLUE}26` : "rgba(255,255,255,0.04)", boxShadow: on ? `0 0 24px ${BLUE}66` : "none", display: "flex", alignItems: "center", gap: 18, padding: "0 20px", opacity: s, transform: `translateX(${(1 - s) * -40}px) scale(${i === 1 ? 1 - 0.03 * pulse(frame, 32) : 1})` }}>
|
| 702 |
+
<span style={{ fontFamily: theme.mono, fontSize: 22, color: BLUE }}>{i + 1}</span>
|
| 703 |
+
<div style={{ width: 96, height: 60, borderRadius: 8, background: i % 2 ? `linear-gradient(135deg, ${BLUE}, #0a2a66)` : "linear-gradient(135deg, #000, #0b1730)", border: "1px solid rgba(255,255,255,0.2)" }} />
|
| 704 |
+
<span style={{ fontFamily: theme.body, fontSize: 25, color: theme.white }}>{t}</span>
|
| 705 |
+
</div>
|
| 706 |
+
);
|
| 707 |
+
})}
|
| 708 |
+
<div style={{ position: "absolute", left: 520, top: 80, width: 1040, height: 560, borderRadius: 18, overflow: "hidden", border: "1px solid rgba(255,255,255,0.18)", background: `radial-gradient(70% 70% at 30% 20%, ${BLUE}33, #000 70%)`, boxShadow: `0 0 50px ${BLUE}33` }}>
|
| 709 |
+
{active === 0 ? (
|
| 710 |
+
<div style={{ position: "absolute", left: 70, top: 160, opacity: pop, transform: `translateY(${(1 - pop) * 30}px)` }}>
|
| 711 |
+
<p style={{ margin: 0, fontFamily: theme.mono, fontSize: 22, letterSpacing: 5, color: BLUE }}>SCENE 1</p>
|
| 712 |
+
<p style={{ margin: "14px 0 0", fontFamily: theme.display, fontSize: 86, lineHeight: 1.05, color: theme.white, textShadow: `0 0 30px ${BLUE}` }}>Price is<br />a signal.</p>
|
| 713 |
+
</div>
|
| 714 |
+
) : (
|
| 715 |
+
<>
|
| 716 |
+
<p style={{ position: "absolute", left: 70, top: 56, margin: 0, fontFamily: theme.display, fontSize: 52, color: theme.white, opacity: pop }}>Paid readers, doubled</p>
|
| 717 |
+
{[0.28, 0.5, 1].map((h, i) => (
|
| 718 |
+
<div key={i} style={{ position: "absolute", left: 90 + i * 250, bottom: 90, width: 170, height: 340 * h * ease(clamp01(since, 8 + i * 7, 34 + i * 7)), borderRadius: "12px 12px 0 0", background: i === 2 ? "#ffffff" : BLUE, boxShadow: `0 0 30px ${BLUE}` }} />
|
| 719 |
+
))}
|
| 720 |
+
<p style={{ position: "absolute", left: 70, bottom: 30, margin: 0, fontFamily: theme.body, fontSize: 26, color: "rgba(255,255,255,0.85)", opacity: pop }}>You raised prices twice and the paid tier doubled.</p>
|
| 721 |
+
</>
|
| 722 |
+
)}
|
| 723 |
+
{render > 0 && (
|
| 724 |
+
<div style={{ ...panel, position: "absolute", left: 270, top: 190, width: 500, padding: "28px 32px", background: "rgba(0,0,0,0.92)", opacity: clamp01(frame, 110, 118) }}>
|
| 725 |
+
<p style={{ margin: 0, fontFamily: theme.body, fontSize: 30, color: theme.white }}>{ready ? "✓ Your MP4 is ready" : "Rendering MP4"}</p>
|
| 726 |
+
<div style={{ marginTop: 20, height: 10, borderRadius: 5, background: "rgba(255,255,255,0.14)" }}>
|
| 727 |
+
<div style={{ width: `${(ready ? 1 : render) * 100}%`, height: "100%", borderRadius: 5, background: BLUE, boxShadow: `0 0 16px ${BLUE}` }} />
|
| 728 |
+
</div>
|
| 729 |
+
</div>
|
| 730 |
+
)}
|
| 731 |
+
</div>
|
| 732 |
+
</Win>
|
| 733 |
+
<Cursor
|
| 734 |
+
keys={[
|
| 735 |
+
{ f: 0, x: 1560, y: 880 },
|
| 736 |
+
{ f: 26, ...at(280, 373) },
|
| 737 |
+
{ f: 38, ...at(280, 373) },
|
| 738 |
+
{ f: 100, ...at(1450, 36) },
|
| 739 |
+
{ f: 124, ...at(1450, 36) },
|
| 740 |
+
{ f: 150, ...at(1250, 480) },
|
| 741 |
+
]}
|
| 742 |
+
clicks={[32, 108]}
|
| 743 |
+
/>
|
| 744 |
</AbsoluteFill>
|
| 745 |
);
|
| 746 |
}
|
| 747 |
|
| 748 |
+
/** 07: the Launch Kit appears, then Launchpad schedules it across the week. */
|
| 749 |
+
function LaunchKit() {
|
| 750 |
const frame = useCurrentFrame();
|
| 751 |
+
const { fps } = useVideoConfig();
|
| 752 |
+
const cards = [
|
| 753 |
+
{ label: "X THREAD", body: "I doubled my price. My paid tier doubled too. Here is what I learned." },
|
| 754 |
+
{ label: "LINKEDIN", body: "Most writers underprice. I did for two years. Then I ran an experiment." },
|
| 755 |
+
{ label: "SUBSTACK NOTES", body: "Price is a signal. Readers told me so, with their wallets." },
|
| 756 |
+
{ label: "BLUESKY", body: "Raised my price to $10. Paid readers went up, not down." },
|
| 757 |
+
];
|
| 758 |
+
const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
|
| 759 |
+
const slots: Record<number, string> = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" };
|
| 760 |
return (
|
| 761 |
<AbsoluteFill>
|
| 762 |
+
<WarpField speed={0.9} count={260} />
|
| 763 |
+
<Caption eyebrow="07 · Launch" title="A week of posts, scheduled." />
|
| 764 |
+
<Win>
|
| 765 |
+
{cards.map((c, i) => {
|
| 766 |
+
const s = spring({ frame: frame - 8 - i * 7, fps, config: { damping: 14 } });
|
| 767 |
return (
|
| 768 |
+
<div key={c.label} style={{ position: "absolute", left: 80 + i * 390, top: 30, width: 350, height: 230, padding: 26, borderRadius: 18, border: "1px solid rgba(255,255,255,0.14)", background: "rgba(255,255,255,0.04)", opacity: s, transform: `translateY(${(1 - s) * 140}px) rotate(${interpolate(s, [0, 1], [(i - 1.5) * 10, 0])}deg)`, display: "grid", alignContent: "start", gap: 14 }}>
|
| 769 |
+
<span style={{ fontFamily: theme.mono, fontSize: 20, letterSpacing: 3, color: BLUE }}>{c.label}</span>
|
| 770 |
+
<span style={{ fontFamily: theme.body, fontSize: 26, lineHeight: 1.4, color: theme.white }}>{c.body}</span>
|
| 771 |
</div>
|
| 772 |
);
|
| 773 |
})}
|
| 774 |
+
<Label style={{ left: 80, top: 300 }}>Launchpad</Label>
|
| 775 |
+
{days.map((d, i) => {
|
| 776 |
+
const lit = frame >= 76 + i * 6;
|
| 777 |
+
const s = spring({ frame: frame - 76 - i * 6, fps, config: { damping: 12 } });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
return (
|
| 779 |
+
<div key={d} style={{ position: "absolute", left: 80 + i * 215, top: 345, width: 195, height: 130, borderRadius: 14, border: `1px solid ${lit ? BLUE : "rgba(255,255,255,0.14)"}`, background: lit ? `${BLUE}33` : "rgba(255,255,255,0.03)", boxShadow: lit ? `0 0 22px ${BLUE}88` : "none", padding: 14, fontFamily: theme.mono, fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
| 780 |
+
{d}
|
| 781 |
+
{lit && (
|
| 782 |
+
<div style={{ marginTop: 22, padding: "8px 0", textAlign: "center", borderRadius: 999, background: theme.white, color: theme.black, fontFamily: theme.body, fontWeight: 600, fontSize: 21, transform: `scale(${0.6 + 0.4 * s})`, boxShadow: `0 0 14px ${BLUE}` }}>{slots[i]}</div>
|
| 783 |
)}
|
| 784 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
);
|
| 786 |
+
})}
|
| 787 |
+
<div style={{ position: "absolute", left: 80, top: 540, display: "flex", gap: 14 }}>
|
| 788 |
+
{["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => (
|
| 789 |
+
<span key={t} style={{ padding: "10px 18px", border: `1px solid ${BLUE}88`, borderRadius: 999, fontFamily: theme.mono, fontSize: 20, letterSpacing: 2, color: "rgba(255,255,255,0.75)", opacity: clamp01(frame, 40 + i * 6, 50 + i * 6) }}>
|
| 790 |
+
{t}
|
| 791 |
+
</span>
|
| 792 |
+
))}
|
| 793 |
+
</div>
|
| 794 |
+
<div style={{ position: "absolute", left: 1300, top: 530, width: 280, height: 70, borderRadius: 999, background: BLUE, display: "grid", placeItems: "center", fontFamily: theme.body, fontWeight: 600, fontSize: 28, color: theme.white, transform: `scale(${1 - 0.06 * pulse(frame, 68)})`, boxShadow: `0 0 ${24 + pulse(frame, 68) * 40}px ${BLUE}` }}>
|
| 795 |
+
Schedule all
|
| 796 |
+
</div>
|
| 797 |
+
</Win>
|
| 798 |
+
<Cursor keys={[{ f: 0, x: 1560, y: 880 }, { f: 62, ...at(1440, 566) }, { f: 100, ...at(1440, 566) }, { f: 140, ...at(1100, 600) }]} clicks={[68]} />
|
| 799 |
</AbsoluteFill>
|
| 800 |
);
|
| 801 |
}
|
|
|
|
| 823 |
);
|
| 824 |
}
|
| 825 |
|
| 826 |
+
const RENDER: Record<SceneId, () => JSX.Element> = {
|
| 827 |
warp: Warp,
|
| 828 |
+
sync: Sync,
|
| 829 |
+
ask: Ask,
|
| 830 |
+
map: MindMap,
|
| 831 |
audio: Audio,
|
| 832 |
+
video: VideoCreate,
|
| 833 |
+
edit: VideoEdit,
|
| 834 |
+
kit: LaunchKit,
|
| 835 |
outro: Outro,
|
| 836 |
};
|
| 837 |
|
|
|
|
| 840 |
return (
|
| 841 |
<AbsoluteFill style={{ backgroundColor: theme.black }}>
|
| 842 |
<SoundTrack src={staticFile("demo-vo/music.wav")} volume={musicVolume} />
|
| 843 |
+
{SCENES.map((scene) => {
|
| 844 |
const from = cursor;
|
| 845 |
cursor += S(scene.len - XFADE);
|
| 846 |
const Scene = RENDER[scene.id];
|
|
|
|
| 849 |
<FadeScene len={scene.len}>
|
| 850 |
<Scene />
|
| 851 |
</FadeScene>
|
| 852 |
+
{scene.vo !== undefined && (
|
| 853 |
+
<Sequence from={S(scene.vo)} name={`vo-${scene.id}`}>
|
| 854 |
+
<SoundTrack src={staticFile(`demo-vo/${scene.id}.mp3`)} volume={1} />
|
| 855 |
+
</Sequence>
|
| 856 |
+
)}
|
| 857 |
</Sequence>
|
| 858 |
);
|
| 859 |
})}
|
renderer/src/compositions/demoVo.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
| 1 |
// Generated by scripts/make_demo_vo.py: narration clip lengths in seconds. Do not edit by hand.
|
| 2 |
-
export const DEMO_VO_SECONDS = {"warp": 3.
|
|
|
|
| 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;
|