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 { DEMO_VO_SECONDS } from "./demoVo"; import { Lockup } from "./Lockup"; /** Landing page product demo: the real app, one feature at a time. 1920x1080, 30 fps, ~47 s. * Scenes: intro, sync, ask, map, audio, video, edit, launch kit, outro. Each product scene is a * window with a cursor that does the thing; the narrator adds a sparse journey-of-discovery line here and there. * Strict palette: black, #217cff, white. */ export const DEMO_FPS = 30; const S = (seconds: number) => Math.round(seconds * DEMO_FPS); // Narration (public/demo-vo/*.mp3, voiced by "Nora Vale, Mission Control"): a few scenes (intro, sync, map, // launch kit, outro) speak, starting `vo` seconds in. Scenes keep their minimum length for the animation and stretch when a // line needs more room, so it always ends with a breath before the scene changes. const BASE_SCENES = [ { id: "warp", len: 5.2, vo: 0.6 }, { id: "sync", len: 5.0, vo: 0.5 }, { id: "ask", len: 6.0 }, { id: "map", len: 6.5, vo: 0.6 }, { id: "audio", len: 5.5 }, { id: "video", len: 6.5 }, { id: "edit", len: 6.0 }, { id: "kit", len: 5.5, vo: 0.5 }, { id: "outro", len: 4.2, vo: 0.5 }, ] as const; type SceneId = (typeof BASE_SCENES)[number]["id"]; const XFADE = 0.5; // seconds of overlap between scenes const BREATH = 0.8; // gap between the end of a line and the scene change (includes the crossfade) // Narration clip lengths, written by scripts/make_demo_vo.py; the music ducks under them. const VO_SECONDS: Partial> = DEMO_VO_SECONDS; const SCENES = BASE_SCENES.map((s) => { const vo = "vo" in s ? s.vo : undefined; const line = VO_SECONDS[s.id]; return { id: s.id, vo, len: vo !== undefined && line ? Math.max(s.len, vo + line + BREATH) : s.len }; }); /** Frame ranges where the narrator is speaking, in composition time. */ const VO_WINDOWS: [number, number][] = (() => { let cursor = 0; const windows: [number, number][] = []; for (const scene of SCENES) { const line = VO_SECONDS[scene.id]; if (scene.vo !== undefined && line) { const start = cursor + scene.vo; windows.push([Math.round(start * DEMO_FPS), Math.round((start + line) * DEMO_FPS)]); } cursor += scene.len - XFADE; } return windows; })(); const MUSIC = 0.5; const MUSIC_DUCKED = 0.17; /** Music level: full between lines, eased down around each line (0.3 s ramps). */ function musicVolume(frame: number): number { const ramp = 9; 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], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); level = Math.min(level, MUSIC - (MUSIC - MUSIC_DUCKED) * into); } } return level; } export const DEMO_DURATION = S(SCENES.reduce((n, s) => n + s.len, 0) - XFADE * (SCENES.length - 1)); const BLUE = theme.blue; const ease = Easing.bezier(0.22, 1, 0.36, 1); // Shared pieces /** 3D starfield flying toward the camera. `speed` can ramp for warp streaks. */ export function WarpField({ speed, count = 420, streak = 0 }: { speed: number; count?: number; streak?: number }) { const frame = useCurrentFrame(); const { width, height } = useVideoConfig(); const cx = width / 2; const cy = height / 2; return ( {Array.from({ length: count }).map((_, i) => { const angle = random(`a${i}`) * Math.PI * 2; const base = random(`z${i}`); const z = (((base - frame * speed * 0.004) % 1) + 1) % 1; // 1 far, 0 at the camera const depth = 0.04 + z; const r = (random(`r${i}`) * 0.9 + 0.1) * 900; const x = cx + (Math.cos(angle) * r) / depth / 6; const y = cy + (Math.sin(angle) * r) / depth / 6; const size = Math.max(0.4, (1 - z) * 2.6); const alpha = Math.min(1, (1 - z) * 1.4); const blue = random(`b${i}`) < 0.25; if (streak > 0.02) { const z2 = z + streak * 0.08; const d2 = 0.04 + z2; const x2 = cx + (Math.cos(angle) * r) / d2 / 6; const y2 = cy + (Math.sin(angle) * r) / d2 / 6; return ( ); } return ; })} ); } /** A diagonal light flare that sweeps across, with a hot core and anamorphic streak. */ export function Flare({ at, duration = 24, y = 50 }: { at: number; duration?: number; y?: number }) { const frame = useCurrentFrame(); const p = interpolate(frame, [at, at + duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease }); if (p <= 0 || p >= 1) return null; const x = interpolate(p, [0, 1], [-10, 110]); const a = Math.sin(p * Math.PI); return (
); } function FadeScene({ len, children }: { len: number; children: ReactNode }) { const frame = useCurrentFrame(); const total = S(len); const fade = S(XFADE); const opacity = interpolate(frame, [0, fade, total - fade, total], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); const scale = interpolate(frame, [0, total], [1.04, 1], { easing: ease }); return {children}; } function Caption({ eyebrow, title, delay = 0 }: { eyebrow: string; title: string; delay?: number }) { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const s = spring({ frame: frame - delay, fps, config: { damping: 18 } }); return (

{eyebrow}

{title}

); } const panel: CSSProperties = { border: "1px solid rgba(255,255,255,0.14)", borderRadius: 22, background: "rgba(0,0,0,0.72)", boxShadow: `0 0 60px ${BLUE}33, 0 0 2px ${BLUE}`, }; function typed(text: string, frame: number, start: number, cps = 26): string { const n = Math.max(0, Math.floor(((frame - start) / DEMO_FPS) * cps)); return text.slice(0, n); } const clamp01 = (frame: number, a: number, b: number) => interpolate(frame, [a, b], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); /** 0 to 1 and back over 10 frames from `at`: the press of a clicked control. */ const pulse = (frame: number, at: number) => Math.sin(clamp01(frame, at, at + 10) * Math.PI); // App window and cursor /** Where a window's content area starts in scene coordinates, so cursor targets can be written in window space. */ const WX = 141; const WY = 303; const at = (x: number, y: number) => ({ x: WX + x, y: WY + y }); /** A browser-style app window: 1640 x 668 of content under a 52 px title bar. */ function Win({ children }: { children: ReactNode }) { return (
{[0, 1, 2].map((i) => ( ))} notestack.ai/app
{children}
); } function Chip({ children, on = false, press = 0, style }: { children: ReactNode; on?: boolean; press?: number; style?: CSSProperties }) { return ( {children} ); } function Label({ children, style }: { children: ReactNode; style?: CSSProperties }) { return {children}; } type Key = { f: number; x: number; y: number }; /** A mouse cursor that glides through `keys` (scene coordinates) and ripples on each frame in `clicks`. */ function Cursor({ keys, clicks = [] }: { keys: Key[]; clicks?: number[] }) { const frame = useCurrentFrame(); const fs = keys.map((k) => k.f); const opts = { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease } as const; const x = interpolate(frame, fs, keys.map((k) => k.x), opts); const y = interpolate(frame, fs, keys.map((k) => k.y), opts); const down = clicks.reduce((m, c) => Math.max(m, pulse(frame, c)), 0); return ( <> {clicks.map((c) => { const p = clamp01(frame, c, c + 16); if (p <= 0 || p >= 1) return null; return
; })} ); } // Scenes function Warp() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const speed = interpolate(frame, [0, 40, 70, 102], [14, 26, 6, 1.5], { extrapolateRight: "clamp" }); const streak = interpolate(frame, [0, 35, 70], [1, 1, 0], { extrapolateRight: "clamp" }); const title = spring({ frame: frame - 42, fps, config: { damping: 14 } }); const sub = spring({ frame: frame - 58, fps, config: { damping: 18 } }); return (

