Remove marketing-videos Remotion project

The standalone Remotion video project is no longer needed in this repo.
Also updates local Claude Code settings with additional tool permissions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-07 00:03:49 -06:00
parent 46d37875e5
commit e6ed766ccd
74 changed files with 6 additions and 24816 deletions

View File

@@ -35,7 +35,12 @@
"Bash(chmod:*)",
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-monochrome.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-sunset.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)"
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
"Bash(wc:*)",
"Bash(echo:*)",
"Bash(ffprobe:*)",
"WebFetch(domain:www.cbssports.com)",
"WebFetch(domain:www.mlb.com)"
]
}
}

View File

@@ -1,2 +0,0 @@
# Get your Mapbox token at https://console.mapbox.com/account/access-tokens/
REMOTION_MAPBOX_TOKEN=pk.your-mapbox-access-token

View File

@@ -1,16 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
out/
# Remotion cache
.remotion/
# Environment
.env
.env.local
# OS files
.DS_Store

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +0,0 @@
{
"name": "sportstime-marketing-videos",
"version": "1.0.0",
"description": "Marketing videos for SportsTime app",
"scripts": {
"start": "remotion studio",
"build": "remotion render",
"render:route": "remotion render TheRoute out/the-route.mp4 --gl=angle --concurrency=1",
"render:checklist": "remotion render TheChecklist out/the-checklist.mp4",
"render:bucketlist": "remotion render TheBucketList out/the-bucket-list.mp4",
"render:squad": "remotion render TheSquad out/the-squad.mp4",
"render:handoff": "remotion render TheHandoff out/the-handoff.mp4",
"render:fantest": "remotion render TheFanTest out/the-fan-test.mp4",
"render:groupchat": "remotion render TheGroupChat out/the-group-chat.mp4",
"render:all-originals": "npm run render:route && npm run render:checklist && npm run render:bucketlist && npm run render:squad && npm run render:handoff && npm run render:fantest && npm run render:groupchat",
"render:V03-H01": "remotion render V03-H01 out/week1/V03-H01.mp4",
"render:V10-H01": "remotion render V10-H01 out/week1/V10-H01.mp4",
"render:V03-H02": "remotion render V03-H02 out/week1/V03-H02.mp4",
"render:V10-H02": "remotion render V10-H02 out/week1/V10-H02.mp4",
"render:V03-H03": "remotion render V03-H03 out/week1/V03-H03.mp4",
"render:V17-H01": "remotion render V17-H01 out/week1/V17-H01.mp4",
"render:V17-H02": "remotion render V17-H02 out/week1/V17-H02.mp4",
"render:V06-H01": "remotion render V06-H01 out/week1/V06-H01.mp4",
"render:V08-H01": "remotion render V08-H01 out/week1/V08-H01.mp4",
"render:V05-LA-01": "remotion render V05-LA-01 out/week1/V05-LA-01.mp4",
"render:V05-NY-01": "remotion render V05-NY-01 out/week1/V05-NY-01.mp4",
"render:V05-TX-01": "remotion render V05-TX-01 out/week1/V05-TX-01.mp4",
"render:V05-CA-01": "remotion render V05-CA-01 out/week1/V05-CA-01.mp4",
"render:V08-LA-01": "remotion render V08-LA-01 out/week1/V08-LA-01.mp4",
"render:V04-H01": "remotion render V04-H01 out/week1/V04-H01.mp4",
"render:V20-H01": "remotion render V20-H01 out/week1/V20-H01.mp4",
"render:V14-H01": "remotion render V14-H01 out/week1/V14-H01.mp4",
"render:V04-H02": "remotion render V04-H02 out/week1/V04-H02.mp4",
"render:V02-H01": "remotion render V02-H01 out/week1/V02-H01.mp4",
"render:V19-H01": "remotion render V19-H01 out/week1/V19-H01.mp4",
"render:week1": "bash scripts/render-week1.sh",
"render:all": "npm run render:all-originals && npm run render:week1"
},
"type": "module",
"dependencies": {
"@remotion/cli": "^4.0.410",
"@remotion/transitions": "^4.0.410",
"@turf/turf": "^7.3.2",
"@types/mapbox-gl": "^3.4.1",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"mapbox-gl": "^3.18.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"remotion": "^4.0.410",
"typescript": "^5.9.3"
}
}

View File

@@ -1,39 +0,0 @@
# Asset Placeholder Directory
Replace these placeholders with real assets before rendering final videos.
## Screen Recordings (`screenrecs/`)
Record these on-device using the SportsTime app:
| File | Description | Screen to Record |
|------|-------------|-----------------|
| `date_range.mp4` | Picking dates in trip wizard | Trip Wizard → Date Range step |
| `follow_team.mp4` | Following a team flow | Trip Wizard → Follow Team mode |
| `by_games.mp4` | Browsing by games | Trip Wizard → By Games mode |
| `route_generated.mp4` | Route result appearing | Trip Detail → after planning |
| `poll_create.mp4` | Creating a group poll | Trip Options → Share/Poll |
| `tracker.mp4` | Stadium tracker/bucket list | Profile → Stadium Tracker |
**Recording tips:**
- Use iPhone screen recording (Settings → Control Center)
- Portrait orientation, 1080x1920 native
- 5-10 seconds per clip
- Clean, smooth taps (no fumbling)
## Overlays (`overlays/`)
| File | Description |
|------|-------------|
| `imessage_bg.png` | iMessage background texture (optional) |
| `chat_bubbles.png` | Chat bubble overlay (optional) |
| `vote_bubbles.png` | Vote notification overlay (optional) |
## B-Roll (`broll/`)
| File | Description | Source |
|------|-------------|--------|
| `highway.mp4` | Highway driving footage | Stock/Pexels |
| `city.mp4` | City skyline/aerial | Stock/Pexels |
| `stadium.mp4` | Stadium exterior | Stock/Pexels |
**Note:** B-roll is optional. Videos render with placeholder scenes when assets are missing.

View File

@@ -1,4 +0,0 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env bash
#
# Batch render all 20 Week 1 Reels videos.
# Usage: bash scripts/render-week1.sh [--concurrency N]
#
# Options:
# --concurrency N Number of parallel renders (default: 2)
# --ids ID1,ID2 Comma-separated list of specific IDs to render
#
set -euo pipefail
CONCURRENCY=${1:-2}
OUT_DIR="out/week1"
mkdir -p "$OUT_DIR"
IDS=(
V03-H01
V10-H01
V03-H02
V10-H02
V03-H03
V17-H01
V17-H02
V06-H01
V08-H01
V05-LA-01
V05-NY-01
V05-TX-01
V05-CA-01
V08-LA-01
V04-H01
V20-H01
V14-H01
V04-H02
V02-H01
V19-H01
)
# Parse optional --ids flag
if [[ "${1:-}" == "--ids" ]]; then
IFS=',' read -ra IDS <<< "${2:-}"
shift 2
fi
TOTAL=${#IDS[@]}
DONE=0
FAILED=0
echo ""
echo "=== SportsTime Week 1 Batch Render ==="
echo " Videos: $TOTAL"
echo " Output: $OUT_DIR/"
echo " Concurrency: $CONCURRENCY"
echo ""
render_one() {
local id=$1
local out="$OUT_DIR/${id}.mp4"
echo "[START] $id$out"
if npx remotion render "$id" "$out" --log=error 2>&1; then
echo "[DONE] $id"
return 0
else
echo "[FAIL] $id"
return 1
fi
}
export -f render_one
export OUT_DIR
# Run renders with controlled concurrency using xargs
printf '%s\n' "${IDS[@]}" | xargs -P "$CONCURRENCY" -I {} bash -c 'render_one "$@"' _ {}
echo ""
echo "=== Render Complete ==="
echo " Output: $OUT_DIR/"
ls -lh "$OUT_DIR"/*.mp4 2>/dev/null || echo " (no .mp4 files found - check errors above)"

View File

@@ -1,178 +0,0 @@
import React from "react";
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";
import { TheFanTest } from "./videos/TheFanTest";
import { TheGroupChat } from "./videos/TheGroupChat";
import { StadiumCountFlex } from "./videos/StadiumCountFlex";
import { GroupChatChaos } from "./videos/GroupChatChaos";
import { LocalCityRoute } from "./videos/LocalCityRoute";
import { SpreadsheetEra } from "./videos/SpreadsheetEra";
import { AwayGameTake } from "./videos/AwayGameTake";
import { VideoFromConfig } from "./engine";
import type { VideoConfig } from "./engine";
import week1Configs from "./configs/week1.json";
/**
* Wrapper component that receives config via defaultProps.
* Avoids inline arrow functions in Composition component prop.
*/
const ConfigVideo: React.FC<{ config: VideoConfig }> = ({ config }) => {
return <VideoFromConfig config={config} />;
};
/**
* 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;
const configs = week1Configs as VideoConfig[];
return (
<>
{/* Original hand-crafted marketing videos */}
<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}
/>
{/* Video 6: The Fan Test - Viral identity challenge */}
<Composition
id="TheFanTest"
component={TheFanTest}
durationInFrames={18 * FPS} // 18 seconds = 540 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
{/* Video 7: The Group Chat - Viral group chat chaos */}
<Composition
id="TheGroupChat"
component={TheGroupChat}
durationInFrames={16 * FPS} // 16 seconds = 480 frames
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
</Folder>
{/* Hand-crafted TikTok/Reels - unique visual identity per video */}
<Folder name="TikTok-Originals">
<Composition
id="StadiumCountFlex"
component={StadiumCountFlex}
durationInFrames={15 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="GroupChatChaos"
component={GroupChatChaos}
durationInFrames={16 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="LocalCityRoute"
component={LocalCityRoute}
durationInFrames={14 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="SpreadsheetEra"
component={SpreadsheetEra}
durationInFrames={15 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
<Composition
id="AwayGameTake"
component={AwayGameTake}
durationInFrames={15 * FPS}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
</Folder>
{/* Week 1: 20 config-driven TikTok/Reels videos */}
<Folder name="Week1-Reels">
{configs.map((config) => (
<Composition
key={config.id}
id={config.id}
component={ConfigVideo}
defaultProps={{ config }}
durationInFrames={Math.round(config.targetLengthSec * FPS)}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
))}
</Folder>
</>
);
};

View File

@@ -1,185 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,43 +0,0 @@
import React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
/**
* Subtle film grain overlay that makes videos feel organic/real.
* Uses deterministic noise per frame for reproducible renders.
*/
export const FilmGrain: React.FC<{ opacity?: number }> = ({
opacity = 0.04,
}) => {
const frame = useCurrentFrame();
// Shift the noise pattern each frame using a CSS trick
const offsetX = ((frame * 73) % 200) - 100;
const offsetY = ((frame * 47) % 200) - 100;
return (
<AbsoluteFill
style={{
pointerEvents: "none",
mixBlendMode: "overlay",
opacity,
}}
>
<svg width="100%" height="100%">
<filter id={`grain-${frame % 10}`}>
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
seed={frame}
stitchTiles="stitch"
/>
</filter>
<rect
width="100%"
height="100%"
filter={`url(#grain-${frame % 10})`}
transform={`translate(${offsetX}, ${offsetY})`}
/>
</svg>
</AbsoluteFill>
);
};

View File

@@ -1,203 +0,0 @@
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

@@ -1,173 +0,0 @@
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

@@ -1,188 +0,0 @@
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

@@ -1,306 +0,0 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "./theme";
/**
* TikTok-native kinetic caption system.
*
* Unlike generic subtitle overlays, each style mimics real TikTok
* caption patterns: punch zooms, word pops, highlight boxes, etc.
*/
type CaptionEntry = {
text: string;
startSec: number;
endSec: number;
style?: "punch" | "highlight" | "stack" | "whisper" | "shake";
};
type TikTokCaptionProps = {
captions: CaptionEntry[];
/** Vertical position from bottom (px) */
bottomOffset?: number;
};
export const TikTokCaption: React.FC<TikTokCaptionProps> = ({
captions,
bottomOffset = 280,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentSec = frame / fps;
const active = captions.find(
(c) => currentSec >= c.startSec && currentSec < c.endSec
);
if (!active) return null;
const startFrame = active.startSec * fps;
const endFrame = active.endSec * fps;
const localFrame = frame - startFrame;
const style = active.style || "punch";
const exitOpacity = interpolate(
frame,
[endFrame - 4, endFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
style={{
position: "absolute",
bottom: bottomOffset,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
opacity: exitOpacity,
zIndex: 100,
}}
>
{style === "punch" && (
<PunchCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "highlight" && (
<HighlightCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "stack" && (
<StackCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "whisper" && (
<WhisperCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "shake" && (
<ShakeCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
</div>
);
};
/** Punch zoom in - text scales from big to normal with impact */
const PunchCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const prog = spring({
frame: localFrame,
fps,
config: { damping: 10, stiffness: 280 },
});
const scale = interpolate(prog, [0, 1], [1.8, 1]);
const opacity = interpolate(prog, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 52,
fontWeight: 900,
color: "white",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: -1,
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
transform: `scale(${scale})`,
opacity,
maxWidth: 900,
lineHeight: 1.15,
}}
>
{text}
</span>
);
};
/** Highlight box - text with colored background box that wipes in */
const HighlightCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const boxProg = spring({
frame: localFrame,
fps,
config: { damping: 15, stiffness: 200 },
});
const textProg = spring({
frame: localFrame - 3,
fps,
config: { damping: 20, stiffness: 180 },
});
return (
<div style={{ position: "relative", display: "inline-block" }}>
<div
style={{
position: "absolute",
inset: "-8px -20px",
background: theme.colors.accent,
borderRadius: 8,
transform: `scaleX(${boxProg})`,
transformOrigin: "left",
}}
/>
<span
style={{
position: "relative",
fontFamily: theme.fonts.display,
fontSize: 46,
fontWeight: 800,
color: "white",
opacity: interpolate(textProg, [0, 1], [0, 1]),
letterSpacing: -0.5,
textShadow: "0 2px 8px rgba(0,0,0,0.3)",
}}
>
{text}
</span>
</div>
);
};
/** Stack - words stack vertically, each popping in */
const StackCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const words = text.split(" ");
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
{words.map((word, i) => {
const delay = i * 2;
const prog = spring({
frame: localFrame - delay,
fps,
config: { damping: 12, stiffness: 250 },
});
return (
<span
key={i}
style={{
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 900,
color: "white",
textTransform: "uppercase",
letterSpacing: 2,
transform: `scale(${interpolate(prog, [0, 1], [0.5, 1])}) translateY(${interpolate(prog, [0, 1], [20, 0])}px)`,
opacity: interpolate(prog, [0, 0.5], [0, 1], {
extrapolateRight: "clamp",
}),
textShadow:
"0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
lineHeight: 1.0,
}}
>
{word}
</span>
);
})}
</div>
);
};
/** Whisper - small italic text that fades in gently */
const WhisperCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const opacity = interpolate(localFrame, [0, fps * 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 400,
fontStyle: "italic",
color: "rgba(255,255,255,0.8)",
textAlign: "center",
opacity,
letterSpacing: 1,
textShadow: "0 2px 12px rgba(0,0,0,0.6)",
}}
>
{text}
</span>
);
};
/** Shake - text shakes briefly on entrance then settles */
const ShakeCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const prog = spring({
frame: localFrame,
fps,
config: { damping: 8, stiffness: 300 },
});
// Shake offsets that decay over ~5 frames
const shakeIntensity = interpolate(localFrame, [0, 5], [8, 0], {
extrapolateRight: "clamp",
});
const OFFSETS = [
{ x: -1, y: 1 },
{ x: 1, y: -1 },
{ x: -1, y: -1 },
{ x: 1, y: 1 },
{ x: 0, y: -1 },
];
const offset = OFFSETS[localFrame % OFFSETS.length];
const sx = offset.x * shakeIntensity;
const sy = offset.y * shakeIntensity;
return (
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 50,
fontWeight: 900,
color: "white",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: -0.5,
transform: `translate(${sx}px, ${sy}px) scale(${interpolate(prog, [0, 1], [1.3, 1])})`,
opacity: interpolate(prog, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
}),
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
maxWidth: 900,
lineHeight: 1.15,
}}
>
{text}
</span>
);
};
export type { CaptionEntry };

View File

@@ -1,9 +0,0 @@
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";
export { FilmGrain } from "./FilmGrain";
export { TikTokCaption } from "./TikTokCaption";
export type { CaptionEntry } from "./TikTokCaption";

View File

@@ -1,47 +0,0 @@
// 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

