- 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>
1374 lines
40 KiB
TypeScript
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>
|
|
);
|
|
};
|