import React from "react"; import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate, Sequence, } from "remotion"; import { TransitionSeries, linearTiming } from "@remotion/transitions"; import { fade } from "@remotion/transitions/fade"; import { slide } from "@remotion/transitions/slide"; import { theme } from "../../components/shared/theme"; import { FilmGrain } from "../../components/shared/FilmGrain"; import { TikTokCaption } from "../../components/shared/TikTokCaption"; import type { CaptionEntry } from "../../components/shared/TikTokCaption"; /** * V04: "Spreadsheet Era Is Over" (Pain -> Relief Humor) * * Hook: Clown emoji + "how we used to plan trips" * Pain: Ugly spreadsheet chaos with formula errors * Break: "nah." * Relief: Clean SportsTime wizard + itinerary * CTA: "The spreadsheet era is over." * Length: 15 seconds (450 frames at 30fps) * * Visual identity: Split personality. "Before" is chaotic green * spreadsheet energy. "After" is sleek, dark, clean SportsTime. */ const CAPTIONS: CaptionEntry[] = [ { text: "how we used to plan trips \u{1F921}", startSec: 0.2, endSec: 1.8, style: "punch" }, { text: "the spreadsheet era", startSec: 2.2, endSec: 4.0, style: "shake" }, { text: "FORMULA ERROR", startSec: 4.5, endSec: 5.3, style: "shake" }, { text: "nah.", startSec: 5.7, endSec: 6.3, style: "whisper" }, { text: "20 seconds to plan", startSec: 7.0, endSec: 9.0, style: "highlight" }, { text: "the era is over", startSec: 10.8, endSec: 12.5, style: "punch" }, { text: "search SportsTime", startSec: 13.2, endSec: 14.8, style: "whisper" }, ]; // ─── Scene 1: Hook (0-2s) ─────────────────────────────────────────── const HookScene: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Clown emoji springs in with bouncy config const clownProgress = spring({ frame, fps, config: { damping: 8, stiffness: 180 }, }); const clownScale = interpolate(clownProgress, [0, 1], [0, 1]); const clownRotation = interpolate(frame, [0, 2 * fps], [0, 5], { extrapolateRight: "clamp", }); // Text punches in from 1.6x scale to 1x const textProgress = spring({ frame: frame - 8, fps, config: { damping: 12, stiffness: 220 }, }); const textScale = interpolate(textProgress, [0, 1], [1.6, 1]); const textOpacity = interpolate(textProgress, [0, 0.3], [0, 1], { extrapolateRight: "clamp", }); return ( {/* Clown emoji */}
{"\u{1F921}"}
{/* "How we used to plan trips" */}
How we used to plan trips
); }; // ─── Scene 2: Spreadsheet Hell (2-5.5s) ───────────────────────────── const COLUMN_HEADERS = ["Date", "City", "Stadium", "Game", "Hotel", "Cost"]; type CellData = { text: string; highlight?: "yellow" | "red" | "green" | "none"; bold?: boolean; }; const SPREADSHEET_ROWS: CellData[][] = [ [ { text: "6/15", highlight: "none" }, { text: "Dallas", highlight: "green", bold: true }, { text: "Globe Life", highlight: "none" }, { text: "Rangers", highlight: "none" }, { text: "Marriott??", highlight: "yellow" }, { text: "$189", highlight: "none" }, ], [ { text: "6/16", highlight: "none" }, { text: "Houston", highlight: "green", bold: true }, { text: "Minute Maid", highlight: "none" }, { text: "Astros", highlight: "none" }, { text: "TBD", highlight: "red" }, { text: "$???", highlight: "red" }, ], [ { text: "6/17", highlight: "none" }, { text: "Austin", highlight: "none" }, { text: "???", highlight: "red" }, { text: "\u2014", highlight: "red" }, { text: "Airbnb?", highlight: "yellow" }, { text: "$150?", highlight: "yellow" }, ], [ { text: "6/18", highlight: "none" }, { text: "San Antonio", highlight: "none", bold: true }, { text: "Alamodome?", highlight: "yellow" }, { text: "wait is there\na game?", highlight: "red" }, { text: "", highlight: "none" }, { text: "$??", highlight: "red" }, ], ]; const getCellBackground = (highlight?: string): string => { switch (highlight) { case "yellow": return "#FFF9C4"; case "red": return "#FFCDD2"; case "green": return "#C8E6C9"; default: return "white"; } }; const SpreadsheetScene: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Slow zoom in for anxiety const sheetZoom = interpolate(frame, [0, 3.5 * fps], [1.0, 1.08], { extrapolateRight: "clamp", }); // Shake timing: starts after all rows are in (~2.5s into scene = frame 75) const shakeStartFrame = Math.round(2.6 * fps); const isShaking = frame >= shakeStartFrame && frame < shakeStartFrame + 8; const SHAKE_OFFSETS = [ { x: -12, y: 8 }, { x: 15, y: -10 }, { x: -8, y: -14 }, { x: 14, y: 6 }, { x: -15, y: 12 }, { x: 10, y: -8 }, { x: -6, y: 15 }, { x: 12, y: -12 }, ]; const shakeOffset = isShaking ? SHAKE_OFFSETS[(frame - shakeStartFrame) % SHAKE_OFFSETS.length] : { x: 0, y: 0 }; // Formula error overlay const errorFrame = shakeStartFrame + 4; const showError = frame >= errorFrame; const errorOpacity = showError ? interpolate(frame, [errorFrame, errorFrame + 4], [0, 1], { extrapolateRight: "clamp", }) : 0; // Red tint overlay after shake const redTintOpacity = frame >= shakeStartFrame ? interpolate(frame, [shakeStartFrame, shakeStartFrame + 6], [0, 0.1], { extrapolateRight: "clamp", }) : 0; // Column headers appear first const headersProgress = spring({ frame: frame - 4, fps, config: { damping: 15, stiffness: 200 }, }); const CELL_WIDTH = 155; const CELL_HEIGHT = 80; const GRID_LEFT = (1080 - CELL_WIDTH * 6) / 2; const GRID_TOP = 340; return ( {/* Gridline pattern */}
{/* Spreadsheet content with zoom + shake */}
{/* Fake toolbar at top */}
=SUM(F2:F5)
{["B", "I", "U"].map((letter) => (
{letter}
))}
{/* Tab bar at top */}
Trip Plan v3 (FINAL)
Budget (old)
{/* Row numbers column */}
{[1, 2, 3, 4, 5].map((num) => (
{num}
))}
{/* Column headers */}
{COLUMN_HEADERS.map((header, i) => (
{header}
))}
{/* Data rows */} {SPREADSHEET_ROWS.map((row, rowIndex) => { const rowDelay = 12 + rowIndex * 10; // stagger 10 frames apart (headers + delay) const rowProgress = spring({ frame: frame - rowDelay, fps, config: { damping: 14, stiffness: 250 }, }); return (
{row.map((cell, colIndex) => { // Stagger individual cells within a row const cellDelay = rowDelay + colIndex * 3; const cellProgress = spring({ frame: frame - cellDelay, fps, config: { damping: 16, stiffness: 280 }, }); return (
12 ? 13 : 16, fontWeight: cell.bold ? 700 : 400, color: cell.highlight === "red" ? "#C62828" : cell.text.includes("?") ? "#E65100" : "#333", textAlign: "center", lineHeight: 1.2, whiteSpace: "pre-wrap", }} > {cell.text}
); })}
); })} {/* Formula error overlay */} {showError && (
{"\u274C"} FORMULA ERROR
)} {/* Random sticky notes / annotations for chaos */}
ask Jake about hotels!!
do they even play in Austin???
{/* Red tint overlay */}
); }; // ─── Scene 3: The Break (5.5-6.5s) ────────────────────────────────── const BreakScene: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Dramatic pause: black for first ~15 frames (0.5s), then "nah." fades in const textDelay = Math.round(0.5 * fps); const textOpacity = interpolate(frame, [textDelay, textDelay + 12], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); return (
nah.
); }; // ─── Scene 4: SportsTime Reveal (6.5-10.5s) ───────────────────────── const WIZARD_STEPS = [ { label: "Pick your sports", tags: [ { emoji: "\u26BE", name: "MLB" }, { emoji: "\u{1F3C8}", name: "NFL" }, ], startFrame: 15, endFrame: 55, }, { label: "Select dates", dateRange: "June 15 \u2013 18", startFrame: 55, endFrame: 85, }, { label: "Generate route", buttonText: "Generate Route", startFrame: 85, endFrame: 120, }, ]; const SportsTimeRevealScene: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Phone float animation const floatY = Math.sin(frame * 0.06) * 4; // Phone entry from bottom const phoneEntry = spring({ frame, fps, config: { damping: 18, stiffness: 120 }, }); const phoneTranslateY = interpolate(phoneEntry, [0, 1], [400, 0]); // Determine which wizard step is active const activeStepIndex = WIZARD_STEPS.findIndex( (step) => frame >= step.startFrame && frame < step.endFrame ); const currentStep = activeStepIndex >= 0 ? WIZARD_STEPS[activeStepIndex] : null; // Button press animation in step 3 const buttonPressFrame = 100; const buttonScale = frame >= buttonPressFrame && frame < buttonPressFrame + 4 ? 0.95 : frame >= buttonPressFrame + 4 && frame < buttonPressFrame + 8 ? interpolate(frame, [buttonPressFrame + 4, buttonPressFrame + 8], [0.95, 1], { extrapolateRight: "clamp", }) : 1; // Button glow pulse const glowPulse = activeStepIndex === 2 ? 0.3 + Math.sin(frame * 0.15) * 0.15 : 0; const PHONE_WIDTH = 380; const PHONE_HEIGHT = 700; const PHONE_BORDER_RADIUS = 52; return ( {/* Teal glow behind phone */}
{/* Phone mockup */}
{/* Dynamic island */}
{/* Screen content */}
{/* Step indicators (3 dots) */}
{[0, 1, 2].map((i) => (
))}
{/* Step content */} {currentStep && activeStepIndex === 0 && (
{currentStep.label}
{currentStep.tags?.map((tag, i) => { const tagProgress = spring({ frame: frame - currentStep.startFrame - i * 6, fps, config: { damping: 10, stiffness: 200 }, }); return (
{tag.emoji} {tag.name}
); })}
)} {currentStep && activeStepIndex === 1 && (
{currentStep.label}
{currentStep.dateRange}
)} {currentStep && activeStepIndex === 2 && (
Ready to plan
{currentStep.buttonText}
)}
); }; // ─── Scene 5: The Result (10.5-13s) ───────────────────────────────── type ItineraryStop = { day: string; text: string; delay: number; }; const ITINERARY_STOPS: ItineraryStop[] = [ { day: "Day 1", text: "Dallas \u2192 Rangers vs Yankees", delay: 0 }, { day: "Day 2", text: "Houston \u2192 Astros vs Red Sox", delay: 6 }, { day: "Day 3", text: "Austin \u2192 Chill Day", delay: 12 }, { day: "Day 4", text: "San Antonio \u2192 Drive Home", delay: 18 }, ]; const ResultScene: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Card entry const cardProgress = spring({ frame: frame - 5, fps, config: { damping: 20, stiffness: 150 }, }); const cardScale = interpolate(cardProgress, [0, 1], [0.85, 1]); const cardOpacity = interpolate(cardProgress, [0, 0.4], [0, 1], { extrapolateRight: "clamp", }); // Bottom badge const badgeDelay = 45; const badgeProgress = spring({ frame: frame - badgeDelay, fps, config: theme.animation.snappy, }); // Sparkle emoji float const sparkleDelay = 55; const sparkleOpacity = interpolate( frame, [sparkleDelay, sparkleDelay + 8], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); const sparkleY = interpolate( frame, [sparkleDelay, sparkleDelay + 30], [0, -40], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); return ( {/* Sparkle */}
{"\u2728"}
{/* Itinerary card */}
{/* Title */}
Texas Baseball Trip
{/* Timeline */}
{/* Orange timeline line */}
{/* Stops */} {ITINERARY_STOPS.map((stop, i) => { const stopProgress = spring({ frame: frame - 10 - stop.delay, fps, config: { damping: 15, stiffness: 200 }, }); const stopOpacity = interpolate(stopProgress, [0, 1], [0, 1]); const stopX = interpolate(stopProgress, [0, 1], [30, 0]); // Checkmark appears after the row const checkDelay = 10 + stop.delay + 10; const checkProgress = spring({ frame: frame - checkDelay, fps, config: { damping: 10, stiffness: 250 }, }); return (
{/* Timeline dot */}
{/* Content */}
{stop.day}: {stop.text}
{/* Checkmark */} {"\u2713"}
); })}
{/* Bottom badge */}
4 days {"\u00B7"} 3 games {"\u00B7"} $0 planning headaches
); }; // ─── Scene 6: CTA (13-15s) ────────────────────────────────────────── const CTAScene: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); // Main text const mainProgress = spring({ frame: frame - 5, fps, config: theme.animation.smooth, }); const mainOpacity = interpolate(frame, [5, 15], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const mainY = interpolate(mainProgress, [0, 1], [25, 0]); // CTA text const ctaProgress = spring({ frame: frame - 20, fps, config: theme.animation.smooth, }); const ctaOpacity = interpolate(frame, [20, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const ctaY = interpolate(ctaProgress, [0, 1], [20, 0]); return ( {/* Subtle accent glow */}
{/* "The spreadsheet era is over." */}
The spreadsheet era{"\n"}is over.
{/* "Search SportsTime" */}
Search SportsTime
); }; // ─── Main Composition ──────────────────────────────────────────────── export const SpreadsheetEra: React.FC = () => { const { fps } = useVideoConfig(); const TRANSITION = 8; // 8-frame transitions (snappy cuts) // TransitionSeries: total visible = sum of durations - sum of transitions // 5 transitions x 8 frames = 40 frames overlap // We need 450 visible frames, so scene sum = 450 + 40 = 490 const SCENES = { hook: 65, // ~2.2s - clown emoji + text spreadsheet: 110, // ~3.7s - spreadsheet chaos theBreak: 35, // ~1.2s - "nah." reveal: 125, // ~4.2s - SportsTime wizard result: 85, // ~2.8s - itinerary card cta: 70, // ~2.3s - final CTA }; // Total: 490 - 40 = 450 frames = 15s return ( {/* Scene 1: Hook - clown emoji */} {/* Scene 2: Spreadsheet Hell */} {/* Scene 3: "nah." */} {/* Scene 4: SportsTime Reveal */} {/* Scene 5: The Result */} {/* Scene 6: CTA */} {/* TikTok captions overlay on top of everything */} ); };