@@ -1,430 +0,0 @@
[
{
"id": "V03-H01",
"base": "V03",
"hook": "NOBODY planned it. So I made a poll.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "Every group chat ever" } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Built the trip in 20 sec" } },
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Dallas", "x": 45, "y": 55 }, { "name": "Houston", "x": 50, "y": 70 }, { "name": "San Antonio", "x": 38, "y": 72 }] } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which trip?", "options": [{ "label": "Texas Triangle", "votes": 4, "emoji": "🤠" }, { "label": "East Coast", "votes": 2, "emoji": "🗽" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Nobody was planning it", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "So I opened SportsTime", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "Built a route in 20 seconds", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Dropped the poll", "startSec": 8.0, "endSec": 9.8, "emphasis": "normal" },
{ "text": "Trip booked by lunch", "startSec": 10.3, "endSec": 12.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Nobody plans it. So I did.",
"targetLengthSec": 15,
"assets": { "screenrec": ["route-generated"], "overlay": ["chat-bubbles", "vote-bubbles"] }
},
{
"id": "V10-H01",
"base": "V10",
"hook": "If your group chat needs 200 messages… use this.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "200 messages later…", "groupName": "Trip Planning 🏈", "groupSize": 5 } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "One app. One route." } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Dallas or Houston first?", "options": [{ "label": "Dallas first", "votes": 3, "emoji": "⭐" }, { "label": "Houston first", "votes": 2, "emoji": "🚀" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Your group chat after someone says 'trip'", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
{ "text": "200 messages… zero plans", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
{ "text": "Or just use this", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Route built. Poll sent.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Trip decided in 3 minutes", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Two hundred messages or one app. Your call.",
"targetLengthSec": 13
},
{
"id": "V03-H02",
"base": "V03",
"hook": "Every sports trip starts with 'we should do this' 🤡",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "🤡" } },
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "The cycle continues…", "messages": [{ "sender": "Jake", "text": "We should do a baseball trip", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "Bro YES", "isMe": false, "delaySec": 0.5 }, { "sender": "Sam", "text": "When?", "isMe": false, "delaySec": 0.8 }, { "sender": "Jake", "text": "Idk someone plan it", "isMe": false, "delaySec": 1.2 }, { "sender": "You", "text": "Fine. I'll do it.", "isMe": true, "delaySec": 1.7 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Picked dates + sports" } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which route?", "options": [{ "label": "LA → SD", "votes": 3, "emoji": "🌴" }, { "label": "NY → BOS", "votes": 2, "emoji": "🗽" }, { "label": "CHI → DET", "votes": 1, "emoji": "🌊" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Every. Single. Time.", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "'Someone plan it'", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Fine. I opened SportsTime.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "3 options. 1 poll.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Trip planned in 2 min", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Every trip starts the same way. End it different.",
"targetLengthSec": 13
},
{
"id": "V10-H02",
"base": "V10",
"hook": "3 trip options. 1 vote. Done.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.0 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Generated 3 routes" } },
{ "type": "POLL", "durationSec": 3.0, "props": { "question": "Pick our trip", "options": [{ "label": "Texas Triangle 🤠", "votes": 4, "emoji": "🤠" }, { "label": "Cali Coast 🌴", "votes": 3, "emoji": "🌴" }, { "label": "Northeast 🗽", "votes": 1, "emoji": "🗽" }] } },
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "Group chat: decided", "messages": [{ "sender": "Jake", "text": "Texas it is!!", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "LETS GO 🤠", "isMe": false, "delaySec": 0.5 }, { "sender": "You", "text": "Booked ✅", "isMe": true, "delaySec": 0.9 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "3 trip options", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
{ "text": "Generated in seconds", "startSec": 2.3, "endSec": 4.3, "emphasis": "normal" },
{ "text": "1 vote to decide", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Done. Trip booked.", "startSec": 7.5, "endSec": 9.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Three options. One vote. Done.",
"targetLengthSec": 13
},
{
"id": "V03-H03",
"base": "V03",
"hook": "All talk → I shipped the itinerary.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "3 months of 'we should'", "messages": [{ "sender": "Jake", "text": "We should do a trip", "isMe": false, "delaySec": 0.15 }, { "sender": "Mike", "text": "For sure", "isMe": false, "delaySec": 0.4 }, { "sender": "Sam", "text": "Down", "isMe": false, "delaySec": 0.6 }] } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "So I just did it" } },
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 54, "y": 30 }, { "name": "Detroit", "x": 62, "y": 33 }] } },
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "We going?", "options": [{ "label": "YES 🔥", "votes": 4, "emoji": "🔥" }, { "label": "Can't make it", "votes": 0, "emoji": "😢" }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "3 months of all talk", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
{ "text": "'We should do a trip' — nobody moves", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
{ "text": "So I opened SportsTime", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Built the route, mapped the games", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Shipped the itinerary", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" },
{ "text": "4-0 vote. We're going.", "startSec": 12.5, "endSec": 14.0, "emphasis": "highlight" }
],
"cta": "Search SportsTime on the App Store",
"vo": "All talk. Until I shipped the itinerary.",
"targetLengthSec": 15
},
{
"id": "V17-H01",
"base": "V17",
"hook": "The spreadsheet era is over.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Pick dates → pick sports" } },
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "No spreadsheet needed.", "variant": "slam" } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Route + games auto-matched" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Stop planning trips in spreadsheets", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "Pick your dates and sports", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "No spreadsheet needed", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Route + games auto-matched", "startSec": 8.0, "endSec": 10.5, "emphasis": "normal" },
{ "text": "Done in 20 seconds", "startSec": 11.0, "endSec": 12.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Spreadsheets are over. This is how you plan now.",
"targetLengthSec": 13
},
{
"id": "V17-H02",
"base": "V17",
"hook": "I used to waste 2 hours. Now it's 20 seconds.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "2 hours → 20 seconds", "variant": "split" } },
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/by-games.mp4", "caption": "The whole flow" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Planning a sports trip used to take hours", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
{ "text": "2 hours → 20 seconds", "startSec": 2.8, "endSec": 4.5, "emphasis": "highlight" },
{ "text": "Pick sports. Pick dates. Get a route.", "startSec": 5.0, "endSec": 7.5, "emphasis": "normal" },
{ "text": "That's literally it", "startSec": 8.0, "endSec": 9.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Two hours of Googling or twenty seconds. You pick.",
"targetLengthSec": 12
},
{
"id": "V06-H01",
"base": "V06",
"hook": "Plan a multi-game weekend in 3 taps.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.0 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Tap 1: Dates" } },
{ "type": "TEXTPUNCH", "durationSec": 1.5, "props": { "text": "3 taps. Multiple games.", "variant": "slam" } },
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Tap 3: Route" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Multi-game weekend?", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
{ "text": "Tap 1: Pick your dates", "startSec": 2.3, "endSec": 4.0, "emphasis": "normal" },
{ "text": "Tap 2: Pick your sports", "startSec": 4.3, "endSec": 5.5, "emphasis": "normal" },
{ "text": "Tap 3: Route generated", "startSec": 6.0, "endSec": 8.0, "emphasis": "highlight" },
{ "text": "That easy", "startSec": 8.5, "endSec": 10.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Three taps. Multi-game weekend. Planned.",
"targetLengthSec": 12
},
{
"id": "V08-H01",
"base": "V08",
"hook": "Already driving? Add games without chaos.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Add a game stop mid-drive" } },
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Rerouted", "cities": [{ "name": "Start", "x": 30, "y": 40 }, { "name": "Game Stop", "x": 50, "y": 50 }, { "name": "Destination", "x": 70, "y": 40 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Already on the road?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "Add a game stop without the chaos", "startSec": 2.8, "endSec": 5.0, "emphasis": "normal" },
{ "text": "Route adjusts automatically", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Detour = more games", "startSec": 8.0, "endSec": 10.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Mid-drive game stop. No chaos.",
"targetLengthSec": 13
},
{
"id": "V05-LA-01",
"base": "V05",
"hook": "LA this weekend? Here's a REAL 2-game run.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim", "cities": [{ "name": "Dodger Stadium", "x": 40, "y": 45 }, { "name": "Angel Stadium", "x": 55, "y": 60 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Full weekend itinerary" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "LA this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Dodgers Friday → Angels Saturday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "30 min drive between stadiums", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "2-game weekend locked", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "SportsTime planned it all", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "LA two-game weekend. Planned in seconds.",
"targetLengthSec": 13
},
{
"id": "V05-NY-01",
"base": "V05",
"hook": "NYC this weekend? You can hit 2 games.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NYC → Jersey", "cities": [{ "name": "Yankee Stadium", "x": 48, "y": 35 }, { "name": "MetLife Stadium", "x": 42, "y": 42 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Weekend mapped out" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "NYC this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Yankees Saturday → Giants Sunday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Both games. One weekend.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Route optimized automatically", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "That's the move", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "NYC two-gamer. Hit both without the hassle.",
"targetLengthSec": 13
},
{
"id": "V05-TX-01",
"base": "V05",
"hook": "Texas road trip? Dallas → Houston + games.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Texas Triangle", "cities": [{ "name": "Dallas", "x": 45, "y": 38 }, { "name": "Austin", "x": 42, "y": 58 }, { "name": "Houston", "x": 55, "y": 60 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "3 cities. 4 games." } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Texas road trip?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Dallas → Austin → Houston", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "4 games along the route", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Cowboys. Longhorns. Astros. Rockets.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "All planned in one app", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Texas Triangle. Four games. One route.",
"targetLengthSec": 13
},
{
"id": "V05-CA-01",
"base": "V05",
"hook": "California weekend? SF → Sac → Bay games.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NorCal Run", "cities": [{ "name": "San Francisco", "x": 20, "y": 42 }, { "name": "Sacramento", "x": 30, "y": 35 }, { "name": "Oakland", "x": 22, "y": 44 }] } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Bay Area weekend" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "California this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "SF → Sacramento → back to the Bay", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Warriors. Kings. Giants.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "3 games in one weekend", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "Route mapped automatically", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "NorCal three-game run. SportsTime planned it.",
"targetLengthSec": 13
},
{
"id": "V08-LA-01",
"base": "V08",
"hook": "LA → San Diego drive? Add a game stop.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Found a game on the way" } },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim → San Diego", "cities": [{ "name": "LA", "x": 25, "y": 35 }, { "name": "Anaheim", "x": 35, "y": 48 }, { "name": "San Diego", "x": 30, "y": 70 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Driving LA to San Diego?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "There's a game on the way", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "Angels game in Anaheim = perfect pit stop", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "Then Padres in SD", "startSec": 8.0, "endSec": 9.5, "emphasis": "normal" },
{ "text": "2 games. 1 drive.", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "LA to San Diego. Two games along the way.",
"targetLengthSec": 13
},
{
"id": "V04-H01",
"base": "V04",
"hook": "My friend: 4 stadiums. Me: 27.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 27, "totalStadiums": 120, "caption": "And counting" } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Track every stadium" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "My friend: 4 stadiums", "startSec": 0.3, "endSec": 1.5, "emphasis": "normal" },
{ "text": "Me: 27 and counting", "startSec": 1.8, "endSec": 3.5, "emphasis": "highlight" },
{ "text": "Every stadium tracked", "startSec": 4.0, "endSec": 6.0, "emphasis": "normal" },
{ "text": "MLB. NFL. NBA. NHL.", "startSec": 6.5, "endSec": 8.0, "emphasis": "bold" },
{ "text": "What's your count?", "startSec": 8.5, "endSec": 10.0, "emphasis": "normal" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Four stadiums? That's cute.",
"targetLengthSec": 13
},
{
"id": "V20-H01",
"base": "V20",
"hook": "No tracking = no bucket list.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 15, "totalStadiums": 120, "caption": "Start tracking" } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Every visit logged" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "You've been to how many stadiums?", "startSec": 0.3, "endSec": 2.0, "emphasis": "normal" },
{ "text": "But you're not tracking them?", "startSec": 2.3, "endSec": 4.0, "emphasis": "bold" },
{ "text": "Start your stadium bucket list", "startSec": 4.5, "endSec": 6.5, "emphasis": "highlight" },
{ "text": "Every visit. Every league.", "startSec": 7.0, "endSec": 9.0, "emphasis": "normal" },
{ "text": "How many can you hit?", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "If you're not tracking, it doesn't count.",
"targetLengthSec": 13
},
{
"id": "V14-H01",
"base": "V14",
"hook": "Trying to hit every stadium before 35.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 42, "totalStadiums": 120, "caption": "42 down, 78 to go" } },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Next trip: Midwest", "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 53, "y": 30 }, { "name": "Minneapolis", "x": 45, "y": 22 }, { "name": "Kansas City", "x": 42, "y": 45 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Every stadium before 35", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "42 down. 78 to go.", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
{ "text": "Planning the next run", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
{ "text": "Midwest loop: 4 new stadiums", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
{ "text": "The bucket list is real", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Every stadium before thirty-five. Clock's ticking.",
"targetLengthSec": 13
},
{
"id": "V04-H02",
"base": "V04",
"hook": "Drop your stadium count. I'll wait.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "👀" } },
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 31, "totalStadiums": 120, "caption": "What's yours?" } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Drop your stadium count", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
{ "text": "I'll wait 👀", "startSec": 2.0, "endSec": 3.5, "emphasis": "normal" },
{ "text": "Mine: 31 across 4 leagues", "startSec": 3.8, "endSec": 5.5, "emphasis": "highlight" },
{ "text": "Track yours", "startSec": 5.8, "endSec": 7.0, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Drop your count. I'll wait.",
"targetLengthSec": 12
},
{
"id": "V02-H01",
"base": "V02",
"hook": "If you've never done an away-game trip…",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team on the road" } },
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Away game route", "cities": [{ "name": "Home", "x": 35, "y": 40 }, { "name": "Away Game 1", "x": 55, "y": 35 }, { "name": "Away Game 2", "x": 70, "y": 42 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Never done an away-game trip?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
{ "text": "Follow your team on the road", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
{ "text": "SportsTime finds the games + builds the route", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
{ "text": "New city. Your team. Road trip.", "startSec": 8.0, "endSec": 10.0, "emphasis": "normal" },
{ "text": "Start with one trip", "startSec": 10.5, "endSec": 11.5, "emphasis": "bold" }
],
"cta": "Search SportsTime on the App Store",
"vo": "If you've never done an away game trip, start here.",
"targetLengthSec": 13
},
{
"id": "V19-H01",
"base": "V19",
"hook": "Hot take: away games are the real fandom.",
"scenes": [
{ "type": "HOOK", "durationSec": 2.5 },
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "Home games are easy mode.", "variant": "slam" } },
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team anywhere" } },
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Away game road trip", "cities": [{ "name": "Your City", "x": 30, "y": 45 }, { "name": "Rival City", "x": 60, "y": 35 }, { "name": "Another Stop", "x": 75, "y": 50 }] } },
{ "type": "CTA", "durationSec": 2.0 }
],
"captions": [
{ "text": "Hot take incoming 🔥", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
{ "text": "Home games are easy mode", "startSec": 2.5, "endSec": 4.3, "emphasis": "highlight" },
{ "text": "Real fans follow the team on the road", "startSec": 4.8, "endSec": 6.8, "emphasis": "normal" },
{ "text": "Plan the away game trip", "startSec": 7.3, "endSec": 9.0, "emphasis": "normal" },
{ "text": "Route + games + drive times", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" },
{ "text": "Prove you're a real one", "startSec": 11.5, "endSec": 13.0, "emphasis": "highlight" }
],
"cta": "Search SportsTime on the App Store",
"vo": "Away games are the real fandom. Prove it.",
"targetLengthSec": 15
}
]

View File

@@ -1,180 +0,0 @@
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 type { VideoConfig, SceneConfig } from "./types";
import {
HookCard,
ChatScene,
ScreenRecScene,
MapScene,
PollScene,
FlexScene,
TextPunchScene,
CTAEndCard,
KineticCaption,
} from "./scenes";
type VideoFromConfigProps = {
config: VideoConfig;
};
/**
* Config-driven video renderer.
*
* Reads a VideoConfig and renders each scene in order using TransitionSeries.
* Kinetic captions overlay on top of all scenes.
*/
export const VideoFromConfig: React.FC<VideoFromConfigProps> = ({ config }) => {
const { fps } = useVideoConfig();
const TRANSITION_FRAMES = 8; // Snappy transitions for TikTok pace
const renderScene = (scene: SceneConfig, index: number) => {
const props = scene.props || {};
switch (scene.type) {
case "HOOK":
return <HookCard hookText={config.hook} emoji={props.emoji as string} />;
case "CHAT":
return (
<ChatScene
groupName={props.groupName as string}
groupSize={props.groupSize as number}
messages={props.messages as any[]}
overlayText={props.overlayText as string}
/>
);
case "SCREENREC":
return (
<ScreenRecScene
assetKey={props.assetKey as string}
caption={props.caption as string}
showFrame={props.showFrame as boolean}
phoneScale={props.phoneScale as number}
videoStartSec={props.videoStartSec as number}
/>
);
case "MAP":
return (
<MapScene
cities={props.cities as any[]}
caption={props.caption as string}
routeColor={props.routeColor as string}
/>
);
case "POLL":
return (
<PollScene
question={props.question as string}
options={props.options as any[]}
caption={props.caption as string}
/>
);
case "FLEX":
return (
<FlexScene
stadiumCount={props.stadiumCount as number}
totalStadiums={props.totalStadiums as number}
caption={props.caption as string}
leagues={props.leagues as string[]}
/>
);
case "TEXTPUNCH":
return (
<TextPunchScene
text={props.text as string}
subtext={props.subtext as string}
variant={props.variant as "slam" | "typewriter" | "split"}
/>
);
case "CTA":
return (
<CTAEndCard
ctaText={config.cta}
tagline={props.tagline as string}
/>
);
default:
return (
<AbsoluteFill
style={{
background: "#0A0A0A",
justifyContent: "center",
alignItems: "center",
}}
>
<span style={{ color: "red", fontSize: 32 }}>
Unknown scene: {scene.type}
</span>
</AbsoluteFill>
);
}
};
// Choose transition type based on scene pair
const getTransition = (fromType: SceneConfig["type"], toType: SceneConfig["type"]) => {
// Slide in for screen recordings (feels like opening an app)
if (toType === "SCREENREC") {
return slide({ direction: "from-right" });
}
// Slide for map reveals
if (toType === "MAP") {
return slide({ direction: "from-bottom" });
}
// Default: fade
return fade();
};
const scenes = config.scenes;
const numTransitions = scenes.length - 1;
return (
<AbsoluteFill>
{/* Scene layer with transitions */}
<TransitionSeries>
{scenes.map((scene, index) => {
const durationFrames = Math.round(scene.durationSec * fps);
const elements: React.ReactNode[] = [];
elements.push(
<TransitionSeries.Sequence
key={`scene-${index}`}
durationInFrames={durationFrames}
>
{renderScene(scene, index)}
</TransitionSeries.Sequence>
);
// Add transition between scenes (not after the last one)
if (index < numTransitions) {
const nextScene = scenes[index + 1];
elements.push(
<TransitionSeries.Transition
key={`trans-${index}`}
presentation={getTransition(scene.type, nextScene.type)}
timing={linearTiming({ durationInFrames: TRANSITION_FRAMES })}
/>
);
}
return elements;
})}
</TransitionSeries>
{/* Caption overlay on top of everything */}
{config.captions.length > 0 && (
<KineticCaption captions={config.captions} />
)}
</AbsoluteFill>
);
};

View File

@@ -1,9 +0,0 @@
export { VideoFromConfig } from "./VideoFromConfig";
export type {
VideoConfig,
SceneConfig,
SceneType,
CaptionLine,
Week1Configs,
} from "./types";
export { ASSET_KEYS } from "./types";

View File

@@ -1,273 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type CTAEndCardProps = {
/** CTA text line (should include "Search SportsTime") */
ctaText?: string;
/** Optional tagline above CTA */
tagline?: string;
};
export const CTAEndCard: React.FC<CTAEndCardProps> = ({
ctaText = "Search SportsTime on the App Store",
tagline,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// --- App icon animation (scale + bounce) ---
const iconSpring = spring({
frame,
fps,
config: { damping: 15, stiffness: 100 },
});
const iconScale = interpolate(iconSpring, [0, 1], [0.3, 1]);
const iconOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
// --- Wordmark fades in with the icon ---
const wordmarkOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
// --- Tagline animation (0.4s delay) ---
const taglineDelay = Math.round(0.4 * fps);
const taglineSpring = spring({
frame: frame - taglineDelay,
fps,
config: theme.animation.snappy,
});
const taglineOpacity = interpolate(taglineSpring, [0, 1], [0, 1]);
const taglineTranslateY = interpolate(taglineSpring, [0, 1], [20, 0]);
// --- Search bar animation (0.5s delay) ---
const searchBarDelay = Math.round(0.5 * fps);
const searchBarSpring = spring({
frame: frame - searchBarDelay,
fps,
config: theme.animation.snappy,
});
const searchBarOpacity = interpolate(searchBarSpring, [0, 1], [0, 1]);
const searchBarTranslateY = interpolate(searchBarSpring, [0, 1], [30, 0]);
// --- Pulse/glow on search bar border (breathing orange glow) ---
const pulseSpeed = 1.5; // seconds per cycle
const pulseCycle = (frame / fps) * (1 / pulseSpeed) * Math.PI * 2;
const pulseIntensity = (Math.sin(pulseCycle) + 1) / 2; // 0 to 1
const glowAlpha = interpolate(pulseIntensity, [0, 1], [0.1, 0.5]);
const borderAlpha = interpolate(pulseIntensity, [0, 1], [0.2, 0.5]);
// --- "Available on the App Store" text (0.7s delay) ---
const availableDelay = Math.round(0.7 * fps);
const availableSpring = spring({
frame: frame - availableDelay,
fps,
config: theme.animation.smooth,
});
const availableOpacity = interpolate(availableSpring, [0, 1], [0, 1]);
return (
<AbsoluteFill>
{/* Background gradient */}
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
}}
/>
{/* Center content: icon + wordmark + tagline */}
<AbsoluteFill
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
{/* App icon */}
<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",
transform: `scale(${iconScale})`,
opacity: iconOpacity,
}}
>
{/* Simple stadium SVG icon: ellipse base + arch + flag */}
<svg
width={100}
height={100}
viewBox="0 0 100 100"
fill="none"
>
{/* Stadium base ellipse */}
<ellipse
cx={50}
cy={70}
rx={36}
ry={12}
stroke="white"
strokeWidth={3.5}
fill="none"
/>
{/* Stadium arch */}
<path
d="M 20 70 Q 20 28 50 28 Q 80 28 80 70"
stroke="white"
strokeWidth={3.5}
fill="none"
strokeLinecap="round"
/>
{/* Flag pole */}
<line
x1={50}
y1={28}
x2={50}
y2={12}
stroke="white"
strokeWidth={3}
strokeLinecap="round"
/>
{/* Flag */}
<path
d="M 50 12 L 62 17 L 50 22"
stroke="white"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* "SportsTime" wordmark */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -2,
marginTop: 32,
opacity: wordmarkOpacity,
}}
>
SportsTime
</div>
{/* Tagline (optional) */}
{tagline && (
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 36,
fontWeight: 500,
color: theme.colors.textSecondary,
marginTop: 16,
opacity: taglineOpacity,
transform: `translateY(${taglineTranslateY}px)`,
}}
>
{tagline}
</div>
)}
</AbsoluteFill>
{/* CTA search bar - positioned near bottom */}
<div
style={{
position: "absolute",
bottom: 200,
left: 0,
right: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
opacity: searchBarOpacity,
transform: `translateY(${searchBarTranslateY}px)`,
}}
>
{/* Search bar container */}
<div
style={{
width: 800,
height: 80,
borderRadius: 40,
background: "rgba(255, 255, 255, 0.1)",
border: `1.5px solid rgba(255, 255, 255, ${borderAlpha})`,
boxShadow: `0 0 ${interpolate(pulseIntensity, [0, 1], [10, 30])}px rgba(255, 107, 53, ${glowAlpha})`,
display: "flex",
alignItems: "center",
paddingLeft: 28,
paddingRight: 28,
gap: 16,
}}
>
{/* Search icon (magnifying glass SVG) */}
<svg
width={28}
height={28}
viewBox="0 0 24 24"
fill="none"
style={{ flexShrink: 0 }}
>
<circle
cx={10.5}
cy={10.5}
r={7}
stroke={theme.colors.textSecondary}
strokeWidth={2}
/>
<line
x1={15.5}
y1={15.5}
x2={21}
y2={21}
stroke={theme.colors.textSecondary}
strokeWidth={2}
strokeLinecap="round"
/>
</svg>
{/* CTA text */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 28,
fontWeight: 500,
color: theme.colors.textSecondary,
whiteSpace: "nowrap",
}}
>
{ctaText}
</span>
</div>
{/* "Available on the App Store" text below search bar */}
<div
style={{
marginTop: 20,
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 400,
color: theme.colors.textMuted,
opacity: availableOpacity,
}}
>
Available on the App Store
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -1,423 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type ChatMessage = {
sender: string;
text: string;
isMe: boolean;
delaySec: number;
};
type ChatSceneProps = {
/** Group name shown in header */
groupName?: string;
/** Number of people in group */
groupSize?: number;
/** Messages to display */
messages?: ChatMessage[];
/** Overlay caption text shown at top */
overlayText?: string;
};
const DEFAULT_MESSAGES: ChatMessage[] = [
{ sender: "Jake", text: "We should do a baseball trip", isMe: false, delaySec: 0.15 },
{ sender: "Mike", text: "I'm down", isMe: false, delaySec: 0.5 },
{ sender: "Sam", text: "Same", isMe: false, delaySec: 0.75 },
{ sender: "You", text: "Let's goooo", isMe: true, delaySec: 1.0 },
{ sender: "Jake", text: "When tho", isMe: false, delaySec: 1.4 },
{ sender: "Mike", text: "idk lol", isMe: false, delaySec: 1.7 },
{ sender: "Sam", text: "Maybe June?", isMe: false, delaySec: 2.0 },
{ sender: "Jake", text: "Or July", isMe: false, delaySec: 2.25 },
];
const SENDER_COLORS = ["#34C759", "#FF9500", "#AF52DE", "#FF2D55"];
/**
* Assigns a stable color to each unique sender name (excluding "me" messages).
*/
const buildSenderColorMap = (messages: ChatMessage[]): Record<string, string> => {
const map: Record<string, string> = {};
let colorIndex = 0;
for (const msg of messages) {
if (!msg.isMe && !(msg.sender in map)) {
map[msg.sender] = SENDER_COLORS[colorIndex % SENDER_COLORS.length];
colorIndex++;
}
}
return map;
};
/**
* Typing indicator with 3 bouncing dots.
*/
const TypingIndicator: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
const dots = [0, 1, 2];
// Each dot bounces on a ~0.6s cycle, staggered by ~0.15s
const cycleDuration = 0.6 * fps;
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
flexDirection: "column",
}}
>
<div
style={{
background: "#2C2C2E",
borderRadius: 20,
borderBottomLeftRadius: 6,
padding: "14px 20px",
display: "flex",
gap: 6,
alignItems: "center",
}}
>
{dots.map((i) => {
const stagger = i * 0.15 * fps;
const cycleFrame = (frame + stagger) % cycleDuration;
const bounceProgress = Math.sin((cycleFrame / cycleDuration) * Math.PI);
const translateY = interpolate(bounceProgress, [0, 1], [0, -8]);
return (
<div
key={i}
style={{
width: 10,
height: 10,
borderRadius: 5,
background: "rgba(255, 255, 255, 0.4)",
transform: `translateY(${translateY}px)`,
}}
/>
);
})}
</div>
</div>
);
};
export const ChatScene: React.FC<ChatSceneProps> = ({
groupName = "The Boys",
groupSize = 4,
messages = DEFAULT_MESSAGES,
overlayText,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const senderColorMap = buildSenderColorMap(messages);
// Subtle zoom-in over the full scene duration
const chatZoom = interpolate(frame, [0, durationInFrames], [1.0, 1.04], {
extrapolateRight: "clamp",
});
// Determine when the last message appears to show typing indicator after
const lastMessageDelaySec = messages.length > 0
? Math.max(...messages.map((m) => m.delaySec))
: 0;
const typingStartFrame = (lastMessageDelaySec + 0.4) * fps;
const typingProgress = spring({
frame: frame - typingStartFrame,
fps,
config: { damping: 14, stiffness: 200 },
});
const typingScale = interpolate(typingProgress, [0, 1], [0.3, 1]);
const typingOpacity = interpolate(typingProgress, [0, 1], [0, 1]);
// Overlay text animation (springs in at 0.3s)
const overlayDelay = 0.3 * fps;
const overlayProgress = spring({
frame: frame - overlayDelay,
fps,
config: { damping: 14, stiffness: 180 },
});
const overlayOpacity = interpolate(
frame - overlayDelay,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const overlayScale = interpolate(overlayProgress, [0, 1], [0.85, 1]);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* Zoomable chat container */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: `scale(${chatZoom})`,
transformOrigin: "center 40%",
}}
>
{/* Status bar */}
<div
style={{
padding: "16px 32px 12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: "white",
fontWeight: 600,
}}
>
9:41
</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{/* Signal bars */}
<div style={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
{[6, 8, 10, 12].map((h, i) => (
<div
key={i}
style={{
width: 3,
height: h,
background: "white",
borderRadius: 1,
}}
/>
))}
</div>
{/* Battery icon */}
<div
style={{
width: 24,
height: 12,
border: "1.5px solid white",
borderRadius: 3,
marginLeft: 4,
position: "relative",
}}
>
<div
style={{
width: "70%",
height: "100%",
background: "white",
borderRadius: 1.5,
}}
/>
<div
style={{
position: "absolute",
right: -5,
top: 3,
width: 3,
height: 6,
background: "white",
borderRadius: "0 2px 2px 0",
}}
/>
</div>
</div>
</div>
{/* Chat header */}
<div
style={{
padding: "8px 32px 16px",
borderBottom: "1px solid rgba(255,255,255,0.1)",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{/* Group avatar with gradient */}
<div
style={{
width: 44,
height: 44,
borderRadius: 22,
background: "linear-gradient(135deg, #34C759, #007AFF)",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 700,
color: "white",
}}
>
{groupSize}
</span>
</div>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: "white",
}}
>
{groupName}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: "rgba(255,255,255,0.5)",
}}
>
{groupSize} people
</div>
</div>
</div>
{/* Messages */}
<div
style={{
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{messages.map((msg, index) => {
const msgDelayFrames = msg.delaySec * fps;
const msgProgress = spring({
frame: frame - msgDelayFrames,
fps,
config: { damping: 14, stiffness: 200 },
});
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
if (frame < msgDelayFrames) return null;
const senderColor = msg.isMe ? "#007AFF" : (senderColorMap[msg.sender] || SENDER_COLORS[0]);
return (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.isMe ? "flex-end" : "flex-start",
transform: `scale(${msgScale})`,
opacity: msgOpacity,
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
}}
>
{/* Sender name (only for non-"me" messages) */}
{!msg.isMe && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: senderColor,
marginBottom: 2,
marginLeft: 12,
}}
>
{msg.sender}
</span>
)}
{/* Message bubble */}
<div
style={{
background: msg.isMe ? "#007AFF" : "#2C2C2E",
borderRadius: 20,
borderBottomRightRadius: msg.isMe ? 6 : 20,
borderBottomLeftRadius: msg.isMe ? 20 : 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: "white",
lineHeight: 1.3,
}}
>
{msg.text}
</span>
</div>
</div>
);
})}
{/* Typing indicator (appears after last message) */}
{frame >= typingStartFrame && (
<div
style={{
transform: `scale(${typingScale})`,
opacity: typingOpacity,
transformOrigin: "left bottom",
}}
>
<TypingIndicator frame={frame} fps={fps} />
</div>
)}
</div>
</div>
{/* Overlay text pill badge */}
{overlayText && (
<div
style={{
position: "absolute",
top: 100,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${overlayScale})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.7)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
padding: "16px 40px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,0.15)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 800,
color: "white",
letterSpacing: -0.5,
}}
>
{overlayText}
</span>
</div>
</div>
)}
</AbsoluteFill>
);
};

