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