Your knowledge,
in orbit.

); } const SOURCES = ["Substack", "Ghost", "WordPress", "Medium", "RSS", "Markdown"]; const POSTS = [ "Why Things Fall", "Curved Space", "Notes on Orbits", "What Is Entropy?", "The Quantum Leap", "Light as a Wave", "Time Is Strange", "Black Hole Basics", ]; const POST_DATES = ["Mar 12", "Mar 04", "Feb 21", "Feb 09", "Jan 30", "Jan 18", "Jan 03", "Dec 20"]; /** 01: paste a link, the archive syncs in. */ function Sync() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const url = typed("yourblog.com", frame, 8, 14); const press = pulse(frame, 52); const count = Math.round(142 * ease(clamp01(frame, 58, 120))); const bar = clamp01(frame, 54, 84); return (
{url || "yourblog.com"} |
Sync
0 && bar < 1 ? 1 : 0 }} />
{SOURCES.map((name, i) => { const on = spring({ frame: frame - 14 - i * 5, fps, config: { damping: 16 } }); return ( 0.5} style={{ padding: "8px 20px", fontSize: 21, fontFamily: theme.mono, opacity: 0.3 + on * 0.7 }}> {name} ); })}
{count} POSTS SYNCED
{POSTS.map((title, i) => { const s = spring({ frame: frame - 60 - i * 5, fps, config: { damping: 14 } }); return (
✓ {title} {POST_DATES[i]}
); })}
); } /** 02: ask a question, click the citation, see the exact lines. */ function Ask() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const q = typed("What have I written about gravity?", frame, 8, 30); const answer = spring({ frame: frame - 66, fps, config: { damping: 18 } }); const cite = spring({ frame: frame - 92, fps, config: { damping: 16 } }); const open = spring({ frame: frame - 114, fps, config: { damping: 18 } }); const steps = ["Searching for gravity|spacetime", "Reading Why Things Fall, lines 12 to 20", "Reading Curved Space, lines 4 to 9"]; const context = ["Newton called it a force pulling things down.", "Einstein saw something different: gravity is", "the curvature of spacetime itself.", "Mass tells space how to bend.", "Objects follow the straightest path."]; return (
{q || " "}
{steps.map((s, i) => ( {s} ))}

You wrote that gravity is the curvature of spacetime and that orbits are objects falling around that curve .

{[ { n: 1, label: "Why Things Fall · L12 to 14" }, { n: 2, label: "Curved Space · L4 to 9" }, ].map((c, i) => ( 0.5 && (i === 0 ? frame >= 112 : false)} press={i === 0 ? pulse(frame, 112) : 0} style={{ position: "absolute", left: 80 + i * 450, top: 460, width: 420, height: 60, fontFamily: theme.mono, fontSize: 21, opacity: cite, gap: 10 }}> [{c.n}] {c.label} ))}

