import type { CSSProperties, ReactNode } from "react"; import { AbsoluteFill, Audio as SoundTrack, Easing, interpolate, random, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion"; import { theme } from "../theme"; import { Flare, WarpField } from "./LandingDemo"; import { Lockup } from "./Lockup"; import { LAUNCH_BPM, LAUNCH_SCENES, LAUNCH_SECONDS, type LaunchSceneId } from "./launchTimeline"; /** Launch trailer: a journey of knowledge through the galaxies of untapped potential. * One galaxy per feature, cut on the beat (see launchTimeline.ts, generated with the score), * narrated by Nora Vale, ending on "Notestack AI. Coming soon." 1920x1080, 30 fps. */ export const LAUNCH_FPS = 30; const S = (seconds: number) => Math.round(seconds * LAUNCH_FPS); export const LAUNCH_DURATION = S(LAUNCH_SECONDS); const BLUE = theme.blue; const WHITE = theme.white; const BEAT = 60 / LAUNCH_BPM; const XF = 0.3; // each scene fades in over the tail of the previous one const ease = Easing.bezier(0.22, 1, 0.36, 1); const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const; // ---------------------------------------------------------------- audio const VO_WINDOWS: [number, number][] = LAUNCH_SCENES.filter((s) => s.vo !== null).map((s) => [ S(s.start + (s.vo ?? 0)), S(s.start + (s.vo ?? 0) + (s.voLen ?? 0)), ]); const MUSIC = 0.62; const DUCKED = 0.24; function musicVolume(frame: number): number { const ramp = 8; let level = MUSIC; for (const [a, b] of VO_WINDOWS) { if (frame >= a - ramp && frame <= b + ramp) { const into = interpolate(frame, [a - ramp, a, b, b + ramp], [0, 1, 1, 0], clamp); level = Math.min(level, MUSIC - (MUSIC - DUCKED) * into); } } return level; } // ---------------------------------------------------------------- shared pieces /** 1 on each beat, decaying quickly. Scenes start on bar lines, so local time is beat aligned. */ function usePulse(sharp = 8) { const frame = useCurrentFrame(); const t = frame / LAUNCH_FPS; return Math.exp(-(t % BEAT) * sharp); } /** A spiral galaxy with a white core; rotates slowly, drifts toward the camera over the scene. */ function Galaxy({ seed, x, y, size, tilt = 0.55, arms = 2, spin = 1, coreBoost = 0 }: { seed: string; x: number; y: number; size: number; tilt?: number; arms?: number; spin?: number; coreBoost?: number; }) { const frame = useCurrentFrame(); const rot = frame * 0.0035 * spin + random(`${seed}-rot`) * 6; const count = 520; const stars = []; for (let i = 0; i < count; i++) { const arm = i % arms; const u = Math.sqrt(random(`${seed}-u${i}`)); const r = u * size; const theta = u * 5.2 + (arm * Math.PI * 2) / arms + rot + (random(`${seed}-j${i}`) - 0.5) * 0.7 * (1 - u * 0.4); const spread = (random(`${seed}-s${i}`) - 0.5) * size * 0.12; const px = Math.cos(theta) * (r + spread); const py = Math.sin(theta) * (r + spread) * tilt; const near = 1 - u; const fill = near > 0.55 ? WHITE : random(`${seed}-c${i}`) < 0.55 ? BLUE : "#9cc4ff"; stars.push(); } const core = 0.85 + coreBoost; return ( {stars} ); } /** Deep space backdrop: drifting stars and a faint far galaxy. */ function Space({ drift = 1, children }: { drift?: number; children?: ReactNode }) { const frame = useCurrentFrame(); const { width, height } = useVideoConfig(); const t = frame / LAUNCH_FPS; return ( {Array.from({ length: 260 }).map((_, i) => { const depth = 0.3 + random(`d${i}`) * 0.7; const x = (((random(`x${i}`) * width - t * 22 * depth * drift) % width) + width) % width; const y = random(`y${i}`) * height; const tw = 0.4 + 0.6 * Math.abs(Math.sin(t * (0.5 + random(`p${i}`)) + i)); return ; })} {children} ); } const panel: CSSProperties = { border: "1px solid rgba(255,255,255,0.16)", borderRadius: 24, background: "rgba(0,0,0,0.7)", boxShadow: `0 0 60px ${BLUE}33, 0 0 2px ${BLUE}`, backdropFilter: "blur(2px)", }; function useIn(delay: number, damping = 16) { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); return spring({ frame: frame - delay, fps, config: { damping } }); } function typed(text: string, frame: number, start: number, cps = 30): string { return text.slice(0, Math.max(0, Math.floor(((frame - start) / LAUNCH_FPS) * cps))); } // ---------------------------------------------------------------- journey HUD const STOPS: { id: LaunchSceneId; short: string; name: string; line: string }[] = [ { id: "sources", short: "Sources", name: "The Source Nebula", line: "Everything you made, glowing back at you." }, { id: "research", short: "Research", name: "The Research Cluster", line: "The spark of a thought you forgot you had." }, { id: "map", short: "Topic map", name: "The Constellation Field", line: "Drift through your own ideas. Stay a while." }, { id: "voice", short: "Voice", name: "The Voice Pulsar", line: "It feels like you. Only braver." }, { id: "audio", short: "Audio", name: "The Audio Belt", line: "Lean back. Let it wash over you." }, { id: "video", short: "Studio", name: "The Studio Galaxy", line: "Color, motion, wonder. Watch it twice." }, { id: "launchkit", short: "Launch Kit", name: "The Launch Kit Spiral", line: "Your heart races. The good kind of nervous." }, { id: "launchpad", short: "Launchpad", name: "The Launchpad", line: "Three, two, one, and you are flying." }, { id: "resurface", short: "Resurface", name: "The Evergreen Belt", line: "An old favorite, shining again." }, ]; function Hud({ index }: { index: number }) { const frame = useCurrentFrame(); const pulse = usePulse(10); const inn = useIn(2, 20); const travel = interpolate(frame, [0, 24], [index - 1, index], { ...clamp, easing: ease }); const x0 = 360; const x1 = 1560; const px = (i: number) => x0 + ((x1 - x0) * i) / (STOPS.length - 1); return (
GALAXY {String(index + 1).padStart(2, "0")} / {String(STOPS.length).padStart(2, "0")}
{STOPS.map((s, i) => { const done = i <= index; const here = i === index; return ( {s.short.toUpperCase()} ); })}
); } /** Frame for every feature stop: galaxy flying in on the right, name and line on the left, the visual on top. */ function Stop({ id, children }: { id: LaunchSceneId; children: ReactNode }) { const frame = useCurrentFrame(); const { durationInFrames } = useVideoConfig(); const index = STOPS.findIndex((s) => s.id === id); const stop = STOPS[index]; const pulse = usePulse(9); const approach = interpolate(frame, [0, durationInFrames], [0.55, 1.08], { easing: Easing.out(Easing.quad) }); const eyebrow = useIn(4, 18); const name = useIn(8, 15); const line = useIn(16, 20); const visual = useIn(10, 17); return (

GALAXY {String(index + 1).padStart(2, "0")}

{stop.name}

{stop.line}

{children}
); } // ---------------------------------------------------------------- feature visuals (900 x 660 box) /** A ringed planet, like the one in the logo: lit sphere, atmosphere, and a tilted ring whose far * side passes behind the body and near side in front. Drawn in its own layer above the orbits. */ function Planet({ cx, cy, r }: { cx: number; cy: number; r: number }) { const frame = useCurrentFrame(); const tilt = -18; const rx = r * 1.75; const ry = r * 0.36; const shimmer = 0.75 + 0.25 * Math.sin(frame * 0.08); const ring = (half: "back" | "front") => ( ); return ( {ring("back")} {/* Soft cloud bands, clipped to the sphere, drifting slowly. */} {[-0.45, -0.1, 0.3].map((y, i) => ( ))} {/* Night side. */} {ring("front")} ); } function SourcesVisual() { const frame = useCurrentFrame(); const kinds = ["Blog", "Newsletter", "Website", "Markdown", "PDF", "Substack", "Ghost", "WordPress"]; const cx = 450; const cy = 330; const count = Math.round(interpolate(frame, [40, 240], [0, 248], { ...clamp, easing: ease })); return (
{kinds.map((k, i) => { const arrive = interpolate(frame, [12 + i * 9, 50 + i * 9], [0, 1], { ...clamp, easing: ease }); const ring = i % 2 ? 220 : 330; const ringY = i % 2 ? 86 : 130; const a = (i / kinds.length) * Math.PI * 2 + frame * 0.012; const tx = cx + Math.cos(a) * ring; const ty = cy + Math.sin(a) * ringY; const sx = cx + Math.cos(a) * 1100; const sy = cy + Math.sin(a) * 700; const x = sx + (tx - sx) * arrive; const y = sy + (ty - sy) * arrive; const front = Math.sin(a) > 0; return (
{k}
); })}
{count} POSTS IN ORBIT
); } function ResearchVisual() { const frame = useCurrentFrame(); const q = "What have I said about pricing?"; const answer = "You argue for charging early and raising prices with each new tier"; const cite1 = useIn(62, 14); const cite2 = useIn(72, 14); const lines = useIn(80, 18); return (
{typed(q, frame, 4, 34)} |
{typed(answer, frame, 30, 40)} 1 2
sources/blog/2024-03-11-price-early.md
{["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) => (
{l}
))}
); } function MapVisual() { const frame = useCurrentFrame(); const pulse = usePulse(6); const nodes = [ { x: 450, y: 320, r: 26, name: "Writing" }, { x: 250, y: 200, r: 16, name: "Pricing" }, { x: 660, y: 190, r: 20, name: "AI tools", rising: true }, { x: 700, y: 430, r: 14, name: "Newsletters" }, { x: 230, y: 470, r: 12, name: "Habits", dormant: true }, { x: 470, y: 560, r: 15, name: "Audience" }, { x: 120, y: 330, r: 9, name: "Design" }, { x: 820, y: 300, r: 10, name: "Research" }, ]; const edges = [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [1, 6], [2, 7], [3, 5], [2, 3], [1, 4]]; return ( {edges.map(([a, b], i) => { const p = interpolate(frame, [18 + i * 5, 40 + i * 5], [0, 1], clamp); const A = nodes[a]; const B = nodes[b]; return ; })} {nodes.map((n, i) => { const s = spring({ frame: frame - 6 - i * 4, fps: LAUNCH_FPS, config: { damping: 12 } }); return ( {n.dormant && } {n.name} ); })} RISING 2.4x DORMANT ); } function VoiceVisual() { const frame = useCurrentFrame(); const traits = [["Tone", "warm, direct"], ["Openers", "a sharp question"], ["Rhythm", "short, punchy lines"], ["Avoids", "jargon, hype"]]; const match = Math.round(interpolate(frame, [70, 140], [0, 96], { ...clamp, easing: ease })); return (
{Array.from({ length: 60 }).map((_, i) => { const h = 16 + Math.abs(Math.sin(i * 0.55 + frame * 0.22) * Math.sin(i * 0.13 + frame * 0.07)) * 120; return ; })}
{traits.map(([k, v], i) => { const s = spring({ frame: frame - 20 - i * 10, fps: LAUNCH_FPS, config: { damping: 15 } }); return (
{k.toUpperCase()}
{v}
); })}
VOICE MATCH {match}%
); } function AudioVisual() { const frame = useCurrentFrame(); const speaker = Math.floor(frame / 36) % 2; 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."]; const host = (label: string, active: boolean, x: number) => (
{label}
); return (
{host("A", speaker === 0, 60)} {host("B", speaker === 1, 670)} {Array.from({ length: 36 }).map((_, i) => { const h = 10 + Math.abs(Math.sin(i * 0.8 + frame * 0.35) * Math.cos(i * 0.21 - frame * 0.1)) * 150; return ; })}
HOST {speaker ? "B" : "A"}
{lines[Math.floor(frame / 36) % lines.length]}
); } function VideoVisual() { const frame = useCurrentFrame(); const card = (delay: number, style: CSSProperties, label: string, body: ReactNode) => { const s = spring({ frame: frame - delay, fps: LAUNCH_FPS, config: { damping: 14 } }); return (
{label}
{body}
); }; const play =
; return (
{card(4, { left: 40, top: 60, width: 250, height: 444 }, "SHORT 9:16",
Your archive already is a podcast
)} {card(14, { left: 320, top: 40, width: 520, height: 292 }, "EXPLAINER 16:9",
{play}Why pricing early works
)} {card(24, { left: 320, top: 360, width: 250, height: 250 }, "AUDIOGRAM 1:1", {Array.from({ length: 16 }).map((_, i) => { const h = 12 + Math.abs(Math.sin(i + frame * 0.3)) * 90; return ; })})} {card(34, { left: 590, top: 360, width: 250, height: 250 }, "QUOTE CARD",
"Free teaches people your work is worth nothing."
)}
); } function LaunchKitVisual() { const frame = useCurrentFrame(); 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"]]; return (
{items.map(([k, v], i) => { const s = spring({ frame: frame - 8 - i * S(BEAT), fps: LAUNCH_FPS, config: { damping: 13 } }); return (
{k}
{v}
); })}
); } function LaunchpadVisual() { const frame = useCurrentFrame(); const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; 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"]]; const lift = interpolate(frame, [120, 200], [0, 1], { ...clamp, easing: Easing.in(Easing.cubic) }); return (
{days.map((d) => (
{d}
))} {Array.from({ length: 21 }).map((_, cell) => { const col = cell % 7; const row = Math.floor(cell / 7); const post = posts.find(([c, r]) => c === col && r === row); const s = post ? spring({ frame: frame - 10 - posts.indexOf(post) * 8, fps: LAUNCH_FPS, config: { damping: 12 } }) : 0; const sent = post && frame > 90 + posts.indexOf(post) * 5; return (
{post && (
{post[2]}
)}
); })}
100 ? WHITE : "rgba(255,255,255,0.6)" }}> {frame > 100 ? "7 POSTS LIVE" : "7 POSTS SCHEDULED"}
); } function ResurfaceVisual() { const frame = useCurrentFrame(); const years = [2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]; const rise = interpolate(frame, [30, 80], [0, 1], { ...clamp, easing: ease }); const score = Math.round(interpolate(frame, [60, 110], [0, 92], clamp)); const x = (i: number) => 60 + i * 110; return (
{years.map((y, i) => ( {y} ))}
0.5 ? "99" : "44"}` }}>
MARCH 2021
The quiet power of a small audience
EVERGREEN {score} RESHARE
); } // ---------------------------------------------------------------- bookend scenes function Intro() { const frame = useCurrentFrame(); const a = useIn(24, 22); const b = useIn(S(3.4), 22); const c = useIn(S(6.2), 20); const push = interpolate(frame, [0, S(8)], [1, 1.12]); return (

At the edge of something vast.

Curious. A little breathless.

A JOURNEY OF KNOWLEDGE

); } function Launch() { const frame = useCurrentFrame(); const speed = interpolate(frame, [0, 20, 90, 120], [2, 22, 18, 8], clamp); const streak = interpolate(frame, [0, 12, 95, 120], [0, 1, 1, 0.3], clamp); const title = useIn(10, 14); const sub = useIn(28, 18); const shake = frame < 14 ? Math.sin(frame * 3.1) * (14 - frame) * 0.8 : 0; return (

A journey of knowledge

exploring the galaxies of untapped potential

); } function Montage() { const frame = useCurrentFrame(); const { durationInFrames } = useVideoConfig(); const speed = interpolate(frame, [0, durationInFrames], [4, 40], { easing: Easing.in(Easing.quad) }); const lit = Math.floor(frame / S(BEAT / 2)); // one galaxy per eighth note const whiteout = interpolate(frame, [durationInFrames - 14, durationInFrames], [0, 1], clamp); const go = useIn(S(2.6), 12); return (
{STOPS.map((s, i) => { const on = i < lit; return (
GALAXY {String(i + 1).padStart(2, "0")}
{s.short}
); })}

ALL SYSTEMS GO

); } function Outro() { const frame = useCurrentFrame(); const { durationInFrames } = useVideoConfig(); const flash = interpolate(frame, [0, 22], [1, 0], clamp); const logo = useIn(4, 13); const name = useIn(12, 15); const soon = useIn(S(3.2), 12); const tag = useIn(S(4.4), 20); const pulse = usePulse(5); const end = interpolate(frame, [durationInFrames - 30, durationInFrames], [1, 0], clamp); const letters = "COMING SOON".split(""); return (
{letters.map((ch, i) => { const s = spring({ frame: frame - S(3.2) - i * 2, fps: LAUNCH_FPS, config: { damping: 11 } }); return ( {ch} ); })}

Your knowledge, in orbit. notestack.ai

); } // ---------------------------------------------------------------- composition const RENDER: Record JSX.Element> = { intro: Intro, launch: Launch, sources: () => , research: () => , map: () => , voice: () => , audio: () => , video: () => , launchkit: () => , launchpad: () => , resurface: () => , montage: Montage, outro: Outro, }; function FadeIn({ skip, children }: { skip: boolean; children: ReactNode }) { const frame = useCurrentFrame(); const opacity = skip ? 1 : interpolate(frame, [0, S(XF)], [0, 1], clamp); return {children}; } export function LaunchTrailer() { return ( {LAUNCH_SCENES.map((scene, i) => { const last = i === LAUNCH_SCENES.length - 1; const Scene = RENDER[scene.id]; // Hard cut into the outro (the montage whites out); crossfade everywhere else. const skipFade = i === 0 || scene.id === "outro" || scene.id === "launch"; return ( {scene.vo !== null && ( )} ); })} ); }