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 (
);
};
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 (
);
})}
);
}
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 (
);
})}
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 && (
)}
);
})}
);
}