View File

@@ -1,346 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type FlexSceneProps = {
/** Number of stadiums visited */
stadiumCount?: number;
/** Total stadiums possible */
totalStadiums?: number;
/** Caption text */
caption?: string;
/** League labels to show */
leagues?: string[];
};
export const FlexScene: React.FC<FlexSceneProps> = ({
stadiumCount = 27,
totalStadiums = 120,
caption,
leagues = ["MLB", "NFL", "NBA", "NHL"],
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// --- Stadium icon dots (arc above counter) ---
const totalDots = 8;
const visitedDots = Math.round(
(stadiumCount / totalStadiums) * totalDots
);
const dotsAppearFrame = Math.round(0.2 * fps);
const dotsOpacity = interpolate(
frame,
[dotsAppearFrame, dotsAppearFrame + Math.round(0.3 * fps)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// --- Counter animation ---
// Count up over ~1.5s
const countDuration = Math.round(1.5 * fps);
const rawCount = interpolate(frame, [0, countDuration], [0, stadiumCount], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const displayCount = Math.round(rawCount);
// Counter scale spring
const counterScaleProgress = spring({
frame,
fps,
config: theme.animation.bouncy,
});
const counterScale = interpolate(
counterScaleProgress,
[0, 1],
[0.5, 1]
);
// --- Progress bar ---
// Starts animating after counter reaches target
const progressBarDelay = countDuration + Math.round(0.1 * fps);
const progressBarProgress = spring({
frame: frame - progressBarDelay,
fps,
config: theme.animation.smooth,
});
const progressBarWidth = interpolate(
progressBarProgress,
[0, 1],
[0, (stadiumCount / totalStadiums) * 100]
);
// --- League badges ---
// Spring in staggered, starting 0.3s after progress bar delay
const badgesBaseDelay = progressBarDelay + Math.round(0.3 * fps);
const badgeStaggerFrames = Math.round(0.1 * fps);
// --- Caption animation ---
const captionDelay = Math.round(0.15 * fps);
const captionProgress = spring({
frame: frame - captionDelay,
fps,
config: theme.animation.smooth,
});
const captionOpacity = interpolate(
frame - captionDelay,
[0, Math.round(fps * 0.25)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
return (
<AbsoluteFill>
{/* Background */}
<GradientBackground animate />
{/* Subtle gold radial glow behind counter */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
width: 800,
height: 600,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.gold}1A 0%, transparent 70%)`,
filter: "blur(60px)",
}}
/>
</AbsoluteFill>
{/* Caption badge at top */}
{caption && (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
paddingTop: 120,
zIndex: 20,
}}
>
<div
style={{
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
background: "rgba(0, 0, 0, 0.55)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
borderRadius: 999,
padding: "14px 36px",
border: "1px solid rgba(255, 255, 255, 0.15)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -0.5,
}}
>
{caption}
</span>
</div>
</AbsoluteFill>
)}
{/* Main content */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0,
}}
>
{/* Stadium icon dots arc */}
<div
style={{
position: "relative",
width: 400,
height: 60,
marginBottom: 24,
opacity: dotsOpacity,
}}
>
{Array.from({ length: totalDots }).map((_, i) => {
// Arrange in a subtle arc
const angle =
Math.PI + ((i / (totalDots - 1)) * Math.PI);
const arcWidth = 180;
const arcHeight = 30;
const cx = 200 + Math.cos(angle) * arcWidth;
const cy = 50 + Math.sin(angle) * arcHeight;
const isVisited = i < visitedDots;
return (
<div
key={i}
style={{
position: "absolute",
left: cx - 8,
top: cy - 8,
width: 16,
height: 16,
borderRadius: 8,
background: isVisited
? theme.colors.accent
: "transparent",
border: isVisited
? `2px solid ${theme.colors.accent}`
: "2px solid rgba(255, 255, 255, 0.25)",
boxShadow: isVisited
? `0 0 8px ${theme.colors.accent}80`
: "none",
}}
/>
);
})}
</div>
{/* Large counter number */}
<div
style={{
transform: `scale(${counterScale})`,
transformOrigin: "center center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 160,
fontWeight: 900,
color: theme.colors.gold,
textShadow: `0 0 40px ${theme.colors.gold}66, 0 0 80px ${theme.colors.gold}33`,
lineHeight: 1,
display: "block",
textAlign: "center",
letterSpacing: -4,
}}
>
{displayCount}
</span>
</div>
{/* "/ totalStadiums stadiums" subtitle */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 36,
color: theme.colors.textSecondary,
marginTop: 8,
fontWeight: 500,
letterSpacing: 0.5,
}}
>
/ {totalStadiums} stadiums
</span>
{/* Progress bar */}
<div
style={{
width: 800,
height: 16,
borderRadius: 8,
background: "#2C2C2E",
marginTop: 40,
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
width: `${progressBarWidth}%`,
height: "100%",
borderRadius: 8,
background: `linear-gradient(90deg, ${theme.colors.accent}, ${theme.colors.gold})`,
boxShadow: `0 0 12px ${theme.colors.accent}66`,
transition: "none",
}}
/>
</div>
{/* League badges row */}
<div
style={{
display: "flex",
gap: 16,
marginTop: 40,
justifyContent: "center",
flexWrap: "wrap",
}}
>
{leagues.map((league, index) => {
const badgeDelay = badgesBaseDelay + index * badgeStaggerFrames;
const badgeProgress = spring({
frame: frame - badgeDelay,
fps,
config: theme.animation.snappy,
});
const badgeScale = interpolate(
badgeProgress,
[0, 1],
[0.3, 1]
);
const badgeOpacity = interpolate(
badgeProgress,
[0, 1],
[0, 1]
);
return (
<div
key={league}
style={{
opacity: badgeOpacity,
transform: `scale(${badgeScale})`,
background: "#2C2C2E",
border: "1px solid rgba(255, 255, 255, 0.15)",
borderRadius: 20,
padding: "8px 20px",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 600,
color: theme.colors.text,
}}
>
{league}
</span>
</div>
);
})}
</div>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -1,180 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type HookCardProps = {
hookText: string;
/** Optional emoji shown above the hook text */
emoji?: string;
};
export const HookCard: React.FC<HookCardProps> = ({ hookText, emoji }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = hookText.split(/\s+/);
const staggerFrames = Math.round(0.06 * fps);
// Calculate when the last word finishes appearing (for the underline timing)
const lastWordStartFrame = (words.length - 1) * staggerFrames;
// Emoji spring - appears at frame 3
const emojiProgress = spring({
frame: frame - 3,
fps,
config: theme.animation.bouncy,
});
const emojiScale = interpolate(emojiProgress, [0, 1], [0.3, 2]);
const emojiOpacity = interpolate(emojiProgress, [0, 1], [0, 1]);
// Underline wipe - starts after all words have appeared, with a small buffer
const underlineDelay = lastWordStartFrame + Math.round(fps * 0.3);
const underlineProgress = spring({
frame: frame - underlineDelay,
fps,
config: theme.animation.snappy,
});
const underlineScaleX = interpolate(underlineProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill>
{/* Dark gradient background */}
<GradientBackground />
{/* Radial orange glow behind text */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
width: 900,
height: 600,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}26 0%, transparent 70%)`,
filter: "blur(40px)",
}}
/>
</AbsoluteFill>
{/* Main content container */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 900,
padding: "0 40px",
}}
>
{/* Optional emoji */}
{emoji && (
<div
style={{
fontSize: 64,
marginBottom: 32,
transform: `scale(${emojiScale})`,
opacity: emojiOpacity,
}}
>
{emoji}
</div>
)}
{/* Hook text with word-by-word reveal */}
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: "0 16px",
lineHeight: 1.25,
maxWidth: 900,
}}
>
{words.map((word, index) => {
const wordDelay = index * staggerFrames;
const wordProgress = spring({
frame: frame - wordDelay,
fps,
config: theme.animation.snappy,
});
const wordOpacity = interpolate(
frame - wordDelay,
[0, Math.round(fps * 0.15)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const wordScale = interpolate(
wordProgress,
[0, 1],
[0.7, 1.0]
);
const wordTranslateY = interpolate(
wordProgress,
[0, 1],
[40, 0]
);
return (
<span
key={index}
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 800,
color: theme.colors.text,
textAlign: "center",
opacity: wordOpacity,
transform: `scale(${wordScale}) translateY(${wordTranslateY}px)`,
display: "inline-block",
letterSpacing: -1,
}}
>
{word}
</span>
);
})}
</div>
{/* Orange underline wipe */}
<div
style={{
width: 300,
height: 5,
marginTop: 20,
borderRadius: 3,
background: theme.colors.accent,
transform: `scaleX(${underlineScaleX})`,
transformOrigin: "left",
opacity: underlineScaleX > 0 ? 1 : 0,
}}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -1,103 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import type { CaptionLine } from "../types";
type KineticCaptionProps = {
captions: CaptionLine[];
};
/**
* Kinetic caption overlay that renders on top of all scenes.
* Captions appear/disappear based on their startSec/endSec timing.
* Each caption pops in with a spring and has bold/highlight styling options.
*/
export const KineticCaption: React.FC<KineticCaptionProps> = ({ captions }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Find the currently active caption
const currentSec = frame / fps;
const activeCaptions = captions.filter(
(c) => currentSec >= c.startSec && currentSec < c.endSec
);
if (activeCaptions.length === 0) return null;
const caption = activeCaptions[activeCaptions.length - 1];
const captionStartFrame = caption.startSec * fps;
const captionEndFrame = caption.endSec * fps;
const localFrame = frame - captionStartFrame;
// Entrance spring
const enterProgress = spring({
frame: localFrame,
fps,
config: { damping: 14, stiffness: 200 },
});
const scale = interpolate(enterProgress, [0, 1], [0.7, 1]);
const enterOpacity = interpolate(enterProgress, [0, 1], [0, 1]);
// Exit fade (last 5 frames before endSec)
const exitOpacity = interpolate(
frame,
[captionEndFrame - 5, captionEndFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const opacity = Math.min(enterOpacity, exitOpacity);
const isHighlight = caption.emphasis === "highlight";
const isBold = caption.emphasis === "bold" || isHighlight;
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 220,
pointerEvents: "none",
}}
>
<div
style={{
transform: `scale(${scale})`,
opacity,
maxWidth: 900,
padding: "16px 36px",
borderRadius: 16,
background: isHighlight
? "rgba(255, 107, 53, 0.9)"
: "rgba(0, 0, 0, 0.8)",
backdropFilter: "blur(8px)",
border: isHighlight
? "none"
: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 38,
fontWeight: isBold ? 800 : 600,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.3,
letterSpacing: -0.5,
textShadow: "0 2px 8px rgba(0,0,0,0.5)",
}}
>
{caption.text}
</span>
</div>
</AbsoluteFill>
);
};

View File

@@ -1,402 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type MapCity = {
name: string;
x: number; // percentage position 0-100
y: number; // percentage position 0-100
};
type MapSceneProps = {
/** Cities to show on the map */
cities?: MapCity[];
/** Caption text at top */
caption?: string;
/** Route line color override */
routeColor?: string;
};
const DEFAULT_CITIES: MapCity[] = [
{ name: "Dallas", x: 45, y: 55 },
{ name: "Houston", x: 50, y: 70 },
{ name: "San Antonio", x: 40, y: 72 },
];
export const MapScene: React.FC<MapSceneProps> = ({
cities = DEFAULT_CITIES,
caption,
routeColor = theme.colors.mapLine,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
// Map area: center 80% of screen
const mapLeft = width * 0.1;
const mapTop = height * 0.1;
const mapWidth = width * 0.8;
const mapHeight = height * 0.8;
// Convert percentage coords to pixel coords within the map area
const points = cities.map((city) => ({
name: city.name,
px: mapLeft + (city.x / 100) * mapWidth,
py: mapTop + (city.y / 100) * mapHeight,
}));
// Timing: each segment takes ~0.5s to draw, staggered
const segmentDurationFrames = Math.round(0.5 * fps);
// Calculate total path length and per-segment lengths
const segmentLengths: number[] = [];
let totalPathLength = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i].px - points[i - 1].px;
const dy = points[i].py - points[i - 1].py;
const len = Math.sqrt(dx * dx + dy * dy);
segmentLengths.push(len);
totalPathLength += len;
}
// Build the SVG path d attribute (straight lines between cities)
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`)
.join(" ");
// Calculate cumulative lengths for each segment start
const cumulativeLengths: number[] = [0];
for (let i = 0; i < segmentLengths.length; i++) {
cumulativeLengths.push(cumulativeLengths[i] + segmentLengths[i]);
}
// Route draw animation: each segment draws over segmentDurationFrames, staggered
const getSegmentDrawProgress = (segmentIndex: number): number => {
const segmentStartFrame = segmentIndex * segmentDurationFrames;
return interpolate(
frame,
[segmentStartFrame, segmentStartFrame + segmentDurationFrames],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
};
// Calculate overall drawn length for strokeDashoffset
let drawnLength = 0;
for (let i = 0; i < segmentLengths.length; i++) {
const segProgress = getSegmentDrawProgress(i);
drawnLength += segmentLengths[i] * segProgress;
}
const strokeDashoffset = totalPathLength - drawnLength;
// Determine when each city's route segment completes (for marker appearance)
// City 0 appears when route starts drawing (frame ~0)
// City i appears when segment i-1 finishes drawing
const getCityAppearFrame = (cityIndex: number): number => {
if (cityIndex === 0) return 0;
return cityIndex * segmentDurationFrames;
};
// Traveling dot: appears after entire route is drawn, moves along path
const routeCompleteFrame =
(points.length - 1) * segmentDurationFrames;
const travelDotDelay = routeCompleteFrame + Math.round(fps * 0.3);
const travelDotDuration = Math.round(fps * 1.5);
const travelDotProgress = interpolate(
frame,
[travelDotDelay, travelDotDelay + travelDotDuration],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Calculate traveling dot position along the path
const travelDistance = travelDotProgress * totalPathLength;
let travelDotX = points[0].px;
let travelDotY = points[0].py;
if (travelDotProgress > 0) {
let accumulated = 0;
for (let i = 0; i < segmentLengths.length; i++) {
if (accumulated + segmentLengths[i] >= travelDistance) {
const segFraction =
(travelDistance - accumulated) / segmentLengths[i];
travelDotX =
points[i].px + (points[i + 1].px - points[i].px) * segFraction;
travelDotY =
points[i].py + (points[i + 1].py - points[i].py) * segFraction;
break;
}
accumulated += segmentLengths[i];
}
}
const travelDotOpacity = interpolate(
travelDotProgress,
[0, 0.02, 0.98, 1],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Caption animation
const captionProgress = caption
? spring({
frame: frame - 5,
fps,
config: theme.animation.snappy,
})
: 0;
const captionOpacity = interpolate(captionProgress, [0, 1], [0, 1]);
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
// Pulse animation for markers (repeating)
const pulseFrame = frame % Math.round(fps * 1.5);
const pulseScale = interpolate(
pulseFrame,
[0, Math.round(fps * 1.5)],
[1, 2.5],
{ extrapolateRight: "clamp" }
);
const pulseOpacity = interpolate(
pulseFrame,
[0, Math.round(fps * 1.5)],
[0.6, 0],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
}}
>
{/* Subtle dark grid pattern */}
<AbsoluteFill
style={{
backgroundImage: `
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)
`,
backgroundSize: "60px 60px",
}}
/>
{/* SVG map layer */}
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={{ position: "absolute", top: 0, left: 0 }}
>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="8" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Route glow (blurred duplicate path) */}
<path
d={pathD}
fill="none"
stroke={routeColor}
strokeWidth={12}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={totalPathLength}
strokeDashoffset={strokeDashoffset}
opacity={0.2}
filter="url(#glow)"
/>
{/* Route line (dashed, animated) */}
<path
d={pathD}
fill="none"
stroke={routeColor}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={totalPathLength}
strokeDashoffset={strokeDashoffset}
/>
{/* City markers */}
{points.map((point, index) => {
const appearFrame = getCityAppearFrame(index);
const markerSpring = spring({
frame: frame - appearFrame,
fps,
config: theme.animation.bouncy,
});
const markerScale = interpolate(
markerSpring,
[0, 1],
[0, 1]
);
const markerOpacity = interpolate(
markerSpring,
[0, 0.3],
[0, 1],
{ extrapolateRight: "clamp" }
);
// Label appears slightly after the marker
const labelDelay = appearFrame + Math.round(fps * 0.15);
const labelSpring = spring({
frame: frame - labelDelay,
fps,
config: theme.animation.snappy,
});
const labelOpacity = interpolate(
labelSpring,
[0, 1],
[0, 1]
);
const labelTranslateY = interpolate(
labelSpring,
[0, 1],
[10, 0]
);
// Only show pulse after marker is fully visible
const showPulse = markerSpring > 0.95;
return (
<g key={index}>
{/* Pulse ring (repeating, expanding, fading) */}
{showPulse && (
<circle
cx={point.px}
cy={point.py}
r={24}
fill="none"
stroke={theme.colors.mapMarker}
strokeWidth={2}
opacity={pulseOpacity}
transform={`translate(${point.px * (1 - pulseScale)}, ${point.py * (1 - pulseScale)}) scale(${pulseScale})`}
style={{ transformOrigin: `${point.px}px ${point.py}px` }}
/>
)}
{/* Outer ring */}
<circle
cx={point.px}
cy={point.py}
r={24}
fill="transparent"
stroke={theme.colors.mapMarker}
strokeWidth={3}
opacity={markerOpacity}
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
/>
{/* Inner dot */}
<circle
cx={point.px}
cy={point.py}
r={10}
fill={theme.colors.mapMarker}
opacity={markerOpacity}
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
/>
{/* City name label */}
<text
x={point.px}
y={point.py + 44}
fill={theme.colors.text}
fontSize={28}
fontWeight={700}
fontFamily={theme.fonts.display}
textAnchor="middle"
opacity={labelOpacity}
style={{
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.8))",
}}
>
<tspan dy={labelTranslateY}>{point.name}</tspan>
</text>
</g>
);
})}
{/* Traveling dot along the route */}
{travelDotProgress > 0 && travelDotProgress < 1 && (
<>
{/* Glow behind traveling dot */}
<circle
cx={travelDotX}
cy={travelDotY}
r={18}
fill={routeColor}
opacity={travelDotOpacity * 0.3}
filter="url(#glow)"
/>
{/* Traveling dot */}
<circle
cx={travelDotX}
cy={travelDotY}
r={6}
fill={routeColor}
opacity={travelDotOpacity}
/>
</>
)}
</svg>
{/* Caption pill badge at top */}
{caption && (
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.6)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
padding: "14px 36px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,0.12)",
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: theme.fontSizes.body,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: 0.5,
}}
>
{caption}
</span>
</div>
</div>
)}
</AbsoluteFill>
);
};

View File

@@ -1,408 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type PollOption = {
label: string;
votes: number;
emoji?: string;
};
type PollSceneProps = {
/** Poll question */
question?: string;
/** Poll options with vote counts */
options?: PollOption[];
/** Caption text at top */
caption?: string;
};
const DEFAULT_QUESTION = "Which trip are we doing?";
const DEFAULT_OPTIONS: PollOption[] = [
{ label: "Dallas \u2192 Houston", votes: 3, emoji: "\uD83E\uDD20" },
{ label: "NYC \u2192 Boston", votes: 2, emoji: "\uD83D\uDDFD" },
{ label: "LA \u2192 San Diego", votes: 1, emoji: "\uD83C\uDF34" },
];
const VOTER_NAMES = ["Jake", "Mike", "Sam", "Alex", "Chris"];
const FILL_COLORS = [
theme.colors.accent,
"#FF8F5E",
"#FFB088",
];
const getFillColor = (index: number): string => {
if (index < FILL_COLORS.length) return FILL_COLORS[index];
return FILL_COLORS[FILL_COLORS.length - 1];
};
export const PollScene: React.FC<PollSceneProps> = ({
question = DEFAULT_QUESTION,
options = DEFAULT_OPTIONS,
caption,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const maxVotes = Math.max(...options.map((o) => o.votes));
// Card entrance - slides up and fades in
const cardEntrance = spring({
frame,
fps,
config: theme.animation.snappy,
});
const cardTranslateY = interpolate(cardEntrance, [0, 1], [80, 0]);
const cardOpacity = interpolate(cardEntrance, [0, 1], [0, 1]);
// Caption entrance
const captionEntrance = spring({
frame: frame - 3,
fps,
config: theme.animation.snappy,
});
const captionOpacity = interpolate(captionEntrance, [0, 1], [0, 1]);
const captionTranslateY = interpolate(captionEntrance, [0, 1], [-20, 0]);
// Question text entrance
const questionEntrance = spring({
frame: frame - Math.round(fps * 0.15),
fps,
config: theme.animation.snappy,
});
const questionOpacity = interpolate(questionEntrance, [0, 1], [0, 1]);
// Bar fill delay base (after card is in)
const barStartFrame = Math.round(fps * 0.4);
// Calculate when all bars are done filling for the "Poll sent" badge
const lastBarStartFrame = barStartFrame + (options.length - 1) * Math.round(fps * 0.2);
const barFillDuration = Math.round(fps * 0.5);
const allBarsDoneFrame = lastBarStartFrame + barFillDuration + Math.round(fps * 0.3);
// "Poll sent" badge
const pollSentEntrance = spring({
frame: frame - allBarsDoneFrame,
fps,
config: theme.animation.snappy,
});
const pollSentOpacity = interpolate(pollSentEntrance, [0, 1], [0, 1]);
const pollSentScale = interpolate(pollSentEntrance, [0, 1], [0.5, 1]);
return (
<AbsoluteFill>
<GradientBackground />
{/* Caption at top */}
{caption && (
<div
style={{
position: "absolute",
top: 120,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 10,
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
}}
>
<div
style={{
padding: "14px 32px",
borderRadius: 100,
background: "rgba(255, 255, 255, 0.08)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255, 255, 255, 0.12)",
fontFamily: theme.fonts.text,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
letterSpacing: 0.5,
}}
>
{caption}
</div>
</div>
)}
{/* Poll card centered vertically */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
width: 900,
borderRadius: 24,
padding: 48,
background: "#1C1C1E",
border: "1px solid rgba(255, 255, 255, 0.1)",
opacity: cardOpacity,
transform: `translateY(${cardTranslateY}px)`,
display: "flex",
flexDirection: "column",
gap: 32,
}}
>
{/* Question */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 700,
color: theme.colors.text,
textAlign: "center",
opacity: questionOpacity,
lineHeight: 1.3,
}}
>
{question}
</div>
{/* Options */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 20,
}}
>
{options.map((option, index) => {
const staggerDelay = barStartFrame + index * Math.round(fps * 0.2);
const fillProgress = spring({
frame: frame - staggerDelay,
fps,
config: { damping: 30, stiffness: 120 },
});
const fillWidth = interpolate(
fillProgress,
[0, 1],
[0, (option.votes / maxVotes) * 100]
);
// Vote count fades in after bar fills
const voteCountDelay = staggerDelay + Math.round(fps * 0.3);
const voteCountEntrance = spring({
frame: frame - voteCountDelay,
fps,
config: theme.animation.snappy,
});
const voteCountOpacity = interpolate(
voteCountEntrance,
[0, 1],
[0, 1]
);
const isWinner = option.votes === maxVotes;
const fillColor = getFillColor(index);
return (
<div
key={index}
style={{
position: "relative",
width: "100%",
height: 72,
borderRadius: 16,
background: "#2C2C2E",
overflow: "hidden",
boxShadow: isWinner && fillProgress > 0.8
? `0 0 24px ${fillColor}40`
: "none",
}}
>
{/* Fill bar */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: `${fillWidth}%`,
borderRadius: 16,
background: fillColor,
transition: "none",
}}
/>
{/* Label and emoji */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
padding: "0 24px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{option.emoji && (
<span style={{ fontSize: 28 }}>{option.emoji}</span>
)}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 24,
fontWeight: 600,
color: theme.colors.text,
}}
>
{option.label}
</span>
</div>
{/* Vote count */}
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 600,
color: theme.colors.text,
opacity: voteCountOpacity,
whiteSpace: "nowrap",
}}
>
{option.votes} {option.votes === 1 ? "vote" : "votes"}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* "Poll sent" badge below the card */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, ${cardTranslateY + 240}px) scale(${pollSentScale})`,
opacity: pollSentOpacity,
marginTop: options.length * 46 + 60,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 28px",
borderRadius: 100,
background: `${theme.colors.success}20`,
border: `1px solid ${theme.colors.success}40`,
}}
>
<span style={{ fontSize: 20 }}>{"\u2713"}</span>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 600,
color: theme.colors.success,
}}
>
Poll sent
</span>
</div>
</div>
</AbsoluteFill>
{/* Vote notification pills - bottom right */}
<div
style={{
position: "absolute",
bottom: 160,
right: 60,
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 12,
}}
>
{VOTER_NAMES.map((name, index) => {
const notifStartFrame =
barStartFrame + index * Math.round(fps * 0.3);
const notifEndFrame = notifStartFrame + Math.round(fps * 1.0);
const enterProgress = spring({
frame: frame - notifStartFrame,
fps,
config: theme.animation.snappy,
});
const exitProgress = spring({
frame: frame - notifEndFrame,
fps,
config: theme.animation.smooth,
});
const notifOpacity = interpolate(
enterProgress,
[0, 1],
[0, 1]
) * interpolate(exitProgress, [0, 1], [1, 0]);
const notifTranslateX = interpolate(
enterProgress,
[0, 1],
[60, 0]
);
const notifScale = interpolate(enterProgress, [0, 1], [0.7, 1]);
if (notifOpacity <= 0.01) return null;
return (
<div
key={name}
style={{
padding: "10px 22px",
borderRadius: 100,
background: "rgba(255, 255, 255, 0.1)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.15)",
fontFamily: theme.fonts.text,
fontSize: 20,
fontWeight: 600,
color: theme.colors.text,
opacity: notifOpacity,
transform: `translateX(${notifTranslateX}px) scale(${notifScale})`,
whiteSpace: "nowrap",
}}
>
{name} voted!
</div>
);
})}
</div>
</AbsoluteFill>
);
};

