chore: remove scraper, add docs, add marketing-videos gitignore

- Remove Scripts/ directory (scraper no longer needed)
- Add themed background documentation to CLAUDE.md
- Add .gitignore for marketing-videos to prevent node_modules tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-26 18:13:12 -06:00
parent bfa172de38
commit dbb0099776
129 changed files with 14805 additions and 25325 deletions

View File

@@ -0,0 +1,75 @@
import { Composition, Folder } from "remotion";
import { TheRoute } from "./videos/TheRoute";
import { TheChecklist } from "./videos/TheChecklist";
import { TheBucketList } from "./videos/TheBucketList";
import { TheSquad } from "./videos/TheSquad";
import { TheHandoff } from "./videos/TheHandoff";
/**
* SportsTime Marketing Videos
*
* All videos are portrait format (1080x1920) at 30fps
* Designed for App Store, YouTube, Instagram, and TikTok
*/
export const RemotionRoot: React.FC = () => {
const FPS = 30;
const WIDTH = 1080;
const HEIGHT = 1920;
return (
<>
<Folder name="SportsTime-Marketing">
{/* Video 1: The Route - Map animation showcasing trip planning */}
<Composition
id="TheRoute"
component={TheRoute}
durationInFrames={15 * FPS} // 15 seconds = 450 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
{/* Video 2: The Checklist - Wizard walkthrough */}
<Composition
id="TheChecklist"
component={TheChecklist}
durationInFrames={20 * FPS} // 20 seconds = 600 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
{/* Video 3: The Bucket List - Stadium progress tracking */}
<Composition
id="TheBucketList"
component={TheBucketList}
durationInFrames={12 * FPS} // 12 seconds = 360 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
{/* Video 4: The Squad - Group polling feature */}
<Composition
id="TheSquad"
component={TheSquad}
durationInFrames={18 * FPS} // 18 seconds = 540 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
{/* Video 5: The Handoff - PDF export showcase */}
<Composition
id="TheHandoff"
component={TheHandoff}
durationInFrames={10 * FPS} // 10 seconds = 300 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
</Folder>
</>
);
};

View File