[1] WHY THINGS FALL · L12 TO 14

{context.map((l, i) => { const hit = i >= 1 && i <= 3; return (
{11 + i} {l}
); })}

✓ Line range verified

); } function Marker({ n, glow }: { n: number; glow: number }) { return ( 0.5 ? "" : "55"}`, border: `1px solid ${BLUE}`, boxShadow: `0 0 ${glow * 24}px ${BLUE}`, textAlign: "center" }}> {n} ); } const TOPICS = [ "Gravity", "Spacetime", "Black holes", "Orbits", "Relativity", "Quantum", "Entropy", "Optics", "Waves", "Energy", "Chaos", "Cosmology", "Fields", "Symmetry", "Time", ]; const MAP_SHAPES = ["Orbit", "Spiral", "Figure", "Cluster"]; type Shape = "orbit" | "spiral" | "figure" | "cluster"; /** Where topic `i` sits in each shape of the Mind Constellation (window coordinates). */ function place(shape: Shape, i: number): { x: number; y: number } { const cx = 620; const cy = 350; const n = TOPICS.length; if (shape === "orbit") { if (i === 0) return { x: cx, y: cy }; const r = [170, 270, 360][(i - 1) % 3]; const a = i * 2.4; return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 }; } if (shape === "spiral") { const t = i / (n - 1); const a = t * Math.PI * 2.4 + 0.4; const r = 110 + t * 260; return { x: cx + Math.cos(a) * r * 1.5, y: cy + Math.sin(a) * r * 0.58 }; } if (shape === "figure") { // A constellation figure: one long wave of stars across the sky. return { x: 140 + i * 78, y: cy + (i % 2 ? -1 : 1) * (90 + (i % 3) * 30) }; } const centers = [{ x: 330, y: 250 }, { x: 800, y: 220 }, { x: 640, y: 490 }]; const c = centers[i % 3]; const k = Math.floor(i / 3); const a = k * 1.25 + (i % 3); const r = 36 + k * 24; return { x: c.x + Math.cos(a) * r * 1.3, y: c.y + Math.sin(a) * r }; } /** Frame windows in which the layout morphs to the next shape (after each pill click). */ const MORPHS: [Shape, number, number][] = [["spiral", 58, 82], ["figure", 94, 118], ["cluster", 126, 150]]; function mapPos(i: number, frame: number): { x: number; y: number } { let { x, y } = place("orbit", i); for (const [shape, from, to] of MORPHS) { const t = ease(clamp01(frame, from, to)); const next = place(shape, i); x += (next.x - x) * t; y += (next.y - y) * t; } return { x, y }; } /** 03: the Mind Constellation, switching shapes and diving into a topic. */ function MindMap() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const activeShape = frame < 56 ? 0 : frame < 92 ? 1 : frame < 124 ? 2 : 3; const panelIn = spring({ frame: frame - 158, fps, config: { damping: 18 } }); const hubWin = place("cluster", 0); const hub = at(hubWin.x, hubWin.y); const selected = frame >= 158; const ring = clamp01(frame, 4, 40); const dive = 1 + 0.14 * ease(clamp01(frame, 158, 195)); // the camera drifts in on the picked star const burst = ease(clamp01(frame, 158, 182)); return ( {/* Orbit rings draw in, then fade as the stars leave them. */} {[170, 270, 360].map((r, i) => ( ))} {TOPICS.map((_, i) => { if (i === 0) return null; const parent = i < 4 ? 0 : i - 3; const from = mapPos(i, frame); const to = mapPos(parent, frame); const on = clamp01(frame, 6 + i * 3, 24 + i * 3); const lit = !selected || parent === 0; const t = (frame * 0.018 + i * 0.37) % 1; // a pulse of light travelling toward the hub return ( {on >= 1 && } ); })} {TOPICS.map((name, i) => { const base = mapPos(i, frame); const p = { x: base.x + Math.sin(frame * 0.03 + i * 1.9) * 5, y: base.y + Math.cos(frame * 0.025 + i * 1.3) * 5 }; const born = spring({ frame: frame - 2 - i * 3, fps, config: { damping: 12 } }); const r = (i === 0 ? 17 : 7 + ((i * 7) % 5)) * born; const twinkle = 1 + 0.25 * Math.sin(frame * 0.14 + i * 1.7); const near = i === 0 || (i >= 1 && i <= 3); const dim = selected && !near ? 0.3 : 1; return ( {i === 0 && selected && } {name} ); })}
{MAP_SHAPES.map((name, i) => ( {name} ))}

Scroll to dive in. Drag to move. Click a star to fly to it.

Gravity

{["Spacetime", "Black holes", "Orbits"].map((t) => ( {t} ))}
{["Why Things Fall", "Curved Space", "Notes on Orbits"].map((t) => (

{t}

))}
); } /** 04: pick a format, create, play. */ function Audio() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const formats = ["Deep dive", "Brief", "Debate"]; const picked = frame >= 22 ? 2 : 0; const bar = clamp01(frame, 52, 84); const playing = clamp01(frame, 84, 96); const lines = [ { who: "A", text: "So the big idea here is that gravity is curved spacetime." }, { who: "B", text: "Right, and orbits are just objects falling around the curve." }, { who: "A", text: "Which she wrote about back in March, in Curved Space." }, ]; const active = Math.min(lines.length - 1, Math.floor(Math.max(0, frame - 92) / 24)); return ( {formats.map((f, i) => ( {f} ))}
{bar > 0 && bar < 1 ? "Recording..." : "Create audio"}
0 && bar < 1 ? 1 : 0 }} />
{Array.from({ length: 64 }).map((_, i) => { const v = Math.abs(Math.sin(frame * 0.35 + i * 0.55) * Math.cos(frame * 0.11 + i * 0.21)); const env = spring({ frame: frame - 90, fps, config: { damping: 20 } }); return
; })}
{lines.map((l, i) => { const on = i === active; return (
= 92 + i * 24 ? (on ? 1 : 0.45) : 0, fontFamily: theme.body, fontSize: 32, color: theme.white }}> {l.who} {l.text}
); })}
); } const VIDEO_STYLES = ["16:9 Explainer", "9:16 Short", "1:1 Square"]; const TEMPLATE_LOOKS: CSSProperties[] = [ { background: "linear-gradient(135deg, #000, #0b1730)" }, { background: `linear-gradient(135deg, ${BLUE}, #0a2a66)` }, { background: "linear-gradient(135deg, #ffffff, #cfe0ff)" }, { background: "linear-gradient(135deg, #0b1730, #217cff55)" }, ]; const TEMPLATE_NAMES = ["Night", "Signal", "Paper", "Studio"]; const VOICES = ["Nora Vale", "Your voice (clone)", "+ Create a voice"]; /** 05: wizard: pick a style, a template and a voice, create. */ function VideoCreate() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const style = frame >= 34 ? 1 : 0; const tpl = frame >= 74 ? 1 : 0; const voice = frame >= 114 ? 1 : 0; const go = pulse(frame, 162); const busy = clamp01(frame, 164, 194); return ( {VIDEO_STYLES.map((t, i) => ( {t} ))} {TEMPLATE_NAMES.map((name, i) => { const s = spring({ frame: frame - 6 - i * 5, fps, config: { damping: 16 } }); const sel = i === tpl; return (
{name}
); })} {VOICES.map((v, i) => ( {i < 2 && ▶} {v} ))}
{busy > 0 ? "Making your video..." : "Create video"}
); } const SCENE_TITLES = ["Gravity is geometry", "Inverse square", "Orbits", "Try it yourself"]; /** 06: the scene editor, then render the MP4. */ function VideoEdit() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const active = frame >= 32 ? 1 : 0; const since = frame >= 32 ? frame - 32 : frame; const pop = spring({ frame: since, fps, config: { damping: 16 } }); const render = clamp01(frame, 110, 164); const ready = frame >= 166; const tabs = ["Edit Scenes", "Images/footage", "Audio", "Settings"]; return ( {tabs.map((t, i) => ( {t} ))}
Download MP4
{SCENE_TITLES.map((t, i) => { const s = spring({ frame: frame - 4 - i * 5, fps, config: { damping: 16 } }); const on = i === active; return (
{i + 1}
{t}
); })}
{active === 0 ? (

SCENE 1

Gravity is
a curve.

) : ( <>

Gravity fades with distance

{[1, 0.25, 0.11].map((h, i) => (
))}

Double the distance and gravity drops to a quarter.

)} {render > 0 && (

{ready ? "✓ Your MP4 is ready" : "Rendering MP4"}

)}
); } /** 07: the Launch Kit appears, then Launchpad schedules it across the week. */ function LaunchKit() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const cards = [ { label: "X THREAD", body: "Gravity is not a force. It is geometry. A thread on how Einstein changed everything." }, { label: "LINKEDIN", body: "Most of us learned gravity wrong. Here is the picture physicists actually use." }, { label: "SUBSTACK NOTES", body: "Mass tells space how to bend. Space tells mass how to move." }, { label: "BLUESKY", body: "Orbits are just falling, forever, around a curve." }, ]; const days = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; const slots: Record = { 0: "X", 1: "LinkedIn", 2: "Notes", 3: "Bluesky", 4: "X", 5: "LinkedIn", 6: "Notes" }; return ( {cards.map((c, i) => { const s = spring({ frame: frame - 8 - i * 7, fps, config: { damping: 14 } }); return (
{c.label} {c.body}
); })} {days.map((d, i) => { const lit = frame >= 76 + i * 6; const s = spring({ frame: frame - 76 - i * 6, fps, config: { damping: 12 } }); return (
{d} {lit && (
{slots[i]}
)}
); })}
{["HOOKS", "SEO PACK", "CAROUSEL", "QUOTE CARDS"].map((t, i) => ( {t} ))}
Schedule all
); } function Outro() { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const s = spring({ frame: frame - 8, fps, config: { damping: 14 } }); const cta = spring({ frame: frame - 30, fps, config: { damping: 18 } }); return ( {/* The navbar mark (frontend/public/logo.svg, copied to public/logo.svg). */}

Your knowledge, in orbit.

Start free at notestack.ai

); } const RENDER: Record JSX.Element> = { warp: Warp, sync: Sync, ask: Ask, map: MindMap, audio: Audio, video: VideoCreate, edit: VideoEdit, kit: LaunchKit, outro: Outro, }; export function LandingDemo() { let cursor = 0; return ( {SCENES.map((scene) => { const from = cursor; cursor += S(scene.len - XFADE); const Scene = RENDER[scene.id]; return ( {scene.vo !== undefined && ( )} ); })} ); }