View File

@@ -1,318 +0,0 @@
import React, { useState } from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Video,
staticFile,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { GradientBackground } from "../../components/shared/Background";
type ScreenRecSceneProps = {
/** Asset key for the screen recording, e.g. "screenrecs/date_range.mp4" */
assetKey?: string;
/** Caption text overlay at top */
caption?: string;
/** Whether to show the phone device frame */
showFrame?: boolean;
/** Scale of the phone (default 0.85) */
phoneScale?: number;
/** Optional start time offset in seconds for the video */
videoStartSec?: number;
};
const Placeholder: React.FC = () => (
<div
style={{
width: "100%",
height: "100%",
background: `linear-gradient(135deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 50%, ${theme.colors.accent}22 100%)`,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: 20,
}}
>
{/* Play icon triangle */}
<div
style={{
width: 0,
height: 0,
borderLeft: "40px solid rgba(255, 255, 255, 0.6)",
borderTop: "24px solid transparent",
borderBottom: "24px solid transparent",
marginLeft: 10,
}}
/>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: theme.fontSizes.body,
color: theme.colors.textSecondary,
fontWeight: 500,
letterSpacing: 1,
}}
>
Screen Recording
</span>
</div>
);
const ScreenVideo: React.FC<{
assetKey: string;
startFromFrame: number;
}> = ({ assetKey, startFromFrame }) => {
const [hasError, setHasError] = useState(false);
if (hasError) {
return <Placeholder />;
}
try {
const src = staticFile(assetKey);
return (
<Video
src={src}
startFrom={startFromFrame}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onError={() => setHasError(true)}
/>
);
} catch {
return <Placeholder />;
}
};
export const ScreenRecScene: React.FC<ScreenRecSceneProps> = ({
assetKey,
caption,
showFrame = true,
phoneScale = 0.85,
videoStartSec = 0,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const startFromFrame = Math.round(videoStartSec * fps);
// --- Phone entrance animations ---
// Slide up from below
const slideProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const translateY = interpolate(slideProgress, [0, 1], [200, 0]);
// Scale up
const scaleProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const scaleValue = interpolate(scaleProgress, [0, 1], [0.92, 1.0]) * phoneScale;
// --- Caption animation (0.15s delay) ---
const captionDelayFrames = Math.round(0.15 * fps);
const captionProgress = spring({
frame: frame - captionDelayFrames,
fps,
config: theme.animation.smooth,
});
const captionOpacity = interpolate(
frame - captionDelayFrames,
[0, Math.round(fps * 0.25)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
// --- Phone dimensions ---
const phoneWidth = width * 0.75;
const phoneHeight = height * 0.8;
const cornerRadius = 60;
const bezelWidth = 12;
return (
<AbsoluteFill>
{/* Background */}
<GradientBackground />
{/* Subtle orange glow behind phone */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
position: "absolute",
width: phoneWidth * 1.4,
height: phoneHeight * 0.8,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}1A 0%, transparent 70%)`,
filter: "blur(60px)",
}}
/>
</AbsoluteFill>
{/* Caption badge */}
{caption && (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
paddingTop: 100,
zIndex: 20,
}}
>
<div
style={{
opacity: captionOpacity,
transform: `translateY(${captionTranslateY}px)`,
background: "rgba(0, 0, 0, 0.55)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
borderRadius: 24,
padding: "14px 32px",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -0.5,
}}
>
{caption}
</span>
</div>
</AbsoluteFill>
)}
{/* Phone device */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
transform: `translateY(${translateY}px) scale(${scaleValue})`,
}}
>
{showFrame ? (
<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",
}}
>
{assetKey ? (
<ScreenVideo
assetKey={assetKey}
startFromFrame={startFromFrame}
/>
) : (
<Placeholder />
)}
</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>
) : (
/* No frame - just the screen content */
<div
style={{
width: phoneWidth - 40,
height: phoneHeight - 40,
borderRadius: cornerRadius - 20,
overflow: "hidden",
background: theme.colors.background,
boxShadow: "0 50px 100px rgba(0, 0, 0, 0.5)",
}}
>
{assetKey ? (
<ScreenVideo
assetKey={assetKey}
startFromFrame={startFromFrame}
/>
) : (
<Placeholder />
)}
</div>
)}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};

View File

@@ -1,463 +0,0 @@
import React, { useMemo } from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
type TextPunchSceneProps = {
/** The punch line text */
text?: string;
/** Optional secondary smaller text below */
subtext?: string;
/** Style variant */
variant?: "slam" | "typewriter" | "split";
};
// ---------------------------------------------------------------------------
// Deterministic "random" shake offsets so renders are reproducible
// ---------------------------------------------------------------------------
const SHAKE_OFFSETS: { x: number; y: number }[] = [
{ x: -6, y: 4 },
{ x: 8, y: -5 },
{ x: -3, y: 7 },
];
// ---------------------------------------------------------------------------
// Variant: slam
// ---------------------------------------------------------------------------
const SlamVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
text,
frame,
fps,
}) => {
// Main text slam spring
const slamProgress = spring({
frame,
fps,
config: { damping: 12, stiffness: 300 },
});
const textScale = interpolate(slamProgress, [0, 1], [2.0, 1.0]);
const textOpacity = interpolate(slamProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Impact frame: the frame at which scale first reaches ~1.0
// With damping 12, stiffness 300 the spring crosses 1.0 around frame 5-6
const impactFrame = 5;
// Screen shake: 3 frames starting at impact
let shakeX = 0;
let shakeY = 0;
const shakeFrame = frame - impactFrame;
if (shakeFrame >= 0 && shakeFrame < SHAKE_OFFSETS.length) {
shakeX = SHAKE_OFFSETS[shakeFrame].x;
shakeY = SHAKE_OFFSETS[shakeFrame].y;
}
// Orange accent line appears after slam lands
const lineDelay = impactFrame + 4;
const lineProgress = spring({
frame: frame - lineDelay,
fps,
config: theme.animation.snappy,
});
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
// Radial orange glow pulse on impact
const glowPeak = impactFrame;
const glowOpacity = interpolate(
frame,
[glowPeak, glowPeak + 4, glowPeak + 14],
[0, 0.45, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return (
<AbsoluteFill
style={{
transform: `translate(${shakeX}px, ${shakeY}px)`,
}}
>
{/* Radial orange glow */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
}}
>
<div
style={{
width: 900,
height: 600,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
filter: "blur(60px)",
}}
/>
</AbsoluteFill>
{/* Text + underline */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 900,
color: theme.colors.text,
textAlign: "center",
maxWidth: 850,
lineHeight: 1.15,
transform: `scale(${textScale})`,
opacity: textOpacity,
letterSpacing: -1,
}}
>
{text}
</div>
{/* Orange accent line */}
<div
style={{
width: 300,
height: 5,
marginTop: 24,
borderRadius: 3,
background: theme.colors.accent,
transform: `scaleX(${lineScaleX})`,
transformOrigin: "left",
opacity: lineScaleX > 0.01 ? 1 : 0,
}}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Variant: typewriter
// ---------------------------------------------------------------------------
const TypewriterVariant: React.FC<{
text: string;
frame: number;
fps: number;
}> = ({ text, frame, fps }) => {
// ~2 chars per frame
const charsPerFrame = 2;
const visibleChars = Math.min(frame * charsPerFrame, text.length);
const typingComplete = visibleChars >= text.length;
const typingCompleteFrame = Math.ceil(text.length / charsPerFrame);
// Cursor blink: after typing completes, blink 2x then disappear
// Each blink cycle = 8 frames on, 6 frames off
const cursorVisible = (() => {
if (!typingComplete) return true; // solid while typing
const elapsed = frame - typingCompleteFrame;
if (elapsed < 0) return true;
// 2 blink cycles: on 8, off 6, on 8, off 6 = 28 frames total
const blinkCycle = 14; // 8 on + 6 off
if (elapsed >= blinkCycle * 2) return false; // disappeared
const withinCycle = elapsed % blinkCycle;
return withinCycle < 8;
})();
// Scan line effect: thin horizontal lines scrolling down slowly
const scanLineOffset = (frame * 0.5) % 8;
return (
<AbsoluteFill>
{/* Scan line overlay */}
<AbsoluteFill
style={{
backgroundImage: `repeating-linear-gradient(
0deg,
transparent,
transparent 6px,
rgba(255, 255, 255, 0.05) 6px,
rgba(255, 255, 255, 0.05) 8px
)`,
backgroundPosition: `0 ${scanLineOffset}px`,
pointerEvents: "none",
zIndex: 10,
}}
/>
{/* Text container */}
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
maxWidth: 900,
padding: "0 60px",
display: "flex",
flexWrap: "wrap",
alignItems: "baseline",
}}
>
<span
style={{
fontFamily: "SF Mono, Menlo, monospace",
fontSize: 56,
fontWeight: 700,
color: theme.colors.text,
lineHeight: 1.3,
letterSpacing: -0.5,
whiteSpace: "pre-wrap",
}}
>
{text.slice(0, Math.floor(visibleChars))}
</span>
{/* Cursor */}
{cursorVisible && (
<span
style={{
display: "inline-block",
width: 4,
height: 56,
background: theme.colors.accent,
marginLeft: 2,
verticalAlign: "bottom",
borderRadius: 2,
}}
/>
)}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Variant: split
// ---------------------------------------------------------------------------
const SplitVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
text,
frame,
fps,
}) => {
const words = text.split(/\s+/);
const midpoint = Math.ceil(words.length / 2);
const topHalf = words.slice(0, midpoint).join(" ");
const bottomHalf = words.slice(midpoint).join(" ");
// Top half slides from left
const topProgress = spring({
frame,
fps,
config: { damping: 14, stiffness: 160 },
});
const topTranslateX = interpolate(topProgress, [0, 1], [-600, 0]);
const topOpacity = interpolate(topProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Bottom half slides from right, slight delay (3 frames)
const bottomProgress = spring({
frame: frame - 3,
fps,
config: { damping: 14, stiffness: 160 },
});
const bottomTranslateX = interpolate(bottomProgress, [0, 1], [600, 0]);
const bottomOpacity = interpolate(bottomProgress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
// Dividing line appears after both halves have settled
const lineDelay = 12;
const lineProgress = spring({
frame: frame - lineDelay,
fps,
config: theme.animation.snappy,
});
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
maxWidth: 900,
padding: "0 60px",
gap: 0,
}}
>
{/* Top half */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 800,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.2,
transform: `translateX(${topTranslateX}px)`,
opacity: topOpacity,
letterSpacing: -1,
}}
>
{topHalf}
</div>
{/* Orange dividing line */}
<div
style={{
width: 400,
height: 3,
marginTop: 20,
marginBottom: 20,
background: theme.colors.accent,
transform: `scaleX(${lineScaleX})`,
transformOrigin: "center",
opacity: lineScaleX > 0.01 ? 1 : 0,
borderRadius: 2,
}}
/>
{/* Bottom half */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 800,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.2,
transform: `translateX(${bottomTranslateX}px)`,
opacity: bottomOpacity,
letterSpacing: -1,
}}
>
{bottomHalf}
</div>
</div>
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const TextPunchScene: React.FC<TextPunchSceneProps> = ({
text = "The spreadsheet era is over.",
subtext,
variant = "slam",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Subtext fades in 0.4s after the main animation completes
const mainAnimationEndFrame = useMemo(() => {
switch (variant) {
case "slam":
// Slam lands ~5 frames, accent line ~9 frames → settled ~15
return 15;
case "typewriter": {
// Typing at 2 chars/frame + 2 blink cycles (28 frames)
const typingDone = Math.ceil(text.length / 2);
return typingDone;
}
case "split":
// Both halves settle ~15 frames, line at ~24
return 24;
default:
return 15;
}
}, [variant, text]);
const subtextDelay = mainAnimationEndFrame + Math.round(0.4 * fps);
const subtextOpacity = subtext
? interpolate(
frame - subtextDelay,
[0, Math.round(0.25 * fps)],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 0;
const subtextTranslateY = subtext
? interpolate(
frame - subtextDelay,
[0, Math.round(0.25 * fps)],
[12, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 0;
return (
<AbsoluteFill style={{ background: theme.colors.background }}>
{/* Variant content */}
{variant === "slam" && (
<SlamVariant text={text} frame={frame} fps={fps} />
)}
{variant === "typewriter" && (
<TypewriterVariant text={text} frame={frame} fps={fps} />
)}
{variant === "split" && (
<SplitVariant text={text} frame={frame} fps={fps} />
)}
{/* Subtext (shared across all variants) */}
{subtext && (
<AbsoluteFill
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
}}
>
<div
style={{
position: "absolute",
bottom: "32%",
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 500,
color: theme.colors.textSecondary,
textAlign: "center",
maxWidth: 800,
padding: "0 60px",
opacity: subtextOpacity,
transform: `translateY(${subtextTranslateY}px)`,
}}
>
{subtext}
</div>
</AbsoluteFill>
)}
</AbsoluteFill>
);
};

View File

@@ -1,9 +0,0 @@
export { HookCard } from "./HookCard";
export { ChatScene } from "./ChatScene";
export { ScreenRecScene } from "./ScreenRecScene";
export { MapScene } from "./MapScene";
export { PollScene } from "./PollScene";
export { FlexScene } from "./FlexScene";
export { TextPunchScene } from "./TextPunchScene";
export { CTAEndCard } from "./CTAEndCard";
export { KineticCaption } from "./KineticCaption";

View File

@@ -1,89 +0,0 @@
/**
* Config-driven video engine types.
*
* Each video is defined by a VideoConfig that specifies its scenes,
* captions, CTA, and timing. The VideoFromConfig component reads
* these configs and renders the appropriate scene components.
*/
export type SceneType =
| "HOOK"
| "CHAT"
| "SCREENREC"
| "MAP"
| "POLL"
| "FLEX"
| "TEXTPUNCH"
| "CTA";
export type CaptionLine = {
text: string;
/** Seconds from video start when this caption appears */
startSec: number;
/** Seconds from video start when this caption disappears */
endSec: number;
/** Optional emphasis style */
emphasis?: "normal" | "bold" | "highlight";
};
export type SceneConfig = {
type: SceneType;
/** Duration in seconds for this scene */
durationSec: number;
/** Scene-specific props passed to the scene component */
props?: Record<string, unknown>;
};
export type VideoConfig = {
/** Unique composition ID, e.g. "V03_H01" */
id: string;
/** Base video concept, e.g. "V03" */
base: string;
/** Hook text shown in the first scene */
hook: string;
/** Ordered scene list */
scenes: SceneConfig[];
/** Kinetic caption lines */
captions: CaptionLine[];
/** CTA line (must include "Search SportsTime") */
cta: string;
/** Optional voiceover line */
vo?: string;
/** Target total length in seconds (12-18) */
targetLengthSec: number;
/** Asset keys used by this video */
assets?: {
screenrec?: string[];
overlay?: string[];
broll?: string[];
};
};
export type Week1Configs = VideoConfig[];
/** Asset key registry - maps logical names to file paths in public/ */
export const ASSET_KEYS = {
screenrecs: {
"date-range": "screenrecs/date-range.mp4",
"follow-team": "screenrecs/follow-team.mp4",
"by-games": "screenrecs/by-games.mp4",
"route-generated": "screenrecs/route-generated.mp4",
"poll-create": "screenrecs/poll-create.mp4",
tracker: "screenrecs/tracker.mp4",
},
overlays: {
"imessage-bg": "overlays/imessage-bg.png",
"chat-bubbles": "overlays/chat-bubbles.png",
"vote-bubbles": "overlays/vote-bubbles.png",
},
broll: {
highway: "broll/highway.mp4",
city: "broll/city.mp4",
stadium: "broll/stadium.mp4",
},
} as const;
export type AssetCategory = keyof typeof ASSET_KEYS;
export type ScreenrecKey = keyof typeof ASSET_KEYS.screenrecs;
export type OverlayKey = keyof typeof ASSET_KEYS.overlays;
export type BrollKey = keyof typeof ASSET_KEYS.broll;

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,979 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { theme } from "../../components/shared/theme";
import { FilmGrain } from "../../components/shared/FilmGrain";
import { TikTokCaption } from "../../components/shared/TikTokCaption";
import type { CaptionEntry } from "../../components/shared/TikTokCaption";
// ---------------------------------------------------------------------------
// Captions
// ---------------------------------------------------------------------------
const CAPTIONS: CaptionEntry[] = [
{ text: "Be honest.", startSec: 0.2, endSec: 1.2, style: "punch" },
{ text: "How many stadiums?", startSec: 1.3, endSec: 2.3, style: "shake" },
{ text: "47 and counting", startSec: 3.0, endSec: 5.0, style: "highlight" },
{ text: "Every. Single. One.", startSec: 6.0, endSec: 8.0, style: "stack" },
{ text: "Track yours", startSec: 9.0, endSec: 11.0, style: "whisper" },
{ text: "DROP YOUR NUMBER", startSec: 12.0, endSec: 14.5, style: "punch" },
];
// ---------------------------------------------------------------------------
// Scene 1 : HOOK (0 - 2.5s, 75 frames)
// ---------------------------------------------------------------------------
const HookScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const WORDS = [
{ text: "Be honest.", delayFrames: 6 },
{ text: "How many stadiums", delayFrames: 30 },
{ text: "have you actually", delayFrames: 45 },
{ text: "been to?", delayFrames: 55 },
];
// Subtle background shake that decays
const shakeDecay = interpolate(frame, [0, 2 * fps], [1, 0], {
extrapolateRight: "clamp",
});
const SHAKE_OFFSETS = [
{ x: -3, y: 2 },
{ x: 4, y: -3 },
{ x: -2, y: -2 },
{ x: 3, y: 3 },
{ x: -1, y: -1 },
{ x: 2, y: 1 },
];
const shakeIdx = frame % SHAKE_OFFSETS.length;
const currentShake = SHAKE_OFFSETS[shakeIdx];
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: 60,
transform: `translate(${currentShake.x * shakeDecay}px, ${currentShake.y * shakeDecay}px)`,
}}
>
{WORDS.map((word, i) => {
const localFrame = frame - word.delayFrames;
const slamSpring = spring({
frame: Math.max(0, localFrame),
fps,
config: { damping: 10, stiffness: 280 },
});
const scale = localFrame < 0 ? 0 : interpolate(slamSpring, [0, 1], [2, 1]);
const opacity = localFrame < 0
? 0
: interpolate(slamSpring, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
fontFamily: theme.fonts.display,
fontSize: 72,
fontWeight: 900,
color: theme.colors.gold,
textAlign: "center",
lineHeight: 1.15,
letterSpacing: -2,
transform: `scale(${scale})`,
opacity,
textShadow: `0 0 40px rgba(255, 215, 0, 0.4), 0 4px 12px rgba(0,0,0,0.8)`,
}}
>
{word.text}
</div>
);
})}
</div>
<FilmGrain opacity={0.06} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 2 : THE COUNTER (2.5 - 5.5s, 90 frames)
// ---------------------------------------------------------------------------
const LEAGUE_PILLS = [
{ label: "MLB", delay: 0 },
{ label: "NFL", delay: 4 },
{ label: "NBA", delay: 8 },
{ label: "NHL", delay: 12 },
];
const CounterScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const TARGET = 47;
const TOTAL = 120;
// Counter ramp: accelerate then decelerate over 2 seconds
const counterProgress = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// Ease-out curve
const eased = 1 - Math.pow(1 - counterProgress, 3);
const currentCount = Math.round(eased * TARGET);
// Progress bar fill
const barProgress = interpolate(frame, [0.2 * fps, 2.2 * fps], [0, TARGET / TOTAL], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Gold glow pulse
const glowPulse = interpolate(
Math.sin(frame * 0.08),
[-1, 1],
[0.15, 0.3]
);
// Number glow intensifies as count goes up
const numberGlow = interpolate(counterProgress, [0, 1], [20, 60]);
// Sub-text reveal
const subOpacity = interpolate(frame, [1.5 * fps, 2 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Gold radial glow */}
<div
style={{
position: "absolute",
top: "30%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "120%",
height: "70%",
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 65%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 32,
zIndex: 1,
}}
>
{/* Giant counter */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 220,
fontWeight: 900,
color: theme.colors.gold,
lineHeight: 1,
letterSpacing: -8,
textShadow: `0 0 ${numberGlow}px rgba(255, 215, 0, 0.8), 0 0 ${numberGlow * 2}px rgba(255, 215, 0, 0.3)`,
}}
>
{currentCount}
</div>
{/* Progress bar */}
<div
style={{
width: 900,
height: 12,
borderRadius: 6,
background: "#1A1A1A",
overflow: "hidden",
}}
>
<div
style={{
width: `${barProgress * 100}%`,
height: "100%",
borderRadius: 6,
background: "linear-gradient(90deg, #FFD700 0%, #FFA500 100%)",
boxShadow: "0 0 20px rgba(255, 215, 0, 0.5)",
}}
/>
</div>
{/* Count label */}
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 500,
color: theme.colors.text,
opacity: subOpacity,
letterSpacing: 2,
}}
>
{currentCount} / {TOTAL} stadiums
</div>
{/* League pills */}
<div
style={{
display: "flex",
gap: 16,
marginTop: 16,
}}
>
{LEAGUE_PILLS.map((pill, i) => {
const pillSpring = spring({
frame: frame - 2 * fps - pill.delay,
fps,
config: { damping: 12, stiffness: 200 },
});
const pillScale = frame - 2 * fps - pill.delay < 0
? 0
: interpolate(pillSpring, [0, 1], [0.5, 1]);
const pillOpacity = frame - 2 * fps - pill.delay < 0
? 0
: interpolate(pillSpring, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<div
key={i}
style={{
background: "rgba(255, 215, 0, 0.1)",
border: "2px solid rgba(255, 215, 0, 0.6)",
borderRadius: 24,
padding: "10px 28px",
transform: `scale(${pillScale})`,
opacity: pillOpacity,
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: theme.colors.gold,
letterSpacing: 2,
}}
>
{pill.label}
</span>
</div>
);
})}
</div>
</div>
<FilmGrain opacity={0.05} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 3 : STADIUM MAP (5.5 - 9s, 105 frames)
// ---------------------------------------------------------------------------
const SPORTS_EMOJI = ["\u26BE", "\uD83C\uDFC8", "\uD83C\uDFC0", "\uD83C\uDFD2"];
const TOTAL_CIRCLES = 30;
const FILLED_COUNT = 24;
const COLS = 5;
const ROWS = 6;
const StadiumMapScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Circles fill one by one, 2 frames apart
const filledSoFar = Math.min(
FILLED_COUNT,
Math.max(0, Math.floor((frame - 0.3 * fps) / 2))
);
// Gold radial glow pulse
const glowPulse = interpolate(
Math.sin(frame * 0.06),
[-1, 1],
[0.12, 0.25]
);
// Running count text
const countSpring = spring({
frame: Math.max(0, frame - 0.3 * fps),
fps,
config: { damping: 30, stiffness: 80 },
});
const countOpacity = interpolate(frame, [0.2 * fps, 0.5 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Gold radial glow */}
<div
style={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "120%",
height: "70%",
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 60%)`,
}}
/>
{/* Running count in top-right */}
<div
style={{
position: "absolute",
top: 100,
right: 80,
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 900,
color: theme.colors.gold,
opacity: countOpacity,
textShadow: "0 0 30px rgba(255, 215, 0, 0.6)",
}}
>
{filledSoFar}
</div>
{/* Stadium grid */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 36,
zIndex: 1,
}}
>
{Array.from({ length: ROWS }).map((_, row) => (
<div key={row} style={{ display: "flex", gap: 48 }}>
{Array.from({ length: COLS }).map((_, col) => {
const idx = row * COLS + col;
const isFilled = idx < filledSoFar;
const isLatest = idx === filledSoFar - 1 && filledSoFar > 0;
const emojiChar = SPORTS_EMOJI[idx % SPORTS_EMOJI.length];
// Pop spring for fill
const fillSpring = isFilled
? spring({
frame: Math.max(0, frame - 0.3 * fps - idx * 2),
fps,
config: { damping: 8, stiffness: 300 },
})
: 0;
const circleScale = isFilled
? interpolate(fillSpring, [0, 1], [0.3, 1])
: 1;
// Pulse for the latest filled circle
const pulseScale = isLatest
? 1 + 0.08 * Math.sin(frame * 0.3)
: 1;
return (
<div
key={col}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
}}
>
<div
style={{
width: 72,
height: 72,
borderRadius: 36,
background: isFilled
? theme.colors.gold
: "transparent",
border: isFilled
? "3px solid rgba(255, 215, 0, 0.9)"
: "3px solid rgba(128, 128, 128, 0.3)",
boxShadow: isFilled
? `0 0 ${isLatest ? 28 : 16}px rgba(255, 215, 0, ${isLatest ? 0.8 : 0.4})`
: "none",
transform: `scale(${circleScale * pulseScale})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{isFilled && (
<span style={{ fontSize: 28, lineHeight: 1 }}>
{emojiChar}
</span>
)}
</div>
</div>
);
})}
</div>
))}
</div>
<FilmGrain opacity={0.05} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 4 : TRACKER APP (9 - 12s, 90 frames)
// ---------------------------------------------------------------------------
const TRACKER_ROWS = [
{ stadium: "Dodger Stadium", city: "LA", date: "Apr 12" },
{ stadium: "Fenway Park", city: "Boston", date: "May 3" },
{ stadium: "Wrigley Field", city: "Chicago", date: "May 18" },
{ stadium: "Yankee Stadium", city: "NYC", date: "Jun 7" },
{ stadium: "Oracle Park", city: "SF", date: "Jun 22" },
{ stadium: "Coors Field", city: "Denver", date: "Jul 4" },
{ stadium: "PNC Park", city: "Pittsburgh", date: "Jul 19" },
{ stadium: "Camden Yards", city: "Baltimore", date: "Aug 1" },
{ stadium: "Minute Maid Park", city: "Houston", date: "Aug 14" },
{ stadium: "T-Mobile Park", city: "Seattle", date: "Sep 2" },
];
const TrackerAppScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Slow zoom drift on phone 1.0 -> 1.02
const zoomDrift = interpolate(frame, [0, 3 * fps], [1.0, 1.02], {
extrapolateRight: "clamp",
});
// Scroll offset: rows slide up over time
const scrollOffset = interpolate(frame, [0.3 * fps, 2.8 * fps], [0, 280], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Phone entrance
const phoneSpring = spring({
frame,
fps,
config: { damping: 18, stiffness: 120 },
});
const phoneScale = interpolate(phoneSpring, [0, 1], [0.85, 1]);
const phoneOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame */}
<div
style={{
width: 440,
height: 920,
borderRadius: 60,
background: "#1C1C1E",
border: "4px solid #333",
overflow: "hidden",
position: "relative",
transform: `scale(${phoneScale * zoomDrift})`,
opacity: phoneOpacity,
boxShadow: "0 40px 80px rgba(0,0,0,0.6), 0 0 60px rgba(255,215,0,0.08)",
}}
>
{/* Dynamic Island */}
<div
style={{
position: "absolute",
top: 14,
left: "50%",
transform: "translateX(-50%)",
width: 130,
height: 36,
borderRadius: 18,
background: "#000",
zIndex: 10,
}}
/>
{/* Status bar area */}
<div
style={{
height: 70,
background: "rgba(10,10,10,0.95)",
}}
/>
{/* App header */}
<div
style={{
padding: "16px 28px 12px",
background: "rgba(10,10,10,0.95)",
borderBottom: "1px solid rgba(255,215,0,0.15)",
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 26,
fontWeight: 800,
color: theme.colors.gold,
letterSpacing: -0.5,
}}
>
Stadium Tracker
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textSecondary,
marginTop: 4,
}}
>
47 of 120 visited
</div>
</div>
{/* Scrolling list */}
<div
style={{
position: "relative",
overflow: "hidden",
flex: 1,
height: 700,
}}
>
<div
style={{
transform: `translateY(-${scrollOffset}px)`,
padding: "8px 0",
}}
>
{TRACKER_ROWS.map((row, i) => {
const rowDelay = i * 3;
const rowSpring = spring({
frame: Math.max(0, frame - 0.2 * fps - rowDelay),
fps,
config: { damping: 20, stiffness: 180 },
});
const rowOpacity = frame - 0.2 * fps - rowDelay < 0
? 0
: interpolate(rowSpring, [0, 0.4], [0, 1], {
extrapolateRight: "clamp",
});
const rowX = interpolate(rowSpring, [0, 1], [40, 0]);
return (
<div
key={i}
style={{
display: "flex",
alignItems: "center",
padding: "16px 28px",
borderBottom: "1px solid rgba(255,255,255,0.06)",
opacity: rowOpacity,
transform: `translateX(${rowX}px)`,
}}
>
{/* Check badge */}
<div
style={{
width: 36,
height: 36,
borderRadius: 18,
background: "rgba(255,215,0,0.15)",
border: "2px solid rgba(255,215,0,0.5)",
display: "flex",
justifyContent: "center",
alignItems: "center",
marginRight: 16,
flexShrink: 0,
}}
>
<span
style={{
color: theme.colors.gold,
fontSize: 18,
fontWeight: 700,
}}
>
{"\u2713"}
</span>
</div>
{/* Stadium info */}
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 700,
color: theme.colors.text,
lineHeight: 1.2,
}}
>
{row.stadium}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textSecondary,
marginTop: 2,
}}
>
{row.city}
</div>
</div>
{/* Date */}
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
flexShrink: 0,
}}
>
{row.date}
</div>
</div>
);
})}
</div>
</div>
</div>
<FilmGrain opacity={0.04} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Scene 5 : CTA (12 - 15s, 90 frames)
// ---------------------------------------------------------------------------
const CTAScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "Drop your number." slam
const headlineSlam = spring({
frame,
fps,
config: { damping: 10, stiffness: 260 },
});
const headlineScale = interpolate(headlineSlam, [0, 1], [2, 1]);
const headlineOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// "Search SportsTime" text
const subProgress = spring({
frame: frame - 0.8 * fps,
fps,
config: theme.animation.snappy,
});
const subOpacity = interpolate(
frame - 0.8 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const subY = interpolate(subProgress, [0, 1], [20, 0]);
// App icon
const iconProgress = spring({
frame: frame - 0.4 * fps,
fps,
config: { damping: 14, stiffness: 140 },
});
const iconScale = interpolate(iconProgress, [0, 1], [0.4, 1]);
const iconOpacity = interpolate(
frame - 0.4 * fps,
[0, 0.15 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Subtle golden glow behind everything
const bgGlow = interpolate(frame, [0, 0.5 * fps], [0, 0.15], {
extrapolateRight: "clamp",
});
// Search icon subtle bounce
const searchBounce = frame - 0.8 * fps > 0
? 1 + 0.03 * Math.sin((frame - 0.8 * fps) * 0.15)
: 1;
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Background glow */}
<div
style={{
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "130%",
height: "60%",
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${bgGlow}) 0%, transparent 65%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 40,
padding: 60,
zIndex: 1,
}}
>
{/* App icon */}
<div
style={{
width: 120,
height: 120,
borderRadius: 28,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: "0 16px 48px rgba(255, 107, 53, 0.4)",
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity: iconOpacity,
transform: `scale(${iconScale})`,
}}
>
<svg width="64" height="64" 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>
{/* "Drop your number." */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 900,
color: theme.colors.gold,
textAlign: "center",
lineHeight: 1.15,
letterSpacing: -2,
transform: `scale(${headlineScale})`,
opacity: headlineOpacity,
textShadow:
"0 0 40px rgba(255, 215, 0, 0.5), 0 4px 16px rgba(0,0,0,0.8)",
}}
>
Drop your number.
</div>
{/* Search CTA */}
<div
style={{
opacity: subOpacity,
transform: `translateY(${subY}px) scale(${searchBounce})`,
display: "flex",
alignItems: "center",
gap: 14,
}}
>
{/* Search icon */}
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="rgba(255,255,255,0.7)"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 36,
fontWeight: 500,
color: theme.colors.text,
letterSpacing: 0.5,
}}
>
Search{" "}
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
SportsTime
</span>
</span>
</div>
</div>
<FilmGrain opacity={0.05} />
</AbsoluteFill>
);
};
// ---------------------------------------------------------------------------
// Main Composition
// ---------------------------------------------------------------------------
/**
* VIDEO 1: "Stadium Count Flex"
*
* Competitive flex / brag video for TikTok.
* Gold + dark scoreboard aesthetic. ESPN stat graphics meets TikTok energy.
* 1080x1920, 30fps, 15 seconds (450 frames).
*
* Scene breakdown:
* - 0:00-0:02.5 (0-75): HOOK - "Be honest. How many stadiums have you been to?"
* - 0:02.5-0:05.5 (75-165): THE COUNTER - Number animates 0 -> 47
* - 0:05.5-0:09 (165-270): STADIUM MAP - Grid of circles filling up
* - 0:09-0:12 (270-360): TRACKER APP - Simulated phone screen
* - 0:12-0:15 (360-450): CTA - "Drop your number."
*/
export const StadiumCountFlex: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 10;
const SCENE_DURATIONS = {
hook: Math.round(2.5 * fps), // 75 frames
counter: Math.round(3 * fps), // 90 frames
stadiumMap: Math.round(3.5 * fps), // 105 frames
trackerApp: Math.round(3 * fps), // 90 frames
cta: Math.round(3 * fps), // 90 frames
};
return (
<AbsoluteFill>
<TransitionSeries>
{/* Scene 1: HOOK */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hook}>
<HookScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: THE COUNTER */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.counter}>
<CounterScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-bottom" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: STADIUM MAP */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stadiumMap}>
<StadiumMapScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: TRACKER APP */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.trackerApp}>
<TrackerAppScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: CTA */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.cta}>
<CTAScene />
</TransitionSeries.Sequence>
</TransitionSeries>
{/* TikTok caption overlay on top of everything */}
<TikTokCaption captions={CAPTIONS} bottomOffset={260} />
</AbsoluteFill>
);
};

View File

@@ -1,236 +0,0 @@
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

@@ -1,284 +0,0 @@
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

@@ -1,266 +0,0 @@
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

@@ -1,160 +0,0 @@
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

@@ -1,241 +0,0 @@
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

@@ -1,287 +0,0 @@
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

@@ -1,621 +0,0 @@
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

@@ -1,131 +0,0 @@
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

@@ -1,274 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
import { TapIndicator } from "../../components/shared/TapIndicator";
/**
* Scene 2: Follow Team mode selection
*
* Shows the planning mode UI inside a phone frame with "Follow Team" being selected.
* On-screen text: "Follow Team mode"
*/
export const FollowTeamScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "Follow Team mode" on-screen text overlay (outside phone)
const labelBadgeProgress = spring({
frame: frame - 1.2 * fps,
fps,
config: theme.animation.snappy,
});
const labelBadgeOpacity = interpolate(
frame - 1.2 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame with app UI */}
<AppScreenshot delay={0} scale={0.88}>
<MockScreen>
<FollowTeamScreenContent />
</MockScreen>
</AppScreenshot>
{/* On-screen label: "Follow Team mode" - overlaid outside phone */}
<div
style={{
position: "absolute",
bottom: 120,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: labelBadgeOpacity,
transform: `scale(${interpolate(labelBadgeProgress, [0, 1], [0.8, 1])})`,
zIndex: 10,
}}
>
<div
style={{
background: theme.colors.accent,
padding: "14px 36px",
borderRadius: 40,
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 30,
fontWeight: 700,
color: theme.colors.text,
}}
>
Follow Team mode
</span>
</div>
</div>
</AbsoluteFill>
);
};
/** Inner screen content rendered inside the phone frame */
const FollowTeamScreenContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const modes = [
{
name: "Explore Region",
desc: "Discover games in an area",
icon: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
selected: false,
},
{
name: "Follow Team",
desc: "Chase your team on the road",
icon: "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",
selected: true,
},
{
name: "Custom Trip",
desc: "Build your own route",
icon: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7",
selected: false,
},
];
// Header entrance
const labelProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const labelOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const labelY = interpolate(labelProgress, [0, 1], [20, 0]);
return (
<div style={{ padding: 12 }}>
{/* Header */}
<div
style={{
opacity: labelOpacity,
transform: `translateY(${labelY}px)`,
marginBottom: 36,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 34,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 6,
}}
>
Planning Mode
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
}}
>
How do you want to plan?
</div>
</div>
{/* Mode cards */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{modes.map((mode, index) => {
const cardProgress = spring({
frame: frame - (0.2 + index * 0.12) * fps,
fps,
config: theme.animation.smooth,
});
const scale = interpolate(cardProgress, [0, 1], [0.92, 1]);
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
// Follow Team gets selected
const selectProgress = spring({
frame: frame - 0.9 * fps,
fps,
config: theme.animation.snappy,
});
const isSelected = mode.selected && selectProgress > 0.5;
const borderColor = isSelected ? theme.colors.accent : "transparent";
const bgColor = isSelected ? `rgba(255, 107, 53, 0.12)` : "#1C1C1E";
return (
<div
key={mode.name}
style={{
background: bgColor,
borderRadius: 16,
padding: 24,
display: "flex",
alignItems: "center",
gap: 18,
transform: `scale(${scale})`,
opacity,
border: `3px solid ${borderColor}`,
}}
>
{/* Icon */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: isSelected
? theme.colors.accent
: "rgba(255,255,255,0.08)",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexShrink: 0,
}}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke={isSelected ? "white" : theme.colors.textSecondary}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={mode.icon} />
</svg>
</div>
{/* Text */}
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: isSelected ? theme.colors.accent : theme.colors.text,
marginBottom: 2,
}}
>
{mode.name}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 16,
color: theme.colors.textSecondary,
}}
>
{mode.desc}
</div>
</div>
{/* Selected checkmark */}
{isSelected && (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill={theme.colors.accent} />
<path
d="M7 12l3 3 7-7"
stroke="white"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,175 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 1: Bold "HOT TAKE" hook
*
* Full-screen provocative text that grabs attention instantly.
* "If you've never done an away-game road trip... are you even a fan?"
*/
export const HotTakeScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "HOT TAKE" badge slam
const badgeSlam = spring({
frame,
fps,
config: { damping: 12, stiffness: 200 },
});
const badgeScale = interpolate(badgeSlam, [0, 1], [3, 1]);
const badgeOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// Main text reveal (staggered lines)
const line1Progress = spring({
frame: frame - 0.4 * fps,
fps,
config: theme.animation.smooth,
});
const line1Opacity = interpolate(
frame - 0.4 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const line1Y = interpolate(line1Progress, [0, 1], [40, 0]);
const line2Progress = spring({
frame: frame - 0.7 * fps,
fps,
config: theme.animation.smooth,
});
const line2Opacity = interpolate(
frame - 0.7 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const line2Y = interpolate(line2Progress, [0, 1], [40, 0]);
// Emphasis pulse on "are you even a fan?"
const emphasisPulse = spring({
frame: frame - 1.2 * fps,
fps,
config: { damping: 8, stiffness: 150 },
});
const emphasisScale = interpolate(emphasisPulse, [0, 0.5, 1], [0.8, 1.08, 1]);
// Subtle background pulse
const bgPulse = interpolate(
frame,
[0, 0.15 * fps],
[0, 1],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Red flash on slam */}
<AbsoluteFill
style={{
background: `radial-gradient(ellipse at center, rgba(255, 50, 50, ${0.15 * bgPulse}) 0%, transparent 70%)`,
}}
/>
{/* Content container */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 40,
padding: 60,
}}
>
{/* HOT TAKE badge */}
<div
style={{
opacity: badgeOpacity,
transform: `scale(${badgeScale})`,
}}
>
<div
style={{
background: "#FF2222",
padding: "16px 48px",
borderRadius: 12,
boxShadow: "0 8px 32px rgba(255, 34, 34, 0.5)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 52,
fontWeight: 900,
color: theme.colors.text,
letterSpacing: 6,
textTransform: "uppercase",
}}
>
HOT TAKE
</span>
</div>
</div>
{/* Main provocative text */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
marginTop: 24,
}}
>
<div
style={{
opacity: line1Opacity,
transform: `translateY(${line1Y}px)`,
fontFamily: theme.fonts.display,
fontSize: 46,
fontWeight: 600,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.3,
}}
>
If you've never done an{"\n"}away-game road trip...
</div>
<div
style={{
opacity: line2Opacity,
transform: `translateY(${line2Y}px) scale(${emphasisScale})`,
fontFamily: theme.fonts.display,
fontSize: 54,
fontWeight: 900,
color: theme.colors.accent,
textAlign: "center",
lineHeight: 1.3,
marginTop: 8,
}}
>
are you even a fan?
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -1,269 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 5: "This is your weekend." reaction + CTA
*
* Emotional payoff moment with the CTA:
* "Search SportsTime and run your team's road stretch."
*/
export const ReactionScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "This is your weekend." text slam
const headlineSlam = spring({
frame,
fps,
config: { damping: 14, stiffness: 160 },
});
const headlineScale = interpolate(headlineSlam, [0, 1], [1.5, 1]);
const headlineOpacity = interpolate(frame, [0, 0.2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
// VO text line
const voProgress = spring({
frame: frame - 0.8 * fps,
fps,
config: theme.animation.smooth,
});
const voOpacity = interpolate(
frame - 0.8 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const voY = interpolate(voProgress, [0, 1], [20, 0]);
// CTA entrance
const ctaProgress = spring({
frame: frame - 1.5 * fps,
fps,
config: theme.animation.snappy,
});
const ctaOpacity = interpolate(
frame - 1.5 * fps,
[0, 0.25 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const ctaScale = interpolate(ctaProgress, [0, 1], [0.85, 1]);
// App icon entrance
const iconProgress = spring({
frame: frame - 1.8 * fps,
fps,
config: { damping: 15, stiffness: 100 },
});
const iconScale = interpolate(iconProgress, [0, 1], [0.5, 1]);
const iconOpacity = interpolate(
frame - 1.8 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Wordmark
const wordmarkProgress = spring({
frame: frame - 2.1 * fps,
fps,
config: theme.animation.smooth,
});
const wordmarkOpacity = interpolate(
frame - 2.1 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Background glow
const glowOpacity = interpolate(
frame,
[0, 0.5 * fps],
[0, 0.12],
{ extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Background accent glow */}
<div
style={{
position: "absolute",
top: "25%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "140%",
height: "60%",
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 48,
padding: 60,
}}
>
{/* "This is your weekend." */}
<div
style={{
opacity: headlineOpacity,
transform: `scale(${headlineScale})`,
fontFamily: theme.fonts.display,
fontSize: 64,
fontWeight: 900,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.2,
letterSpacing: -2,
}}
>
This is your{"\n"}weekend.
</div>
{/* VO callout text */}
<div
style={{
opacity: voOpacity,
transform: `translateY(${voY}px)`,
fontFamily: theme.fonts.text,
fontSize: 26,
fontWeight: 500,
color: theme.colors.textSecondary,
textAlign: "center",
lineHeight: 1.5,
maxWidth: 800,
}}
>
Do at least one away-game run{"\n"}this season.
</div>
{/* App icon + wordmark */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 20,
marginTop: 24,
}}
>
{/* App Icon */}
<div
style={{
width: 120,
height: 120,
borderRadius: 28,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: `0 16px 48px rgba(255, 107, 53, 0.4)`,
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity: iconOpacity,
transform: `scale(${iconScale})`,
}}
>
<svg
width="64"
height="64"
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>
{/* SportsTime wordmark */}
<div
style={{
opacity: wordmarkOpacity,
transform: `translateY(${interpolate(wordmarkProgress, [0, 1], [10, 0])}px)`,
fontFamily: theme.fonts.display,
fontSize: 48,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -1,
}}
>
SportsTime
</div>
</div>
{/* CTA text */}
<div
style={{
opacity: ctaOpacity,
transform: `scale(${ctaScale})`,
}}
>
<div
style={{
background: "rgba(255, 255, 255, 0.08)",
borderRadius: 16,
padding: "20px 40px",
border: "1px solid rgba(255, 255, 255, 0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 500,
color: theme.colors.textSecondary,
textAlign: "center",
}}
>
Search{" "}
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
SportsTime
</span>{" "}
on the App Store
</span>
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -1,321 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
/**
* Scene 3: Road games surfaced
*
* Shows upcoming away games inside a phone frame, cards sliding in.
* On-screen text: "Plan it in seconds"
*/
type GameCard = {
opponent: string;
opponentColor: string;
date: string;
venue: string;
city: string;
};
const ROAD_GAMES: GameCard[] = [
{
opponent: "@ Dodgers",
opponentColor: "#005A9C",
date: "Fri, Jun 12",
venue: "Dodger Stadium",
city: "Los Angeles, CA",
},
{
opponent: "@ Giants",
opponentColor: "#FD5A1E",
date: "Sun, Jun 14",
venue: "Oracle Park",
city: "San Francisco, CA",
},
{
opponent: "@ Padres",
opponentColor: "#2F241D",
date: "Tue, Jun 16",
venue: "Petco Park",
city: "San Diego, CA",
},
{
opponent: "@ D-backs",
opponentColor: "#A71930",
date: "Thu, Jun 18",
venue: "Chase Field",
city: "Phoenix, AZ",
},
];
export const RoadGamesScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// "Plan it in seconds" label overlay (outside phone)
const planLabelProgress = spring({
frame: frame - 2 * fps,
fps,
config: theme.animation.snappy,
});
const planLabelOpacity = interpolate(
frame - 2 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame with app UI */}
<AppScreenshot delay={0} scale={0.88}>
<MockScreen>
<RoadGamesScreenContent />
</MockScreen>
</AppScreenshot>
{/* "Plan it in seconds" label - overlaid outside phone */}
<div
style={{
position: "absolute",
bottom: 120,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: planLabelOpacity,
transform: `scale(${interpolate(planLabelProgress, [0, 1], [0.8, 1])})`,
zIndex: 10,
}}
>
<div
style={{
background: theme.colors.accent,
padding: "14px 36px",
borderRadius: 40,
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 30,
fontWeight: 700,
color: theme.colors.text,
}}
>
Plan it in seconds
</span>
</div>
</div>
</AbsoluteFill>
);
};
/** Inner screen content rendered inside the phone frame */
const RoadGamesScreenContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Header entrance
const headerProgress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const headerOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const headerY = interpolate(headerProgress, [0, 1], [20, 0]);
return (
<div style={{ padding: 12 }}>
{/* Header */}
<div
style={{
opacity: headerOpacity,
transform: `translateY(${headerY}px)`,
marginBottom: 32,
}}
>
{/* Team badge */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 8,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: "#C9082A",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 16,
fontWeight: 900,
color: "white",
}}
>
ATL
</span>
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.text,
}}
>
Braves Road Games
</div>
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 18,
color: theme.colors.textSecondary,
}}
>
June 2026 away stretch
</div>
</div>
{/* Game cards */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{ROAD_GAMES.map((game, index) => {
const cardDelay = 0.3 + index * 0.2;
const cardProgress = spring({
frame: frame - cardDelay * fps,
fps,
config: theme.animation.snappy,
});
const translateX = interpolate(cardProgress, [0, 1], [300, 0]);
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
return (
<div
key={game.venue}
style={{
background: "#1C1C1E",
borderRadius: 16,
padding: 22,
display: "flex",
alignItems: "center",
gap: 16,
transform: `translateX(${translateX}px)`,
opacity,
borderLeft: `4px solid ${game.opponentColor}`,
}}
>
{/* Date block */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
minWidth: 60,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: theme.colors.textMuted,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{game.date.split(", ")[0]}
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: theme.colors.text,
}}
>
{game.date.split(" ")[1].replace(",", "")}
</div>
</div>
{/* Divider */}
<div
style={{
width: 2,
height: 40,
background: "rgba(255,255,255,0.1)",
borderRadius: 1,
}}
/>
{/* Game info */}
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 3,
}}
>
{game.opponent}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 15,
color: theme.colors.textSecondary,
}}
>
{game.venue}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: theme.colors.textMuted,
}}
>
{game.city}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,301 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
/**
* Scene 4: Route + itinerary shown
*
* Animated route line connecting cities with itinerary card, inside phone frame.
* Shows the full trip: LA -> SF -> SD -> PHX
*/
type Stop = {
city: string;
abbr: string;
x: number;
y: number;
game: string;
date: string;
};
const STOPS: Stop[] = [
{ city: "Los Angeles", abbr: "LAX", x: 200, y: 340, game: "vs Dodgers", date: "Jun 12" },
{ city: "San Francisco", abbr: "SFO", x: 160, y: 160, game: "vs Giants", date: "Jun 14" },
{ city: "San Diego", abbr: "SAN", x: 280, y: 440, game: "vs Padres", date: "Jun 16" },
{ city: "Phoenix", abbr: "PHX", x: 550, y: 380, game: "vs D-backs", date: "Jun 18" },
];
export const RouteItineraryScene: React.FC = () => {
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Phone frame with app UI */}
<AppScreenshot delay={0} scale={0.88}>
<MockScreen>
<RouteScreenContent />
</MockScreen>
</AppScreenshot>
</AbsoluteFill>
);
};
/** Inner screen content rendered inside the phone frame */
const RouteScreenContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Route line draw progress
const lineProgress = interpolate(
frame,
[0.2 * fps, 1.8 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Itinerary card at bottom
const cardProgress = spring({
frame: frame - 2 * fps,
fps,
config: theme.animation.smooth,
});
const cardY = interpolate(cardProgress, [0, 1], [150, 0]);
const cardOpacity = interpolate(
frame - 2 * fps,
[0, 0.3 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Build SVG path segments
const pathSegments: string[] = [];
for (let i = 0; i < STOPS.length - 1; i++) {
const from = STOPS[i];
const to = STOPS[i + 1];
if (i === 0) {
pathSegments.push(`M ${from.x} ${from.y}`);
}
const cx = (from.x + to.x) / 2;
const cy = Math.min(from.y, to.y) - 40;
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
}
const fullPath = pathSegments.join(" ");
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Map area */}
<div
style={{
position: "absolute",
top: 0,
left: 12,
right: 12,
height: "60%",
}}
>
{/* Subtle map grid */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
`,
backgroundSize: "50px 50px",
borderRadius: 16,
}}
/>
{/* Route line SVG */}
<svg
width="100%"
height="100%"
viewBox="0 0 700 550"
style={{ position: "absolute", inset: 0 }}
>
{/* Route line background (dimmed) */}
<path
d={fullPath}
fill="none"
stroke="rgba(255, 107, 53, 0.15)"
strokeWidth="4"
strokeLinecap="round"
/>
{/* Route line animated */}
<path
d={fullPath}
fill="none"
stroke={theme.colors.accent}
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="2000"
strokeDashoffset={2000 * (1 - lineProgress)}
/>
</svg>
{/* Stop markers */}
{STOPS.map((stop, index) => {
const markerDelay = 0.3 + index * 0.4;
const markerProgress = spring({
frame: frame - markerDelay * fps,
fps,
config: { damping: 12, stiffness: 180 },
});
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
const leftPct = (stop.x / 700) * 100;
const topPct = (stop.y / 550) * 100;
return (
<div
key={stop.abbr}
style={{
position: "absolute",
left: `${leftPct}%`,
top: `${topPct}%`,
transform: `translate(-50%, -50%) scale(${markerScale})`,
opacity: markerOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 6,
}}
>
{/* Ping ring */}
<div
style={{
position: "absolute",
width: 40,
height: 40,
borderRadius: "50%",
border: `2px solid ${theme.colors.accent}`,
opacity: 0.3,
}}
/>
{/* Marker dot */}
<div
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: theme.colors.secondary,
border: "3px solid white",
boxShadow: `0 4px 12px rgba(78, 205, 196, 0.5)`,
}}
/>
{/* City label */}
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 15,
fontWeight: 700,
color: theme.colors.text,
textShadow: "0 2px 8px rgba(0,0,0,0.8)",
whiteSpace: "nowrap",
}}
>
{stop.city}
</div>
</div>
);
})}
</div>
{/* Itinerary card at bottom */}
<div
style={{
position: "absolute",
bottom: 20,
left: 16,
right: 16,
opacity: cardOpacity,
transform: `translateY(${cardY}px)`,
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 20,
padding: 24,
border: `2px solid rgba(255, 107, 53, 0.3)`,
}}
>
{/* Card header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 700,
color: theme.colors.text,
}}
>
West Coast Run
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 15,
color: theme.colors.textSecondary,
}}
>
7 days
</div>
</div>
{/* Stats row */}
<div style={{ display: "flex", gap: 16 }}>
{[
{ label: "Games", value: "4" },
{ label: "Cities", value: "4" },
{ label: "Miles", value: "1,240" },
].map((stat) => (
<div key={stat.label} style={{ flex: 1, textAlign: "center" }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 28,
fontWeight: 700,
color: theme.colors.accent,
}}
>
{stat.value}
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textSecondary,
}}
>
{stat.label}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,97 +0,0 @@
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 { HotTakeScene } from "./HotTakeScene";
import { FollowTeamScene } from "./FollowTeamScene";
import { RoadGamesScene } from "./RoadGamesScene";
import { RouteItineraryScene } from "./RouteItineraryScene";
import { ReactionScene } from "./ReactionScene";
import { GradientBackground } from "../../components/shared";
/**
* V02: "The Fan Test" (Viral)
*
* Hook: "If you've never done an away-game road trip... are you even a fan?"
* Concept: Identity challenge -> Follow Team demo.
* Length: 18 seconds (540 frames at 30fps)
*
* Scene breakdown:
* - 0:00-0:03 (0-90): Bold HOT TAKE hook
* - 0:03-0:06 (90-180): Follow Team mode selection
* - 0:06-0:10 (180-300): Road games surfaced
* - 0:10-0:14 (300-420): Route + itinerary shown
* - 0:14-0:18 (420-540): "This is your weekend." reaction + CTA
*
* On-screen text: "HOT TAKE", "Follow Team mode", "Plan it in seconds"
* CTA: "Search SportsTime and run your team's road stretch."
* Why it performs: debate comments + identity trigger.
*/
export const TheFanTest: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION_DURATION = 12;
const SCENE_DURATIONS = {
hotTake: 3 * fps, // 90 frames
followTeam: 3 * fps, // 90 frames
roadGames: 4 * fps, // 120 frames
routeItinerary: 4 * fps, // 120 frames
reaction: 4 * fps, // 120 frames
};
return (
<AbsoluteFill>
<GradientBackground />
<TransitionSeries>
{/* Scene 1: HOT TAKE hook */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hotTake}>
<HotTakeScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 2: Follow Team mode selection */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.followTeam}>
<FollowTeamScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 3: Road games surfaced */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.roadGames}>
<RoadGamesScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 4: Route + itinerary */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.routeItinerary}>
<RouteItineraryScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
/>
{/* Scene 5: Reaction + CTA */}
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reaction}>
<ReactionScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -1,189 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 2: "All talk" - the chat dies
*
* Shows the last few messages then silence.
* Typing indicator appears... then vanishes.
* Bold overlay: "All talk."
*/
export const AllTalkScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Typing indicator dots animation
const typingAppear = interpolate(
frame,
[0.2 * fps, 0.4 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const typingDisappear = interpolate(
frame,
[1 * fps, 1.2 * fps],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const typingOpacity = typingAppear * typingDisappear;
// Dot bounce cycle (frame-based, not CSS)
const dotBounce = (dotIndex: number) => {
const cycle = ((frame + dotIndex * 4) % 18) / 18;
return interpolate(cycle, [0, 0.5, 1], [0, -6, 0]);
};
// "All talk." slam
const slamDelay = 1.3 * fps;
const slamProgress = spring({
frame: frame - slamDelay,
fps,
config: { damping: 12, stiffness: 200 },
});
const slamScale = interpolate(slamProgress, [0, 1], [2.5, 1]);
const slamOpacity = interpolate(
frame - slamDelay,
[0, 0.1 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* Stale chat messages (static, from previous scene context) */}
<div
style={{
padding: "120px 24px 20px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{/* Last visible messages */}
<ChatBubble sender="Sam" color="#AF52DE" text="Maybe June?" isMe={false} />
<ChatBubble sender="Jake" color="#34C759" text="Or July" isMe={false} />
<ChatBubble sender="Mike" color="#FF9500" text="Whatever works" isMe={false} />
<ChatBubble sender="" color="#007AFF" text="Ok so..." isMe={true} />
{/* Typing indicator */}
<div
style={{
display: "flex",
alignItems: "flex-start",
opacity: typingOpacity,
marginTop: 8,
}}
>
<div
style={{
background: "#2C2C2E",
borderRadius: 20,
borderBottomLeftRadius: 6,
padding: "14px 20px",
display: "flex",
gap: 5,
alignItems: "center",
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
width: 8,
height: 8,
borderRadius: 4,
background: "rgba(255,255,255,0.4)",
transform: `translateY(${dotBounce(i)}px)`,
}}
/>
))}
</div>
</div>
</div>
{/* "All talk." overlay slam */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
opacity: slamOpacity,
}}
>
<div
style={{
transform: `scale(${slamScale})`,
fontFamily: theme.fonts.display,
fontSize: 80,
fontWeight: 900,
color: "white",
letterSpacing: -3,
textShadow: "0 4px 40px rgba(0,0,0,0.8)",
}}
>
All talk.
</div>
</div>
</AbsoluteFill>
);
};
/** Simple static chat bubble (no animation) */
const ChatBubble: React.FC<{
sender: string;
color: string;
text: string;
isMe: boolean;
}> = ({ sender, color, text, isMe }) => (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: isMe ? "flex-end" : "flex-start",
}}
>
{!isMe && sender && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color,
marginBottom: 2,
marginLeft: 12,
}}
>
{sender}
</span>
)}
<div
style={{
background: isMe ? "#007AFF" : "#2C2C2E",
borderRadius: 20,
borderBottomRightRadius: isMe ? 6 : 20,
borderBottomLeftRadius: isMe ? 20 : 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: "white",
lineHeight: 1.3,
}}
>
{text}
</span>
</div>
</div>
);

