feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash

- Fix repeat-city travel placement: use stop indices instead of global city name
  matching so Follow Team trips with repeat cities show travel correctly
- Add TravelPlacement helper and regression tests (7 tests)
- Add alternate app icons for each theme, auto-switch on theme change
- Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20)
- Add marketing video configs, engine, and new video components
- Add docs and data exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-06 09:36:34 -06:00
parent fdcecafaa3
commit 8e937a5646
77 changed files with 143400 additions and 83 deletions

View File

@@ -8,11 +8,24 @@ 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
*
@@ -101,13 +114,58 @@ export const RemotionRoot: React.FC = () => {
/>
</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={() => <VideoFromConfig config={config} />}
component={ConfigVideo}
defaultProps={{ config }}
durationInFrames={Math.round(config.targetLengthSec * FPS)}
fps={FPS}
width={WIDTH}

View File

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

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

@@ -4,3 +4,6 @@ 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

@@ -0,0 +1,430 @@
[
{
"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

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

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

View File

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,89 @@
/**
* 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;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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