Files
Sportstime/marketing-videos/src/videos/SpreadsheetEra/index.tsx
Trey t 8e937a5646 feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash
- Fix repeat-city travel placement: use stop indices instead of global city name
  matching so Follow Team trips with repeat cities show travel correctly
- Add TravelPlacement helper and regression tests (7 tests)
- Add alternate app icons for each theme, auto-switch on theme change
- Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20)
- Add marketing video configs, engine, and new video components
- Add docs and data exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 09:36:34 -06:00

1374 lines
40 KiB
TypeScript

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 (
<AbsoluteFill
style={{
background: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
{/* Clown emoji */}
<div
style={{
fontSize: 120,
transform: `scale(${clownScale}) rotate(${clownRotation}deg)`,
marginBottom: 40,
lineHeight: 1,
}}
>
{"\u{1F921}"}
</div>
{/* "How we used to plan trips" */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 800,
color: "white",
textAlign: "center",
transform: `scale(${textScale})`,
opacity: textOpacity,
lineHeight: 1.2,
maxWidth: 900,
padding: "0 40px",
}}
>
How we used to plan trips
</div>
<FilmGrain opacity={0.06} />
</AbsoluteFill>
);
};
// ─── 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 (
<AbsoluteFill
style={{
background: "#E8F5E9",
}}
>
{/* Gridline pattern */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(0,0,0,0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.08) 1px, transparent 1px)
`,
backgroundSize: `${CELL_WIDTH}px ${CELL_HEIGHT}px`,
backgroundPosition: `${GRID_LEFT}px ${GRID_TOP}px`,
}}
/>
{/* Spreadsheet content with zoom + shake */}
<div
style={{
position: "absolute",
inset: 0,
transform: `scale(${sheetZoom}) translate(${shakeOffset.x}px, ${shakeOffset.y}px)`,
transformOrigin: "center 45%",
}}
>
{/* Fake toolbar at top */}
<div
style={{
position: "absolute",
top: 120,
left: 60,
right: 60,
height: 48,
background: "#D5D5D5",
borderRadius: 4,
display: "flex",
alignItems: "center",
padding: "0 16px",
gap: 12,
}}
>
<div
style={{
width: 180,
height: 28,
background: "white",
borderRadius: 3,
border: "1px solid #999",
display: "flex",
alignItems: "center",
paddingLeft: 8,
fontFamily: "monospace",
fontSize: 14,
color: "#333",
}}
>
=SUM(F2:F5)
</div>
{["B", "I", "U"].map((letter) => (
<div
key={letter}
style={{
width: 28,
height: 28,
background: "#E8E8E8",
borderRadius: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "serif",
fontSize: 16,
fontWeight: letter === "B" ? 700 : 400,
fontStyle: letter === "I" ? "italic" : "normal",
textDecoration: letter === "U" ? "underline" : "none",
color: "#444",
}}
>
{letter}
</div>
))}
</div>
{/* Tab bar at top */}
<div
style={{
position: "absolute",
top: 180,
left: 60,
display: "flex",
gap: 0,
}}
>
<div
style={{
background: "white",
padding: "8px 24px",
borderRadius: "6px 6px 0 0",
fontFamily: "monospace",
fontSize: 14,
color: "#00B050",
fontWeight: 700,
border: "1px solid #ccc",
borderBottom: "none",
}}
>
Trip Plan v3 (FINAL)
</div>
<div
style={{
background: "#E0E0E0",
padding: "8px 24px",
borderRadius: "6px 6px 0 0",
fontFamily: "monospace",
fontSize: 14,
color: "#999",
border: "1px solid #ccc",
borderBottom: "none",
}}
>
Budget (old)
</div>
</div>
{/* Row numbers column */}
<div
style={{
position: "absolute",
left: GRID_LEFT - 40,
top: GRID_TOP,
}}
>
{[1, 2, 3, 4, 5].map((num) => (
<div
key={num}
style={{
width: 36,
height: CELL_HEIGHT,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "monospace",
fontSize: 13,
color: "#888",
borderRight: "1px solid rgba(0,0,0,0.15)",
background: "#F0F0F0",
}}
>
{num}
</div>
))}
</div>
{/* Column headers */}
<div
style={{
position: "absolute",
left: GRID_LEFT,
top: GRID_TOP,
display: "flex",
opacity: interpolate(headersProgress, [0, 1], [0, 1]),
transform: `scaleY(${interpolate(headersProgress, [0, 1], [0.5, 1])})`,
transformOrigin: "top",
}}
>
{COLUMN_HEADERS.map((header, i) => (
<div
key={header}
style={{
width: CELL_WIDTH,
height: CELL_HEIGHT,
background: "#00B050",
border: "1px solid #009940",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "monospace",
fontSize: 16,
fontWeight: 700,
color: "white",
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{header}
</div>
))}
</div>
{/* 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 (
<div
key={rowIndex}
style={{
position: "absolute",
left: GRID_LEFT,
top: GRID_TOP + CELL_HEIGHT * (rowIndex + 1),
display: "flex",
opacity: interpolate(rowProgress, [0, 1], [0, 1]),
transform: `scale(${interpolate(rowProgress, [0, 1], [0.7, 1])})`,
transformOrigin: "left center",
}}
>
{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 (
<div
key={colIndex}
style={{
width: CELL_WIDTH,
height: CELL_HEIGHT,
background: getCellBackground(cell.highlight),
border: "1px solid #C0C0C0",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "4px 6px",
opacity: interpolate(cellProgress, [0, 1], [0, 1]),
transform: `scale(${interpolate(cellProgress, [0, 1], [0.3, 1])})`,
}}
>
<span
style={{
fontFamily: "monospace",
fontSize: cell.text.length > 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}
</span>
</div>
);
})}
</div>
);
})}
{/* Formula error overlay */}
{showError && (
<div
style={{
position: "absolute",
left: GRID_LEFT + 120,
top: GRID_TOP + CELL_HEIGHT * 3.5,
transform: "rotate(-3deg)",
opacity: errorOpacity,
zIndex: 10,
}}
>
<div
style={{
background: "rgba(211, 47, 47, 0.95)",
padding: "14px 32px",
borderRadius: 8,
border: "3px solid #B71C1C",
boxShadow: "0 4px 20px rgba(211, 47, 47, 0.5)",
}}
>
<span
style={{
fontFamily: "monospace",
fontSize: 36,
fontWeight: 900,
color: "white",
letterSpacing: 1,
}}
>
{"\u274C"} FORMULA ERROR
</span>
</div>
</div>
)}
{/* Random sticky notes / annotations for chaos */}
<div
style={{
position: "absolute",
right: 80,
top: GRID_TOP + 60,
transform: "rotate(4deg)",
opacity: interpolate(frame, [20, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
>
<div
style={{
background: "#FFEB3B",
padding: "12px 16px",
boxShadow: "2px 2px 8px rgba(0,0,0,0.2)",
fontFamily: "Comic Sans MS, cursive",
fontSize: 16,
color: "#333",
transform: "rotate(-2deg)",
maxWidth: 140,
lineHeight: 1.3,
}}
>
ask Jake about hotels!!
</div>
</div>
<div
style={{
position: "absolute",
left: 70,
top: GRID_TOP + CELL_HEIGHT * 4.5,
transform: "rotate(-5deg)",
opacity: interpolate(frame, [40, 50], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
}}
>
<div
style={{
background: "#FF8A80",
padding: "10px 14px",
boxShadow: "2px 2px 8px rgba(0,0,0,0.2)",
fontFamily: "Comic Sans MS, cursive",
fontSize: 14,
color: "#B71C1C",
maxWidth: 160,
lineHeight: 1.3,
}}
>
do they even play in Austin???
</div>
</div>
</div>
{/* Red tint overlay */}
<div
style={{
position: "absolute",
inset: 0,
background: `rgba(255, 0, 0, ${redTintOpacity})`,
pointerEvents: "none",
}}
/>
<FilmGrain opacity={0.06} />
</AbsoluteFill>
);
};
// ─── 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 (
<AbsoluteFill
style={{
background: "#000000",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 900,
fontStyle: "italic",
color: "white",
opacity: textOpacity,
letterSpacing: -1,
}}
>
nah.
</div>
<FilmGrain opacity={0.06} />
</AbsoluteFill>
);
};
// ─── 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 (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Teal glow behind phone */}
<div
style={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 600,
height: 600,
background: `radial-gradient(ellipse, ${theme.colors.secondary}20 0%, transparent 70%)`,
borderRadius: "50%",
}}
/>
{/* Phone mockup */}
<div
style={{
transform: `translateY(${phoneTranslateY + floatY}px)`,
filter: "drop-shadow(0 20px 60px rgba(0,0,0,0.6))",
}}
>
<div
style={{
width: PHONE_WIDTH,
height: PHONE_HEIGHT,
background: "#1C1C1E",
borderRadius: PHONE_BORDER_RADIUS,
border: "3px solid #3A3A3C",
overflow: "hidden",
position: "relative",
}}
>
{/* Dynamic island */}
<div
style={{
position: "absolute",
top: 14,
left: "50%",
transform: "translateX(-50%)",
width: 110,
height: 32,
background: "#000",
borderRadius: 16,
zIndex: 10,
}}
/>
{/* Screen content */}
<div
style={{
position: "absolute",
top: 60,
left: 16,
right: 16,
bottom: 16,
background: theme.colors.background,
borderRadius: PHONE_BORDER_RADIUS - 12,
overflow: "hidden",
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "30px 20px",
}}
>
{/* Step indicators (3 dots) */}
<div
style={{
display: "flex",
gap: 10,
marginBottom: 40,
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
width: 10,
height: 10,
borderRadius: 5,
background:
i <= activeStepIndex
? theme.colors.accent
: "rgba(255,255,255,0.2)",
transition: "background 0.2s",
}}
/>
))}
</div>
{/* Step content */}
{currentStep && activeStepIndex === 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 24,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 600,
color: "white",
}}
>
{currentStep.label}
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", justifyContent: "center" }}>
{currentStep.tags?.map((tag, i) => {
const tagProgress = spring({
frame: frame - currentStep.startFrame - i * 6,
fps,
config: { damping: 10, stiffness: 200 },
});
return (
<div
key={tag.name}
style={{
background: theme.colors.accent,
borderRadius: 20,
padding: "10px 22px",
display: "flex",
alignItems: "center",
gap: 8,
transform: `scale(${interpolate(tagProgress, [0, 1], [0, 1])})`,
opacity: interpolate(tagProgress, [0, 1], [0, 1]),
}}
>
<span style={{ fontSize: 22 }}>{tag.emoji}</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
fontWeight: 600,
color: "white",
}}
>
{tag.name}
</span>
</div>
);
})}
</div>
</div>
)}
{currentStep && activeStepIndex === 1 && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 24,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 600,
color: "white",
}}
>
{currentStep.label}
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.accent,
background: "rgba(255, 107, 53, 0.1)",
padding: "16px 32px",
borderRadius: 16,
border: `2px solid ${theme.colors.accent}40`,
}}
>
{currentStep.dateRange}
</div>
</div>
)}
{currentStep && activeStepIndex === 2 && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 30,
marginTop: 20,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 600,
color: "rgba(255,255,255,0.7)",
}}
>
Ready to plan
</div>
<div
style={{
background: theme.colors.accent,
borderRadius: 16,
padding: "18px 48px",
transform: `scale(${buttonScale})`,
boxShadow: `0 0 ${30 + glowPulse * 40}px rgba(255, 107, 53, ${glowPulse + 0.2})`,
cursor: "pointer",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: "white",
letterSpacing: 0.5,
}}
>
{currentStep.buttonText}
</span>
</div>
</div>
)}
</div>
</div>
</div>
<FilmGrain opacity={0.03} />
</AbsoluteFill>
);
};
// ─── 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 (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Sparkle */}
<div
style={{
position: "absolute",
top: "22%",
left: "55%",
fontSize: 48,
opacity: sparkleOpacity,
transform: `translateY(${sparkleY}px)`,
zIndex: 10,
}}
>
{"\u2728"}
</div>
{/* Itinerary card */}
<div
style={{
width: 900,
background: "rgba(255, 255, 255, 0.06)",
backdropFilter: "blur(20px)",
borderRadius: 28,
border: "1px solid rgba(255, 255, 255, 0.1)",
padding: "44px 48px",
transform: `scale(${cardScale})`,
opacity: cardOpacity,
boxShadow: "0 16px 60px rgba(0, 0, 0, 0.4)",
}}
>
{/* Title */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: "white",
marginBottom: 36,
letterSpacing: -0.5,
}}
>
Texas Baseball Trip
</div>
{/* Timeline */}
<div style={{ position: "relative", paddingLeft: 32 }}>
{/* Orange timeline line */}
<div
style={{
position: "absolute",
left: 7,
top: 8,
bottom: 8,
width: 3,
background: theme.colors.accent,
borderRadius: 2,
}}
/>
{/* 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 (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
gap: 20,
marginBottom: i < ITINERARY_STOPS.length - 1 ? 24 : 0,
opacity: stopOpacity,
transform: `translateX(${stopX}px)`,
}}
>
{/* Timeline dot */}
<div
style={{
position: "absolute",
left: 0,
width: 16,
height: 16,
borderRadius: 8,
background: theme.colors.accent,
border: "3px solid #1A1A2E",
}}
/>
{/* Content */}
<div style={{ flex: 1 }}>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
fontWeight: 600,
color: theme.colors.accent,
marginRight: 12,
}}
>
{stop.day}:
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 500,
color: "white",
}}
>
{stop.text}
</span>
</div>
{/* Checkmark */}
<span
style={{
fontSize: 24,
color: theme.colors.success,
opacity: interpolate(checkProgress, [0, 1], [0, 1]),
transform: `scale(${interpolate(checkProgress, [0, 1], [0, 1])})`,
}}
>
{"\u2713"}
</span>
</div>
);
})}
</div>
{/* Bottom badge */}
<div
style={{
marginTop: 36,
display: "flex",
justifyContent: "center",
opacity: interpolate(badgeProgress, [0, 1], [0, 1]),
transform: `scale(${interpolate(badgeProgress, [0, 1], [0.8, 1])}) translateY(${interpolate(badgeProgress, [0, 1], [10, 0])}px)`,
}}
>
<div
style={{
background: theme.colors.accent,
borderRadius: 14,
padding: "12px 28px",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: "white",
letterSpacing: 0.3,
}}
>
4 days {"\u00B7"} 3 games {"\u00B7"} $0 planning headaches
</span>
</div>
</div>
</div>
<FilmGrain opacity={0.03} />
</AbsoluteFill>
);
};
// ─── 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 (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Subtle accent glow */}
<div
style={{
position: "absolute",
top: "35%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "120%",
height: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}10 0%, transparent 70%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 32,
padding: "0 60px",
}}
>
{/* "The spreadsheet era is over." */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 48,
fontWeight: 800,
color: "white",
textAlign: "center",
opacity: mainOpacity,
transform: `translateY(${mainY}px)`,
lineHeight: 1.2,
letterSpacing: -1,
}}
>
The spreadsheet era{"\n"}is over.
</div>
{/* "Search SportsTime" */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 600,
color: theme.colors.accent,
textAlign: "center",
opacity: ctaOpacity,
transform: `translateY(${ctaY}px)`,
}}
>
Search SportsTime
</div>
</div>
<FilmGrain opacity={0.03} />
</AbsoluteFill>
);
};
// ─── 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 (
<AbsoluteFill>
<TransitionSeries>
{/* Scene 1: Hook - clown emoji */}
<TransitionSeries.Sequence durationInFrames={SCENES.hook}>
<HookScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 2: Spreadsheet Hell */}
<TransitionSeries.Sequence durationInFrames={SCENES.spreadsheet}>
<SpreadsheetScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 3: "nah." */}
<TransitionSeries.Sequence durationInFrames={SCENES.theBreak}>
<BreakScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-bottom" })}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 4: SportsTime Reveal */}
<TransitionSeries.Sequence durationInFrames={SCENES.reveal}>
<SportsTimeRevealScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 5: The Result */}
<TransitionSeries.Sequence durationInFrames={SCENES.result}>
<ResultScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 6: CTA */}
<TransitionSeries.Sequence durationInFrames={SCENES.cta}>
<CTAScene />
</TransitionSeries.Sequence>
</TransitionSeries>
{/* TikTok captions overlay on top of everything */}
<TikTokCaption captions={CAPTIONS} />
</AbsoluteFill>
);
};