View File

@@ -1,215 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 6: CTA ending
*
* Clean branded ending.
* "If your group chat is all talk → SportsTime"
*/
export const CTAScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Text line 1
const line1Progress = spring({
frame,
fps,
config: theme.animation.smooth,
});
const line1Opacity = interpolate(frame, [0, 0.25 * fps], [0, 1], {
extrapolateRight: "clamp",
});
const line1Y = interpolate(line1Progress, [0, 1], [30, 0]);
// Arrow / divider
const arrowProgress = spring({
frame: frame - 0.5 * fps,
fps,
config: theme.animation.snappy,
});
const arrowOpacity = interpolate(
frame - 0.5 * fps,
[0, 0.15 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const arrowScale = interpolate(arrowProgress, [0, 1], [0.5, 1]);
// App icon + name
const brandProgress = spring({
frame: frame - 0.8 * fps,
fps,
config: { damping: 15, stiffness: 100 },
});
const brandScale = interpolate(brandProgress, [0, 1], [0.6, 1]);
const brandOpacity = interpolate(
frame - 0.8 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Search CTA
const ctaProgress = spring({
frame: frame - 1.2 * fps,
fps,
config: theme.animation.smooth,
});
const ctaOpacity = interpolate(
frame - 1.2 * fps,
[0, 0.2 * fps],
[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",
}}
>
{/* Accent glow */}
<div
style={{
position: "absolute",
top: "30%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "130%",
height: "50%",
background: `radial-gradient(ellipse, ${theme.colors.accent}18 0%, transparent 70%)`,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 36,
padding: 60,
}}
>
{/* Line 1: "If your group chat is all talk" */}
<div
style={{
opacity: line1Opacity,
transform: `translateY(${line1Y}px)`,
fontFamily: theme.fonts.display,
fontSize: 42,
fontWeight: 700,
color: theme.colors.text,
textAlign: "center",
lineHeight: 1.3,
letterSpacing: -1,
}}
>
If your group chat{"\n"}is all talk
</div>
{/* Arrow */}
<div
style={{
opacity: arrowOpacity,
transform: `scale(${arrowScale})`,
}}
>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
<path
d="M12 5v14m0 0l-6-6m6 6l6-6"
stroke={theme.colors.accent}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* App icon + name */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
opacity: brandOpacity,
transform: `scale(${brandScale})`,
}}
>
<div
style={{
width: 100,
height: 100,
borderRadius: 24,
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
boxShadow: "0 12px 40px rgba(255, 107, 53, 0.4)",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="56" height="56" 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>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 44,
fontWeight: 700,
color: theme.colors.text,
letterSpacing: -1,
}}
>
SportsTime
</div>
</div>
{/* CTA */}
<div
style={{
opacity: ctaOpacity,
transform: `translateY(${interpolate(ctaProgress, [0, 1], [15, 0])}px)`,
}}
>
<div
style={{
background: "rgba(255, 255, 255, 0.08)",
borderRadius: 14,
padding: "16px 36px",
border: "1px solid rgba(255, 255, 255, 0.12)",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 22,
fontWeight: 500,
color: theme.colors.textSecondary,
}}
>
Search{" "}
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
SportsTime
</span>{" "}
on the App Store
</span>
</div>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -1,254 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 1: iMessage group chat blowing up
*
* Messages stack in rapid succession with spring pop-ins.
* Overlay: "Every group chat ever"
* Fast zooms on the chat as messages fly in.
*/
type Message = {
sender: string;
color: string;
text: string;
isMe: boolean;
delaySeconds: number;
};
const MESSAGES: Message[] = [
{ sender: "Jake", color: "#34C759", text: "We should do a baseball trip", isMe: false, delaySeconds: 0.15 },
{ sender: "Mike", color: "#FF9500", text: "I'm down", isMe: false, delaySeconds: 0.5 },
{ sender: "Sam", color: "#AF52DE", text: "Same", isMe: false, delaySeconds: 0.75 },
{ sender: "You", color: "#007AFF", text: "Let's goooo", isMe: true, delaySeconds: 1.0 },
{ sender: "Jake", color: "#34C759", text: "When tho", isMe: false, delaySeconds: 1.4 },
{ sender: "Mike", color: "#FF9500", text: "idk", isMe: false, delaySeconds: 1.7 },
{ sender: "Sam", color: "#AF52DE", text: "Maybe June?", isMe: false, delaySeconds: 2.0 },
{ sender: "Jake", color: "#34C759", text: "Or July", isMe: false, delaySeconds: 2.25 },
{ sender: "Mike", color: "#FF9500", text: "Whatever works", isMe: false, delaySeconds: 2.45 },
{ sender: "You", color: "#007AFF", text: "Ok so...", isMe: true, delaySeconds: 2.7 },
];
export const ChatScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Subtle zoom-in on the whole chat
const chatZoom = interpolate(frame, [0, 3.5 * fps], [1, 1.04], {
extrapolateRight: "clamp",
});
// Overlay text: "Every group chat ever"
const overlayProgress = spring({
frame: frame - 0.3 * fps,
fps,
config: { damping: 14, stiffness: 180 },
});
const overlayOpacity = interpolate(
frame - 0.3 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* iMessage-style chat background */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: `scale(${chatZoom})`,
transformOrigin: "center 40%",
}}
>
{/* Status bar */}
<div
style={{
padding: "16px 32px 12px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 16, color: "white" }}>
9:41
</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<div style={{ width: 16, height: 10, border: "1.5px solid white", borderRadius: 2 }}>
<div style={{ width: "70%", height: "100%", background: "white", borderRadius: 1 }} />
</div>
</div>
</div>
{/* Chat header */}
<div
style={{
padding: "8px 32px 16px",
borderBottom: "1px solid rgba(255,255,255,0.1)",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
{/* Group avatar */}
<div
style={{
width: 44,
height: 44,
borderRadius: 22,
background: "linear-gradient(135deg, #34C759, #007AFF)",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<span style={{ fontSize: 18 }}>4</span>
</div>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: "white",
}}
>
The Boys
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: "rgba(255,255,255,0.5)",
}}
>
4 people
</div>
</div>
</div>
{/* Messages */}
<div
style={{
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{MESSAGES.map((msg, index) => {
const msgDelay = msg.delaySeconds * fps;
const msgProgress = spring({
frame: frame - msgDelay,
fps,
config: { damping: 14, stiffness: 200 },
});
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
if (frame < msgDelay) return null;
return (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.isMe ? "flex-end" : "flex-start",
transform: `scale(${msgScale})`,
opacity: msgOpacity,
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
}}
>
{/* Sender name (not for "me") */}
{!msg.isMe && (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 13,
color: msg.color,
marginBottom: 2,
marginLeft: 12,
}}
>
{msg.sender}
</span>
)}
<div
style={{
background: msg.isMe ? "#007AFF" : "#2C2C2E",
borderRadius: 20,
borderBottomRightRadius: msg.isMe ? 6 : 20,
borderBottomLeftRadius: msg.isMe ? 20 : 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 20,
color: "white",
lineHeight: 1.3,
}}
>
{msg.text}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Overlay: "Every group chat ever" */}
<div
style={{
position: "absolute",
top: 100,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
backdropFilter: "blur(10px)",
padding: "16px 40px",
borderRadius: 16,
border: "1px solid rgba(255,255,255,0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 36,
fontWeight: 800,
color: "white",
letterSpacing: -0.5,
}}
>
Every group chat ever
</span>
</div>
</div>
</AbsoluteFill>
);
};

View File

@@ -1,332 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
/**
* Scene 5: Poll it. Done.
*
* First half: poll appears in group chat
* Votes come in rapidly with spring animations
* Overlay: "Poll it. Done."
*/
type Vote = {
name: string;
color: string;
delaySeconds: number;
};
const VOTES: Vote[] = [
{ name: "Jake", color: "#34C759", delaySeconds: 0.6 },
{ name: "Mike", color: "#FF9500", delaySeconds: 0.85 },
{ name: "Sam", color: "#AF52DE", delaySeconds: 1.05 },
];
export const PollScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Poll card entrance
const pollEntrance = spring({
frame,
fps,
config: theme.animation.snappy,
});
const pollScale = interpolate(pollEntrance, [0, 1], [0.7, 1]);
const pollOpacity = interpolate(pollEntrance, [0, 1], [0, 1]);
// Overlay: "Poll it. Done."
const overlayDelay = 1.5 * fps;
const overlayProgress = spring({
frame: frame - overlayDelay,
fps,
config: { damping: 12, stiffness: 200 },
});
const overlayScale = interpolate(overlayProgress, [0, 1], [2, 1]);
const overlayOpacity = interpolate(
frame - overlayDelay,
[0, 0.1 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// Vote count animation
const voteCount = VOTES.filter(
(v) => frame >= v.delaySeconds * fps
).length;
const totalVoters = VOTES.length + 1; // +1 for "You"
// Progress bar width
const yesWidth = interpolate(
(voteCount + 1) / totalVoters,
[0, 1],
[0, 100]
);
return (
<AbsoluteFill style={{ background: "#000000" }}>
{/* Chat context */}
<div style={{ padding: "120px 24px 0" }}>
{/* "You" sent the poll */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
marginBottom: 16,
}}
>
<div
style={{
background: "#007AFF",
borderRadius: 20,
borderBottomRightRadius: 6,
padding: "12px 18px",
maxWidth: "75%",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 20, color: "white" }}>
I made us a trip. Vote
</span>
</div>
</div>
{/* Poll card */}
<div
style={{
transform: `scale(${pollScale})`,
opacity: pollOpacity,
transformOrigin: "left top",
}}
>
<div
style={{
background: "#1C1C1E",
borderRadius: 20,
padding: 28,
border: "1px solid rgba(255,255,255,0.1)",
}}
>
{/* Poll header */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: `linear-gradient(135deg, ${theme.colors.accent}, ${theme.colors.accentDark})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="10" rx="1" fill="white" />
<rect x="14" y="7" width="7" height="14" rx="1" fill="white" />
</svg>
</div>
<div>
<div style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 700, color: theme.colors.text }}>
SportsTime Trip
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 14, color: theme.colors.textSecondary }}>
West Coast Baseball Run
</div>
</div>
</div>
{/* Trip details */}
<div style={{ marginBottom: 20, padding: "14px 16px", background: "rgba(255,255,255,0.04)", borderRadius: 12 }}>
<div style={{ fontFamily: theme.fonts.text, fontSize: 16, color: theme.colors.text, marginBottom: 4 }}>
Jun 1218 &middot; 4 games &middot; 4 cities
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 14, color: theme.colors.textSecondary }}>
LA SF SD Phoenix
</div>
</div>
{/* Vote bar */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<span style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 700, color: theme.colors.text }}>
I'm in
</span>
<span style={{ fontFamily: theme.fonts.text, fontSize: 16, color: theme.colors.textSecondary }}>
{voteCount + 1}/{totalVoters}
</span>
</div>
<div style={{ height: 8, background: "rgba(255,255,255,0.08)", borderRadius: 4 }}>
<div
style={{
height: "100%",
width: `${yesWidth}%`,
background: theme.colors.success,
borderRadius: 4,
}}
/>
</div>
</div>
{/* Vote avatars */}
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{/* You (always voted) */}
<div
style={{
width: 36,
height: 36,
borderRadius: 18,
background: "#007AFF",
display: "flex",
justifyContent: "center",
alignItems: "center",
border: "2px solid #4CAF50",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 13, fontWeight: 700, color: "white" }}>
You
</span>
</div>
{/* Other votes pop in */}
{VOTES.map((vote) => {
const voteProgress = spring({
frame: frame - vote.delaySeconds * fps,
fps,
config: { damping: 10, stiffness: 200 },
});
const scale = interpolate(voteProgress, [0, 1], [0, 1]);
if (frame < vote.delaySeconds * fps) return null;
return (
<div
key={vote.name}
style={{
width: 36,
height: 36,
borderRadius: 18,
background: vote.color,
display: "flex",
justifyContent: "center",
alignItems: "center",
transform: `scale(${scale})`,
border: "2px solid #4CAF50",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 11, fontWeight: 700, color: "white" }}>
{vote.name.charAt(0)}
</span>
</div>
);
})}
</div>
</div>
</div>
{/* Reaction messages after votes */}
{frame >= 1.2 * fps && (
<div style={{ marginTop: 16 }}>
<ReactionBubble
text="LFG"
color="#34C759"
sender="Jake"
frame={frame}
fps={fps}
delay={1.2}
/>
</div>
)}
{frame >= 1.4 * fps && (
<div style={{ marginTop: 8 }}>
<ReactionBubble
text="\ud83d\udd25\ud83d\udd25\ud83d\udd25"
color="#FF9500"
sender="Mike"
frame={frame}
fps={fps}
delay={1.4}
/>
</div>
)}
</div>
{/* "Poll it. Done." overlay */}
<div
style={{
position: "absolute",
bottom: 180,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
}}
>
<div
style={{
transform: `scale(${overlayScale})`,
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 900,
color: "white",
letterSpacing: -2,
textShadow: "0 4px 30px rgba(0,0,0,0.8)",
}}
>
Poll it. Done.
</div>
</div>
</AbsoluteFill>
);
};
/** Reaction chat bubble */
const ReactionBubble: React.FC<{
text: string;
color: string;
sender: string;
frame: number;
fps: number;
delay: number;
}> = ({ text, color, sender, frame, fps, delay }) => {
const progress = spring({
frame: frame - delay * fps,
fps,
config: { damping: 14, stiffness: 200 },
});
const scale = interpolate(progress, [0, 1], [0.3, 1]);
const opacity = interpolate(progress, [0, 1], [0, 1]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
transform: `scale(${scale})`,
opacity,
transformOrigin: "left bottom",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 13, color, marginBottom: 2, marginLeft: 12 }}>
{sender}
</span>
<div
style={{
background: "#2C2C2E",
borderRadius: 20,
borderBottomLeftRadius: 6,
padding: "12px 18px",
}}
>
<span style={{ fontFamily: theme.fonts.text, fontSize: 20, color: "white" }}>{text}</span>
</div>
</div>
);
};

View File

@@ -1,290 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
/**
* Scene 4: Routes generated
*
* Route auto-generates inside phone frame.
* Map with animated route line + game cards appear.
* Overlay: "Real route. Real games."
*/
type Stop = {
city: string;
x: number;
y: number;
};
const STOPS: Stop[] = [
{ city: "LA", x: 180, y: 300 },
{ city: "SF", x: 140, y: 130 },
{ city: "SD", x: 240, y: 400 },
{ city: "PHX", x: 480, y: 340 },
];
export const RouteScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Overlay text
const overlayProgress = spring({
frame: frame - 0.3 * fps,
fps,
config: theme.animation.snappy,
});
const overlayOpacity = interpolate(
frame - 0.3 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Overlay: "Real route. Real games." */}
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
padding: "14px 36px",
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 34,
fontWeight: 800,
color: "white",
}}
>
Real route. Real games.
</span>
</div>
</div>
{/* Phone with route */}
<div style={{ marginTop: 40 }}>
<AppScreenshot delay={0} scale={0.82}>
<MockScreen>
<RouteContent />
</MockScreen>
</AppScreenshot>
</div>
</AbsoluteFill>
);
};
/** Route map + game list inside phone */
const RouteContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Route draw
const lineProgress = interpolate(
frame,
[0.15 * fps, 1.5 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const pathSegments: string[] = [];
for (let i = 0; i < STOPS.length - 1; i++) {
const from = STOPS[i];
const to = STOPS[i + 1];
if (i === 0) pathSegments.push(`M ${from.x} ${from.y}`);
const cx = (from.x + to.x) / 2;
const cy = Math.min(from.y, to.y) - 30;
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
}
const fullPath = pathSegments.join(" ");
// Game cards
const games = [
{ team: "@ Dodgers", venue: "Dodger Stadium", date: "Jun 12", color: "#005A9C" },
{ team: "@ Giants", venue: "Oracle Park", date: "Jun 14", color: "#FD5A1E" },
{ team: "@ Padres", venue: "Petco Park", date: "Jun 16", color: "#2F241D" },
{ team: "@ D-backs", venue: "Chase Field", date: "Jun 18", color: "#A71930" },
];
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Map section (top 45%) */}
<div style={{ position: "absolute", top: 0, left: 8, right: 8, height: "45%" }}>
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: `
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
borderRadius: 12,
}}
/>
<svg width="100%" height="100%" viewBox="0 0 600 480" style={{ position: "absolute", inset: 0 }}>
<path d={fullPath} fill="none" stroke="rgba(255,107,53,0.15)" strokeWidth="3.5" strokeLinecap="round" />
<path
d={fullPath}
fill="none"
stroke={theme.colors.accent}
strokeWidth="3.5"
strokeLinecap="round"
strokeDasharray="1500"
strokeDashoffset={1500 * (1 - lineProgress)}
/>
</svg>
{STOPS.map((stop, index) => {
const markerProgress = spring({
frame: frame - (0.2 + index * 0.35) * fps,
fps,
config: { damping: 12, stiffness: 180 },
});
const leftPct = (stop.x / 600) * 100;
const topPct = (stop.y / 480) * 100;
return (
<div
key={stop.city}
style={{
position: "absolute",
left: `${leftPct}%`,
top: `${topPct}%`,
transform: `translate(-50%, -50%) scale(${interpolate(markerProgress, [0, 1], [0, 1])})`,
opacity: interpolate(markerProgress, [0, 1], [0, 1]),
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<div
style={{
width: 16,
height: 16,
borderRadius: "50%",
background: theme.colors.secondary,
border: "2.5px solid white",
boxShadow: "0 2px 8px rgba(78,205,196,0.5)",
}}
/>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 13,
fontWeight: 700,
color: "white",
textShadow: "0 1px 6px rgba(0,0,0,0.8)",
}}
>
{stop.city}
</span>
</div>
);
})}
</div>
{/* Game cards (bottom 55%) */}
<div
style={{
position: "absolute",
bottom: 12,
left: 12,
right: 12,
top: "46%",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 20,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 4,
}}
>
Your Games
</div>
{games.map((game, index) => {
const cardDelay = 1.2 + index * 0.18;
const cardProgress = spring({
frame: frame - cardDelay * fps,
fps,
config: theme.animation.snappy,
});
const translateX = interpolate(cardProgress, [0, 1], [250, 0]);
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
return (
<div
key={game.venue}
style={{
background: "#1C1C1E",
borderRadius: 14,
padding: "14px 18px",
display: "flex",
alignItems: "center",
gap: 14,
transform: `translateX(${translateX}px)`,
opacity,
borderLeft: `4px solid ${game.color}`,
}}
>
<div style={{ minWidth: 50, textAlign: "center" }}>
<div style={{ fontFamily: theme.fonts.display, fontSize: 16, fontWeight: 700, color: theme.colors.text }}>
{game.date.split(" ")[1]}
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 11, color: theme.colors.textMuted }}>
{game.date.split(" ")[0]}
</div>
</div>
<div style={{ width: 1.5, height: 32, background: "rgba(255,255,255,0.08)" }} />
<div>
<div style={{ fontFamily: theme.fonts.display, fontSize: 17, fontWeight: 700, color: theme.colors.text }}>
{game.team}
</div>
<div style={{ fontFamily: theme.fonts.text, fontSize: 13, color: theme.colors.textSecondary }}>
{game.venue}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,281 +0,0 @@
import React from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "../../components/shared/theme";
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
import { TapIndicator } from "../../components/shared/TapIndicator";
/**
* Scene 3: "So I planned it myself"
*
* User opens SportsTime, builds trip.
* Shows date range + sport selection inside phone frame.
* Overlay: "So I planned it myself"
*/
export const SolutionScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Overlay text entrance
const overlayProgress = spring({
frame: frame - 0.3 * fps,
fps,
config: theme.animation.snappy,
});
const overlayOpacity = interpolate(
frame - 0.3 * fps,
[0, 0.2 * fps],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
background: theme.colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
{/* Overlay: "So I planned it myself" */}
<div
style={{
position: "absolute",
top: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
opacity: overlayOpacity,
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
zIndex: 20,
}}
>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
padding: "14px 36px",
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.15)",
}}
>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 34,
fontWeight: 800,
color: "white",
}}
>
So I planned it myself
</span>
</div>
</div>
{/* Phone with trip builder */}
<div style={{ marginTop: 40 }}>
<AppScreenshot delay={0} scale={0.82}>
<MockScreen>
<TripBuilderContent />
</MockScreen>
</AppScreenshot>
</div>
{/* Tap on the date range */}
<TapIndicator x={540} y={680} delay={0.8} />
</AbsoluteFill>
);
};
/** Trip builder screen content */
const TripBuilderContent: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const sports = [
{ name: "MLB", color: "#002D72", emoji: "\u26be" },
{ name: "NBA", color: "#C9082A", emoji: "\ud83c\udfc0" },
{ name: "NFL", color: "#013369", emoji: "\ud83c\udfc8" },
{ name: "NHL", color: "#000000", emoji: "\ud83c\udfd2" },
];
// MLB gets selected
const mlbSelect = spring({
frame: frame - 0.6 * fps,
fps,
config: theme.animation.snappy,
});
const mlbSelected = mlbSelect > 0.5;
// Date range highlight
const dateHighlight = spring({
frame: frame - 1.2 * fps,
fps,
config: theme.animation.smooth,
});
return (
<div style={{ padding: 12 }}>
{/* Header */}
<div style={{ marginBottom: 28 }}>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 32,
fontWeight: 700,
color: theme.colors.text,
marginBottom: 4,
}}
>
New Trip
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 17,
color: theme.colors.textSecondary,
}}
>
What do you want to see?
</div>
</div>
{/* Sport chips */}
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
{sports.map((sport, i) => {
const chipProgress = spring({
frame: frame - i * 4,
fps,
config: theme.animation.smooth,
});
const isSelected = sport.name === "MLB" && mlbSelected;
return (
<div
key={sport.name}
style={{
background: isSelected ? sport.color : "#1C1C1E",
borderRadius: 14,
padding: "14px 22px",
display: "flex",
alignItems: "center",
gap: 8,
opacity: interpolate(chipProgress, [0, 1], [0, 1]),
transform: `scale(${interpolate(chipProgress, [0, 1], [0.9, 1])})`,
border: isSelected ? "2px solid white" : "2px solid transparent",
}}
>
<span style={{ fontSize: 20 }}>{sport.emoji}</span>
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 18,
fontWeight: 700,
color: "white",
}}
>
{sport.name}
</span>
</div>
);
})}
</div>
{/* Date range section */}
<div
style={{
background: "#1C1C1E",
borderRadius: 16,
padding: 22,
marginBottom: 20,
border: `2px solid ${dateHighlight > 0.5 ? theme.colors.accent : "transparent"}`,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
marginBottom: 10,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
Travel Dates
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 24,
fontWeight: 700,
color: dateHighlight > 0.5 ? theme.colors.accent : theme.colors.text,
}}
>
Jun 12 Jun 18
</div>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 15,
color: theme.colors.textSecondary,
}}
>
7 days
</div>
</div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="18" rx="2" stroke={theme.colors.textSecondary} strokeWidth="2" />
<line x1="16" y1="2" x2="16" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
<line x1="8" y1="2" x2="8" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
<line x1="3" y1="10" x2="21" y2="10" stroke={theme.colors.textSecondary} strokeWidth="2" />
</svg>
</div>
</div>
{/* Region */}
<div
style={{
background: "#1C1C1E",
borderRadius: 16,
padding: 22,
}}
>
<div
style={{
fontFamily: theme.fonts.text,
fontSize: 14,
color: theme.colors.textMuted,
marginBottom: 10,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
Region
</div>
<div
style={{
fontFamily: theme.fonts.display,
fontSize: 22,
fontWeight: 600,
color: theme.colors.text,
}}
>
West Coast
</div>
</div>
</div>
);
};