@@ -0,0 +1,185 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Img,
staticFile,
} from "remotion";
import { theme } from "./theme";
type AppScreenshotProps = {
src?: string;
delay?: number;
scale?: number;
showDeviceFrame?: boolean;
children?: React.ReactNode;
};
export const AppScreenshot: React.FC<AppScreenshotProps> = ({
src,
delay = 0,
scale = 0.85,
showDeviceFrame = true,
children,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const delayFrames = delay * fps;
const progress = spring({
frame: frame - delayFrames,
fps,
config: theme.animation.smooth,
});
const opacity = interpolate(
frame - delayFrames,
[0, fps * 0.3],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const scaleValue = interpolate(progress, [0, 1], [0.9, 1]) * scale;
if (frame < delayFrames) {
return null;
}
const phoneWidth = width * 0.75;
const phoneHeight = height * 0.8;
const cornerRadius = 60;
const bezelWidth = 12;
return (
<div
style={{
opacity,
transform: `scale(${scaleValue})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
}}
>
{showDeviceFrame ? (
<div
style={{
position: "relative",
width: phoneWidth,
height: phoneHeight,
background: "#1C1C1E",
borderRadius: cornerRadius,
padding: bezelWidth,
boxShadow: `
0 50px 100px rgba(0, 0, 0, 0.5),
0 20px 40px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1)
`,
}}
>
{/* Dynamic Island */}
<div
style={{
position: "absolute",
top: bezelWidth + 15,
left: "50%",
transform: "translateX(-50%)",
width: 120,
height: 36,
background: "#000",
borderRadius: 18,
zIndex: 10,
}}
/>
{/* Screen content */}
<div
style={{
width: "100%",
height: "100%",
borderRadius: cornerRadius - bezelWidth,
overflow: "hidden",
background: theme.colors.background,
position: "relative",
}}
>
{src ? (
<Img
src={staticFile(src)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
children
)}
</div>
{/* Home indicator */}
<div
style={{
position: "absolute",
bottom: bezelWidth + 10,
left: "50%",
transform: "translateX(-50%)",
width: 140,
height: 5,
background: "rgba(255, 255, 255, 0.3)",
borderRadius: 3,
}}
/>
</div>
) : (
<div
style={{
width: phoneWidth - 40,
height: phoneHeight - 40,
borderRadius: cornerRadius - 20,
overflow: "hidden",
background: theme.colors.background,
boxShadow: `0 30px 80px rgba(0, 0, 0, 0.4)`,
}}
>
{src ? (
<Img
src={staticFile(src)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
children
)}
</div>
)}
</div>
);
};
// Mock screen content components
type MockScreenProps = {
children: React.ReactNode;
};
export const MockScreen: React.FC<MockScreenProps> = ({ children }) => {
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: theme.spacing.lg,
paddingTop: 80, // Account for Dynamic Island
}}
>
{children}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,79 @@
import React from "react";
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
import { theme } from "./theme";
type GradientBackgroundProps = {
animate?: boolean;
};
export const GradientBackground: React.FC<GradientBackgroundProps> = ({
animate = false,
}) => {
const frame = useCurrentFrame();
const gradientAngle = animate
? interpolate(frame, [0, 300], [180, 200], { extrapolateRight: "clamp" })
: 180;
return (
<AbsoluteFill
style={{
background: `linear-gradient(${gradientAngle}deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
}}
/>
);
};
// Subtle grid pattern background
export const GridBackground: React.FC = () => {
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
}}
/>
</AbsoluteFill>
);
};
// Radial glow background
type GlowBackgroundProps = {
color?: string;
intensity?: number;
};
export const GlowBackground: React.FC<GlowBackgroundProps> = ({
color = theme.colors.accent,
intensity = 0.15,
}) => {
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
<div
style={{
position: "absolute",
top: "30%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "150%",
height: "80%",
background: `radial-gradient(ellipse, ${color}${Math.round(intensity * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)`,
}}
/>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,203 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "./theme";
type LogoEndcardProps = {
tagline?: string;
showAppStoreBadge?: boolean;
};
export const LogoEndcard: React.FC<LogoEndcardProps> = ({
tagline,
showAppStoreBadge = false,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Logo entrance with slight bounce
const logoScale = spring({
frame,
fps,
config: { damping: 15, stiffness: 100 },
});
const logoOpacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Tagline entrance (delayed)
const taglineProgress = spring({
frame,
fps,
delay: fps * 0.4,
config: theme.animation.smooth,
});
const taglineOpacity = interpolate(
frame,
[fps * 0.4, fps * 0.6],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const taglineY = interpolate(taglineProgress, [0, 1], [20, 0]);
// App Store badge entrance (delayed further)
const badgeProgress = spring({
frame,
fps,
delay: fps * 0.7,
config: theme.animation.smooth,
});
const badgeOpacity = interpolate(
frame,
[fps * 0.7, fps * 0.9],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* App Icon */}
<div
style={{
transform: `scale(${logoScale})`,
opacity: logoOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: theme.spacing.lg,
}}
>
{/* Icon placeholder - rounded square with gradient */}
<div
style={{
width: 180,
height: 180,
borderRadius: 40,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.4)`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{/* Simple stadium icon representation */}
<svg
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
>
<ellipse
cx="50"
cy="60"
rx="40"
ry="20"
stroke="white"
strokeWidth="4"
fill="none"
/>
<path
d="M10 60 L10 40 Q50 10 90 40 L90 60"
stroke="white"
strokeWidth="4"
fill="none"
/>
<line
x1="50"
y1="30"
x2="50"
y2="15"
stroke="white"
strokeWidth="3"
/>
<circle cx="50" cy="12" r="4" fill="white" />
</svg>
</div>
{/* Wordmark */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: theme.fontSizes.hero,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -2,
}}
>
SportsTime
</div>
</div>
{/* Tagline */}
{tagline && (
<div
style={{
position: "absolute",
bottom: showAppStoreBadge ? 280 : 200,
opacity: taglineOpacity,
transform: `translateY(${taglineY}px)`,
fontFamily: theme.fonts.text,
fontSize: theme.fontSizes.subtitle,
fontWeight: 500,
color: theme.colors.textSecondary,
letterSpacing: 0.5,
}}
>
{tagline}
</div>
)}
{/* App Store Badge */}
{showAppStoreBadge && (
<div
style={{
position: "absolute",
bottom: 120,
opacity: badgeOpacity,
transform: `scale(${badgeProgress})`,
}}
>
<div
style={{
padding: "16px 32px",
background: theme.colors.text,
borderRadius: 12,
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<svg width="32" height="32" viewBox="0 0 32 32" fill="black">
<path d="M16 2C8.268 2 2 8.268 2 16s6.268 14 14 14 14-6.268 14-14S23.732 2 16 2zm5.788 20.683c-.314.467-.782.7-1.404.7-.311 0-.622-.078-.933-.233l-3.451-1.867-3.451 1.867c-.311.155-.622.233-.933.233-.622 0-1.09-.233-1.404-.7-.314-.467-.389-1.012-.225-1.635l.933-3.917-3.062-2.567c-.467-.389-.7-.856-.7-1.4 0-.544.233-1.012.7-1.4l3.062-2.567-.933-3.917c-.164-.623-.089-1.168.225-1.635.314-.467.782-.7 1.404-.7.311 0 .622.078.933.233L16 5.142l3.451-1.867c.311-.155.622-.233.933-.233.622 0 1.09.233 1.404.7.314.467.389 1.012.225 1.635l-.933 3.917 3.062 2.567c.467.388.7.856.7 1.4 0 .544-.233 1.011-.7 1.4l-3.062 2.567.933 3.917c.164.623.089 1.168-.225 1.635z" />
</svg>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 600,
color: "black",
}}
>
Download on the App Store
</span>
</div>
</div>
)}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,173 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "./theme";
type TapIndicatorProps = {
x: number;
y: number;
delay?: number;
showRipple?: boolean;
};
export const TapIndicator: React.FC<TapIndicatorProps> = ({
x,
y,
delay = 0,
showRipple = true,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const delayFrames = delay * fps;
const localFrame = frame - delayFrames;
if (localFrame < 0) {
return null;
}
// Finger appears
const fingerProgress = spring({
frame: localFrame,
fps,
config: theme.animation.smooth,
});
// Finger presses down
const pressProgress = spring({
frame: localFrame - fps * 0.2,
fps,
config: { damping: 30, stiffness: 300 },
});
const fingerScale = interpolate(fingerProgress, [0, 1], [0.5, 1]);
const fingerOpacity = interpolate(fingerProgress, [0, 1], [0, 1]);
const pressScale = interpolate(pressProgress, [0, 1], [1, 0.9]);
// Ripple effect
const rippleProgress = interpolate(
localFrame,
[fps * 0.25, fps * 0.7],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const rippleScale = interpolate(rippleProgress, [0, 1], [0.5, 2]);
const rippleOpacity = interpolate(rippleProgress, [0, 0.3, 1], [0, 0.5, 0]);
return (
<div
style={{
position: "absolute",
left: x,
top: y,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
>
{/* Ripple */}
{showRipple && rippleProgress > 0 && (
<div
style={{
position: "absolute",
width: 80,
height: 80,
borderRadius: "50%",
background: theme.colors.accent,
opacity: rippleOpacity,
transform: `translate(-50%, -50%) scale(${rippleScale})`,
left: "50%",
top: "50%",
}}
/>
)}
{/* Finger circle */}
<div
style={{
width: 60,
height: 60,
borderRadius: "50%",
background: `rgba(255, 255, 255, 0.9)`,
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3)`,
opacity: fingerOpacity,
transform: `scale(${fingerScale * pressScale})`,
}}
/>
</div>
);
};
// Swipe indicator for tutorial-style animations
type SwipeIndicatorProps = {
startX: number;
startY: number;
endX: number;
endY: number;
delay?: number;
duration?: number;
};
export const SwipeIndicator: React.FC<SwipeIndicatorProps> = ({
startX,
startY,
endX,
endY,
delay = 0,
duration = 0.8,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const delayFrames = delay * fps;
const durationFrames = duration * fps;
const localFrame = frame - delayFrames;
if (localFrame < 0 || localFrame > durationFrames + fps * 0.3) {
return null;
}
const progress = interpolate(
localFrame,
[0, durationFrames],
[0, 1],
{ extrapolateRight: "clamp" }
);
const x = interpolate(progress, [0, 1], [startX, endX]);
const y = interpolate(progress, [0, 1], [startY, endY]);
const opacity = interpolate(
localFrame,
[0, fps * 0.1, durationFrames, durationFrames + fps * 0.2],
[0, 1, 1, 0],
{ extrapolateRight: "clamp" }
);
return (
<div
style={{
position: "absolute",
left: x,
top: y,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
opacity,
}}
>
<div
style={{
width: 50,
height: 50,
borderRadius: "50%",
background: `rgba(255, 255, 255, 0.9)`,
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3)`,
}}
/>
</div>
);
};

View File

@@ -0,0 +1,188 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "./theme";
type TextRevealProps = {
text: string;
fontSize?: number;
color?: string;
fontWeight?: number;
textAlign?: "left" | "center" | "right";
delay?: number;
style?: React.CSSProperties;
};
export const TextReveal: React.FC<TextRevealProps> = ({
text,
fontSize = theme.fontSizes.title,
color = theme.colors.text,
fontWeight = 700,
textAlign = "center",
delay = 0,
style = {},
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const delayFrames = delay * fps;
// Spring for smooth entrance
const progress = spring({
frame: frame - delayFrames,
fps,
config: theme.animation.smooth,
});
const opacity = interpolate(
frame - delayFrames,
[0, fps * 0.3],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateY = interpolate(progress, [0, 1], [30, 0]);
if (frame < delayFrames) {
return null;
}
return (
<div
style={{
fontFamily: theme.fonts.display,
fontSize,
fontWeight,
color,
textAlign,
opacity,
transform: `translateY(${translateY}px)`,
letterSpacing: -1,
lineHeight: 1.2,
...style,
}}
>
{text}
</div>
);
};
// Multi-line version with staggered reveal
type TextRevealMultilineProps = {
lines: string[];
fontSize?: number;
color?: string;
fontWeight?: number;
textAlign?: "left" | "center" | "right";
staggerDelay?: number;
startDelay?: number;
lineHeight?: number;
};
export const TextRevealMultiline: React.FC<TextRevealMultilineProps> = ({
lines,
fontSize = theme.fontSizes.title,
color = theme.colors.text,
fontWeight = 700,
textAlign = "center",
staggerDelay = 0.1,
startDelay = 0,
lineHeight = 1.3,
}) => {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: textAlign === "center" ? "center" : textAlign === "right" ? "flex-end" : "flex-start",
gap: 8,
}}
>
{lines.map((line, index) => (
<TextReveal
key={index}
text={line}
fontSize={fontSize}
color={color}
fontWeight={fontWeight}
textAlign={textAlign}
delay={startDelay + index * staggerDelay}
style={{ lineHeight }}
/>
))}
</div>
);
};
// Highlight style text (with background)
type HighlightTextProps = {
text: string;
fontSize?: number;
delay?: number;
};
export const HighlightText: React.FC<HighlightTextProps> = ({
text,
fontSize = theme.fontSizes.subtitle,
delay = 0,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const delayFrames = delay * fps;
const progress = spring({
frame: frame - delayFrames,
fps,
config: theme.animation.snappy,
});
const scaleX = interpolate(progress, [0, 1], [0, 1]);
const textOpacity = interpolate(
frame - delayFrames,
[fps * 0.15, fps * 0.3],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
if (frame < delayFrames) {
return null;
}
return (
<div style={{ position: "relative", display: "inline-block" }}>
{/* Background highlight */}
<div
style={{
position: "absolute",
top: 0,
left: -12,
right: -12,
bottom: 0,
background: theme.colors.accent,
borderRadius: 8,
transform: `scaleX(${scaleX})`,
transformOrigin: "left",
}}
/>
{/* Text */}
<span
style={{
position: "relative",
fontFamily: theme.fonts.display,
fontSize,
fontWeight: 700,
color: theme.colors.text,
opacity: textOpacity,
padding: "4px 0",
}}
>
{text}
</span>
</div>
);
};

View File

@@ -0,0 +1,6 @@
export { theme } from "./theme";
export { LogoEndcard } from "./LogoEndcard";
export { TextReveal, TextRevealMultiline, HighlightText } from "./TextReveal";
export { TapIndicator, SwipeIndicator } from "./TapIndicator";
export { AppScreenshot, MockScreen } from "./AppScreenshot";
export { GradientBackground, GridBackground, GlowBackground } from "./Background";

View File

@@ -0,0 +1,47 @@
// SportsTime Marketing Video Theme
export const theme = {
colors: {
background: "#0A0A0A",
backgroundGradientStart: "#0A0A0A",
backgroundGradientEnd: "#1A1A2E",
accent: "#FF6B35",
accentDark: "#E55A25",
secondary: "#4ECDC4",
secondaryDark: "#3DBDB5",
text: "#FFFFFF",
textSecondary: "#B0B0B0",
textMuted: "#6B6B6B",
success: "#4CAF50",
gold: "#FFD700",
mapLine: "#FF6B35",
mapMarker: "#4ECDC4",
},
fonts: {
display: "SF Pro Display, -apple-system, BlinkMacSystemFont, sans-serif",
text: "SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif",
},
fontSizes: {
hero: 72,
headline: 56,
title: 48,
subtitle: 36,
body: 28,
caption: 20,
},
spacing: {
xs: 8,
sm: 16,
md: 24,
lg: 40,
xl: 64,
xxl: 96,
},
animation: {
smooth: { damping: 200 },
snappy: { damping: 20, stiffness: 200 },
bouncy: { damping: 8 },
heavy: { damping: 15, stiffness: 80, mass: 2 },
},
} as const;
export type Theme = typeof theme;

View File

@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);

View File

@@ -0,0 +1,236 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type AchievementBadgeProps = {
name: string;
description?: string;
delay?: number;
};
export const AchievementBadge: React.FC<AchievementBadgeProps> = ({
name,
description = "Achievement Unlocked",
delay = 0,
}) => {
const frame = useCurrentFrame();
const { fps, width } = useVideoConfig();
const delayFrames = delay * fps;
const localFrame = frame - delayFrames;
if (localFrame < 0) {
return null;
}
// Badge entrance with bounce
const entranceProgress = spring({
frame: localFrame,
fps,
config: { damping: 12, stiffness: 150 },
});
const scale = interpolate(entranceProgress, [0, 1], [0.3, 1]);
const opacity = interpolate(entranceProgress, [0, 0.5, 1], [0, 1, 1]);
// Shimmer animation
const shimmerProgress = interpolate(
localFrame,
[fps * 0.5, fps * 1.5],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const shimmerX = interpolate(shimmerProgress, [0, 1], [-200, width + 200]);
// Glow pulse
const glowPulse = interpolate(
(localFrame % 40) / 40,
[0, 0.5, 1],
[0.3, 0.6, 0.3]
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
transform: `scale(${scale})`,
opacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 32,
}}
>
{/* Achievement badge */}
<div
style={{
position: "relative",
width: 200,
height: 200,
}}
>
{/* Outer glow */}
<div
style={{
position: "absolute",
inset: -20,
borderRadius: "50%",
background: `radial-gradient(circle, ${theme.colors.gold}${Math.round(glowPulse * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)`,
}}
/>
{/* Badge background */}
<div
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
background: `linear-gradient(135deg, ${theme.colors.gold} 0%, #B8860B 50%, ${theme.colors.gold} 100%)`,
boxShadow: `
0 10px 40px rgba(255, 215, 0, 0.4),
inset 0 -4px 10px rgba(0, 0, 0, 0.2),
inset 0 4px 10px rgba(255, 255, 255, 0.3)
`,
overflow: "hidden",
}}
>
{/* Shimmer overlay */}
<div
style={{
position: "absolute",
top: 0,
left: shimmerX,
width: 100,
height: "100%",
background: `linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)`,
transform: "skewX(-20deg)",
}}
/>
</div>
{/* Inner circle */}
<div
style={{
position: "absolute",
inset: 15,
borderRadius: "50%",
background: `linear-gradient(180deg, #1C1C1E 0%, #2C2C2E 100%)`,
display: "flex",
justifyContent: "center",
alignItems: "center",
border: `3px solid ${theme.colors.gold}`,
}}
>
{/* Trophy icon */}
<svg width="80" height="80" viewBox="0 0 24 24" fill="none">
<path
d="M12 15c3.866 0 7-3.134 7-7V4H5v4c0 3.866 3.134 7 7 7z"
fill={theme.colors.gold}
/>
<path
d="M5 4H3v4c0 1.657 1.343 3 3 3V4zM19 4h2v4c0 1.657-1.343 3-3 3V4z"
fill={theme.colors.gold}
opacity={0.7}
/>
<rect x="10" y="15" width="4" height="4" fill={theme.colors.gold} />
<rect x="8" y="19" width="8" height="2" rx="1" fill={theme.colors.gold} />
</svg>
</div>
</div>
{/* Achievement text */}
<div style={{ textAlign: "center" }}>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.gold,
textTransform: "uppercase",
letterSpacing: 3,
marginBottom: 8,
}}
>
{description}
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 40,
fontWeight: 700,
color: theme.colors.text,
}}
>
{name}
</div>
</div>
</div>
</AbsoluteFill>
);
};
// Simple text overlay for bucket list tagline
export const CollectThemAll: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const opacity = interpolate(progress, [0, 1], [0, 1]);
const translateY = interpolate(progress, [0, 1], [30, 0]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
textAlign: "center",
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 16,
}}
>
Collect them all.
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 24,
color: theme.colors.textSecondary,
}}
>
Track every stadium. Earn every badge.
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,284 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type StadiumMarker = {
x: number;
y: number;
name: string;
visited: boolean;
};
export const ProgressMap: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Stadium positions (relative to container)
const stadiums: StadiumMarker[] = [
// Visited (green)
{ x: 0.15, y: 0.42, name: "LA", visited: true },
{ x: 0.22, y: 0.35, name: "SF", visited: true },
{ x: 0.28, y: 0.52, name: "Phoenix", visited: true },
{ x: 0.35, y: 0.3, name: "Denver", visited: true },
{ x: 0.48, y: 0.45, name: "Dallas", visited: true },
{ x: 0.55, y: 0.35, name: "KC", visited: true },
{ x: 0.62, y: 0.38, name: "STL", visited: true },
{ x: 0.68, y: 0.32, name: "Chicago", visited: true },
{ x: 0.72, y: 0.36, name: "Detroit", visited: true },
{ x: 0.78, y: 0.42, name: "Cleveland", visited: true },
{ x: 0.82, y: 0.38, name: "Pittsburgh", visited: true },
{ x: 0.88, y: 0.35, name: "NYC", visited: true },
// Not visited (gray)
{ x: 0.25, y: 0.28, name: "Seattle", visited: false },
{ x: 0.38, y: 0.42, name: "Houston", visited: false },
{ x: 0.52, y: 0.3, name: "Minneapolis", visited: false },
{ x: 0.58, y: 0.48, name: "Atlanta", visited: false },
{ x: 0.65, y: 0.28, name: "Milwaukee", visited: false },
{ x: 0.75, y: 0.28, name: "Toronto", visited: false },
{ x: 0.84, y: 0.32, name: "Boston", visited: false },
{ x: 0.66, y: 0.52, name: "Miami", visited: false },
];
const visitedCount = stadiums.filter((s) => s.visited).length;
const totalCount = stadiums.length;
// Animation for markers appearing
const mapEntranceProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const mapScale = interpolate(mapEntranceProgress, [0, 1], [0.9, 1]);
const mapOpacity = interpolate(mapEntranceProgress, [0, 1], [0, 1]);
// Counter animation
const counterProgress = spring({
frame: frame - fps * 0.5,
fps,
config: theme.animation.smooth,
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 40,
}}
>
{/* Header */}
<div
style={{
marginBottom: 30,
paddingTop: 60,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
Your Stadium Map
</div>
{/* Progress counter */}
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
opacity: counterProgress,
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 48,
fontWeight: 700,
color: theme.colors.accent,
}}
>
{visitedCount}
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 24,
color: theme.colors.textMuted,
}}
>
/ {totalCount}
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: theme.colors.textSecondary,
marginLeft: 12,
}}
>
stadiums visited
</span>
</div>
</div>
{/* Map container */}
<div
style={{
flex: 1,
position: "relative",
background: "#1C1C1E",
borderRadius: 24,
overflow: "hidden",
transform: `scale(${mapScale})`,
opacity: mapOpacity,
}}
>
{/* Simplified US outline */}
<svg
viewBox="0 0 100 60"
style={{
position: "absolute",
inset: 20,
opacity: 0.2,
}}
>
<path
d="M10 25 Q15 15 30 12 Q50 8 70 12 Q85 15 90 25 Q92 35 88 45 Q80 52 65 50 Q50 55 35 50 Q20 52 12 45 Q8 35 10 25 Z"
fill="none"
stroke="white"
strokeWidth="0.5"
/>
</svg>
{/* Stadium markers */}
{stadiums.map((stadium, index) => {
const markerDelay = index * 2;
const markerProgress = spring({
frame: frame - markerDelay,
fps,
config: theme.animation.snappy,
});
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
// Pulse effect for visited stadiums
const pulseProgress = stadium.visited
? interpolate(
(frame + index * 10) % 60,
[0, 30, 60],
[1, 1.3, 1]
)
: 1;
return (
<div
key={index}
style={{
position: "absolute",
left: `${stadium.x * 100}%`,
top: `${stadium.y * 100}%`,
transform: `translate(-50%, -50%) scale(${markerScale})`,
opacity: markerOpacity,
}}
>
{/* Pulse ring for visited */}
{stadium.visited && (
<div
style={{
position: "absolute",
width: 24,
height: 24,
borderRadius: "50%",
background: theme.colors.success,
opacity: 0.3,
transform: `translate(-50%, -50%) scale(${pulseProgress})`,
left: "50%",
top: "50%",
}}
/>
)}
{/* Marker dot */}
<div
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: stadium.visited
? theme.colors.success
: theme.colors.textMuted,
border: `2px solid ${stadium.visited ? "white" : theme.colors.textMuted}`,
boxShadow: stadium.visited
? `0 2px 8px rgba(76, 175, 80, 0.5)`
: "none",
}}
/>
</div>
);
})}
</div>
{/* Legend */}
<div
style={{
display: "flex",
justifyContent: "center",
gap: 40,
marginTop: 24,
opacity: counterProgress,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 14,
height: 14,
borderRadius: "50%",
background: theme.colors.success,
}}
/>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
Visited
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 14,
height: 14,
borderRadius: "50%",
background: theme.colors.textMuted,
}}
/>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
Remaining
</span>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,266 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type Stadium = {
name: string;
city: string;
team: string;
color: string;
};
type StadiumStampProps = {
stadium: Stadium;
delay?: number;
};
export const StadiumStamp: React.FC<StadiumStampProps> = ({
stadium,
delay = 0,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const delayFrames = delay * fps;
const localFrame = frame - delayFrames;
if (localFrame < 0) {
return null;
}
// Stamp slam animation (fast drop with bounce)
const slamProgress = spring({
frame: localFrame,
fps,
config: { damping: 8, stiffness: 300 },
});
// Stamp comes from above
const translateY = interpolate(slamProgress, [0, 1], [-200, 0]);
const scale = interpolate(slamProgress, [0, 0.8, 1], [1.5, 1.1, 1]);
const rotation = interpolate(slamProgress, [0, 1], [-5, 0]);
// Ink bleed effect
const inkProgress = interpolate(
localFrame,
[5, 25],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const inkScale = interpolate(inkProgress, [0, 1], [0.8, 1.05]);
const inkOpacity = interpolate(inkProgress, [0, 0.5, 1], [0, 0.15, 0.1]);
// Opacity for initial appearance
const opacity = interpolate(localFrame, [0, 3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
style={{
position: "relative",
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity,
}}
>
{/* Ink bleed effect */}
<div
style={{
position: "absolute",
width: 320,
height: 320,
borderRadius: "50%",
background: stadium.color,
opacity: inkOpacity,
transform: `scale(${inkScale})`,
}}
/>
{/* Stamp */}
<div
style={{
transform: `translateY(${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
width: 280,
height: 280,
borderRadius: "50%",
border: `8px solid ${stadium.color}`,
background: "rgba(255,255,255,0.95)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
padding: 24,
boxShadow: localFrame > 5 ? `0 4px 20px rgba(0,0,0,0.2)` : "none",
}}
>
{/* Stadium name */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 800,
color: stadium.color,
textAlign: "center",
textTransform: "uppercase",
letterSpacing: 2,
marginBottom: 8,
}}
>
{stadium.name}
</div>
{/* Divider */}
<div
style={{
width: 80,
height: 3,
background: stadium.color,
marginBottom: 12,
}}
/>
{/* City */}
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
fontWeight: 600,
color: "#333",
textAlign: "center",
}}
>
{stadium.city}
</div>
{/* Team */}
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: "#666",
textAlign: "center",
marginTop: 4,
}}
>
{stadium.team}
</div>
{/* Visited badge */}
<div
style={{
marginTop: 16,
padding: "6px 16px",
background: stadium.color,
borderRadius: 20,
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 12,
fontWeight: 700,
color: "white",
textTransform: "uppercase",
letterSpacing: 1,
}}
>
Visited
</span>
</div>
</div>
</div>
);
};
// Page flip transition for passport effect
type PassportPageProps = {
children: React.ReactNode;
pageIndex: number;
};
export const PassportPage: React.FC<PassportPageProps> = ({
children,
pageIndex,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Each page flips after its stamp animation completes
const flipDelay = pageIndex * fps * 1.2;
const localFrame = frame - flipDelay;
// Page flip animation (0 = flat, 1 = fully flipped)
const flipProgress = interpolate(
localFrame,
[fps * 0.8, fps * 1.1],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Only show page before it's fully flipped
if (flipProgress >= 1) {
return null;
}
// 3D flip effect
const rotateY = interpolate(flipProgress, [0, 1], [0, -180]);
const zIndex = 100 - pageIndex;
return (
<AbsoluteFill
style={{
perspective: 1500,
perspectiveOrigin: "0% 50%",
}}
>
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
transformStyle: "preserve-3d",
transformOrigin: "left center",
transform: `rotateY(${rotateY}deg)`,
zIndex,
}}
>
{/* Page front */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
backfaceVisibility: "hidden",
background: "#F5F0E6", // Passport paper color
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{children}
</div>
{/* Page back (blank) */}
<div
style={{
position: "absolute",
width: "100%",
height: "100%",
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
background: "#F5F0E6",
}}
/>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,160 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
useVideoConfig,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { StadiumStamp, PassportPage } from "./StadiumStamp";
import { ProgressMap } from "./ProgressMap";
import { AchievementBadge, CollectThemAll } from "./AchievementBadge";
import {
GradientBackground,
LogoEndcard,
TextReveal,
theme,
} from "../../components/shared";
// Stadium data
const stamps = [
{ name: "Wrigley Field", city: "Chicago, IL", team: "Cubs", color: "#0E3386" },
{ name: "Fenway Park", city: "Boston, MA", team: "Red Sox", color: "#BD3039" },
{ name: "Dodger Stadium", city: "Los Angeles, CA", team: "Dodgers", color: "#005A9C" },
];
/**
* Video 3: "The Bucket List"
*
* Goal: Emotionally connect with stadium-chasers, highlight progress tracking
* Length: 12 seconds (360 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:02 (0-60): First stamp slams (Wrigley Field)
* - 0:02-0:04 (60-120): Page flip, second stamp (Fenway)
* - 0:04-0:06 (120-180): Third stamp (Dodger Stadium)
* - 0:06-0:08 (180-240): Progress map reveal with counter
* - 0:08-0:10 (240-300): Achievement badge unlock
* - 0:10-0:12 (300-360): Logo endcard
*/
export const TheBucketList: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 12;
const SCENE_DURATIONS = {
stamp1: 1.5 * fps, // 45 frames
stamp2: 1.2 * fps, // 36 frames
stamp3: 1.2 * fps, // 36 frames
progressMap: 2.5 * fps, // 75 frames
achievement: 2.5 * fps, // 75 frames
logo: 3.1 * fps, // 93 frames
};
return (
<AbsoluteFill>
<TransitionSeries>
{/* Scene 1: First stamp - Wrigley Field */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp1}>
<AbsoluteFill
style={{
background: "#F5F0E6", // Passport paper color
justifyContent: "center",
alignItems: "center",
}}
>
<StadiumStamp stadium={stamps[0]} delay={0} />
</AbsoluteFill>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 8 })}
/>
{/* Scene 2: Second stamp - Fenway Park */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp2}>
<AbsoluteFill
style={{
background: "#F5F0E6",
justifyContent: "center",
alignItems: "center",
}}
>
<StadiumStamp stadium={stamps[1]} delay={0} />
</AbsoluteFill>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 8 })}
/>
{/* Scene 3: Third stamp - Dodger Stadium */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp3}>
<AbsoluteFill
style={{
background: "#F5F0E6",
justifyContent: "center",
alignItems: "center",
}}
>
<StadiumStamp stadium={stamps[2]} delay={0} />
</AbsoluteFill>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Progress map with counter */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.progressMap}>
<ProgressMap />
{/* Counter text overlay */}
<div
style={{
position: "absolute",
bottom: 180,
left: 0,
right: 0,
textAlign: "center",
}}
>
<TextReveal
text="12 down. 8 to go."
fontSize={36}
color={theme.colors.text}
delay={0.5}
/>
</div>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: Achievement badge unlock */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.achievement}>
<AchievementBadge
name="West Coast Complete"
description="Achievement Unlocked"
delay={0}
/>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 6: Logo endcard */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
<LogoEndcard tagline="Your stadium passport." />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,241 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
const checklistItems = [
{ label: "Sports", icon: "🏀" },
{ label: "Dates", icon: "📅" },
{ label: "Cities", icon: "🏙️" },
{ label: "Teams", icon: "⚾" },
];
export const ChecklistIntro: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 24,
}}
>
{checklistItems.map((item, index) => {
// Stagger each item
const itemDelay = index * 8; // 8 frames between each
const localFrame = frame - itemDelay;
// Item entrance
const entranceProgress = spring({
frame: localFrame,
fps,
config: theme.animation.snappy,
});
// Checkmark appears shortly after entrance
const checkDelay = 12;
const checkProgress = spring({
frame: localFrame - checkDelay,
fps,
config: { damping: 12, stiffness: 200 },
});
const opacity = interpolate(
localFrame,
[0, 8],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateX = interpolate(entranceProgress, [0, 1], [-100, 0]);
const checkScale = interpolate(checkProgress, [0, 1], [0, 1]);
const checkOpacity = interpolate(checkProgress, [0, 0.5, 1], [0, 1, 1]);
// Strike-through effect
const strikeWidth = interpolate(
checkProgress,
[0.5, 1],
[0, 100],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: 24,
opacity,
transform: `translateX(${translateX}px)`,
}}
>
{/* Checkbox */}
<div
style={{
width: 56,
height: 56,
borderRadius: 12,
border: `3px solid ${checkProgress > 0 ? theme.colors.success : theme.colors.textMuted}`,
background: checkProgress > 0 ? theme.colors.success : "transparent",
display: "flex",
justifyContent: "center",
alignItems: "center",
transition: "background 0.1s, border-color 0.1s",
}}
>
{/* Checkmark */}
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
style={{
transform: `scale(${checkScale})`,
opacity: checkOpacity,
}}
>
<path
d="M5 12l5 5L19 7"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* Label */}
<div style={{ position: "relative" }}>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 48,
fontWeight: 700,
color: checkProgress > 0.5 ? theme.colors.textMuted : theme.colors.text,
}}
>
{item.label}
</span>
{/* Strike-through */}
<div
style={{
position: "absolute",
top: "50%",
left: 0,
height: 4,
width: `${strikeWidth}%`,
background: theme.colors.success,
borderRadius: 2,
}}
/>
</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};
// Exit animation for checklist
export const ChecklistExit: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 24,
}}
>
{checklistItems.map((item, index) => {
// Stagger each item exit
const itemDelay = index * 4;
const localFrame = frame - itemDelay;
const exitProgress = spring({
frame: localFrame,
fps,
config: { damping: 20, stiffness: 150 },
});
const translateX = interpolate(exitProgress, [0, 1], [0, 200]);
const opacity = interpolate(exitProgress, [0, 0.8, 1], [1, 0.5, 0]);
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: 24,
opacity,
transform: `translateX(${translateX}px)`,
}}
>
{/* Checkbox (checked) */}
<div
style={{
width: 56,
height: 56,
borderRadius: 12,
border: `3px solid ${theme.colors.success}`,
background: theme.colors.success,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path
d="M5 12l5 5L19 7"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* Label */}
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 48,
fontWeight: 700,
color: theme.colors.textMuted,
}}
>
{item.label}
</span>
</div>
);
})}
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,287 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
// Confetti particle component
const Confetti: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const particles = React.useMemo(() => {
const colors = [theme.colors.accent, theme.colors.secondary, theme.colors.gold, "#FF69B4"];
return Array.from({ length: 30 }, (_, i) => ({
id: i,
x: Math.random() * width,
color: colors[Math.floor(Math.random() * colors.length)],
size: 8 + Math.random() * 12,
rotation: Math.random() * 360,
speed: 0.5 + Math.random() * 0.5,
delay: Math.random() * 10,
}));
}, [width]);
return (
<>
{particles.map((particle) => {
const localFrame = frame - particle.delay;
if (localFrame < 0) return null;
const y = interpolate(
localFrame,
[0, fps * 2],
[-50, height + 100],
{ extrapolateRight: "clamp" }
) * particle.speed;
const rotation = particle.rotation + localFrame * 3;
const opacity = interpolate(
localFrame,
[0, fps * 0.5, fps * 1.5, fps * 2],
[0, 1, 1, 0],
{ extrapolateRight: "clamp" }
);
return (
<div
key={particle.id}
style={{
position: "absolute",
left: particle.x,
top: y,
width: particle.size,
height: particle.size,
background: particle.color,
borderRadius: 2,
transform: `rotate(${rotation}deg)`,
opacity,
}}
/>
);
})}
</>
);
};
export const ItineraryReveal: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Loading spinner phase
const loadingDuration = fps * 0.8;
const isLoading = frame < loadingDuration;
// Reveal phase
const revealProgress = spring({
frame: frame - loadingDuration,
fps,
config: theme.animation.smooth,
});
const cardScale = interpolate(revealProgress, [0, 1], [0.8, 1]);
const cardOpacity = interpolate(revealProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
{/* Confetti (appears after reveal) */}
{!isLoading && <Confetti />}
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
}}
>
{isLoading ? (
// Loading spinner
<LoadingSpinner frame={frame} />
) : (
// Itinerary card
<div
style={{
transform: `scale(${cardScale})`,
opacity: cardOpacity,
width: "85%",
maxWidth: 700,
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 28,
padding: 36,
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
}}
>
{/* Success header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
marginBottom: 32,
}}
>
<div
style={{
width: 56,
height: 56,
borderRadius: "50%",
background: theme.colors.success,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M5 12l5 5L19 7"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
}}
>
Trip Generated!
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
}}
>
West Coast Basketball Tour
</div>
</div>
</div>
{/* Mini itinerary preview */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{[
{ day: "Day 1", city: "Los Angeles", game: "Lakers vs Celtics" },
{ day: "Day 2", city: "Phoenix", game: "Suns vs Warriors" },
{ day: "Day 3", city: "Denver", game: "Nuggets vs Heat" },
].map((item, index) => {
const itemProgress = spring({
frame: frame - loadingDuration - index * 8,
fps,
config: theme.animation.smooth,
});
const itemOpacity = interpolate(itemProgress, [0, 1], [0, 1]);
const itemTranslate = interpolate(itemProgress, [0, 1], [20, 0]);
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: 16,
padding: 16,
background: "rgba(255,255,255,0.05)",
borderRadius: 12,
opacity: itemOpacity,
transform: `translateX(${itemTranslate}px)`,
}}
>
<div
style={{
width: 10,
height: 10,
borderRadius: "50%",
background: theme.colors.accent,
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
}}
>
{item.day} {item.city}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.text,
}}
>
{item.game}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</AbsoluteFill>
</AbsoluteFill>
);
};
const LoadingSpinner: React.FC<{ frame: number }> = ({ frame }) => {
const rotation = (frame * 8) % 360;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 24,
}}
>
<div
style={{
width: 60,
height: 60,
borderRadius: "50%",
border: `4px solid ${theme.colors.textMuted}`,
borderTopColor: theme.colors.accent,
transform: `rotate(${rotation}deg)`,
}}
/>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
color: theme.colors.textSecondary,
}}
>
Generating your trip...
</span>
</div>
);
};

View File

@@ -0,0 +1,621 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { TapIndicator } from "../../components/shared/TapIndicator";
type WizardStepProps = {
step: "sports" | "dates" | "regions" | "review";
};
export const WizardStep: React.FC<WizardStepProps> = ({ step }) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const contentMap = {
sports: <SportsStep />,
dates: <DatesStep />,
regions: <RegionsStep />,
review: <ReviewStep />,
};
const tapPositions = {
sports: { x: width * 0.3, y: height * 0.42 },
dates: { x: width * 0.65, y: height * 0.45 },
regions: { x: width * 0.35, y: height * 0.48 },
review: { x: width * 0.5, y: height * 0.7 },
};
return (
<AbsoluteFill>
{contentMap[step]}
<TapIndicator
x={tapPositions[step].x}
y={tapPositions[step].y}
delay={0.5}
/>
</AbsoluteFill>
);
};
const SportsStep: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const sports = [
{ name: "MLB", color: "#002D72", selected: false },
{ name: "NBA", color: "#C9082A", selected: true },
{ name: "NFL", color: "#013369", selected: false },
{ name: "NHL", color: "#000000", selected: false },
];
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 60,
paddingTop: 100,
}}
>
{/* Header */}
<div style={{ marginBottom: 48 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
Choose Sports
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
color: theme.colors.textSecondary,
}}
>
Select the leagues you want to see
</div>
</div>
{/* Sport toggles */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 20,
}}
>
{sports.map((sport, index) => {
const itemProgress = spring({
frame: frame - index * 5,
fps,
config: theme.animation.smooth,
});
const scale = interpolate(itemProgress, [0, 1], [0.9, 1]);
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
// NBA gets selected animation
const isNBA = sport.name === "NBA";
const selectProgress = spring({
frame: frame - fps * 0.7,
fps,
config: theme.animation.snappy,
});
const isSelected = isNBA ? selectProgress > 0.5 : sport.selected;
return (
<div
key={sport.name}
style={{
background: isSelected ? sport.color : "#1C1C1E",
borderRadius: 20,
padding: 32,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transform: `scale(${scale})`,
opacity,
border: isSelected ? `3px solid ${theme.colors.text}` : "3px solid transparent",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
}}
>
{sport.name}
</span>
{/* Toggle indicator */}
<div
style={{
width: 60,
height: 34,
borderRadius: 17,
background: isSelected ? theme.colors.success : "rgba(255,255,255,0.2)",
padding: 3,
display: "flex",
alignItems: isSelected ? "center" : "center",
justifyContent: isSelected ? "flex-end" : "flex-start",
}}
>
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "white",
}}
/>
</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};
const DatesStep: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Calendar highlight animation
const highlightProgress = spring({
frame: frame - fps * 0.5,
fps,
config: theme.animation.smooth,
});
const highlightWidth = interpolate(highlightProgress, [0, 1], [0, 100]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 60,
paddingTop: 100,
}}
>
{/* Header */}
<div style={{ marginBottom: 48 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
Pick Your Window
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
color: theme.colors.textSecondary,
}}
>
When do you want to travel?
</div>
</div>
{/* Mini calendar */}
<div
style={{
background: "#1C1C1E",
borderRadius: 24,
padding: 32,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
marginBottom: 24,
textAlign: "center",
}}
>
June 2026
</div>
{/* Week days header */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 8,
marginBottom: 16,
}}
>
{["S", "M", "T", "W", "T", "F", "S"].map((day, i) => (
<div
key={i}
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textMuted,
textAlign: "center",
}}
>
{day}
</div>
))}
</div>
{/* Calendar dates */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 8,
}}
>
{/* Empty cells for start of month */}
{[...Array(1)].map((_, i) => (
<div key={`empty-${i}`} />
))}
{/* Dates */}
{[...Array(30)].map((_, i) => {
const date = i + 1;
const isInRange = date >= 14 && date <= 17;
const rangeProgress = isInRange ? highlightWidth / 100 : 0;
return (
<div
key={date}
style={{
width: 50,
height: 50,
borderRadius: 25,
background: isInRange && rangeProgress > 0
? theme.colors.accent
: "transparent",
display: "flex",
justifyContent: "center",
alignItems: "center",
fontFamily: theme.fonts.text,
fontSize: 18,
fontWeight: isInRange ? 600 : 400,
color: isInRange && rangeProgress > 0
? theme.colors.text
: theme.colors.textSecondary,
opacity: isInRange ? 1 : rangeProgress > 0 ? 0.5 : 1,
}}
>
{date}
</div>
);
})}
</div>
</div>
{/* Selected range indicator */}
<div
style={{
marginTop: 32,
padding: 24,
background: "rgba(255,107,53,0.1)",
borderRadius: 16,
borderLeft: `4px solid ${theme.colors.accent}`,
opacity: highlightProgress,
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: theme.colors.text,
}}
>
June 14 June 17, 2026
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
marginLeft: 16,
}}
>
4 days
</span>
</div>
</AbsoluteFill>
);
};
const RegionsStep: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width } = useVideoConfig();
const regions = [
{ name: "Northeast", selected: false },
{ name: "Southeast", selected: false },
{ name: "Midwest", selected: false },
{ name: "West Coast", selected: true },
];
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 60,
paddingTop: 100,
}}
>
{/* Header */}
<div style={{ marginBottom: 48 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
Choose Your Territory
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
color: theme.colors.textSecondary,
}}
>
Select regions to explore
</div>
</div>
{/* Region buttons */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{regions.map((region, index) => {
const itemProgress = spring({
frame: frame - index * 5,
fps,
config: theme.animation.smooth,
});
// West Coast gets selected animation
const isWestCoast = region.name === "West Coast";
const selectProgress = spring({
frame: frame - fps * 0.7,
fps,
config: theme.animation.snappy,
});
const isSelected = isWestCoast ? selectProgress > 0.5 : region.selected;
const scale = interpolate(itemProgress, [0, 1], [0.95, 1]);
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
return (
<div
key={region.name}
style={{
background: isSelected ? theme.colors.accent : "#1C1C1E",
borderRadius: 16,
padding: 28,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transform: `scale(${scale})`,
opacity,
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 600,
color: theme.colors.text,
}}
>
{region.name}
</span>
{isSelected && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M5 12l5 5L19 7"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
})}
</div>
</AbsoluteFill>
);
};
const ReviewStep: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const stats = [
{ label: "Games", value: "4" },
{ label: "Cities", value: "3" },
{ label: "Days", value: "6" },
];
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 60,
paddingTop: 100,
}}
>
{/* Header */}
<div style={{ marginBottom: 48 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
Review. Confirm. Done.
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
color: theme.colors.textSecondary,
}}
>
Your trip summary
</div>
</div>
{/* Stats cards */}
<div
style={{
display: "flex",
gap: 20,
marginBottom: 40,
}}
>
{stats.map((stat, index) => {
const itemProgress = spring({
frame: frame - index * 8,
fps,
config: theme.animation.smooth,
});
const scale = interpolate(itemProgress, [0, 1], [0.8, 1]);
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
return (
<div
key={stat.label}
style={{
flex: 1,
background: "#1C1C1E",
borderRadius: 20,
padding: 28,
textAlign: "center",
transform: `scale(${scale})`,
opacity,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 700,
color: theme.colors.accent,
marginBottom: 8,
}}
>
{stat.value}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: theme.colors.textSecondary,
}}
>
{stat.label}
</div>
</div>
);
})}
</div>
{/* Generate button */}
<GenerateButton />
</AbsoluteFill>
);
};
const GenerateButton: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Button appears
const buttonProgress = spring({
frame: frame - fps * 0.5,
fps,
config: theme.animation.smooth,
});
// Pulse effect
const pulseProgress = spring({
frame: frame - fps * 1,
fps,
config: { damping: 8, stiffness: 80 },
});
const pulseScale = interpolate(
pulseProgress,
[0, 0.5, 1],
[1, 1.05, 1]
);
const opacity = interpolate(buttonProgress, [0, 1], [0, 1]);
return (
<div
style={{
marginTop: 20,
opacity,
transform: `scale(${pulseScale})`,
}}
>
<div
style={{
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
borderRadius: 20,
padding: "28px 48px",
textAlign: "center",
boxShadow: `0 10px 40px rgba(255, 107, 53, 0.4)`,
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.text,
}}
>
Generate Trip
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,131 @@
import React from "react";
import {
AbsoluteFill,
useVideoConfig,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { ChecklistIntro, ChecklistExit } from "./ChecklistIntro";
import { WizardStep } from "./WizardSteps";
import { ItineraryReveal } from "./ItineraryReveal";
import {
GradientBackground,
LogoEndcard,
} from "../../components/shared";
/**
* Video 2: "The Checklist"
*
* Goal: Showcase step-by-step wizard, reassure power planners
* Length: 20 seconds (600 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:02 (0-60): Checklist items check off rapidly
* - 0:02-0:05 (60-150): Sports selection step
* - 0:05-0:08 (150-240): Dates step with calendar
* - 0:08-0:11 (240-330): Regions step
* - 0:11-0:14 (330-420): Review step with generate button
* - 0:14-0:17 (420-510): Itinerary reveal with confetti
* - 0:17-0:20 (510-600): Logo endcard
*/
export const TheChecklist: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 15;
const SCENE_DURATIONS = {
checklistIntro: 2 * fps, // 60 frames
checklistExit: 0.5 * fps, // 15 frames
sportsStep: 2.5 * fps, // 75 frames
datesStep: 2.5 * fps, // 75 frames
regionsStep: 2.5 * fps, // 75 frames
reviewStep: 3 * fps, // 90 frames
itineraryReveal: 3 * fps, // 90 frames
logo: 3.5 * fps, // 105 frames
};
return (
<AbsoluteFill>
<GradientBackground />
<TransitionSeries>
{/* Scene 1: Checklist intro animation */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.checklistIntro}>
<ChecklistIntro />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: Checklist exit */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.checklistExit}>
<ChecklistExit />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: Sports selection */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.sportsStep}>
<WizardStep step="sports" />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-bottom" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Dates selection */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.datesStep}>
<WizardStep step="dates" />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-bottom" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: Regions selection */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.regionsStep}>
<WizardStep step="regions" />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-bottom" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 6: Review step */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reviewStep}>
<WizardStep step="review" />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 7: Itinerary reveal with confetti */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.itineraryReveal}>
<ItineraryReveal />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 8: Logo endcard */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
<LogoEndcard />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,332 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Easing,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { TapIndicator } from "../../components/shared/TapIndicator";
export const ExportTrigger: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Menu appearance
const menuProgress = spring({
frame: frame - fps * 0.5,
fps,
config: theme.animation.snappy,
});
const menuOpacity = interpolate(menuProgress, [0, 1], [0, 1]);
const menuTranslateY = interpolate(menuProgress, [0, 1], [100, 0]);
// PDF option highlight
const highlightProgress = spring({
frame: frame - fps * 1,
fps,
config: { damping: 20, stiffness: 150 },
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 40,
paddingTop: 80,
}}
>
{/* Mock trip detail view */}
<div
style={{
background: "#1C1C1E",
borderRadius: 24,
padding: 24,
marginBottom: 20,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 12,
}}
>
West Coast Tour
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
June 14-17, 2026 4 cities 3 games
</div>
</div>
{/* Share button area */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginBottom: 20,
}}
>
<div
style={{
padding: "14px 24px",
background: theme.colors.accent,
borderRadius: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z" />
</svg>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
fontWeight: 600,
color: "white",
}}
>
Share
</span>
</div>
</div>
{/* Share menu (slides up) */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
background: "#2C2C2E",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 24,
transform: `translateY(${menuTranslateY}px)`,
opacity: menuOpacity,
}}
>
<div
style={{
width: 40,
height: 5,
background: "rgba(255,255,255,0.3)",
borderRadius: 3,
margin: "0 auto 24px",
}}
/>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 600,
color: theme.colors.text,
marginBottom: 20,
}}
>
Share Trip
</div>
{/* Share options */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{[
{ icon: "📄", label: "Export PDF", highlight: true },
{ icon: "📱", label: "Share Link", highlight: false },
{ icon: "📋", label: "Copy Details", highlight: false },
].map((option, index) => {
const isHighlighted = option.highlight && highlightProgress > 0.5;
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: 16,
padding: 16,
background: isHighlighted
? theme.colors.accent
: "rgba(255,255,255,0.05)",
borderRadius: 14,
border: isHighlighted
? `2px solid ${theme.colors.text}`
: "2px solid transparent",
}}
>
<span style={{ fontSize: 24 }}>{option.icon}</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.text,
fontWeight: isHighlighted ? 600 : 400,
}}
>
{option.label}
</span>
</div>
);
})}
</div>
</div>
{/* Tap indicators */}
<TapIndicator x={width - 100} y={185} delay={0} />
<TapIndicator x={width / 2} y={height - 200} delay={1} />
</AbsoluteFill>
);
};
export const PDFIconFly: React.FC = () => {
const frame = useCurrentFrame();
const { fps, height } = useVideoConfig();
// Icon materializes
const materializeProgress = spring({
frame,
fps,
config: theme.animation.snappy,
});
// Pulse
const pulseProgress = spring({
frame: frame - fps * 0.3,
fps,
config: { damping: 15, stiffness: 200 },
});
const pulseScale = interpolate(pulseProgress, [0, 0.5, 1], [1, 1.15, 1]);
// Fly away
const flyProgress = interpolate(
frame,
[fps * 0.6, fps * 1.2],
[0, 1],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.in(Easing.quad),
}
);
const translateY = interpolate(flyProgress, [0, 1], [0, -height]);
const scale = interpolate(materializeProgress, [0, 1], [0.5, 1]) * pulseScale;
const opacity = interpolate(flyProgress, [0, 0.8, 1], [1, 1, 0]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
transform: `translateY(${translateY}px) scale(${scale})`,
opacity,
}}
>
{/* PDF Icon */}
<div
style={{
width: 140,
height: 180,
background: "white",
borderRadius: 12,
position: "relative",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
{/* Folded corner */}
<div
style={{
position: "absolute",
top: 0,
right: 0,
width: 40,
height: 40,
background: "#E0E0E0",
clipPath: "polygon(100% 0, 100% 100%, 0 100%)",
borderBottomLeftRadius: 8,
}}
/>
{/* PDF badge */}
<div
style={{
position: "absolute",
bottom: 20,
left: 20,
right: 20,
padding: "12px 0",
background: theme.colors.accent,
borderRadius: 8,
textAlign: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: "white",
}}
>
PDF
</span>
</div>
{/* Content lines */}
<div
style={{
padding: "40px 20px 0",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div
style={{
height: 8,
background: "#DDD",
borderRadius: 4,
width: "80%",
}}
/>
<div
style={{
height: 8,
background: "#DDD",
borderRadius: 4,
width: "60%",
}}
/>
<div
style={{
height: 8,
background: "#DDD",
borderRadius: 4,
width: "70%",
}}
/>
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,365 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type PDFPage = {
title: string;
icon: string;
};
const pages: PDFPage[] = [
{ title: "Day-by-Day", icon: "📅" },
{ title: "Route Map", icon: "🗺️" },
{ title: "Cover", icon: "🏟️" },
];
export const PDFDocumentFan: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
{/* Desk surface */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "70%",
background: "linear-gradient(180deg, #2C2420 0%, #1A1714 100%)",
borderTop: "3px solid rgba(255,255,255,0.05)",
}}
>
{/* Wood grain texture (subtle) */}
<div
style={{
position: "absolute",
inset: 0,
opacity: 0.1,
background: `repeating-linear-gradient(
90deg,
transparent,
transparent 40px,
rgba(255,255,255,0.03) 40px,
rgba(255,255,255,0.03) 42px
)`,
}}
/>
</div>
{/* PDF Pages fanning out */}
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
perspective: 1000,
}}
>
<div
style={{
position: "relative",
width: 500,
height: 650,
marginTop: 100,
}}
>
{pages.map((page, index) => {
// Stagger each page
const pageDelay = (pages.length - 1 - index) * 8;
const localFrame = frame - pageDelay;
const pageProgress = spring({
frame: localFrame,
fps,
config: theme.animation.smooth,
});
// Fan out rotation and position
const baseRotation = (index - 1) * -8; // -8, 0, 8 degrees
const rotation = interpolate(pageProgress, [0, 1], [0, baseRotation]);
const translateX = interpolate(pageProgress, [0, 1], [0, (index - 1) * 60]);
const translateZ = interpolate(pageProgress, [0, 1], [0, -index * 5]);
// Land on desk
const landProgress = spring({
frame: localFrame - 10,
fps,
config: { damping: 25, stiffness: 150 },
});
const dropY = interpolate(landProgress, [0, 1], [-200, 0]);
const scale = interpolate(pageProgress, [0, 1], [0.9, 1]);
const opacity = interpolate(pageProgress, [0, 0.3, 1], [0, 1, 1]);
// Shadow grows as page lands
const shadowOpacity = interpolate(landProgress, [0, 1], [0, 0.3]);
const shadowBlur = interpolate(landProgress, [0, 1], [10, 30]);
return (
<div
key={index}
style={{
position: "absolute",
width: "100%",
height: "100%",
transformStyle: "preserve-3d",
transform: `
translateX(${translateX}px)
translateY(${dropY}px)
translateZ(${translateZ}px)
rotateZ(${rotation}deg)
scale(${scale})
`,
opacity,
zIndex: pages.length - index,
}}
>
{/* Shadow */}
<div
style={{
position: "absolute",
inset: 20,
background: "black",
borderRadius: 16,
filter: `blur(${shadowBlur}px)`,
opacity: shadowOpacity,
transform: "translateY(20px)",
zIndex: -1,
}}
/>
{/* Page */}
<div
style={{
width: "100%",
height: "100%",
background: "white",
borderRadius: 16,
padding: 32,
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
display: "flex",
flexDirection: "column",
}}
>
{/* Page header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 24,
paddingBottom: 16,
borderBottom: "2px solid #EEE",
}}
>
<span style={{ fontSize: 32 }}>{page.icon}</span>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 700,
color: "#333",
}}
>
{page.title}
</span>
</div>
{/* Page content placeholder */}
<div style={{ flex: 1 }}>
{index === 2 ? (
// Cover page
<CoverPageContent />
) : index === 1 ? (
// Map page
<MapPageContent />
) : (
// Day-by-day page
<DayByDayContent />
)}
</div>
{/* SportsTime branding */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
paddingTop: 16,
borderTop: "1px solid #EEE",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 14,
fontWeight: 600,
color: theme.colors.accent,
}}
>
SportsTime
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 12,
color: "#999",
}}
>
Page {pages.length - index}
</span>
</div>
</div>
</div>
);
})}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
const CoverPageContent: React.FC = () => (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%",
gap: 16,
}}
>
<div
style={{
width: 100,
height: 100,
borderRadius: 20,
background: theme.colors.accent,
display: "flex",
justifyContent: "center",
alignItems: "center",
marginBottom: 8,
}}
>
<span style={{ fontSize: 48 }}>🏟</span>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: "#333",
textAlign: "center",
}}
>
West Coast Tour
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: "#666",
}}
>
June 14-17, 2026
</div>
</div>
);
const MapPageContent: React.FC = () => (
<div
style={{
height: "100%",
background: "#F0F0F0",
borderRadius: 12,
position: "relative",
overflow: "hidden",
}}
>
{/* Simplified map */}
<svg
viewBox="0 0 100 80"
style={{
width: "100%",
height: "100%",
}}
>
{/* Route line */}
<path
d="M20 30 L40 35 L60 25 L80 40"
fill="none"
stroke={theme.colors.accent}
strokeWidth="2"
strokeDasharray="4 2"
/>
{/* City markers */}
{[
{ x: 20, y: 30 },
{ x: 40, y: 35 },
{ x: 60, y: 25 },
{ x: 80, y: 40 },
].map((pos, i) => (
<circle
key={i}
cx={pos.x}
cy={pos.y}
r="4"
fill={theme.colors.secondary}
stroke="white"
strokeWidth="2"
/>
))}
</svg>
</div>
);
const DayByDayContent: React.FC = () => (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{["Day 1 - Los Angeles", "Day 2 - Phoenix", "Day 3 - Denver"].map(
(day, i) => (
<div
key={i}
style={{
padding: 12,
background: "#F8F8F8",
borderRadius: 8,
borderLeft: `3px solid ${theme.colors.accent}`,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
fontWeight: 600,
color: "#333",
}}
>
{day}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 12,
color: "#666",
marginTop: 4,
}}
>
Game @ 7:00 PM Stadium details
</div>
</div>
)
)}
</div>
);

View File

@@ -0,0 +1,100 @@
import React from "react";
import {
AbsoluteFill,
useVideoConfig,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { ExportTrigger, PDFIconFly } from "./ExportAnimation";
import { PDFDocumentFan } from "./PDFDocumentFan";
import {
GradientBackground,
LogoEndcard,
TextReveal,
theme,
} from "../../components/shared";
/**
* Video 5: "The Handoff"
*
* Goal: Showcase PDF export, deliver tangible output promise
* Length: 10 seconds (300 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:02 (0-60): Export trigger (share button tap)
* - 0:02-0:04 (60-120): PDF icon materializes and flies
* - 0:04-0:07 (120-210): PDF pages fan out on desk
* - 0:07-0:10 (210-300): Logo endcard
*/
export const TheHandoff: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 10;
const SCENE_DURATIONS = {
exportTrigger: 2 * fps, // 60 frames
pdfFly: 1.5 * fps, // 45 frames
documentFan: 3.5 * fps, // 105 frames
logo: 3 * fps, // 90 frames
};
return (
<AbsoluteFill>
<GradientBackground />
<TransitionSeries>
{/* Scene 1: Export trigger */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.exportTrigger}>
<ExportTrigger />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: PDF icon materializes and flies */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pdfFly}>
<PDFIconFly />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: PDF pages fan out */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.documentFan}>
<PDFDocumentFan />
{/* Text overlay */}
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
textAlign: "center",
}}
>
<TextReveal
text="Print it. Share it. Live it."
fontSize={36}
delay={0.5}
/>
</div>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Logo endcard */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
<LogoEndcard tagline="Your trip, anywhere." />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,194 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
export const GameCardReveal: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const cardScale = spring({
frame,
fps,
config: theme.animation.smooth,
});
const opacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
background: theme.colors.background,
}}
>
<div
style={{
transform: `scale(${cardScale})`,
opacity,
width: 700,
background: "#1C1C1E",
borderRadius: 24,
padding: 32,
boxShadow: "0 20px 60px rgba(0,0,0,0.5)",
}}
>
{/* Game card header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
MLB Yankee Stadium
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.accent,
fontWeight: 600,
}}
>
7:05 PM
</span>
</div>
{/* Teams */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 40,
}}
>
{/* Away team */}
<div style={{ flex: 1, textAlign: "center" }}>
<div
style={{
width: 80,
height: 80,
borderRadius: "50%",
background: "#BD3039",
margin: "0 auto 16px",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: "white",
}}
>
BOS
</span>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
}}
>
Red Sox
</div>
</div>
{/* VS */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.textMuted,
}}
>
VS
</div>
{/* Home team */}
<div style={{ flex: 1, textAlign: "center" }}>
<div
style={{
width: 80,
height: 80,
borderRadius: "50%",
background: "#003087",
margin: "0 auto 16px",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: "white",
}}
>
NYY
</span>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
}}
>
Yankees
</div>
</div>
</div>
{/* Date */}
<div
style={{
marginTop: 24,
paddingTop: 24,
borderTop: "1px solid rgba(255,255,255,0.1)",
textAlign: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: theme.colors.textSecondary,
}}
>
Saturday, June 14, 2026
</span>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,335 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
continueRender,
delayRender,
} from "remotion";
import mapboxgl, { Map } from "mapbox-gl";
import * as turf from "@turf/turf";
import { theme } from "../../components/shared/theme";
// Stadium coordinates: NYC (Yankee Stadium) → Chicago (Wrigley) → Denver (Coors) → LA (Dodger Stadium)
const stadiumCoordinates: [number, number][] = [
[-73.9262, 40.8296], // Yankee Stadium, NYC
[-87.6555, 41.9484], // Wrigley Field, Chicago
[-104.9942, 39.7559], // Coors Field, Denver
[-118.24, 34.0739], // Dodger Stadium, LA
];
const stadiumNames = ["NYC", "Chicago", "Denver", "Los Angeles"];
// Only render map if token is available
const MAPBOX_TOKEN = process.env.REMOTION_MAPBOX_TOKEN;
type MapSceneProps = {
animationDuration?: number; // in seconds
};
export const MapScene: React.FC<MapSceneProps> = ({
animationDuration = 2,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const ref = useRef<HTMLDivElement>(null);
const [handle] = useState(() => delayRender("Loading map..."));
const [map, setMap] = useState<Map | null>(null);
const [mapLoaded, setMapLoaded] = useState(false);
// Calculate route progress
const progress = interpolate(
frame,
[0, animationDuration * fps],
[0, 1],
{
easing: Easing.inOut(Easing.sin),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}
);
// Initialize map
useEffect(() => {
if (!ref.current || !MAPBOX_TOKEN) {
// If no token, continue render without map
continueRender(handle);
return;
}
mapboxgl.accessToken = MAPBOX_TOKEN;
const _map = new Map({
container: ref.current,
zoom: 3.5,
center: [-98, 39], // Center of US
pitch: 0,
bearing: 0,
style: "mapbox://styles/mapbox/dark-v11",
interactive: false,
fadeDuration: 0,
});
_map.on("style.load", () => {
// Add route line source
_map.addSource("route", {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: [],
},
},
});
// Add route line layer
_map.addLayer({
id: "route-line",
type: "line",
source: "route",
paint: {
"line-color": theme.colors.mapLine,
"line-width": 6,
"line-opacity": 0.9,
},
layout: {
"line-cap": "round",
"line-join": "round",
},
});
// Add stadium markers source
_map.addSource("stadiums", {
type: "geojson",
data: {
type: "FeatureCollection",
features: stadiumCoordinates.map((coord, i) => ({
type: "Feature" as const,
properties: { name: stadiumNames[i], index: i },
geometry: { type: "Point" as const, coordinates: coord },
})),
},
});
// Add stadium markers layer (initially hidden, revealed as route progresses)
_map.addLayer({
id: "stadium-markers",
type: "circle",
source: "stadiums",
paint: {
"circle-radius": 12,
"circle-color": theme.colors.mapMarker,
"circle-stroke-width": 3,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 0,
},
});
// Add stadium labels
_map.addLayer({
id: "stadium-labels",
type: "symbol",
source: "stadiums",
layout: {
"text-field": ["get", "name"],
"text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
"text-size": 16,
"text-offset": [0, 1.5],
"text-anchor": "top",
},
paint: {
"text-color": "#FFFFFF",
"text-halo-color": "#000000",
"text-halo-width": 2,
"text-opacity": 0,
},
});
});
_map.on("load", () => {
continueRender(handle);
setMap(_map);
setMapLoaded(true);
});
return () => {
// Don't remove map - causes issues with Remotion
};
}, [handle]);
// Animate route and markers
useEffect(() => {
if (!map || !mapLoaded) return;
const animHandle = delayRender("Animating route...");
// Create the route line using turf for geodesic path
const routeLine = turf.lineString(stadiumCoordinates);
const routeDistance = turf.length(routeLine);
// Calculate current distance along route
const currentDistance = Math.max(0.001, routeDistance * progress);
// Slice the line to current progress
const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
// Update route line
const routeSource = map.getSource("route") as mapboxgl.GeoJSONSource;
if (routeSource) {
routeSource.setData(slicedLine);
}
// Calculate which stadiums should be visible
const totalSegments = stadiumCoordinates.length - 1;
const currentSegment = progress * totalSegments;
// Update marker opacities based on route progress
stadiumCoordinates.forEach((_, index) => {
const segmentProgress = index === 0 ? 0 : index / totalSegments;
const shouldShow = progress >= segmentProgress;
const opacity = shouldShow ? 1 : 0;
// Use filter to show/hide markers based on index
map.setPaintProperty("stadium-markers", "circle-opacity", [
"case",
["<=", ["get", "index"], Math.floor(currentSegment) + (progress > 0 ? 1 : 0) - 1],
1,
["==", ["get", "index"], 0],
progress > 0.01 ? 1 : 0,
0,
]);
map.setPaintProperty("stadium-labels", "text-opacity", [
"case",
["<=", ["get", "index"], Math.floor(currentSegment) + (progress > 0 ? 1 : 0) - 1],
1,
["==", ["get", "index"], 0],
progress > 0.01 ? 1 : 0,
0,
]);
});
map.once("idle", () => continueRender(animHandle));
}, [map, mapLoaded, progress]);
const style: React.CSSProperties = useMemo(
() => ({
width,
height,
position: "absolute",
}),
[width, height]
);
// Fallback if no Mapbox token
if (!MAPBOX_TOKEN) {
return (
<AbsoluteFill
style={{
background: "#1a1a2e",
justifyContent: "center",
alignItems: "center",
}}
>
<FallbackMapAnimation progress={progress} />
</AbsoluteFill>
);
}
return <div ref={ref} style={style} />;
};
// Fallback SVG map animation when Mapbox token is not available
const FallbackMapAnimation: React.FC<{ progress: number }> = ({ progress }) => {
const { width, height } = useVideoConfig();
// Simplified US map points
const points = [
{ x: width * 0.8, y: height * 0.35, name: "NYC" },
{ x: width * 0.55, y: height * 0.35, name: "Chicago" },
{ x: width * 0.4, y: height * 0.4, name: "Denver" },
{ x: width * 0.2, y: height * 0.45, name: "Los Angeles" },
];
// Calculate which segments to show
const totalSegments = points.length - 1;
const currentProgress = progress * totalSegments;
// Build path
let pathD = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
if (currentProgress >= i - 1) {
const segmentProgress = Math.min(1, currentProgress - (i - 1));
const prevPoint = points[i - 1];
const currPoint = points[i];
const x = prevPoint.x + (currPoint.x - prevPoint.x) * segmentProgress;
const y = prevPoint.y + (currPoint.y - prevPoint.y) * segmentProgress;
pathD += ` L ${x} ${y}`;
}
}
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={{ position: "absolute" }}
>
{/* Background US shape (simplified) */}
<ellipse
cx={width * 0.5}
cy={height * 0.45}
rx={width * 0.4}
ry={height * 0.25}
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="2"
/>
{/* Route line */}
<path
d={pathD}
fill="none"
stroke={theme.colors.mapLine}
strokeWidth="6"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Stadium markers */}
{points.map((point, index) => {
const segmentThreshold = index === 0 ? 0.01 : index / totalSegments;
const isVisible = progress >= segmentThreshold;
const opacity = isVisible ? 1 : 0;
return (
<g key={index} opacity={opacity}>
<circle
cx={point.x}
cy={point.y}
r="16"
fill={theme.colors.mapMarker}
stroke="#FFFFFF"
strokeWidth="3"
/>
<text
x={point.x}
y={point.y + 35}
fill="#FFFFFF"
fontSize="20"
fontFamily="system-ui"
fontWeight="bold"
textAnchor="middle"
>
{point.name}
</text>
</g>
);
})}
</svg>
);
};

View File

@@ -0,0 +1,287 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type TimelineDay = {
day: string;
date: string;
city: string;
game?: {
teams: string;
time: string;
sport: string;
};
isTravel?: boolean;
};
const itinerary: TimelineDay[] = [
{
day: "Day 1",
date: "Jun 14",
city: "New York",
game: { teams: "Red Sox @ Yankees", time: "7:05 PM", sport: "MLB" },
},
{
day: "Day 2",
date: "Jun 15",
city: "Chicago",
isTravel: true,
},
{
day: "Day 3",
date: "Jun 16",
city: "Chicago",
game: { teams: "Cardinals @ Cubs", time: "2:20 PM", sport: "MLB" },
},
{
day: "Day 4",
date: "Jun 17",
city: "Denver",
game: { teams: "Dodgers @ Rockies", time: "6:40 PM", sport: "MLB" },
},
];
export const TimelineSlide: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 60,
paddingTop: 100,
}}
>
{/* Header */}
<div
style={{
marginBottom: 40,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
Your Trip
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
color: theme.colors.textSecondary,
}}
>
4 days 4 cities 3 games
</div>
</div>
{/* Timeline */}
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{itinerary.map((day, index) => {
const itemDelay = index * 0.1;
const itemProgress = spring({
frame: frame - itemDelay * fps,
fps,
config: theme.animation.smooth,
});
const opacity = interpolate(
frame - itemDelay * fps,
[0, fps * 0.2],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateX = interpolate(itemProgress, [0, 1], [50, 0]);
return (
<div
key={index}
style={{
display: "flex",
alignItems: "stretch",
gap: 20,
opacity,
transform: `translateX(${translateX}px)`,
}}
>
{/* Timeline indicator */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: 60,
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: day.game
? theme.colors.accent
: theme.colors.textMuted,
}}
/>
{index < itinerary.length - 1 && (
<div
style={{
flex: 1,
width: 2,
background: "rgba(255,255,255,0.2)",
marginTop: 8,
}}
/>
)}
</div>
{/* Day card */}
<div
style={{
flex: 1,
background: "#1C1C1E",
borderRadius: 16,
padding: 24,
borderLeft: day.game
? `4px solid ${theme.colors.accent}`
: "4px solid transparent",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: day.game ? 12 : 0,
}}
>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 600,
color: theme.colors.text,
}}
>
{day.day}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
{day.date}
</div>
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
fontWeight: 600,
color: theme.colors.secondary,
}}
>
{day.city}
</div>
</div>
{day.game && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.text,
}}
>
{day.game.teams}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.accent,
background: "rgba(255,107,53,0.2)",
padding: "4px 8px",
borderRadius: 6,
}}
>
{day.game.sport}
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
{day.game.time}
</span>
</div>
</div>
)}
{day.isTravel && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 8,
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L4 12h3v8h10v-8h3L12 2z"
fill={theme.colors.textMuted}
transform="rotate(90 12 12)"
/>
</svg>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textMuted,
}}
>
Travel day
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,118 @@
import React from "react";
import {
AbsoluteFill,
Sequence,
useVideoConfig,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { MapScene } from "./MapScene";
import { GameCardReveal } from "./GameCardReveal";
import { TimelineSlide } from "./TimelineSlide";
import {
GradientBackground,
TextReveal,
LogoEndcard,
theme,
} from "../../components/shared";
/**
* Video 1: "The Route"
*
* Goal: Establish brand identity and core value proposition
* Length: 15 seconds (450 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:02 (0-60): Map animation with route drawing
* - 0:02-0:05 (60-150): Zoom into game card
* - 0:05-0:08 (150-240): Timeline view slide in
* - 0:08-0:11 (240-330): Text: "Your entire trip. One tap."
* - 0:11-0:15 (330-450): Logo endcard
*/
export const TheRoute: React.FC = () => {
const { fps } = useVideoConfig();
const SCENE_DURATIONS = {
map: 2.5 * fps, // 75 frames
gameCard: 2.5 * fps, // 75 frames
timeline: 3 * fps, // 90 frames
tagline: 3 * fps, // 90 frames
logo: 4 * fps, // 120 frames
};
const TRANSITION_DURATION = 12;
return (
<AbsoluteFill>
<GradientBackground />
<TransitionSeries>
{/* Scene 1: Map with route animation */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.map}>
<MapScene animationDuration={2} />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: Game card reveal */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.gameCard}>
<GameCardReveal />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: Timeline view */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.timeline}>
<TimelineSlide />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Tagline */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.tagline}>
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<TextReveal
text="Your entire trip. One tap."
fontSize={56}
/>
<div style={{ position: "absolute", bottom: 200 }}>
<TextReveal
text="Plan it. See it. Live it."
fontSize={32}
color={theme.colors.textSecondary}
delay={0.5}
/>
</div>
</AbsoluteFill>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: Logo endcard */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
<LogoEndcard showAppStoreBadge />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,219 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type Message = {
text: string;
sender: string;
color: string;
side: "left" | "right";
};
const messages: Message[] = [
{ text: "Lakers game?", sender: "Mike", color: "#552583", side: "left" },
{ text: "Dodgers?", sender: "Sarah", color: "#005A9C", side: "right" },
{ text: "Both??", sender: "Jake", color: "#007AFF", side: "left" },
{ text: "When though", sender: "Sarah", color: "#005A9C", side: "right" },
{ text: "idk June maybe", sender: "Mike", color: "#552583", side: "left" },
{ text: "too many options 😩", sender: "Jake", color: "#007AFF", side: "right" },
];
export const ChatBubbles: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width } = useVideoConfig();
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 40,
paddingTop: 100,
}}
>
{/* Header - looks like a group chat */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
marginBottom: 40,
paddingBottom: 20,
borderBottom: "1px solid rgba(255,255,255,0.1)",
}}
>
{/* Avatar stack */}
<div style={{ display: "flex", marginLeft: 20 }}>
{["#FF6B6B", "#4ECDC4", "#45B7D1"].map((color, i) => (
<div
key={i}
style={{
width: 40,
height: 40,
borderRadius: "50%",
background: color,
border: `3px solid ${theme.colors.background}`,
marginLeft: i > 0 ? -15 : 0,
zIndex: 3 - i,
}}
/>
))}
</div>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 600,
color: theme.colors.text,
}}
>
LA Trip Planning
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textMuted,
}}
>
3 people
</div>
</div>
</div>
{/* Messages */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
{messages.map((message, index) => {
// Stagger each message
const messageDelay = index * 6;
const localFrame = frame - messageDelay;
const entranceProgress = spring({
frame: localFrame,
fps,
config: theme.animation.snappy,
});
const opacity = interpolate(
localFrame,
[0, 5],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateX = message.side === "left"
? interpolate(entranceProgress, [0, 1], [-50, 0])
: interpolate(entranceProgress, [0, 1], [50, 0]);
const scale = interpolate(entranceProgress, [0, 1], [0.8, 1]);
return (
<div
key={index}
style={{
display: "flex",
justifyContent: message.side === "left" ? "flex-start" : "flex-end",
opacity,
transform: `translateX(${translateX}px) scale(${scale})`,
}}
>
<div
style={{
maxWidth: "75%",
display: "flex",
flexDirection: "column",
alignItems: message.side === "left" ? "flex-start" : "flex-end",
}}
>
{/* Sender name */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
marginBottom: 4,
marginLeft: message.side === "left" ? 12 : 0,
marginRight: message.side === "right" ? 12 : 0,
}}
>
{message.sender}
</span>
{/* Bubble */}
<div
style={{
background: message.side === "left" ? "#2C2C2E" : message.color,
padding: "14px 20px",
borderRadius: 22,
borderBottomLeftRadius: message.side === "left" ? 6 : 22,
borderBottomRightRadius: message.side === "right" ? 6 : 22,
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: theme.colors.text,
}}
>
{message.text}
</span>
</div>
</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};
// "Stop the chaos" text overlay
export const StopTheChaos: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const opacity = interpolate(progress, [0, 1], [0, 1]);
const translateY = interpolate(progress, [0, 1], [30, 0]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 700,
color: theme.colors.text,
}}
>
Stop the chaos.
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,438 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type PollOption = {
game: string;
league: string;
votes: { name: string; avatar: string }[];
percentage: number;
isWinner?: boolean;
};
const pollOptions: PollOption[] = [
{
game: "Lakers vs Celtics",
league: "NBA",
votes: [
{ name: "Mike", avatar: "#FF6B6B" },
{ name: "Sarah", avatar: "#4ECDC4" },
{ name: "Jake", avatar: "#45B7D1" },
],
percentage: 100,
isWinner: true,
},
{
game: "Dodgers vs Giants",
league: "MLB",
votes: [
{ name: "Sarah", avatar: "#4ECDC4" },
],
percentage: 33,
},
];
export const PollCreation: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entranceProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const scale = interpolate(entranceProgress, [0, 1], [0.95, 1]);
const opacity = interpolate(entranceProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 40,
paddingTop: 80,
}}
>
<div
style={{
transform: `scale(${scale})`,
opacity,
}}
>
{/* Poll card */}
<div
style={{
background: "#1C1C1E",
borderRadius: 24,
padding: 32,
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
LA Trip - Which games?
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textMuted,
}}
>
Vote for your favorites
</div>
</div>
{/* Options */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{pollOptions.map((option, index) => (
<div
key={index}
style={{
background: "rgba(255,255,255,0.05)",
borderRadius: 16,
padding: 20,
border: `2px solid ${option.isWinner ? theme.colors.accent : "transparent"}`,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 12,
color: theme.colors.accent,
background: "rgba(255,107,53,0.2)",
padding: "4px 8px",
borderRadius: 6,
marginRight: 12,
}}
>
{option.league}
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: theme.colors.text,
}}
>
{option.game}
</span>
</div>
{/* Vote checkbox */}
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
border: `2px solid ${theme.colors.textMuted}`,
}}
/>
</div>
</div>
))}
</div>
{/* Share button */}
<div
style={{
marginTop: 24,
padding: 16,
background: theme.colors.accent,
borderRadius: 14,
textAlign: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 600,
color: theme.colors.text,
}}
>
Share Poll
</span>
</div>
</div>
</div>
</AbsoluteFill>
);
};
export const PollVoting: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<AbsoluteFill
style={{
background: theme.colors.background,
padding: 40,
paddingTop: 80,
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 24,
padding: 32,
}}
>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 8,
}}
>
LA Trip - Which games?
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textMuted,
}}
>
3 votes cast
</div>
</div>
{/* Options with votes */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 20,
}}
>
{pollOptions.map((option, index) => {
// Animate votes appearing
const voteDelay = index * fps * 0.3;
return (
<div
key={index}
style={{
background: "rgba(255,255,255,0.05)",
borderRadius: 16,
padding: 20,
border: option.isWinner
? `2px solid ${theme.colors.accent}`
: "2px solid transparent",
}}
>
{/* Game info */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
}}
>
<div>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 12,
color: theme.colors.accent,
background: "rgba(255,107,53,0.2)",
padding: "4px 8px",
borderRadius: 6,
marginRight: 12,
}}
>
{option.league}
</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.text,
}}
>
{option.game}
</span>
</div>
{option.isWinner && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.success,
fontWeight: 600,
}}
>
Winner
</span>
)}
</div>
{/* Progress bar */}
<PollProgressBar
percentage={option.percentage}
delay={voteDelay}
isWinner={option.isWinner}
/>
{/* Voter avatars */}
<VoterAvatars
votes={option.votes}
startDelay={voteDelay + fps * 0.2}
/>
</div>
);
})}
</div>
</div>
</AbsoluteFill>
);
};
type PollProgressBarProps = {
percentage: number;
delay: number;
isWinner?: boolean;
};
const PollProgressBar: React.FC<PollProgressBarProps> = ({
percentage,
delay,
isWinner,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame: frame - delay,
fps,
config: theme.animation.smooth,
});
const width = interpolate(progress, [0, 1], [0, percentage]);
return (
<div
style={{
height: 8,
background: "rgba(255,255,255,0.1)",
borderRadius: 4,
overflow: "hidden",
marginBottom: 12,
}}
>
<div
style={{
height: "100%",
width: `${width}%`,
background: isWinner
? theme.colors.accent
: theme.colors.secondary,
borderRadius: 4,
}}
/>
</div>
);
};
type VoterAvatarsProps = {
votes: { name: string; avatar: string }[];
startDelay: number;
};
const VoterAvatars: React.FC<VoterAvatarsProps> = ({ votes, startDelay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ display: "flex", marginLeft: 8 }}>
{votes.map((vote, i) => {
const avatarProgress = spring({
frame: frame - startDelay - i * 5,
fps,
config: theme.animation.snappy,
});
const scale = interpolate(avatarProgress, [0, 1], [0, 1]);
const opacity = interpolate(avatarProgress, [0, 1], [0, 1]);
return (
<div
key={i}
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: vote.avatar,
border: `2px solid ${theme.colors.background}`,
marginLeft: i > 0 ? -10 : 0,
transform: `scale(${scale})`,
opacity,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 12,
fontWeight: 600,
color: "white",
}}
>
{vote.name[0]}
</span>
</div>
);
})}
</div>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
}}
>
{votes.length} vote{votes.length > 1 ? "s" : ""}
</span>
</div>
);
};

View File

@@ -0,0 +1,258 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
// Confetti particles
const Confetti: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const particles = React.useMemo(() => {
const colors = [theme.colors.accent, theme.colors.secondary, theme.colors.gold, "#9B59B6"];
return Array.from({ length: 25 }, (_, i) => ({
id: i,
x: Math.random() * width,
color: colors[Math.floor(Math.random() * colors.length)],
size: 6 + Math.random() * 10,
rotation: Math.random() * 360,
speed: 0.4 + Math.random() * 0.4,
delay: Math.random() * 8,
}));
}, [width]);
return (
<>
{particles.map((particle) => {
const localFrame = frame - particle.delay;
if (localFrame < 0) return null;
const y = interpolate(
localFrame,
[0, fps * 2],
[-50, height + 100],
{ extrapolateRight: "clamp" }
) * particle.speed;
const rotation = particle.rotation + localFrame * 4;
const opacity = interpolate(
localFrame,
[0, fps * 0.3, fps * 1.5, fps * 2],
[0, 1, 1, 0],
{ extrapolateRight: "clamp" }
);
return (
<div
key={particle.id}
style={{
position: "absolute",
left: particle.x,
top: y,
width: particle.size,
height: particle.size,
background: particle.color,
borderRadius: 2,
transform: `rotate(${rotation}deg)`,
opacity,
}}
/>
);
})}
</>
);
};
export const ResultsReveal: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Winner card entrance
const cardProgress = spring({
frame,
fps,
config: { damping: 15, stiffness: 120 },
});
const cardScale = interpolate(cardProgress, [0, 1], [0.8, 1]);
const cardOpacity = interpolate(cardProgress, [0, 1], [0, 1]);
// "Democracy wins" text entrance
const textProgress = spring({
frame: frame - fps * 0.6,
fps,
config: theme.animation.smooth,
});
const textOpacity = interpolate(textProgress, [0, 1], [0, 1]);
const textY = interpolate(textProgress, [0, 1], [20, 0]);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
{/* Confetti */}
<Confetti />
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
padding: 40,
}}
>
{/* Winner card */}
<div
style={{
transform: `scale(${cardScale})`,
opacity: cardOpacity,
width: "90%",
maxWidth: 600,
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 28,
padding: 40,
border: `3px solid ${theme.colors.accent}`,
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.3)`,
}}
>
{/* Winner badge */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
marginBottom: 24,
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: "50%",
background: theme.colors.accent,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 700,
color: theme.colors.accent,
textTransform: "uppercase",
letterSpacing: 2,
}}
>
Winner
</span>
</div>
{/* Winning game */}
<div style={{ textAlign: "center", marginBottom: 24 }}>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
background: "rgba(255,107,53,0.2)",
padding: "6px 12px",
borderRadius: 8,
}}
>
NBA
</span>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 700,
color: theme.colors.text,
textAlign: "center",
marginBottom: 16,
}}
>
Lakers vs Celtics
</div>
{/* Vote count */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 16,
}}
>
{/* Voter avatars */}
<div style={{ display: "flex" }}>
{["#FF6B6B", "#4ECDC4", "#45B7D1"].map((color, i) => (
<div
key={i}
style={{
width: 36,
height: 36,
borderRadius: "50%",
background: color,
border: `2px solid #1C1C1E`,
marginLeft: i > 0 ? -10 : 0,
}}
/>
))}
</div>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
}}
>
3 votes 100%
</span>
</div>
</div>
</div>
{/* "Democracy wins" text */}
<div
style={{
position: "absolute",
bottom: 150,
opacity: textOpacity,
transform: `translateY(${textY}px)`,
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 600,
color: theme.colors.textSecondary,
}}
>
Democracy wins.
</span>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,142 @@
import React from "react";
import {
AbsoluteFill,
useVideoConfig,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { ChatBubbles, StopTheChaos } from "./ChatBubbles";
import { PollCreation, PollVoting } from "./PollAnimation";
import { ResultsReveal } from "./ResultsReveal";
import {
GradientBackground,
LogoEndcard,
TextReveal,
theme,
} from "../../components/shared";
/**
* Video 4: "The Squad"
*
* Goal: Promote group polling feature, position app for friend group planning
* Length: 18 seconds (540 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:02 (0-60): Chat bubbles chaos
* - 0:02-0:05 (60-150): "Stop the chaos" + Poll creation
* - 0:05-0:09 (150-270): Votes animating in
* - 0:09-0:13 (270-390): Results reveal with confetti
* - 0:13-0:16 (390-480): Trip planned text
* - 0:16-0:18 (480-540): Logo endcard
*/
export const TheSquad: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 12;
const SCENE_DURATIONS = {
chatBubbles: 2.5 * fps, // 75 frames
stopChaos: 1 * fps, // 30 frames
pollCreation: 2 * fps, // 60 frames
pollVoting: 3 * fps, // 90 frames
results: 3.5 * fps, // 105 frames
tripPlanned: 2.5 * fps, // 75 frames
logo: 3.5 * fps, // 105 frames
};
return (
<AbsoluteFill>
<GradientBackground />
<TransitionSeries>
{/* Scene 1: Chat bubbles chaos */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.chatBubbles}>
<ChatBubbles />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: "Stop the chaos" */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stopChaos}>
<StopTheChaos />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: Poll creation UI */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pollCreation}>
<PollCreation />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Votes animating in */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pollVoting}>
<PollVoting />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: Results reveal with confetti */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.results}>
<ResultsReveal />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 6: Trip planned text */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.tripPlanned}>
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div style={{ textAlign: "center" }}>
<TextReveal
text="Trip planned."
fontSize={48}
/>
<div style={{ marginTop: 16 }}>
<TextReveal
text="Friends aligned."
fontSize={48}
color={theme.colors.accent}
delay={0.3}
/>
</div>
</div>
</AbsoluteFill>
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 7: Logo endcard */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
<LogoEndcard tagline="Plan together." />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};