View File

@@ -1,107 +0,0 @@
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 { ChatScene } from "./ChatScene";
import { AllTalkScene } from "./AllTalkScene";
import { SolutionScene } from "./SolutionScene";
import { RouteScene } from "./RouteScene";
import { PollScene } from "./PollScene";
import { CTAScene } from "./CTAScene";
/**
* V03: "The Group Chat" (Viral)
*
* Hook: iMessage group chat blowing up with "we should do a trip" energy
* Problem: Nobody actually plans it → "All talk."
* Solution: One person opens SportsTime → plans it → polls the group
* Length: 16 seconds (480 frames at 30fps)
*
* Timing breakdown:
* - 0:00-0:03.5 (0-105): Group chat messages flying in
* - 0:03.5-0:05.5 (105-165): Chat dies, typing stops, "All talk."
* - 0:05.5-0:08.5 (165-255): Opens SportsTime, builds trip, "So I planned it myself"
* - 0:08.5-0:11.5 (255-345): Route generates, games appear, "Real route. Real games."
* - 0:11.5-0:14 (345-420): Poll sent to group, votes flood in, "Poll it. Done."
* - 0:14-0:16 (420-480): CTA: "If your group chat is all talk → SportsTime"
*/
export const TheGroupChat: React.FC = () => {
const { fps } = useVideoConfig();
const TRANSITION = 10; // 10 frame transitions (snappy for TikTok pace)
// TransitionSeries subtracts transition duration from total.
// 5 transitions × 10 frames = 50 frames overlap.
// Scene sum must be 480 + 50 = 530 to fill 16s composition.
const SCENES = {
chat: 115, // ~3.8s - group chat chaos
allTalk: 70, // ~2.3s - chat dies, "All talk."
solution: 100, // ~3.3s - opens SportsTime, builds trip
route: 95, // ~3.2s - route generates, games appear
poll: 85, // ~2.8s - poll sent, votes flood in
cta: 65, // ~2.2s - CTA ending
}; // Total: 530 - 50 = 480 frames = 16s
return (
<AbsoluteFill>
<TransitionSeries>
{/* Scene 1: Group chat chaos */}
<TransitionSeries.Sequence durationInFrames={SCENES.chat}>
<ChatScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 2: "All talk." */}
<TransitionSeries.Sequence durationInFrames={SCENES.allTalk}>
<AllTalkScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 3: "So I planned it myself" */}
<TransitionSeries.Sequence durationInFrames={SCENES.solution}>
<SolutionScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 4: "Real route. Real games." */}
<TransitionSeries.Sequence durationInFrames={SCENES.route}>
<RouteScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 5: "Poll it. Done." */}
<TransitionSeries.Sequence durationInFrames={SCENES.poll}>
<PollScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: TRANSITION })}
/>
{/* Scene 6: CTA */}
<TransitionSeries.Sequence durationInFrames={SCENES.cta}>
<CTAScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};

View File

@@ -1,332 +0,0 @@
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

@@ -1,365 +0,0 @@
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

@@ -1,100 +0,0 @@
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

@@ -1,194 +0,0 @@
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

@@ -1,335 +0,0 @@
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

@@ -1,287 +0,0 @@
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

@@ -1,118 +0,0 @@
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

@@ -1,219 +0,0 @@
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

@@ -1,438 +0,0 @@
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

@@ -1,258 +0,0 @@
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

@@ -1,142 +0,0 @@
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>
);
};

View File

@@ -1,23 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "remotion.config.ts"],
"exclude": ["node_modules", "out"]
}