Remove marketing-videos Remotion project
The standalone Remotion video project is no longer needed in this repo. Also updates local Claude Code settings with additional tool permissions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,12 @@
|
|||||||
"Bash(chmod:*)",
|
"Bash(chmod:*)",
|
||||||
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-monochrome.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
|
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-monochrome.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
|
||||||
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-sunset.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
|
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-sunset.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
|
||||||
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)"
|
"Bash(\"/Users/treyt/Desktop/code/SportsTime/SportsTime/Assets.xcassets/AppIcon-midnight.appiconset/Contents.json\" << 'EOF'\n{\n \"images\" : [\n {\n \"filename\" : \"icon.png\",\n \"idiom\" : \"universal\",\n \"platform\" : \"ios\",\n \"size\" : \"1024x1024\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\nEOF)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(ffprobe:*)",
|
||||||
|
"WebFetch(domain:www.cbssports.com)",
|
||||||
|
"WebFetch(domain:www.mlb.com)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Get your Mapbox token at https://console.mapbox.com/account/access-tokens/
|
|
||||||
REMOTION_MAPBOX_TOKEN=pk.your-mapbox-access-token
|
|
||||||
16
marketing-videos/.gitignore
vendored
16
marketing-videos/.gitignore
vendored
@@ -1,16 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
out/
|
|
||||||
|
|
||||||
# Remotion cache
|
|
||||||
.remotion/
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
5280
marketing-videos/package-lock.json
generated
5280
marketing-videos/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "sportstime-marketing-videos",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Marketing videos for SportsTime app",
|
|
||||||
"scripts": {
|
|
||||||
"start": "remotion studio",
|
|
||||||
"build": "remotion render",
|
|
||||||
"render:route": "remotion render TheRoute out/the-route.mp4 --gl=angle --concurrency=1",
|
|
||||||
"render:checklist": "remotion render TheChecklist out/the-checklist.mp4",
|
|
||||||
"render:bucketlist": "remotion render TheBucketList out/the-bucket-list.mp4",
|
|
||||||
"render:squad": "remotion render TheSquad out/the-squad.mp4",
|
|
||||||
"render:handoff": "remotion render TheHandoff out/the-handoff.mp4",
|
|
||||||
"render:fantest": "remotion render TheFanTest out/the-fan-test.mp4",
|
|
||||||
"render:groupchat": "remotion render TheGroupChat out/the-group-chat.mp4",
|
|
||||||
"render:all-originals": "npm run render:route && npm run render:checklist && npm run render:bucketlist && npm run render:squad && npm run render:handoff && npm run render:fantest && npm run render:groupchat",
|
|
||||||
"render:V03-H01": "remotion render V03-H01 out/week1/V03-H01.mp4",
|
|
||||||
"render:V10-H01": "remotion render V10-H01 out/week1/V10-H01.mp4",
|
|
||||||
"render:V03-H02": "remotion render V03-H02 out/week1/V03-H02.mp4",
|
|
||||||
"render:V10-H02": "remotion render V10-H02 out/week1/V10-H02.mp4",
|
|
||||||
"render:V03-H03": "remotion render V03-H03 out/week1/V03-H03.mp4",
|
|
||||||
"render:V17-H01": "remotion render V17-H01 out/week1/V17-H01.mp4",
|
|
||||||
"render:V17-H02": "remotion render V17-H02 out/week1/V17-H02.mp4",
|
|
||||||
"render:V06-H01": "remotion render V06-H01 out/week1/V06-H01.mp4",
|
|
||||||
"render:V08-H01": "remotion render V08-H01 out/week1/V08-H01.mp4",
|
|
||||||
"render:V05-LA-01": "remotion render V05-LA-01 out/week1/V05-LA-01.mp4",
|
|
||||||
"render:V05-NY-01": "remotion render V05-NY-01 out/week1/V05-NY-01.mp4",
|
|
||||||
"render:V05-TX-01": "remotion render V05-TX-01 out/week1/V05-TX-01.mp4",
|
|
||||||
"render:V05-CA-01": "remotion render V05-CA-01 out/week1/V05-CA-01.mp4",
|
|
||||||
"render:V08-LA-01": "remotion render V08-LA-01 out/week1/V08-LA-01.mp4",
|
|
||||||
"render:V04-H01": "remotion render V04-H01 out/week1/V04-H01.mp4",
|
|
||||||
"render:V20-H01": "remotion render V20-H01 out/week1/V20-H01.mp4",
|
|
||||||
"render:V14-H01": "remotion render V14-H01 out/week1/V14-H01.mp4",
|
|
||||||
"render:V04-H02": "remotion render V04-H02 out/week1/V04-H02.mp4",
|
|
||||||
"render:V02-H01": "remotion render V02-H01 out/week1/V02-H01.mp4",
|
|
||||||
"render:V19-H01": "remotion render V19-H01 out/week1/V19-H01.mp4",
|
|
||||||
"render:week1": "bash scripts/render-week1.sh",
|
|
||||||
"render:all": "npm run render:all-originals && npm run render:week1"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@remotion/cli": "^4.0.410",
|
|
||||||
"@remotion/transitions": "^4.0.410",
|
|
||||||
"@turf/turf": "^7.3.2",
|
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
|
||||||
"@types/react": "^19.2.9",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"mapbox-gl": "^3.18.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"remotion": "^4.0.410",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Asset Placeholder Directory
|
|
||||||
|
|
||||||
Replace these placeholders with real assets before rendering final videos.
|
|
||||||
|
|
||||||
## Screen Recordings (`screenrecs/`)
|
|
||||||
Record these on-device using the SportsTime app:
|
|
||||||
|
|
||||||
| File | Description | Screen to Record |
|
|
||||||
|------|-------------|-----------------|
|
|
||||||
| `date_range.mp4` | Picking dates in trip wizard | Trip Wizard → Date Range step |
|
|
||||||
| `follow_team.mp4` | Following a team flow | Trip Wizard → Follow Team mode |
|
|
||||||
| `by_games.mp4` | Browsing by games | Trip Wizard → By Games mode |
|
|
||||||
| `route_generated.mp4` | Route result appearing | Trip Detail → after planning |
|
|
||||||
| `poll_create.mp4` | Creating a group poll | Trip Options → Share/Poll |
|
|
||||||
| `tracker.mp4` | Stadium tracker/bucket list | Profile → Stadium Tracker |
|
|
||||||
|
|
||||||
**Recording tips:**
|
|
||||||
- Use iPhone screen recording (Settings → Control Center)
|
|
||||||
- Portrait orientation, 1080x1920 native
|
|
||||||
- 5-10 seconds per clip
|
|
||||||
- Clean, smooth taps (no fumbling)
|
|
||||||
|
|
||||||
## Overlays (`overlays/`)
|
|
||||||
|
|
||||||
| File | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `imessage_bg.png` | iMessage background texture (optional) |
|
|
||||||
| `chat_bubbles.png` | Chat bubble overlay (optional) |
|
|
||||||
| `vote_bubbles.png` | Vote notification overlay (optional) |
|
|
||||||
|
|
||||||
## B-Roll (`broll/`)
|
|
||||||
|
|
||||||
| File | Description | Source |
|
|
||||||
|------|-------------|--------|
|
|
||||||
| `highway.mp4` | Highway driving footage | Stock/Pexels |
|
|
||||||
| `city.mp4` | City skyline/aerial | Stock/Pexels |
|
|
||||||
| `stadium.mp4` | Stadium exterior | Stock/Pexels |
|
|
||||||
|
|
||||||
**Note:** B-roll is optional. Videos render with placeholder scenes when assets are missing.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { Config } from "@remotion/cli/config";
|
|
||||||
|
|
||||||
Config.setVideoImageFormat("jpeg");
|
|
||||||
Config.setOverwriteOutput(true);
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Batch render all 20 Week 1 Reels videos.
|
|
||||||
# Usage: bash scripts/render-week1.sh [--concurrency N]
|
|
||||||
#
|
|
||||||
# Options:
|
|
||||||
# --concurrency N Number of parallel renders (default: 2)
|
|
||||||
# --ids ID1,ID2 Comma-separated list of specific IDs to render
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CONCURRENCY=${1:-2}
|
|
||||||
OUT_DIR="out/week1"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
|
|
||||||
IDS=(
|
|
||||||
V03-H01
|
|
||||||
V10-H01
|
|
||||||
V03-H02
|
|
||||||
V10-H02
|
|
||||||
V03-H03
|
|
||||||
V17-H01
|
|
||||||
V17-H02
|
|
||||||
V06-H01
|
|
||||||
V08-H01
|
|
||||||
V05-LA-01
|
|
||||||
V05-NY-01
|
|
||||||
V05-TX-01
|
|
||||||
V05-CA-01
|
|
||||||
V08-LA-01
|
|
||||||
V04-H01
|
|
||||||
V20-H01
|
|
||||||
V14-H01
|
|
||||||
V04-H02
|
|
||||||
V02-H01
|
|
||||||
V19-H01
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse optional --ids flag
|
|
||||||
if [[ "${1:-}" == "--ids" ]]; then
|
|
||||||
IFS=',' read -ra IDS <<< "${2:-}"
|
|
||||||
shift 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
TOTAL=${#IDS[@]}
|
|
||||||
DONE=0
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== SportsTime Week 1 Batch Render ==="
|
|
||||||
echo " Videos: $TOTAL"
|
|
||||||
echo " Output: $OUT_DIR/"
|
|
||||||
echo " Concurrency: $CONCURRENCY"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
render_one() {
|
|
||||||
local id=$1
|
|
||||||
local out="$OUT_DIR/${id}.mp4"
|
|
||||||
echo "[START] $id → $out"
|
|
||||||
if npx remotion render "$id" "$out" --log=error 2>&1; then
|
|
||||||
echo "[DONE] $id ✓"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
echo "[FAIL] $id ✗"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
export -f render_one
|
|
||||||
export OUT_DIR
|
|
||||||
|
|
||||||
# Run renders with controlled concurrency using xargs
|
|
||||||
printf '%s\n' "${IDS[@]}" | xargs -P "$CONCURRENCY" -I {} bash -c 'render_one "$@"' _ {}
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Render Complete ==="
|
|
||||||
echo " Output: $OUT_DIR/"
|
|
||||||
ls -lh "$OUT_DIR"/*.mp4 2>/dev/null || echo " (no .mp4 files found - check errors above)"
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Composition, Folder } from "remotion";
|
|
||||||
|
|
||||||
import { TheRoute } from "./videos/TheRoute";
|
|
||||||
import { TheChecklist } from "./videos/TheChecklist";
|
|
||||||
import { TheBucketList } from "./videos/TheBucketList";
|
|
||||||
import { TheSquad } from "./videos/TheSquad";
|
|
||||||
import { TheHandoff } from "./videos/TheHandoff";
|
|
||||||
import { TheFanTest } from "./videos/TheFanTest";
|
|
||||||
import { TheGroupChat } from "./videos/TheGroupChat";
|
|
||||||
import { StadiumCountFlex } from "./videos/StadiumCountFlex";
|
|
||||||
import { GroupChatChaos } from "./videos/GroupChatChaos";
|
|
||||||
import { LocalCityRoute } from "./videos/LocalCityRoute";
|
|
||||||
import { SpreadsheetEra } from "./videos/SpreadsheetEra";
|
|
||||||
import { AwayGameTake } from "./videos/AwayGameTake";
|
|
||||||
|
|
||||||
import { VideoFromConfig } from "./engine";
|
|
||||||
import type { VideoConfig } from "./engine";
|
|
||||||
import week1Configs from "./configs/week1.json";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper component that receives config via defaultProps.
|
|
||||||
* Avoids inline arrow functions in Composition component prop.
|
|
||||||
*/
|
|
||||||
const ConfigVideo: React.FC<{ config: VideoConfig }> = ({ config }) => {
|
|
||||||
return <VideoFromConfig config={config} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SportsTime Marketing Videos
|
|
||||||
*
|
|
||||||
* All videos are portrait format (1080x1920) at 30fps
|
|
||||||
* Designed for App Store, YouTube, Instagram, and TikTok
|
|
||||||
*/
|
|
||||||
export const RemotionRoot: React.FC = () => {
|
|
||||||
const FPS = 30;
|
|
||||||
const WIDTH = 1080;
|
|
||||||
const HEIGHT = 1920;
|
|
||||||
|
|
||||||
const configs = week1Configs as VideoConfig[];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Original hand-crafted marketing videos */}
|
|
||||||
<Folder name="SportsTime-Marketing">
|
|
||||||
{/* Video 1: The Route - Map animation showcasing trip planning */}
|
|
||||||
<Composition
|
|
||||||
id="TheRoute"
|
|
||||||
component={TheRoute}
|
|
||||||
durationInFrames={15 * FPS} // 15 seconds = 450 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Video 2: The Checklist - Wizard walkthrough */}
|
|
||||||
<Composition
|
|
||||||
id="TheChecklist"
|
|
||||||
component={TheChecklist}
|
|
||||||
durationInFrames={20 * FPS} // 20 seconds = 600 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Video 3: The Bucket List - Stadium progress tracking */}
|
|
||||||
<Composition
|
|
||||||
id="TheBucketList"
|
|
||||||
component={TheBucketList}
|
|
||||||
durationInFrames={12 * FPS} // 12 seconds = 360 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Video 4: The Squad - Group polling feature */}
|
|
||||||
<Composition
|
|
||||||
id="TheSquad"
|
|
||||||
component={TheSquad}
|
|
||||||
durationInFrames={18 * FPS} // 18 seconds = 540 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Video 5: The Handoff - PDF export showcase */}
|
|
||||||
<Composition
|
|
||||||
id="TheHandoff"
|
|
||||||
component={TheHandoff}
|
|
||||||
durationInFrames={10 * FPS} // 10 seconds = 300 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Video 6: The Fan Test - Viral identity challenge */}
|
|
||||||
<Composition
|
|
||||||
id="TheFanTest"
|
|
||||||
component={TheFanTest}
|
|
||||||
durationInFrames={18 * FPS} // 18 seconds = 540 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Video 7: The Group Chat - Viral group chat chaos */}
|
|
||||||
<Composition
|
|
||||||
id="TheGroupChat"
|
|
||||||
component={TheGroupChat}
|
|
||||||
durationInFrames={16 * FPS} // 16 seconds = 480 frames
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
</Folder>
|
|
||||||
|
|
||||||
{/* Hand-crafted TikTok/Reels - unique visual identity per video */}
|
|
||||||
<Folder name="TikTok-Originals">
|
|
||||||
<Composition
|
|
||||||
id="StadiumCountFlex"
|
|
||||||
component={StadiumCountFlex}
|
|
||||||
durationInFrames={15 * FPS}
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
<Composition
|
|
||||||
id="GroupChatChaos"
|
|
||||||
component={GroupChatChaos}
|
|
||||||
durationInFrames={16 * FPS}
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
<Composition
|
|
||||||
id="LocalCityRoute"
|
|
||||||
component={LocalCityRoute}
|
|
||||||
durationInFrames={14 * FPS}
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
<Composition
|
|
||||||
id="SpreadsheetEra"
|
|
||||||
component={SpreadsheetEra}
|
|
||||||
durationInFrames={15 * FPS}
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
<Composition
|
|
||||||
id="AwayGameTake"
|
|
||||||
component={AwayGameTake}
|
|
||||||
durationInFrames={15 * FPS}
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
</Folder>
|
|
||||||
|
|
||||||
{/* Week 1: 20 config-driven TikTok/Reels videos */}
|
|
||||||
<Folder name="Week1-Reels">
|
|
||||||
{configs.map((config) => (
|
|
||||||
<Composition
|
|
||||||
key={config.id}
|
|
||||||
id={config.id}
|
|
||||||
component={ConfigVideo}
|
|
||||||
defaultProps={{ config }}
|
|
||||||
durationInFrames={Math.round(config.targetLengthSec * FPS)}
|
|
||||||
fps={FPS}
|
|
||||||
width={WIDTH}
|
|
||||||
height={HEIGHT}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Folder>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
Img,
|
|
||||||
staticFile,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "./theme";
|
|
||||||
|
|
||||||
type AppScreenshotProps = {
|
|
||||||
src?: string;
|
|
||||||
delay?: number;
|
|
||||||
scale?: number;
|
|
||||||
showDeviceFrame?: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppScreenshot: React.FC<AppScreenshotProps> = ({
|
|
||||||
src,
|
|
||||||
delay = 0,
|
|
||||||
scale = 0.85,
|
|
||||||
showDeviceFrame = true,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
|
|
||||||
const progress = spring({
|
|
||||||
frame: frame - delayFrames,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(
|
|
||||||
frame - delayFrames,
|
|
||||||
[0, fps * 0.3],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const scaleValue = interpolate(progress, [0, 1], [0.9, 1]) * scale;
|
|
||||||
|
|
||||||
if (frame < delayFrames) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneWidth = width * 0.75;
|
|
||||||
const phoneHeight = height * 0.8;
|
|
||||||
const cornerRadius = 60;
|
|
||||||
const bezelWidth = 12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity,
|
|
||||||
transform: `scale(${scaleValue})`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showDeviceFrame ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: phoneWidth,
|
|
||||||
height: phoneHeight,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: cornerRadius,
|
|
||||||
padding: bezelWidth,
|
|
||||||
boxShadow: `
|
|
||||||
0 50px 100px rgba(0, 0, 0, 0.5),
|
|
||||||
0 20px 40px rgba(0, 0, 0, 0.3),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Dynamic Island */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: bezelWidth + 15,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: 120,
|
|
||||||
height: 36,
|
|
||||||
background: "#000",
|
|
||||||
borderRadius: 18,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Screen content */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
borderRadius: cornerRadius - bezelWidth,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: theme.colors.background,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<Img
|
|
||||||
src={staticFile(src)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Home indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: bezelWidth + 10,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: 140,
|
|
||||||
height: 5,
|
|
||||||
background: "rgba(255, 255, 255, 0.3)",
|
|
||||||
borderRadius: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: phoneWidth - 40,
|
|
||||||
height: phoneHeight - 40,
|
|
||||||
borderRadius: cornerRadius - 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: theme.colors.background,
|
|
||||||
boxShadow: `0 30px 80px rgba(0, 0, 0, 0.4)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{src ? (
|
|
||||||
<Img
|
|
||||||
src={staticFile(src)}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock screen content components
|
|
||||||
type MockScreenProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MockScreen: React.FC<MockScreenProps> = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: theme.spacing.lg,
|
|
||||||
paddingTop: 80, // Account for Dynamic Island
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
|
||||||
import { theme } from "./theme";
|
|
||||||
|
|
||||||
type GradientBackgroundProps = {
|
|
||||||
animate?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GradientBackground: React.FC<GradientBackgroundProps> = ({
|
|
||||||
animate = false,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
|
|
||||||
const gradientAngle = animate
|
|
||||||
? interpolate(frame, [0, 300], [180, 200], { extrapolateRight: "clamp" })
|
|
||||||
: 180;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(${gradientAngle}deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Subtle grid pattern background
|
|
||||||
export const GridBackground: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: "40px 40px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Radial glow background
|
|
||||||
type GlowBackgroundProps = {
|
|
||||||
color?: string;
|
|
||||||
intensity?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GlowBackground: React.FC<GlowBackgroundProps> = ({
|
|
||||||
color = theme.colors.accent,
|
|
||||||
intensity = 0.15,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "30%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "150%",
|
|
||||||
height: "80%",
|
|
||||||
background: `radial-gradient(ellipse, ${color}${Math.round(intensity * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, useCurrentFrame } from "remotion";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subtle film grain overlay that makes videos feel organic/real.
|
|
||||||
* Uses deterministic noise per frame for reproducible renders.
|
|
||||||
*/
|
|
||||||
export const FilmGrain: React.FC<{ opacity?: number }> = ({
|
|
||||||
opacity = 0.04,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
// Shift the noise pattern each frame using a CSS trick
|
|
||||||
const offsetX = ((frame * 73) % 200) - 100;
|
|
||||||
const offsetY = ((frame * 47) % 200) - 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
pointerEvents: "none",
|
|
||||||
mixBlendMode: "overlay",
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="100%" height="100%">
|
|
||||||
<filter id={`grain-${frame % 10}`}>
|
|
||||||
<feTurbulence
|
|
||||||
type="fractalNoise"
|
|
||||||
baseFrequency="0.65"
|
|
||||||
numOctaves="3"
|
|
||||||
seed={frame}
|
|
||||||
stitchTiles="stitch"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
<rect
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
filter={`url(#grain-${frame % 10})`}
|
|
||||||
transform={`translate(${offsetX}, ${offsetY})`}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "./theme";
|
|
||||||
|
|
||||||
type LogoEndcardProps = {
|
|
||||||
tagline?: string;
|
|
||||||
showAppStoreBadge?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LogoEndcard: React.FC<LogoEndcardProps> = ({
|
|
||||||
tagline,
|
|
||||||
showAppStoreBadge = false,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Logo entrance with slight bounce
|
|
||||||
const logoScale = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 100 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const logoOpacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tagline entrance (delayed)
|
|
||||||
const taglineProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
delay: fps * 0.4,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const taglineOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[fps * 0.4, fps * 0.6],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const taglineY = interpolate(taglineProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
// App Store badge entrance (delayed further)
|
|
||||||
const badgeProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
delay: fps * 0.7,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const badgeOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[fps * 0.7, fps * 0.9],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* App Icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${logoScale})`,
|
|
||||||
opacity: logoOpacity,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing.lg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon placeholder - rounded square with gradient */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 180,
|
|
||||||
height: 180,
|
|
||||||
borderRadius: 40,
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
|
||||||
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.4)`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Simple stadium icon representation */}
|
|
||||||
<svg
|
|
||||||
width="100"
|
|
||||||
height="100"
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<ellipse
|
|
||||||
cx="50"
|
|
||||||
cy="60"
|
|
||||||
rx="40"
|
|
||||||
ry="20"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 60 L10 40 Q50 10 90 40 L90 60"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="50"
|
|
||||||
y1="30"
|
|
||||||
x2="50"
|
|
||||||
y2="15"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
/>
|
|
||||||
<circle cx="50" cy="12" r="4" fill="white" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wordmark */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: theme.fontSizes.hero,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: -2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SportsTime
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tagline */}
|
|
||||||
{tagline && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: showAppStoreBadge ? 280 : 200,
|
|
||||||
opacity: taglineOpacity,
|
|
||||||
transform: `translateY(${taglineY}px)`,
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: theme.fontSizes.subtitle,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tagline}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* App Store Badge */}
|
|
||||||
{showAppStoreBadge && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 120,
|
|
||||||
opacity: badgeOpacity,
|
|
||||||
transform: `scale(${badgeProgress})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px 32px",
|
|
||||||
background: theme.colors.text,
|
|
||||||
borderRadius: 12,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="black">
|
|
||||||
<path d="M16 2C8.268 2 2 8.268 2 16s6.268 14 14 14 14-6.268 14-14S23.732 2 16 2zm5.788 20.683c-.314.467-.782.7-1.404.7-.311 0-.622-.078-.933-.233l-3.451-1.867-3.451 1.867c-.311.155-.622.233-.933.233-.622 0-1.09-.233-1.404-.7-.314-.467-.389-1.012-.225-1.635l.933-3.917-3.062-2.567c-.467-.389-.7-.856-.7-1.4 0-.544.233-1.012.7-1.4l3.062-2.567-.933-3.917c-.164-.623-.089-1.168.225-1.635.314-.467.782-.7 1.404-.7.311 0 .622.078.933.233L16 5.142l3.451-1.867c.311-.155.622-.233.933-.233.622 0 1.09.233 1.404.7.314.467.389 1.012.225 1.635l-.933 3.917 3.062 2.567c.467.388.7.856.7 1.4 0 .544-.233 1.011-.7 1.4l-3.062 2.567.933 3.917c.164.623.089 1.168-.225 1.635z" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "black",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download on the App Store
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "./theme";
|
|
||||||
|
|
||||||
type TapIndicatorProps = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
delay?: number;
|
|
||||||
showRipple?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TapIndicator: React.FC<TapIndicatorProps> = ({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
delay = 0,
|
|
||||||
showRipple = true,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
const localFrame = frame - delayFrames;
|
|
||||||
|
|
||||||
if (localFrame < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finger appears
|
|
||||||
const fingerProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Finger presses down
|
|
||||||
const pressProgress = spring({
|
|
||||||
frame: localFrame - fps * 0.2,
|
|
||||||
fps,
|
|
||||||
config: { damping: 30, stiffness: 300 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const fingerScale = interpolate(fingerProgress, [0, 1], [0.5, 1]);
|
|
||||||
const fingerOpacity = interpolate(fingerProgress, [0, 1], [0, 1]);
|
|
||||||
const pressScale = interpolate(pressProgress, [0, 1], [1, 0.9]);
|
|
||||||
|
|
||||||
// Ripple effect
|
|
||||||
const rippleProgress = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[fps * 0.25, fps * 0.7],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const rippleScale = interpolate(rippleProgress, [0, 1], [0.5, 2]);
|
|
||||||
const rippleOpacity = interpolate(rippleProgress, [0, 0.3, 1], [0, 0.5, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Ripple */}
|
|
||||||
{showRipple && rippleProgress > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.accent,
|
|
||||||
opacity: rippleOpacity,
|
|
||||||
transform: `translate(-50%, -50%) scale(${rippleScale})`,
|
|
||||||
left: "50%",
|
|
||||||
top: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Finger circle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `rgba(255, 255, 255, 0.9)`,
|
|
||||||
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3)`,
|
|
||||||
opacity: fingerOpacity,
|
|
||||||
transform: `scale(${fingerScale * pressScale})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Swipe indicator for tutorial-style animations
|
|
||||||
type SwipeIndicatorProps = {
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
endX: number;
|
|
||||||
endY: number;
|
|
||||||
delay?: number;
|
|
||||||
duration?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SwipeIndicator: React.FC<SwipeIndicatorProps> = ({
|
|
||||||
startX,
|
|
||||||
startY,
|
|
||||||
endX,
|
|
||||||
endY,
|
|
||||||
delay = 0,
|
|
||||||
duration = 0.8,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
const durationFrames = duration * fps;
|
|
||||||
const localFrame = frame - delayFrames;
|
|
||||||
|
|
||||||
if (localFrame < 0 || localFrame > durationFrames + fps * 0.3) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, durationFrames],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const x = interpolate(progress, [0, 1], [startX, endX]);
|
|
||||||
const y = interpolate(progress, [0, 1], [startY, endY]);
|
|
||||||
|
|
||||||
const opacity = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, fps * 0.1, durationFrames, durationFrames + fps * 0.2],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
pointerEvents: "none",
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `rgba(255, 255, 255, 0.9)`,
|
|
||||||
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "./theme";
|
|
||||||
|
|
||||||
type TextRevealProps = {
|
|
||||||
text: string;
|
|
||||||
fontSize?: number;
|
|
||||||
color?: string;
|
|
||||||
fontWeight?: number;
|
|
||||||
textAlign?: "left" | "center" | "right";
|
|
||||||
delay?: number;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TextReveal: React.FC<TextRevealProps> = ({
|
|
||||||
text,
|
|
||||||
fontSize = theme.fontSizes.title,
|
|
||||||
color = theme.colors.text,
|
|
||||||
fontWeight = 700,
|
|
||||||
textAlign = "center",
|
|
||||||
delay = 0,
|
|
||||||
style = {},
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
|
|
||||||
// Spring for smooth entrance
|
|
||||||
const progress = spring({
|
|
||||||
frame: frame - delayFrames,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(
|
|
||||||
frame - delayFrames,
|
|
||||||
[0, fps * 0.3],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
|
||||||
|
|
||||||
if (frame < delayFrames) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize,
|
|
||||||
fontWeight,
|
|
||||||
color,
|
|
||||||
textAlign,
|
|
||||||
opacity,
|
|
||||||
transform: `translateY(${translateY}px)`,
|
|
||||||
letterSpacing: -1,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Multi-line version with staggered reveal
|
|
||||||
type TextRevealMultilineProps = {
|
|
||||||
lines: string[];
|
|
||||||
fontSize?: number;
|
|
||||||
color?: string;
|
|
||||||
fontWeight?: number;
|
|
||||||
textAlign?: "left" | "center" | "right";
|
|
||||||
staggerDelay?: number;
|
|
||||||
startDelay?: number;
|
|
||||||
lineHeight?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TextRevealMultiline: React.FC<TextRevealMultilineProps> = ({
|
|
||||||
lines,
|
|
||||||
fontSize = theme.fontSizes.title,
|
|
||||||
color = theme.colors.text,
|
|
||||||
fontWeight = 700,
|
|
||||||
textAlign = "center",
|
|
||||||
staggerDelay = 0.1,
|
|
||||||
startDelay = 0,
|
|
||||||
lineHeight = 1.3,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: textAlign === "center" ? "center" : textAlign === "right" ? "flex-end" : "flex-start",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lines.map((line, index) => (
|
|
||||||
<TextReveal
|
|
||||||
key={index}
|
|
||||||
text={line}
|
|
||||||
fontSize={fontSize}
|
|
||||||
color={color}
|
|
||||||
fontWeight={fontWeight}
|
|
||||||
textAlign={textAlign}
|
|
||||||
delay={startDelay + index * staggerDelay}
|
|
||||||
style={{ lineHeight }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Highlight style text (with background)
|
|
||||||
type HighlightTextProps = {
|
|
||||||
text: string;
|
|
||||||
fontSize?: number;
|
|
||||||
delay?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HighlightText: React.FC<HighlightTextProps> = ({
|
|
||||||
text,
|
|
||||||
fontSize = theme.fontSizes.subtitle,
|
|
||||||
delay = 0,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
|
|
||||||
const progress = spring({
|
|
||||||
frame: frame - delayFrames,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scaleX = interpolate(progress, [0, 1], [0, 1]);
|
|
||||||
const textOpacity = interpolate(
|
|
||||||
frame - delayFrames,
|
|
||||||
[fps * 0.15, fps * 0.3],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (frame < delayFrames) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative", display: "inline-block" }}>
|
|
||||||
{/* Background highlight */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: -12,
|
|
||||||
right: -12,
|
|
||||||
bottom: 0,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
borderRadius: 8,
|
|
||||||
transform: `scaleX(${scaleX})`,
|
|
||||||
transformOrigin: "left",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Text */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
opacity: textOpacity,
|
|
||||||
padding: "4px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "./theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TikTok-native kinetic caption system.
|
|
||||||
*
|
|
||||||
* Unlike generic subtitle overlays, each style mimics real TikTok
|
|
||||||
* caption patterns: punch zooms, word pops, highlight boxes, etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type CaptionEntry = {
|
|
||||||
text: string;
|
|
||||||
startSec: number;
|
|
||||||
endSec: number;
|
|
||||||
style?: "punch" | "highlight" | "stack" | "whisper" | "shake";
|
|
||||||
};
|
|
||||||
|
|
||||||
type TikTokCaptionProps = {
|
|
||||||
captions: CaptionEntry[];
|
|
||||||
/** Vertical position from bottom (px) */
|
|
||||||
bottomOffset?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TikTokCaption: React.FC<TikTokCaptionProps> = ({
|
|
||||||
captions,
|
|
||||||
bottomOffset = 280,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const currentSec = frame / fps;
|
|
||||||
const active = captions.find(
|
|
||||||
(c) => currentSec >= c.startSec && currentSec < c.endSec
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!active) return null;
|
|
||||||
|
|
||||||
const startFrame = active.startSec * fps;
|
|
||||||
const endFrame = active.endSec * fps;
|
|
||||||
const localFrame = frame - startFrame;
|
|
||||||
const style = active.style || "punch";
|
|
||||||
|
|
||||||
const exitOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[endFrame - 4, endFrame],
|
|
||||||
[1, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: bottomOffset,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
pointerEvents: "none",
|
|
||||||
opacity: exitOpacity,
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{style === "punch" && (
|
|
||||||
<PunchCaption text={active.text} localFrame={localFrame} fps={fps} />
|
|
||||||
)}
|
|
||||||
{style === "highlight" && (
|
|
||||||
<HighlightCaption text={active.text} localFrame={localFrame} fps={fps} />
|
|
||||||
)}
|
|
||||||
{style === "stack" && (
|
|
||||||
<StackCaption text={active.text} localFrame={localFrame} fps={fps} />
|
|
||||||
)}
|
|
||||||
{style === "whisper" && (
|
|
||||||
<WhisperCaption text={active.text} localFrame={localFrame} fps={fps} />
|
|
||||||
)}
|
|
||||||
{style === "shake" && (
|
|
||||||
<ShakeCaption text={active.text} localFrame={localFrame} fps={fps} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Punch zoom in - text scales from big to normal with impact */
|
|
||||||
const PunchCaption: React.FC<{
|
|
||||||
text: string;
|
|
||||||
localFrame: number;
|
|
||||||
fps: number;
|
|
||||||
}> = ({ text, localFrame, fps }) => {
|
|
||||||
const prog = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 10, stiffness: 280 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(prog, [0, 1], [1.8, 1]);
|
|
||||||
const opacity = interpolate(prog, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 52,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "white",
|
|
||||||
textAlign: "center",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: -1,
|
|
||||||
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
maxWidth: 900,
|
|
||||||
lineHeight: 1.15,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Highlight box - text with colored background box that wipes in */
|
|
||||||
const HighlightCaption: React.FC<{
|
|
||||||
text: string;
|
|
||||||
localFrame: number;
|
|
||||||
fps: number;
|
|
||||||
}> = ({ text, localFrame, fps }) => {
|
|
||||||
const boxProg = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const textProg = spring({
|
|
||||||
frame: localFrame - 3,
|
|
||||||
fps,
|
|
||||||
config: { damping: 20, stiffness: 180 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative", display: "inline-block" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: "-8px -20px",
|
|
||||||
background: theme.colors.accent,
|
|
||||||
borderRadius: 8,
|
|
||||||
transform: `scaleX(${boxProg})`,
|
|
||||||
transformOrigin: "left",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 46,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "white",
|
|
||||||
opacity: interpolate(textProg, [0, 1], [0, 1]),
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
textShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Stack - words stack vertically, each popping in */
|
|
||||||
const StackCaption: React.FC<{
|
|
||||||
text: string;
|
|
||||||
localFrame: number;
|
|
||||||
fps: number;
|
|
||||||
}> = ({ text, localFrame, fps }) => {
|
|
||||||
const words = text.split(" ");
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{words.map((word, i) => {
|
|
||||||
const delay = i * 2;
|
|
||||||
const prog = spring({
|
|
||||||
frame: localFrame - delay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 250 },
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "white",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 2,
|
|
||||||
transform: `scale(${interpolate(prog, [0, 1], [0.5, 1])}) translateY(${interpolate(prog, [0, 1], [20, 0])}px)`,
|
|
||||||
opacity: interpolate(prog, [0, 0.5], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
}),
|
|
||||||
textShadow:
|
|
||||||
"0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
|
|
||||||
lineHeight: 1.0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{word}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Whisper - small italic text that fades in gently */
|
|
||||||
const WhisperCaption: React.FC<{
|
|
||||||
text: string;
|
|
||||||
localFrame: number;
|
|
||||||
fps: number;
|
|
||||||
}> = ({ text, localFrame, fps }) => {
|
|
||||||
const opacity = interpolate(localFrame, [0, fps * 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 400,
|
|
||||||
fontStyle: "italic",
|
|
||||||
color: "rgba(255,255,255,0.8)",
|
|
||||||
textAlign: "center",
|
|
||||||
opacity,
|
|
||||||
letterSpacing: 1,
|
|
||||||
textShadow: "0 2px 12px rgba(0,0,0,0.6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Shake - text shakes briefly on entrance then settles */
|
|
||||||
const ShakeCaption: React.FC<{
|
|
||||||
text: string;
|
|
||||||
localFrame: number;
|
|
||||||
fps: number;
|
|
||||||
}> = ({ text, localFrame, fps }) => {
|
|
||||||
const prog = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 8, stiffness: 300 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shake offsets that decay over ~5 frames
|
|
||||||
const shakeIntensity = interpolate(localFrame, [0, 5], [8, 0], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const OFFSETS = [
|
|
||||||
{ x: -1, y: 1 },
|
|
||||||
{ x: 1, y: -1 },
|
|
||||||
{ x: -1, y: -1 },
|
|
||||||
{ x: 1, y: 1 },
|
|
||||||
{ x: 0, y: -1 },
|
|
||||||
];
|
|
||||||
const offset = OFFSETS[localFrame % OFFSETS.length];
|
|
||||||
const sx = offset.x * shakeIntensity;
|
|
||||||
const sy = offset.y * shakeIntensity;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 50,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "white",
|
|
||||||
textAlign: "center",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
transform: `translate(${sx}px, ${sy}px) scale(${interpolate(prog, [0, 1], [1.3, 1])})`,
|
|
||||||
opacity: interpolate(prog, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
}),
|
|
||||||
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
|
|
||||||
maxWidth: 900,
|
|
||||||
lineHeight: 1.15,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type { CaptionEntry };
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export { theme } from "./theme";
|
|
||||||
export { LogoEndcard } from "./LogoEndcard";
|
|
||||||
export { TextReveal, TextRevealMultiline, HighlightText } from "./TextReveal";
|
|
||||||
export { TapIndicator, SwipeIndicator } from "./TapIndicator";
|
|
||||||
export { AppScreenshot, MockScreen } from "./AppScreenshot";
|
|
||||||
export { GradientBackground, GridBackground, GlowBackground } from "./Background";
|
|
||||||
export { FilmGrain } from "./FilmGrain";
|
|
||||||
export { TikTokCaption } from "./TikTokCaption";
|
|
||||||
export type { CaptionEntry } from "./TikTokCaption";
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// SportsTime Marketing Video Theme
|
|
||||||
export const theme = {
|
|
||||||
colors: {
|
|
||||||
background: "#0A0A0A",
|
|
||||||
backgroundGradientStart: "#0A0A0A",
|
|
||||||
backgroundGradientEnd: "#1A1A2E",
|
|
||||||
accent: "#FF6B35",
|
|
||||||
accentDark: "#E55A25",
|
|
||||||
secondary: "#4ECDC4",
|
|
||||||
secondaryDark: "#3DBDB5",
|
|
||||||
text: "#FFFFFF",
|
|
||||||
textSecondary: "#B0B0B0",
|
|
||||||
textMuted: "#6B6B6B",
|
|
||||||
success: "#4CAF50",
|
|
||||||
gold: "#FFD700",
|
|
||||||
mapLine: "#FF6B35",
|
|
||||||
mapMarker: "#4ECDC4",
|
|
||||||
},
|
|
||||||
fonts: {
|
|
||||||
display: "SF Pro Display, -apple-system, BlinkMacSystemFont, sans-serif",
|
|
||||||
text: "SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif",
|
|
||||||
},
|
|
||||||
fontSizes: {
|
|
||||||
hero: 72,
|
|
||||||
headline: 56,
|
|
||||||
title: 48,
|
|
||||||
subtitle: 36,
|
|
||||||
body: 28,
|
|
||||||
caption: 20,
|
|
||||||
},
|
|
||||||
spacing: {
|
|
||||||
xs: 8,
|
|
||||||
sm: 16,
|
|
||||||
md: 24,
|
|
||||||
lg: 40,
|
|
||||||
xl: 64,
|
|
||||||
xxl: 96,
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
smooth: { damping: 200 },
|
|
||||||
snappy: { damping: 20, stiffness: 200 },
|
|
||||||
bouncy: { damping: 8 },
|
|
||||||
heavy: { damping: 15, stiffness: 80, mass: 2 },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type Theme = typeof theme;
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "V03-H01",
|
|
||||||
"base": "V03",
|
|
||||||
"hook": "NOBODY planned it. So I made a poll.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "Every group chat ever" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Built the trip in 20 sec" } },
|
|
||||||
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Dallas", "x": 45, "y": 55 }, { "name": "Houston", "x": 50, "y": 70 }, { "name": "San Antonio", "x": 38, "y": 72 }] } },
|
|
||||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which trip?", "options": [{ "label": "Texas Triangle", "votes": 4, "emoji": "🤠" }, { "label": "East Coast", "votes": 2, "emoji": "🗽" }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Nobody was planning it", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "So I opened SportsTime", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
|
||||||
{ "text": "Built a route in 20 seconds", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Dropped the poll", "startSec": 8.0, "endSec": 9.8, "emphasis": "normal" },
|
|
||||||
{ "text": "Trip booked by lunch", "startSec": 10.3, "endSec": 12.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Nobody plans it. So I did.",
|
|
||||||
"targetLengthSec": 15,
|
|
||||||
"assets": { "screenrec": ["route-generated"], "overlay": ["chat-bubbles", "vote-bubbles"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V10-H01",
|
|
||||||
"base": "V10",
|
|
||||||
"hook": "If your group chat needs 200 messages… use this.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "200 messages later…", "groupName": "Trip Planning 🏈", "groupSize": 5 } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "One app. One route." } },
|
|
||||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Dallas or Houston first?", "options": [{ "label": "Dallas first", "votes": 3, "emoji": "⭐" }, { "label": "Houston first", "votes": 2, "emoji": "🚀" }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Your group chat after someone says 'trip'", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
|
|
||||||
{ "text": "200 messages… zero plans", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
|
|
||||||
{ "text": "Or just use this", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "Route built. Poll sent.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Trip decided in 3 minutes", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Two hundred messages or one app. Your call.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V03-H02",
|
|
||||||
"base": "V03",
|
|
||||||
"hook": "Every sports trip starts with 'we should do this' 🤡",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "🤡" } },
|
|
||||||
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "The cycle continues…", "messages": [{ "sender": "Jake", "text": "We should do a baseball trip", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "Bro YES", "isMe": false, "delaySec": 0.5 }, { "sender": "Sam", "text": "When?", "isMe": false, "delaySec": 0.8 }, { "sender": "Jake", "text": "Idk someone plan it", "isMe": false, "delaySec": 1.2 }, { "sender": "You", "text": "Fine. I'll do it.", "isMe": true, "delaySec": 1.7 }] } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Picked dates + sports" } },
|
|
||||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which route?", "options": [{ "label": "LA → SD", "votes": 3, "emoji": "🌴" }, { "label": "NY → BOS", "votes": 2, "emoji": "🗽" }, { "label": "CHI → DET", "votes": 1, "emoji": "🌊" }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Every. Single. Time.", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "'Someone plan it'", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Fine. I opened SportsTime.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "3 options. 1 poll.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Trip planned in 2 min", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Every trip starts the same way. End it different.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V10-H02",
|
|
||||||
"base": "V10",
|
|
||||||
"hook": "3 trip options. 1 vote. Done.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.0 },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Generated 3 routes" } },
|
|
||||||
{ "type": "POLL", "durationSec": 3.0, "props": { "question": "Pick our trip", "options": [{ "label": "Texas Triangle 🤠", "votes": 4, "emoji": "🤠" }, { "label": "Cali Coast 🌴", "votes": 3, "emoji": "🌴" }, { "label": "Northeast 🗽", "votes": 1, "emoji": "🗽" }] } },
|
|
||||||
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "Group chat: decided", "messages": [{ "sender": "Jake", "text": "Texas it is!!", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "LETS GO 🤠", "isMe": false, "delaySec": 0.5 }, { "sender": "You", "text": "Booked ✅", "isMe": true, "delaySec": 0.9 }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "3 trip options", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
|
|
||||||
{ "text": "Generated in seconds", "startSec": 2.3, "endSec": 4.3, "emphasis": "normal" },
|
|
||||||
{ "text": "1 vote to decide", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "Done. Trip booked.", "startSec": 7.5, "endSec": 9.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Three options. One vote. Done.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V03-H03",
|
|
||||||
"base": "V03",
|
|
||||||
"hook": "All talk → I shipped the itinerary.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "3 months of 'we should'", "messages": [{ "sender": "Jake", "text": "We should do a trip", "isMe": false, "delaySec": 0.15 }, { "sender": "Mike", "text": "For sure", "isMe": false, "delaySec": 0.4 }, { "sender": "Sam", "text": "Down", "isMe": false, "delaySec": 0.6 }] } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "So I just did it" } },
|
|
||||||
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 54, "y": 30 }, { "name": "Detroit", "x": 62, "y": 33 }] } },
|
|
||||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "We going?", "options": [{ "label": "YES 🔥", "votes": 4, "emoji": "🔥" }, { "label": "Can't make it", "votes": 0, "emoji": "😢" }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "3 months of all talk", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
|
|
||||||
{ "text": "'We should do a trip' — nobody moves", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
|
|
||||||
{ "text": "So I opened SportsTime", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "Built the route, mapped the games", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Shipped the itinerary", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" },
|
|
||||||
{ "text": "4-0 vote. We're going.", "startSec": 12.5, "endSec": 14.0, "emphasis": "highlight" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "All talk. Until I shipped the itinerary.",
|
|
||||||
"targetLengthSec": 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V17-H01",
|
|
||||||
"base": "V17",
|
|
||||||
"hook": "The spreadsheet era is over.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Pick dates → pick sports" } },
|
|
||||||
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "No spreadsheet needed.", "variant": "slam" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Route + games auto-matched" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Stop planning trips in spreadsheets", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "Pick your dates and sports", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
|
||||||
{ "text": "No spreadsheet needed", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Route + games auto-matched", "startSec": 8.0, "endSec": 10.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Done in 20 seconds", "startSec": 11.0, "endSec": 12.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Spreadsheets are over. This is how you plan now.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V17-H02",
|
|
||||||
"base": "V17",
|
|
||||||
"hook": "I used to waste 2 hours. Now it's 20 seconds.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "2 hours → 20 seconds", "variant": "split" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/by-games.mp4", "caption": "The whole flow" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Planning a sports trip used to take hours", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
|
|
||||||
{ "text": "2 hours → 20 seconds", "startSec": 2.8, "endSec": 4.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Pick sports. Pick dates. Get a route.", "startSec": 5.0, "endSec": 7.5, "emphasis": "normal" },
|
|
||||||
{ "text": "That's literally it", "startSec": 8.0, "endSec": 9.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Two hours of Googling or twenty seconds. You pick.",
|
|
||||||
"targetLengthSec": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V06-H01",
|
|
||||||
"base": "V06",
|
|
||||||
"hook": "Plan a multi-game weekend in 3 taps.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.0 },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Tap 1: Dates" } },
|
|
||||||
{ "type": "TEXTPUNCH", "durationSec": 1.5, "props": { "text": "3 taps. Multiple games.", "variant": "slam" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Tap 3: Route" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Multi-game weekend?", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
|
|
||||||
{ "text": "Tap 1: Pick your dates", "startSec": 2.3, "endSec": 4.0, "emphasis": "normal" },
|
|
||||||
{ "text": "Tap 2: Pick your sports", "startSec": 4.3, "endSec": 5.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Tap 3: Route generated", "startSec": 6.0, "endSec": 8.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "That easy", "startSec": 8.5, "endSec": 10.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Three taps. Multi-game weekend. Planned.",
|
|
||||||
"targetLengthSec": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V08-H01",
|
|
||||||
"base": "V08",
|
|
||||||
"hook": "Already driving? Add games without chaos.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Add a game stop mid-drive" } },
|
|
||||||
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Rerouted", "cities": [{ "name": "Start", "x": 30, "y": 40 }, { "name": "Game Stop", "x": 50, "y": 50 }, { "name": "Destination", "x": 70, "y": 40 }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Already on the road?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "Add a game stop without the chaos", "startSec": 2.8, "endSec": 5.0, "emphasis": "normal" },
|
|
||||||
{ "text": "Route adjusts automatically", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Detour = more games", "startSec": 8.0, "endSec": 10.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Mid-drive game stop. No chaos.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V05-LA-01",
|
|
||||||
"base": "V05",
|
|
||||||
"hook": "LA this weekend? Here's a REAL 2-game run.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim", "cities": [{ "name": "Dodger Stadium", "x": 40, "y": 45 }, { "name": "Angel Stadium", "x": 55, "y": 60 }] } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Full weekend itinerary" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "LA this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
|
||||||
{ "text": "Dodgers Friday → Angels Saturday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
|
||||||
{ "text": "30 min drive between stadiums", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "2-game weekend locked", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "SportsTime planned it all", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "LA two-game weekend. Planned in seconds.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V05-NY-01",
|
|
||||||
"base": "V05",
|
|
||||||
"hook": "NYC this weekend? You can hit 2 games.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NYC → Jersey", "cities": [{ "name": "Yankee Stadium", "x": 48, "y": 35 }, { "name": "MetLife Stadium", "x": 42, "y": 42 }] } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Weekend mapped out" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "NYC this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
|
||||||
{ "text": "Yankees Saturday → Giants Sunday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Both games. One weekend.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "Route optimized automatically", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "That's the move", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "NYC two-gamer. Hit both without the hassle.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V05-TX-01",
|
|
||||||
"base": "V05",
|
|
||||||
"hook": "Texas road trip? Dallas → Houston + games.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Texas Triangle", "cities": [{ "name": "Dallas", "x": 45, "y": 38 }, { "name": "Austin", "x": 42, "y": 58 }, { "name": "Houston", "x": 55, "y": 60 }] } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "3 cities. 4 games." } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Texas road trip?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
|
||||||
{ "text": "Dallas → Austin → Houston", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
|
||||||
{ "text": "4 games along the route", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "Cowboys. Longhorns. Astros. Rockets.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "All planned in one app", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Texas Triangle. Four games. One route.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V05-CA-01",
|
|
||||||
"base": "V05",
|
|
||||||
"hook": "California weekend? SF → Sac → Bay games.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NorCal Run", "cities": [{ "name": "San Francisco", "x": 20, "y": 42 }, { "name": "Sacramento", "x": 30, "y": 35 }, { "name": "Oakland", "x": 22, "y": 44 }] } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Bay Area weekend" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "California this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
|
||||||
{ "text": "SF → Sacramento → back to the Bay", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Warriors. Kings. Giants.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "3 games in one weekend", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Route mapped automatically", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "NorCal three-game run. SportsTime planned it.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V08-LA-01",
|
|
||||||
"base": "V08",
|
|
||||||
"hook": "LA → San Diego drive? Add a game stop.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Found a game on the way" } },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim → San Diego", "cities": [{ "name": "LA", "x": 25, "y": 35 }, { "name": "Anaheim", "x": 35, "y": 48 }, { "name": "San Diego", "x": 30, "y": 70 }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Driving LA to San Diego?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "There's a game on the way", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
|
||||||
{ "text": "Angels game in Anaheim = perfect pit stop", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Then Padres in SD", "startSec": 8.0, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "2 games. 1 drive.", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "LA to San Diego. Two games along the way.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V04-H01",
|
|
||||||
"base": "V04",
|
|
||||||
"hook": "My friend: 4 stadiums. Me: 27.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 27, "totalStadiums": 120, "caption": "And counting" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Track every stadium" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "My friend: 4 stadiums", "startSec": 0.3, "endSec": 1.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Me: 27 and counting", "startSec": 1.8, "endSec": 3.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Every stadium tracked", "startSec": 4.0, "endSec": 6.0, "emphasis": "normal" },
|
|
||||||
{ "text": "MLB. NFL. NBA. NHL.", "startSec": 6.5, "endSec": 8.0, "emphasis": "bold" },
|
|
||||||
{ "text": "What's your count?", "startSec": 8.5, "endSec": 10.0, "emphasis": "normal" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Four stadiums? That's cute.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V20-H01",
|
|
||||||
"base": "V20",
|
|
||||||
"hook": "No tracking = no bucket list.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 15, "totalStadiums": 120, "caption": "Start tracking" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Every visit logged" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "You've been to how many stadiums?", "startSec": 0.3, "endSec": 2.0, "emphasis": "normal" },
|
|
||||||
{ "text": "But you're not tracking them?", "startSec": 2.3, "endSec": 4.0, "emphasis": "bold" },
|
|
||||||
{ "text": "Start your stadium bucket list", "startSec": 4.5, "endSec": 6.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Every visit. Every league.", "startSec": 7.0, "endSec": 9.0, "emphasis": "normal" },
|
|
||||||
{ "text": "How many can you hit?", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "If you're not tracking, it doesn't count.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V14-H01",
|
|
||||||
"base": "V14",
|
|
||||||
"hook": "Trying to hit every stadium before 35.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 42, "totalStadiums": 120, "caption": "42 down, 78 to go" } },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Next trip: Midwest", "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 53, "y": 30 }, { "name": "Minneapolis", "x": 45, "y": 22 }, { "name": "Kansas City", "x": 42, "y": 45 }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Every stadium before 35", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "42 down. 78 to go.", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Planning the next run", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
|
||||||
{ "text": "Midwest loop: 4 new stadiums", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
|
||||||
{ "text": "The bucket list is real", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Every stadium before thirty-five. Clock's ticking.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V04-H02",
|
|
||||||
"base": "V04",
|
|
||||||
"hook": "Drop your stadium count. I'll wait.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "👀" } },
|
|
||||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 31, "totalStadiums": 120, "caption": "What's yours?" } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Drop your stadium count", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
|
|
||||||
{ "text": "I'll wait 👀", "startSec": 2.0, "endSec": 3.5, "emphasis": "normal" },
|
|
||||||
{ "text": "Mine: 31 across 4 leagues", "startSec": 3.8, "endSec": 5.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "Track yours", "startSec": 5.8, "endSec": 7.0, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Drop your count. I'll wait.",
|
|
||||||
"targetLengthSec": 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V02-H01",
|
|
||||||
"base": "V02",
|
|
||||||
"hook": "If you've never done an away-game trip…",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team on the road" } },
|
|
||||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Away game route", "cities": [{ "name": "Home", "x": 35, "y": 40 }, { "name": "Away Game 1", "x": 55, "y": 35 }, { "name": "Away Game 2", "x": 70, "y": 42 }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Never done an away-game trip?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
|
||||||
{ "text": "Follow your team on the road", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
|
||||||
{ "text": "SportsTime finds the games + builds the route", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
|
|
||||||
{ "text": "New city. Your team. Road trip.", "startSec": 8.0, "endSec": 10.0, "emphasis": "normal" },
|
|
||||||
{ "text": "Start with one trip", "startSec": 10.5, "endSec": 11.5, "emphasis": "bold" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "If you've never done an away game trip, start here.",
|
|
||||||
"targetLengthSec": 13
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "V19-H01",
|
|
||||||
"base": "V19",
|
|
||||||
"hook": "Hot take: away games are the real fandom.",
|
|
||||||
"scenes": [
|
|
||||||
{ "type": "HOOK", "durationSec": 2.5 },
|
|
||||||
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "Home games are easy mode.", "variant": "slam" } },
|
|
||||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team anywhere" } },
|
|
||||||
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Away game road trip", "cities": [{ "name": "Your City", "x": 30, "y": 45 }, { "name": "Rival City", "x": 60, "y": 35 }, { "name": "Another Stop", "x": 75, "y": 50 }] } },
|
|
||||||
{ "type": "CTA", "durationSec": 2.0 }
|
|
||||||
],
|
|
||||||
"captions": [
|
|
||||||
{ "text": "Hot take incoming 🔥", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
|
||||||
{ "text": "Home games are easy mode", "startSec": 2.5, "endSec": 4.3, "emphasis": "highlight" },
|
|
||||||
{ "text": "Real fans follow the team on the road", "startSec": 4.8, "endSec": 6.8, "emphasis": "normal" },
|
|
||||||
{ "text": "Plan the away game trip", "startSec": 7.3, "endSec": 9.0, "emphasis": "normal" },
|
|
||||||
{ "text": "Route + games + drive times", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" },
|
|
||||||
{ "text": "Prove you're a real one", "startSec": 11.5, "endSec": 13.0, "emphasis": "highlight" }
|
|
||||||
],
|
|
||||||
"cta": "Search SportsTime on the App Store",
|
|
||||||
"vo": "Away games are the real fandom. Prove it.",
|
|
||||||
"targetLengthSec": 15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, useVideoConfig } from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
|
|
||||||
import type { VideoConfig, SceneConfig } from "./types";
|
|
||||||
import {
|
|
||||||
HookCard,
|
|
||||||
ChatScene,
|
|
||||||
ScreenRecScene,
|
|
||||||
MapScene,
|
|
||||||
PollScene,
|
|
||||||
FlexScene,
|
|
||||||
TextPunchScene,
|
|
||||||
CTAEndCard,
|
|
||||||
KineticCaption,
|
|
||||||
} from "./scenes";
|
|
||||||
|
|
||||||
type VideoFromConfigProps = {
|
|
||||||
config: VideoConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Config-driven video renderer.
|
|
||||||
*
|
|
||||||
* Reads a VideoConfig and renders each scene in order using TransitionSeries.
|
|
||||||
* Kinetic captions overlay on top of all scenes.
|
|
||||||
*/
|
|
||||||
export const VideoFromConfig: React.FC<VideoFromConfigProps> = ({ config }) => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
const TRANSITION_FRAMES = 8; // Snappy transitions for TikTok pace
|
|
||||||
|
|
||||||
const renderScene = (scene: SceneConfig, index: number) => {
|
|
||||||
const props = scene.props || {};
|
|
||||||
|
|
||||||
switch (scene.type) {
|
|
||||||
case "HOOK":
|
|
||||||
return <HookCard hookText={config.hook} emoji={props.emoji as string} />;
|
|
||||||
|
|
||||||
case "CHAT":
|
|
||||||
return (
|
|
||||||
<ChatScene
|
|
||||||
groupName={props.groupName as string}
|
|
||||||
groupSize={props.groupSize as number}
|
|
||||||
messages={props.messages as any[]}
|
|
||||||
overlayText={props.overlayText as string}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "SCREENREC":
|
|
||||||
return (
|
|
||||||
<ScreenRecScene
|
|
||||||
assetKey={props.assetKey as string}
|
|
||||||
caption={props.caption as string}
|
|
||||||
showFrame={props.showFrame as boolean}
|
|
||||||
phoneScale={props.phoneScale as number}
|
|
||||||
videoStartSec={props.videoStartSec as number}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "MAP":
|
|
||||||
return (
|
|
||||||
<MapScene
|
|
||||||
cities={props.cities as any[]}
|
|
||||||
caption={props.caption as string}
|
|
||||||
routeColor={props.routeColor as string}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "POLL":
|
|
||||||
return (
|
|
||||||
<PollScene
|
|
||||||
question={props.question as string}
|
|
||||||
options={props.options as any[]}
|
|
||||||
caption={props.caption as string}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "FLEX":
|
|
||||||
return (
|
|
||||||
<FlexScene
|
|
||||||
stadiumCount={props.stadiumCount as number}
|
|
||||||
totalStadiums={props.totalStadiums as number}
|
|
||||||
caption={props.caption as string}
|
|
||||||
leagues={props.leagues as string[]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "TEXTPUNCH":
|
|
||||||
return (
|
|
||||||
<TextPunchScene
|
|
||||||
text={props.text as string}
|
|
||||||
subtext={props.subtext as string}
|
|
||||||
variant={props.variant as "slam" | "typewriter" | "split"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "CTA":
|
|
||||||
return (
|
|
||||||
<CTAEndCard
|
|
||||||
ctaText={config.cta}
|
|
||||||
tagline={props.tagline as string}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: "#0A0A0A",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "red", fontSize: 32 }}>
|
|
||||||
Unknown scene: {scene.type}
|
|
||||||
</span>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Choose transition type based on scene pair
|
|
||||||
const getTransition = (fromType: SceneConfig["type"], toType: SceneConfig["type"]) => {
|
|
||||||
// Slide in for screen recordings (feels like opening an app)
|
|
||||||
if (toType === "SCREENREC") {
|
|
||||||
return slide({ direction: "from-right" });
|
|
||||||
}
|
|
||||||
// Slide for map reveals
|
|
||||||
if (toType === "MAP") {
|
|
||||||
return slide({ direction: "from-bottom" });
|
|
||||||
}
|
|
||||||
// Default: fade
|
|
||||||
return fade();
|
|
||||||
};
|
|
||||||
|
|
||||||
const scenes = config.scenes;
|
|
||||||
const numTransitions = scenes.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{/* Scene layer with transitions */}
|
|
||||||
<TransitionSeries>
|
|
||||||
{scenes.map((scene, index) => {
|
|
||||||
const durationFrames = Math.round(scene.durationSec * fps);
|
|
||||||
const elements: React.ReactNode[] = [];
|
|
||||||
|
|
||||||
elements.push(
|
|
||||||
<TransitionSeries.Sequence
|
|
||||||
key={`scene-${index}`}
|
|
||||||
durationInFrames={durationFrames}
|
|
||||||
>
|
|
||||||
{renderScene(scene, index)}
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add transition between scenes (not after the last one)
|
|
||||||
if (index < numTransitions) {
|
|
||||||
const nextScene = scenes[index + 1];
|
|
||||||
elements.push(
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
key={`trans-${index}`}
|
|
||||||
presentation={getTransition(scene.type, nextScene.type)}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_FRAMES })}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
})}
|
|
||||||
</TransitionSeries>
|
|
||||||
|
|
||||||
{/* Caption overlay on top of everything */}
|
|
||||||
{config.captions.length > 0 && (
|
|
||||||
<KineticCaption captions={config.captions} />
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export { VideoFromConfig } from "./VideoFromConfig";
|
|
||||||
export type {
|
|
||||||
VideoConfig,
|
|
||||||
SceneConfig,
|
|
||||||
SceneType,
|
|
||||||
CaptionLine,
|
|
||||||
Week1Configs,
|
|
||||||
} from "./types";
|
|
||||||
export { ASSET_KEYS } from "./types";
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type CTAEndCardProps = {
|
|
||||||
/** CTA text line (should include "Search SportsTime") */
|
|
||||||
ctaText?: string;
|
|
||||||
/** Optional tagline above CTA */
|
|
||||||
tagline?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CTAEndCard: React.FC<CTAEndCardProps> = ({
|
|
||||||
ctaText = "Search SportsTime on the App Store",
|
|
||||||
tagline,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// --- App icon animation (scale + bounce) ---
|
|
||||||
const iconSpring = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 100 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconScale = interpolate(iconSpring, [0, 1], [0.3, 1]);
|
|
||||||
const iconOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// --- Wordmark fades in with the icon ---
|
|
||||||
const wordmarkOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// --- Tagline animation (0.4s delay) ---
|
|
||||||
const taglineDelay = Math.round(0.4 * fps);
|
|
||||||
const taglineSpring = spring({
|
|
||||||
frame: frame - taglineDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const taglineOpacity = interpolate(taglineSpring, [0, 1], [0, 1]);
|
|
||||||
const taglineTranslateY = interpolate(taglineSpring, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
// --- Search bar animation (0.5s delay) ---
|
|
||||||
const searchBarDelay = Math.round(0.5 * fps);
|
|
||||||
const searchBarSpring = spring({
|
|
||||||
frame: frame - searchBarDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const searchBarOpacity = interpolate(searchBarSpring, [0, 1], [0, 1]);
|
|
||||||
const searchBarTranslateY = interpolate(searchBarSpring, [0, 1], [30, 0]);
|
|
||||||
|
|
||||||
// --- Pulse/glow on search bar border (breathing orange glow) ---
|
|
||||||
const pulseSpeed = 1.5; // seconds per cycle
|
|
||||||
const pulseCycle = (frame / fps) * (1 / pulseSpeed) * Math.PI * 2;
|
|
||||||
const pulseIntensity = (Math.sin(pulseCycle) + 1) / 2; // 0 to 1
|
|
||||||
const glowAlpha = interpolate(pulseIntensity, [0, 1], [0.1, 0.5]);
|
|
||||||
const borderAlpha = interpolate(pulseIntensity, [0, 1], [0.2, 0.5]);
|
|
||||||
|
|
||||||
// --- "Available on the App Store" text (0.7s delay) ---
|
|
||||||
const availableDelay = Math.round(0.7 * fps);
|
|
||||||
const availableSpring = spring({
|
|
||||||
frame: frame - availableDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const availableOpacity = interpolate(availableSpring, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{/* Background gradient */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Center content: icon + wordmark + tagline */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* App icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 180,
|
|
||||||
height: 180,
|
|
||||||
borderRadius: 40,
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
|
||||||
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.4)`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
transform: `scale(${iconScale})`,
|
|
||||||
opacity: iconOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Simple stadium SVG icon: ellipse base + arch + flag */}
|
|
||||||
<svg
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
{/* Stadium base ellipse */}
|
|
||||||
<ellipse
|
|
||||||
cx={50}
|
|
||||||
cy={70}
|
|
||||||
rx={36}
|
|
||||||
ry={12}
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={3.5}
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
{/* Stadium arch */}
|
|
||||||
<path
|
|
||||||
d="M 20 70 Q 20 28 50 28 Q 80 28 80 70"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={3.5}
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
{/* Flag pole */}
|
|
||||||
<line
|
|
||||||
x1={50}
|
|
||||||
y1={28}
|
|
||||||
x2={50}
|
|
||||||
y2={12}
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={3}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
{/* Flag */}
|
|
||||||
<path
|
|
||||||
d="M 50 12 L 62 17 L 50 22"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "SportsTime" wordmark */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 72,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: -2,
|
|
||||||
marginTop: 32,
|
|
||||||
opacity: wordmarkOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SportsTime
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tagline (optional) */}
|
|
||||||
{tagline && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
marginTop: 16,
|
|
||||||
opacity: taglineOpacity,
|
|
||||||
transform: `translateY(${taglineTranslateY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tagline}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
|
|
||||||
{/* CTA search bar - positioned near bottom */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 200,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: searchBarOpacity,
|
|
||||||
transform: `translateY(${searchBarTranslateY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Search bar container */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 800,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: 40,
|
|
||||||
background: "rgba(255, 255, 255, 0.1)",
|
|
||||||
border: `1.5px solid rgba(255, 255, 255, ${borderAlpha})`,
|
|
||||||
boxShadow: `0 0 ${interpolate(pulseIntensity, [0, 1], [10, 30])}px rgba(255, 107, 53, ${glowAlpha})`,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingLeft: 28,
|
|
||||||
paddingRight: 28,
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Search icon (magnifying glass SVG) */}
|
|
||||||
<svg
|
|
||||||
width={28}
|
|
||||||
height={28}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx={10.5}
|
|
||||||
cy={10.5}
|
|
||||||
r={7}
|
|
||||||
stroke={theme.colors.textSecondary}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1={15.5}
|
|
||||||
y1={15.5}
|
|
||||||
x2={21}
|
|
||||||
y2={21}
|
|
||||||
stroke={theme.colors.textSecondary}
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* CTA text */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ctaText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "Available on the App Store" text below search bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 20,
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 400,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
opacity: availableOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Available on the App Store
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type ChatMessage = {
|
|
||||||
sender: string;
|
|
||||||
text: string;
|
|
||||||
isMe: boolean;
|
|
||||||
delaySec: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChatSceneProps = {
|
|
||||||
/** Group name shown in header */
|
|
||||||
groupName?: string;
|
|
||||||
/** Number of people in group */
|
|
||||||
groupSize?: number;
|
|
||||||
/** Messages to display */
|
|
||||||
messages?: ChatMessage[];
|
|
||||||
/** Overlay caption text shown at top */
|
|
||||||
overlayText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_MESSAGES: ChatMessage[] = [
|
|
||||||
{ sender: "Jake", text: "We should do a baseball trip", isMe: false, delaySec: 0.15 },
|
|
||||||
{ sender: "Mike", text: "I'm down", isMe: false, delaySec: 0.5 },
|
|
||||||
{ sender: "Sam", text: "Same", isMe: false, delaySec: 0.75 },
|
|
||||||
{ sender: "You", text: "Let's goooo", isMe: true, delaySec: 1.0 },
|
|
||||||
{ sender: "Jake", text: "When tho", isMe: false, delaySec: 1.4 },
|
|
||||||
{ sender: "Mike", text: "idk lol", isMe: false, delaySec: 1.7 },
|
|
||||||
{ sender: "Sam", text: "Maybe June?", isMe: false, delaySec: 2.0 },
|
|
||||||
{ sender: "Jake", text: "Or July", isMe: false, delaySec: 2.25 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SENDER_COLORS = ["#34C759", "#FF9500", "#AF52DE", "#FF2D55"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigns a stable color to each unique sender name (excluding "me" messages).
|
|
||||||
*/
|
|
||||||
const buildSenderColorMap = (messages: ChatMessage[]): Record<string, string> => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
let colorIndex = 0;
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (!msg.isMe && !(msg.sender in map)) {
|
|
||||||
map[msg.sender] = SENDER_COLORS[colorIndex % SENDER_COLORS.length];
|
|
||||||
colorIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typing indicator with 3 bouncing dots.
|
|
||||||
*/
|
|
||||||
const TypingIndicator: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
|
|
||||||
const dots = [0, 1, 2];
|
|
||||||
// Each dot bounces on a ~0.6s cycle, staggered by ~0.15s
|
|
||||||
const cycleDuration = 0.6 * fps;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#2C2C2E",
|
|
||||||
borderRadius: 20,
|
|
||||||
borderBottomLeftRadius: 6,
|
|
||||||
padding: "14px 20px",
|
|
||||||
display: "flex",
|
|
||||||
gap: 6,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dots.map((i) => {
|
|
||||||
const stagger = i * 0.15 * fps;
|
|
||||||
const cycleFrame = (frame + stagger) % cycleDuration;
|
|
||||||
const bounceProgress = Math.sin((cycleFrame / cycleDuration) * Math.PI);
|
|
||||||
const translateY = interpolate(bounceProgress, [0, 1], [0, -8]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 5,
|
|
||||||
background: "rgba(255, 255, 255, 0.4)",
|
|
||||||
transform: `translateY(${translateY}px)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChatScene: React.FC<ChatSceneProps> = ({
|
|
||||||
groupName = "The Boys",
|
|
||||||
groupSize = 4,
|
|
||||||
messages = DEFAULT_MESSAGES,
|
|
||||||
overlayText,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, durationInFrames } = useVideoConfig();
|
|
||||||
|
|
||||||
const senderColorMap = buildSenderColorMap(messages);
|
|
||||||
|
|
||||||
// Subtle zoom-in over the full scene duration
|
|
||||||
const chatZoom = interpolate(frame, [0, durationInFrames], [1.0, 1.04], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine when the last message appears to show typing indicator after
|
|
||||||
const lastMessageDelaySec = messages.length > 0
|
|
||||||
? Math.max(...messages.map((m) => m.delaySec))
|
|
||||||
: 0;
|
|
||||||
const typingStartFrame = (lastMessageDelaySec + 0.4) * fps;
|
|
||||||
|
|
||||||
const typingProgress = spring({
|
|
||||||
frame: frame - typingStartFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const typingScale = interpolate(typingProgress, [0, 1], [0.3, 1]);
|
|
||||||
const typingOpacity = interpolate(typingProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Overlay text animation (springs in at 0.3s)
|
|
||||||
const overlayDelay = 0.3 * fps;
|
|
||||||
const overlayProgress = spring({
|
|
||||||
frame: frame - overlayDelay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 180 },
|
|
||||||
});
|
|
||||||
const overlayOpacity = interpolate(
|
|
||||||
frame - overlayDelay,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
);
|
|
||||||
const overlayScale = interpolate(overlayProgress, [0, 1], [0.85, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill style={{ background: "#000000" }}>
|
|
||||||
{/* Zoomable chat container */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
transform: `scale(${chatZoom})`,
|
|
||||||
transformOrigin: "center 40%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Status bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px 32px 12px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: "white",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
9:41
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
||||||
{/* Signal bars */}
|
|
||||||
<div style={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
|
|
||||||
{[6, 8, 10, 12].map((h, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: 3,
|
|
||||||
height: h,
|
|
||||||
background: "white",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Battery icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 12,
|
|
||||||
border: "1.5px solid white",
|
|
||||||
borderRadius: 3,
|
|
||||||
marginLeft: 4,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "70%",
|
|
||||||
height: "100%",
|
|
||||||
background: "white",
|
|
||||||
borderRadius: 1.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: -5,
|
|
||||||
top: 3,
|
|
||||||
width: 3,
|
|
||||||
height: 6,
|
|
||||||
background: "white",
|
|
||||||
borderRadius: "0 2px 2px 0",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 32px 16px",
|
|
||||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Group avatar with gradient */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
background: "linear-gradient(135deg, #34C759, #007AFF)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groupSize}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groupName}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: "rgba(255,255,255,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{groupSize} people
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "20px 24px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.map((msg, index) => {
|
|
||||||
const msgDelayFrames = msg.delaySec * fps;
|
|
||||||
const msgProgress = spring({
|
|
||||||
frame: frame - msgDelayFrames,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
|
|
||||||
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
if (frame < msgDelayFrames) return null;
|
|
||||||
|
|
||||||
const senderColor = msg.isMe ? "#007AFF" : (senderColorMap[msg.sender] || SENDER_COLORS[0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: msg.isMe ? "flex-end" : "flex-start",
|
|
||||||
transform: `scale(${msgScale})`,
|
|
||||||
opacity: msgOpacity,
|
|
||||||
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Sender name (only for non-"me" messages) */}
|
|
||||||
{!msg.isMe && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 13,
|
|
||||||
color: senderColor,
|
|
||||||
marginBottom: 2,
|
|
||||||
marginLeft: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.sender}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Message bubble */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: msg.isMe ? "#007AFF" : "#2C2C2E",
|
|
||||||
borderRadius: 20,
|
|
||||||
borderBottomRightRadius: msg.isMe ? 6 : 20,
|
|
||||||
borderBottomLeftRadius: msg.isMe ? 20 : 6,
|
|
||||||
padding: "12px 18px",
|
|
||||||
maxWidth: "75%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: "white",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Typing indicator (appears after last message) */}
|
|
||||||
{frame >= typingStartFrame && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${typingScale})`,
|
|
||||||
opacity: typingOpacity,
|
|
||||||
transformOrigin: "left bottom",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TypingIndicator frame={frame} fps={fps} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overlay text pill badge */}
|
|
||||||
{overlayText && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 100,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: overlayOpacity,
|
|
||||||
transform: `scale(${overlayScale})`,
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(0, 0, 0, 0.7)",
|
|
||||||
backdropFilter: "blur(16px)",
|
|
||||||
WebkitBackdropFilter: "blur(16px)",
|
|
||||||
padding: "16px 40px",
|
|
||||||
borderRadius: 999,
|
|
||||||
border: "1px solid rgba(255,255,255,0.15)",
|
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "white",
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{overlayText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { GradientBackground } from "../../components/shared/Background";
|
|
||||||
|
|
||||||
type FlexSceneProps = {
|
|
||||||
/** Number of stadiums visited */
|
|
||||||
stadiumCount?: number;
|
|
||||||
/** Total stadiums possible */
|
|
||||||
totalStadiums?: number;
|
|
||||||
/** Caption text */
|
|
||||||
caption?: string;
|
|
||||||
/** League labels to show */
|
|
||||||
leagues?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FlexScene: React.FC<FlexSceneProps> = ({
|
|
||||||
stadiumCount = 27,
|
|
||||||
totalStadiums = 120,
|
|
||||||
caption,
|
|
||||||
leagues = ["MLB", "NFL", "NBA", "NHL"],
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// --- Stadium icon dots (arc above counter) ---
|
|
||||||
const totalDots = 8;
|
|
||||||
const visitedDots = Math.round(
|
|
||||||
(stadiumCount / totalStadiums) * totalDots
|
|
||||||
);
|
|
||||||
|
|
||||||
const dotsAppearFrame = Math.round(0.2 * fps);
|
|
||||||
const dotsOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[dotsAppearFrame, dotsAppearFrame + Math.round(0.3 * fps)],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Counter animation ---
|
|
||||||
// Count up over ~1.5s
|
|
||||||
const countDuration = Math.round(1.5 * fps);
|
|
||||||
const rawCount = interpolate(frame, [0, countDuration], [0, stadiumCount], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const displayCount = Math.round(rawCount);
|
|
||||||
|
|
||||||
// Counter scale spring
|
|
||||||
const counterScaleProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.bouncy,
|
|
||||||
});
|
|
||||||
const counterScale = interpolate(
|
|
||||||
counterScaleProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0.5, 1]
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Progress bar ---
|
|
||||||
// Starts animating after counter reaches target
|
|
||||||
const progressBarDelay = countDuration + Math.round(0.1 * fps);
|
|
||||||
const progressBarProgress = spring({
|
|
||||||
frame: frame - progressBarDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const progressBarWidth = interpolate(
|
|
||||||
progressBarProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0, (stadiumCount / totalStadiums) * 100]
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- League badges ---
|
|
||||||
// Spring in staggered, starting 0.3s after progress bar delay
|
|
||||||
const badgesBaseDelay = progressBarDelay + Math.round(0.3 * fps);
|
|
||||||
const badgeStaggerFrames = Math.round(0.1 * fps);
|
|
||||||
|
|
||||||
// --- Caption animation ---
|
|
||||||
const captionDelay = Math.round(0.15 * fps);
|
|
||||||
const captionProgress = spring({
|
|
||||||
frame: frame - captionDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const captionOpacity = interpolate(
|
|
||||||
frame - captionDelay,
|
|
||||||
[0, Math.round(fps * 0.25)],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{/* Background */}
|
|
||||||
<GradientBackground animate />
|
|
||||||
|
|
||||||
{/* Subtle gold radial glow behind counter */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 800,
|
|
||||||
height: 600,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(ellipse, ${theme.colors.gold}1A 0%, transparent 70%)`,
|
|
||||||
filter: "blur(60px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
|
|
||||||
{/* Caption badge at top */}
|
|
||||||
{caption && (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
paddingTop: 120,
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: captionOpacity,
|
|
||||||
transform: `translateY(${captionTranslateY}px)`,
|
|
||||||
background: "rgba(0, 0, 0, 0.55)",
|
|
||||||
backdropFilter: "blur(20px)",
|
|
||||||
WebkitBackdropFilter: "blur(20px)",
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: "14px 36px",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{caption}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Stadium icon dots arc */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: 400,
|
|
||||||
height: 60,
|
|
||||||
marginBottom: 24,
|
|
||||||
opacity: dotsOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: totalDots }).map((_, i) => {
|
|
||||||
// Arrange in a subtle arc
|
|
||||||
const angle =
|
|
||||||
Math.PI + ((i / (totalDots - 1)) * Math.PI);
|
|
||||||
const arcWidth = 180;
|
|
||||||
const arcHeight = 30;
|
|
||||||
const cx = 200 + Math.cos(angle) * arcWidth;
|
|
||||||
const cy = 50 + Math.sin(angle) * arcHeight;
|
|
||||||
const isVisited = i < visitedDots;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: cx - 8,
|
|
||||||
top: cy - 8,
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: isVisited
|
|
||||||
? theme.colors.accent
|
|
||||||
: "transparent",
|
|
||||||
border: isVisited
|
|
||||||
? `2px solid ${theme.colors.accent}`
|
|
||||||
: "2px solid rgba(255, 255, 255, 0.25)",
|
|
||||||
boxShadow: isVisited
|
|
||||||
? `0 0 8px ${theme.colors.accent}80`
|
|
||||||
: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Large counter number */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${counterScale})`,
|
|
||||||
transformOrigin: "center center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 160,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
textShadow: `0 0 40px ${theme.colors.gold}66, 0 0 80px ${theme.colors.gold}33`,
|
|
||||||
lineHeight: 1,
|
|
||||||
display: "block",
|
|
||||||
textAlign: "center",
|
|
||||||
letterSpacing: -4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "/ totalStadiums stadiums" subtitle */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 36,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
marginTop: 8,
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
/ {totalStadiums} stadiums
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 800,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#2C2C2E",
|
|
||||||
marginTop: 40,
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${progressBarWidth}%`,
|
|
||||||
height: "100%",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: `linear-gradient(90deg, ${theme.colors.accent}, ${theme.colors.gold})`,
|
|
||||||
boxShadow: `0 0 12px ${theme.colors.accent}66`,
|
|
||||||
transition: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* League badges row */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 16,
|
|
||||||
marginTop: 40,
|
|
||||||
justifyContent: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{leagues.map((league, index) => {
|
|
||||||
const badgeDelay = badgesBaseDelay + index * badgeStaggerFrames;
|
|
||||||
const badgeProgress = spring({
|
|
||||||
frame: frame - badgeDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const badgeScale = interpolate(
|
|
||||||
badgeProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0.3, 1]
|
|
||||||
);
|
|
||||||
const badgeOpacity = interpolate(
|
|
||||||
badgeProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0, 1]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={league}
|
|
||||||
style={{
|
|
||||||
opacity: badgeOpacity,
|
|
||||||
transform: `scale(${badgeScale})`,
|
|
||||||
background: "#2C2C2E",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: "8px 20px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{league}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { GradientBackground } from "../../components/shared/Background";
|
|
||||||
|
|
||||||
type HookCardProps = {
|
|
||||||
hookText: string;
|
|
||||||
/** Optional emoji shown above the hook text */
|
|
||||||
emoji?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HookCard: React.FC<HookCardProps> = ({ hookText, emoji }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const words = hookText.split(/\s+/);
|
|
||||||
const staggerFrames = Math.round(0.06 * fps);
|
|
||||||
|
|
||||||
// Calculate when the last word finishes appearing (for the underline timing)
|
|
||||||
const lastWordStartFrame = (words.length - 1) * staggerFrames;
|
|
||||||
|
|
||||||
// Emoji spring - appears at frame 3
|
|
||||||
const emojiProgress = spring({
|
|
||||||
frame: frame - 3,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.bouncy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const emojiScale = interpolate(emojiProgress, [0, 1], [0.3, 2]);
|
|
||||||
const emojiOpacity = interpolate(emojiProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Underline wipe - starts after all words have appeared, with a small buffer
|
|
||||||
const underlineDelay = lastWordStartFrame + Math.round(fps * 0.3);
|
|
||||||
const underlineProgress = spring({
|
|
||||||
frame: frame - underlineDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const underlineScaleX = interpolate(underlineProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{/* Dark gradient background */}
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
{/* Radial orange glow behind text */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 900,
|
|
||||||
height: 600,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(ellipse, ${theme.colors.accent}26 0%, transparent 70%)`,
|
|
||||||
filter: "blur(40px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
|
|
||||||
{/* Main content container */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
maxWidth: 900,
|
|
||||||
padding: "0 40px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Optional emoji */}
|
|
||||||
{emoji && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 64,
|
|
||||||
marginBottom: 32,
|
|
||||||
transform: `scale(${emojiScale})`,
|
|
||||||
opacity: emojiOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hook text with word-by-word reveal */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "0 16px",
|
|
||||||
lineHeight: 1.25,
|
|
||||||
maxWidth: 900,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{words.map((word, index) => {
|
|
||||||
const wordDelay = index * staggerFrames;
|
|
||||||
|
|
||||||
const wordProgress = spring({
|
|
||||||
frame: frame - wordDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const wordOpacity = interpolate(
|
|
||||||
frame - wordDelay,
|
|
||||||
[0, Math.round(fps * 0.15)],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const wordScale = interpolate(
|
|
||||||
wordProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0.7, 1.0]
|
|
||||||
);
|
|
||||||
|
|
||||||
const wordTranslateY = interpolate(
|
|
||||||
wordProgress,
|
|
||||||
[0, 1],
|
|
||||||
[40, 0]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
opacity: wordOpacity,
|
|
||||||
transform: `scale(${wordScale}) translateY(${wordTranslateY}px)`,
|
|
||||||
display: "inline-block",
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{word}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orange underline wipe */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 300,
|
|
||||||
height: 5,
|
|
||||||
marginTop: 20,
|
|
||||||
borderRadius: 3,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
transform: `scaleX(${underlineScaleX})`,
|
|
||||||
transformOrigin: "left",
|
|
||||||
opacity: underlineScaleX > 0 ? 1 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import type { CaptionLine } from "../types";
|
|
||||||
|
|
||||||
type KineticCaptionProps = {
|
|
||||||
captions: CaptionLine[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kinetic caption overlay that renders on top of all scenes.
|
|
||||||
* Captions appear/disappear based on their startSec/endSec timing.
|
|
||||||
* Each caption pops in with a spring and has bold/highlight styling options.
|
|
||||||
*/
|
|
||||||
export const KineticCaption: React.FC<KineticCaptionProps> = ({ captions }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Find the currently active caption
|
|
||||||
const currentSec = frame / fps;
|
|
||||||
const activeCaptions = captions.filter(
|
|
||||||
(c) => currentSec >= c.startSec && currentSec < c.endSec
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeCaptions.length === 0) return null;
|
|
||||||
|
|
||||||
const caption = activeCaptions[activeCaptions.length - 1];
|
|
||||||
const captionStartFrame = caption.startSec * fps;
|
|
||||||
const captionEndFrame = caption.endSec * fps;
|
|
||||||
const localFrame = frame - captionStartFrame;
|
|
||||||
|
|
||||||
// Entrance spring
|
|
||||||
const enterProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 200 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(enterProgress, [0, 1], [0.7, 1]);
|
|
||||||
const enterOpacity = interpolate(enterProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Exit fade (last 5 frames before endSec)
|
|
||||||
const exitOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[captionEndFrame - 5, captionEndFrame],
|
|
||||||
[1, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const opacity = Math.min(enterOpacity, exitOpacity);
|
|
||||||
|
|
||||||
const isHighlight = caption.emphasis === "highlight";
|
|
||||||
const isBold = caption.emphasis === "bold" || isHighlight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingBottom: 220,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
maxWidth: 900,
|
|
||||||
padding: "16px 36px",
|
|
||||||
borderRadius: 16,
|
|
||||||
background: isHighlight
|
|
||||||
? "rgba(255, 107, 53, 0.9)"
|
|
||||||
: "rgba(0, 0, 0, 0.8)",
|
|
||||||
backdropFilter: "blur(8px)",
|
|
||||||
border: isHighlight
|
|
||||||
? "none"
|
|
||||||
: "1px solid rgba(255, 255, 255, 0.1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 38,
|
|
||||||
fontWeight: isBold ? 800 : 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
textShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{caption.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type MapCity = {
|
|
||||||
name: string;
|
|
||||||
x: number; // percentage position 0-100
|
|
||||||
y: number; // percentage position 0-100
|
|
||||||
};
|
|
||||||
|
|
||||||
type MapSceneProps = {
|
|
||||||
/** Cities to show on the map */
|
|
||||||
cities?: MapCity[];
|
|
||||||
/** Caption text at top */
|
|
||||||
caption?: string;
|
|
||||||
/** Route line color override */
|
|
||||||
routeColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_CITIES: MapCity[] = [
|
|
||||||
{ name: "Dallas", x: 45, y: 55 },
|
|
||||||
{ name: "Houston", x: 50, y: 70 },
|
|
||||||
{ name: "San Antonio", x: 40, y: 72 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MapScene: React.FC<MapSceneProps> = ({
|
|
||||||
cities = DEFAULT_CITIES,
|
|
||||||
caption,
|
|
||||||
routeColor = theme.colors.mapLine,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
// Map area: center 80% of screen
|
|
||||||
const mapLeft = width * 0.1;
|
|
||||||
const mapTop = height * 0.1;
|
|
||||||
const mapWidth = width * 0.8;
|
|
||||||
const mapHeight = height * 0.8;
|
|
||||||
|
|
||||||
// Convert percentage coords to pixel coords within the map area
|
|
||||||
const points = cities.map((city) => ({
|
|
||||||
name: city.name,
|
|
||||||
px: mapLeft + (city.x / 100) * mapWidth,
|
|
||||||
py: mapTop + (city.y / 100) * mapHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Timing: each segment takes ~0.5s to draw, staggered
|
|
||||||
const segmentDurationFrames = Math.round(0.5 * fps);
|
|
||||||
|
|
||||||
// Calculate total path length and per-segment lengths
|
|
||||||
const segmentLengths: number[] = [];
|
|
||||||
let totalPathLength = 0;
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
const dx = points[i].px - points[i - 1].px;
|
|
||||||
const dy = points[i].py - points[i - 1].py;
|
|
||||||
const len = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
segmentLengths.push(len);
|
|
||||||
totalPathLength += len;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the SVG path d attribute (straight lines between cities)
|
|
||||||
const pathD = points
|
|
||||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
// Calculate cumulative lengths for each segment start
|
|
||||||
const cumulativeLengths: number[] = [0];
|
|
||||||
for (let i = 0; i < segmentLengths.length; i++) {
|
|
||||||
cumulativeLengths.push(cumulativeLengths[i] + segmentLengths[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route draw animation: each segment draws over segmentDurationFrames, staggered
|
|
||||||
const getSegmentDrawProgress = (segmentIndex: number): number => {
|
|
||||||
const segmentStartFrame = segmentIndex * segmentDurationFrames;
|
|
||||||
return interpolate(
|
|
||||||
frame,
|
|
||||||
[segmentStartFrame, segmentStartFrame + segmentDurationFrames],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate overall drawn length for strokeDashoffset
|
|
||||||
let drawnLength = 0;
|
|
||||||
for (let i = 0; i < segmentLengths.length; i++) {
|
|
||||||
const segProgress = getSegmentDrawProgress(i);
|
|
||||||
drawnLength += segmentLengths[i] * segProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
const strokeDashoffset = totalPathLength - drawnLength;
|
|
||||||
|
|
||||||
// Determine when each city's route segment completes (for marker appearance)
|
|
||||||
// City 0 appears when route starts drawing (frame ~0)
|
|
||||||
// City i appears when segment i-1 finishes drawing
|
|
||||||
const getCityAppearFrame = (cityIndex: number): number => {
|
|
||||||
if (cityIndex === 0) return 0;
|
|
||||||
return cityIndex * segmentDurationFrames;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Traveling dot: appears after entire route is drawn, moves along path
|
|
||||||
const routeCompleteFrame =
|
|
||||||
(points.length - 1) * segmentDurationFrames;
|
|
||||||
const travelDotDelay = routeCompleteFrame + Math.round(fps * 0.3);
|
|
||||||
const travelDotDuration = Math.round(fps * 1.5);
|
|
||||||
|
|
||||||
const travelDotProgress = interpolate(
|
|
||||||
frame,
|
|
||||||
[travelDotDelay, travelDotDelay + travelDotDuration],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate traveling dot position along the path
|
|
||||||
const travelDistance = travelDotProgress * totalPathLength;
|
|
||||||
let travelDotX = points[0].px;
|
|
||||||
let travelDotY = points[0].py;
|
|
||||||
|
|
||||||
if (travelDotProgress > 0) {
|
|
||||||
let accumulated = 0;
|
|
||||||
for (let i = 0; i < segmentLengths.length; i++) {
|
|
||||||
if (accumulated + segmentLengths[i] >= travelDistance) {
|
|
||||||
const segFraction =
|
|
||||||
(travelDistance - accumulated) / segmentLengths[i];
|
|
||||||
travelDotX =
|
|
||||||
points[i].px + (points[i + 1].px - points[i].px) * segFraction;
|
|
||||||
travelDotY =
|
|
||||||
points[i].py + (points[i + 1].py - points[i].py) * segFraction;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
accumulated += segmentLengths[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const travelDotOpacity = interpolate(
|
|
||||||
travelDotProgress,
|
|
||||||
[0, 0.02, 0.98, 1],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Caption animation
|
|
||||||
const captionProgress = caption
|
|
||||||
? spring({
|
|
||||||
frame: frame - 5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
})
|
|
||||||
: 0;
|
|
||||||
const captionOpacity = interpolate(captionProgress, [0, 1], [0, 1]);
|
|
||||||
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
|
|
||||||
|
|
||||||
// Pulse animation for markers (repeating)
|
|
||||||
const pulseFrame = frame % Math.round(fps * 1.5);
|
|
||||||
const pulseScale = interpolate(
|
|
||||||
pulseFrame,
|
|
||||||
[0, Math.round(fps * 1.5)],
|
|
||||||
[1, 2.5],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const pulseOpacity = interpolate(
|
|
||||||
pulseFrame,
|
|
||||||
[0, Math.round(fps * 1.5)],
|
|
||||||
[0.6, 0],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Subtle dark grid pattern */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: "60px 60px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* SVG map layer */}
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
style={{ position: "absolute", top: 0, left: 0 }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<filter id="glow">
|
|
||||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Route glow (blurred duplicate path) */}
|
|
||||||
<path
|
|
||||||
d={pathD}
|
|
||||||
fill="none"
|
|
||||||
stroke={routeColor}
|
|
||||||
strokeWidth={12}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeDasharray={totalPathLength}
|
|
||||||
strokeDashoffset={strokeDashoffset}
|
|
||||||
opacity={0.2}
|
|
||||||
filter="url(#glow)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Route line (dashed, animated) */}
|
|
||||||
<path
|
|
||||||
d={pathD}
|
|
||||||
fill="none"
|
|
||||||
stroke={routeColor}
|
|
||||||
strokeWidth={4}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeDasharray={totalPathLength}
|
|
||||||
strokeDashoffset={strokeDashoffset}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* City markers */}
|
|
||||||
{points.map((point, index) => {
|
|
||||||
const appearFrame = getCityAppearFrame(index);
|
|
||||||
|
|
||||||
const markerSpring = spring({
|
|
||||||
frame: frame - appearFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.bouncy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const markerScale = interpolate(
|
|
||||||
markerSpring,
|
|
||||||
[0, 1],
|
|
||||||
[0, 1]
|
|
||||||
);
|
|
||||||
|
|
||||||
const markerOpacity = interpolate(
|
|
||||||
markerSpring,
|
|
||||||
[0, 0.3],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Label appears slightly after the marker
|
|
||||||
const labelDelay = appearFrame + Math.round(fps * 0.15);
|
|
||||||
const labelSpring = spring({
|
|
||||||
frame: frame - labelDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const labelOpacity = interpolate(
|
|
||||||
labelSpring,
|
|
||||||
[0, 1],
|
|
||||||
[0, 1]
|
|
||||||
);
|
|
||||||
const labelTranslateY = interpolate(
|
|
||||||
labelSpring,
|
|
||||||
[0, 1],
|
|
||||||
[10, 0]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only show pulse after marker is fully visible
|
|
||||||
const showPulse = markerSpring > 0.95;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={index}>
|
|
||||||
{/* Pulse ring (repeating, expanding, fading) */}
|
|
||||||
{showPulse && (
|
|
||||||
<circle
|
|
||||||
cx={point.px}
|
|
||||||
cy={point.py}
|
|
||||||
r={24}
|
|
||||||
fill="none"
|
|
||||||
stroke={theme.colors.mapMarker}
|
|
||||||
strokeWidth={2}
|
|
||||||
opacity={pulseOpacity}
|
|
||||||
transform={`translate(${point.px * (1 - pulseScale)}, ${point.py * (1 - pulseScale)}) scale(${pulseScale})`}
|
|
||||||
style={{ transformOrigin: `${point.px}px ${point.py}px` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Outer ring */}
|
|
||||||
<circle
|
|
||||||
cx={point.px}
|
|
||||||
cy={point.py}
|
|
||||||
r={24}
|
|
||||||
fill="transparent"
|
|
||||||
stroke={theme.colors.mapMarker}
|
|
||||||
strokeWidth={3}
|
|
||||||
opacity={markerOpacity}
|
|
||||||
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Inner dot */}
|
|
||||||
<circle
|
|
||||||
cx={point.px}
|
|
||||||
cy={point.py}
|
|
||||||
r={10}
|
|
||||||
fill={theme.colors.mapMarker}
|
|
||||||
opacity={markerOpacity}
|
|
||||||
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* City name label */}
|
|
||||||
<text
|
|
||||||
x={point.px}
|
|
||||||
y={point.py + 44}
|
|
||||||
fill={theme.colors.text}
|
|
||||||
fontSize={28}
|
|
||||||
fontWeight={700}
|
|
||||||
fontFamily={theme.fonts.display}
|
|
||||||
textAnchor="middle"
|
|
||||||
opacity={labelOpacity}
|
|
||||||
style={{
|
|
||||||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.8))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<tspan dy={labelTranslateY}>{point.name}</tspan>
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Traveling dot along the route */}
|
|
||||||
{travelDotProgress > 0 && travelDotProgress < 1 && (
|
|
||||||
<>
|
|
||||||
{/* Glow behind traveling dot */}
|
|
||||||
<circle
|
|
||||||
cx={travelDotX}
|
|
||||||
cy={travelDotY}
|
|
||||||
r={18}
|
|
||||||
fill={routeColor}
|
|
||||||
opacity={travelDotOpacity * 0.3}
|
|
||||||
filter="url(#glow)"
|
|
||||||
/>
|
|
||||||
{/* Traveling dot */}
|
|
||||||
<circle
|
|
||||||
cx={travelDotX}
|
|
||||||
cy={travelDotY}
|
|
||||||
r={6}
|
|
||||||
fill={routeColor}
|
|
||||||
opacity={travelDotOpacity}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Caption pill badge at top */}
|
|
||||||
{caption && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 80,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: captionOpacity,
|
|
||||||
transform: `translateY(${captionTranslateY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(0, 0, 0, 0.6)",
|
|
||||||
backdropFilter: "blur(16px)",
|
|
||||||
WebkitBackdropFilter: "blur(16px)",
|
|
||||||
padding: "14px 36px",
|
|
||||||
borderRadius: 999,
|
|
||||||
border: "1px solid rgba(255,255,255,0.12)",
|
|
||||||
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: theme.fontSizes.body,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{caption}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { GradientBackground } from "../../components/shared/Background";
|
|
||||||
|
|
||||||
type PollOption = {
|
|
||||||
label: string;
|
|
||||||
votes: number;
|
|
||||||
emoji?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PollSceneProps = {
|
|
||||||
/** Poll question */
|
|
||||||
question?: string;
|
|
||||||
/** Poll options with vote counts */
|
|
||||||
options?: PollOption[];
|
|
||||||
/** Caption text at top */
|
|
||||||
caption?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_QUESTION = "Which trip are we doing?";
|
|
||||||
const DEFAULT_OPTIONS: PollOption[] = [
|
|
||||||
{ label: "Dallas \u2192 Houston", votes: 3, emoji: "\uD83E\uDD20" },
|
|
||||||
{ label: "NYC \u2192 Boston", votes: 2, emoji: "\uD83D\uDDFD" },
|
|
||||||
{ label: "LA \u2192 San Diego", votes: 1, emoji: "\uD83C\uDF34" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const VOTER_NAMES = ["Jake", "Mike", "Sam", "Alex", "Chris"];
|
|
||||||
|
|
||||||
const FILL_COLORS = [
|
|
||||||
theme.colors.accent,
|
|
||||||
"#FF8F5E",
|
|
||||||
"#FFB088",
|
|
||||||
];
|
|
||||||
|
|
||||||
const getFillColor = (index: number): string => {
|
|
||||||
if (index < FILL_COLORS.length) return FILL_COLORS[index];
|
|
||||||
return FILL_COLORS[FILL_COLORS.length - 1];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PollScene: React.FC<PollSceneProps> = ({
|
|
||||||
question = DEFAULT_QUESTION,
|
|
||||||
options = DEFAULT_OPTIONS,
|
|
||||||
caption,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const maxVotes = Math.max(...options.map((o) => o.votes));
|
|
||||||
|
|
||||||
// Card entrance - slides up and fades in
|
|
||||||
const cardEntrance = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const cardTranslateY = interpolate(cardEntrance, [0, 1], [80, 0]);
|
|
||||||
const cardOpacity = interpolate(cardEntrance, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Caption entrance
|
|
||||||
const captionEntrance = spring({
|
|
||||||
frame: frame - 3,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const captionOpacity = interpolate(captionEntrance, [0, 1], [0, 1]);
|
|
||||||
const captionTranslateY = interpolate(captionEntrance, [0, 1], [-20, 0]);
|
|
||||||
|
|
||||||
// Question text entrance
|
|
||||||
const questionEntrance = spring({
|
|
||||||
frame: frame - Math.round(fps * 0.15),
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const questionOpacity = interpolate(questionEntrance, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Bar fill delay base (after card is in)
|
|
||||||
const barStartFrame = Math.round(fps * 0.4);
|
|
||||||
|
|
||||||
// Calculate when all bars are done filling for the "Poll sent" badge
|
|
||||||
const lastBarStartFrame = barStartFrame + (options.length - 1) * Math.round(fps * 0.2);
|
|
||||||
const barFillDuration = Math.round(fps * 0.5);
|
|
||||||
const allBarsDoneFrame = lastBarStartFrame + barFillDuration + Math.round(fps * 0.3);
|
|
||||||
|
|
||||||
// "Poll sent" badge
|
|
||||||
const pollSentEntrance = spring({
|
|
||||||
frame: frame - allBarsDoneFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const pollSentOpacity = interpolate(pollSentEntrance, [0, 1], [0, 1]);
|
|
||||||
const pollSentScale = interpolate(pollSentEntrance, [0, 1], [0.5, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
{/* Caption at top */}
|
|
||||||
{caption && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 120,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 10,
|
|
||||||
opacity: captionOpacity,
|
|
||||||
transform: `translateY(${captionTranslateY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "14px 32px",
|
|
||||||
borderRadius: 100,
|
|
||||||
background: "rgba(255, 255, 255, 0.08)",
|
|
||||||
backdropFilter: "blur(20px)",
|
|
||||||
WebkitBackdropFilter: "blur(20px)",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.12)",
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{caption}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Poll card centered vertically */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 900,
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 48,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
||||||
opacity: cardOpacity,
|
|
||||||
transform: `translateY(${cardTranslateY}px)`,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Question */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
opacity: questionOpacity,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{question}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{options.map((option, index) => {
|
|
||||||
const staggerDelay = barStartFrame + index * Math.round(fps * 0.2);
|
|
||||||
|
|
||||||
const fillProgress = spring({
|
|
||||||
frame: frame - staggerDelay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 30, stiffness: 120 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const fillWidth = interpolate(
|
|
||||||
fillProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0, (option.votes / maxVotes) * 100]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vote count fades in after bar fills
|
|
||||||
const voteCountDelay = staggerDelay + Math.round(fps * 0.3);
|
|
||||||
const voteCountEntrance = spring({
|
|
||||||
frame: frame - voteCountDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const voteCountOpacity = interpolate(
|
|
||||||
voteCountEntrance,
|
|
||||||
[0, 1],
|
|
||||||
[0, 1]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isWinner = option.votes === maxVotes;
|
|
||||||
const fillColor = getFillColor(index);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
height: 72,
|
|
||||||
borderRadius: 16,
|
|
||||||
background: "#2C2C2E",
|
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: isWinner && fillProgress > 0.8
|
|
||||||
? `0 0 24px ${fillColor}40`
|
|
||||||
: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Fill bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: `${fillWidth}%`,
|
|
||||||
borderRadius: 16,
|
|
||||||
background: fillColor,
|
|
||||||
transition: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Label and emoji */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "0 24px",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.emoji && (
|
|
||||||
<span style={{ fontSize: 28 }}>{option.emoji}</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote count */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
opacity: voteCountOpacity,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.votes} {option.votes === 1 ? "vote" : "votes"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "Poll sent" badge below the card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: `translate(-50%, ${cardTranslateY + 240}px) scale(${pollSentScale})`,
|
|
||||||
opacity: pollSentOpacity,
|
|
||||||
marginTop: options.length * 46 + 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "12px 28px",
|
|
||||||
borderRadius: 100,
|
|
||||||
background: `${theme.colors.success}20`,
|
|
||||||
border: `1px solid ${theme.colors.success}40`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 20 }}>{"\u2713"}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.success,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Poll sent
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
|
|
||||||
{/* Vote notification pills - bottom right */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 160,
|
|
||||||
right: 60,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "flex-end",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{VOTER_NAMES.map((name, index) => {
|
|
||||||
const notifStartFrame =
|
|
||||||
barStartFrame + index * Math.round(fps * 0.3);
|
|
||||||
const notifEndFrame = notifStartFrame + Math.round(fps * 1.0);
|
|
||||||
|
|
||||||
const enterProgress = spring({
|
|
||||||
frame: frame - notifStartFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const exitProgress = spring({
|
|
||||||
frame: frame - notifEndFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const notifOpacity = interpolate(
|
|
||||||
enterProgress,
|
|
||||||
[0, 1],
|
|
||||||
[0, 1]
|
|
||||||
) * interpolate(exitProgress, [0, 1], [1, 0]);
|
|
||||||
|
|
||||||
const notifTranslateX = interpolate(
|
|
||||||
enterProgress,
|
|
||||||
[0, 1],
|
|
||||||
[60, 0]
|
|
||||||
);
|
|
||||||
|
|
||||||
const notifScale = interpolate(enterProgress, [0, 1], [0.7, 1]);
|
|
||||||
|
|
||||||
if (notifOpacity <= 0.01) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
style={{
|
|
||||||
padding: "10px 22px",
|
|
||||||
borderRadius: 100,
|
|
||||||
background: "rgba(255, 255, 255, 0.1)",
|
|
||||||
backdropFilter: "blur(12px)",
|
|
||||||
WebkitBackdropFilter: "blur(12px)",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
opacity: notifOpacity,
|
|
||||||
transform: `translateX(${notifTranslateX}px) scale(${notifScale})`,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name} voted!
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
Video,
|
|
||||||
staticFile,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { GradientBackground } from "../../components/shared/Background";
|
|
||||||
|
|
||||||
type ScreenRecSceneProps = {
|
|
||||||
/** Asset key for the screen recording, e.g. "screenrecs/date_range.mp4" */
|
|
||||||
assetKey?: string;
|
|
||||||
/** Caption text overlay at top */
|
|
||||||
caption?: string;
|
|
||||||
/** Whether to show the phone device frame */
|
|
||||||
showFrame?: boolean;
|
|
||||||
/** Scale of the phone (default 0.85) */
|
|
||||||
phoneScale?: number;
|
|
||||||
/** Optional start time offset in seconds for the video */
|
|
||||||
videoStartSec?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Placeholder: React.FC = () => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 50%, ${theme.colors.accent}22 100%)`,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play icon triangle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: "40px solid rgba(255, 255, 255, 0.6)",
|
|
||||||
borderTop: "24px solid transparent",
|
|
||||||
borderBottom: "24px solid transparent",
|
|
||||||
marginLeft: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: theme.fontSizes.body,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Screen Recording
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ScreenVideo: React.FC<{
|
|
||||||
assetKey: string;
|
|
||||||
startFromFrame: number;
|
|
||||||
}> = ({ assetKey, startFromFrame }) => {
|
|
||||||
const [hasError, setHasError] = useState(false);
|
|
||||||
|
|
||||||
if (hasError) {
|
|
||||||
return <Placeholder />;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const src = staticFile(assetKey);
|
|
||||||
return (
|
|
||||||
<Video
|
|
||||||
src={src}
|
|
||||||
startFrom={startFromFrame}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
onError={() => setHasError(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return <Placeholder />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ScreenRecScene: React.FC<ScreenRecSceneProps> = ({
|
|
||||||
assetKey,
|
|
||||||
caption,
|
|
||||||
showFrame = true,
|
|
||||||
phoneScale = 0.85,
|
|
||||||
videoStartSec = 0,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
const startFromFrame = Math.round(videoStartSec * fps);
|
|
||||||
|
|
||||||
// --- Phone entrance animations ---
|
|
||||||
|
|
||||||
// Slide up from below
|
|
||||||
const slideProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const translateY = interpolate(slideProgress, [0, 1], [200, 0]);
|
|
||||||
|
|
||||||
// Scale up
|
|
||||||
const scaleProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scaleValue = interpolate(scaleProgress, [0, 1], [0.92, 1.0]) * phoneScale;
|
|
||||||
|
|
||||||
// --- Caption animation (0.15s delay) ---
|
|
||||||
const captionDelayFrames = Math.round(0.15 * fps);
|
|
||||||
|
|
||||||
const captionProgress = spring({
|
|
||||||
frame: frame - captionDelayFrames,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const captionOpacity = interpolate(
|
|
||||||
frame - captionDelayFrames,
|
|
||||||
[0, Math.round(fps * 0.25)],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
|
|
||||||
|
|
||||||
// --- Phone dimensions ---
|
|
||||||
const phoneWidth = width * 0.75;
|
|
||||||
const phoneHeight = height * 0.8;
|
|
||||||
const cornerRadius = 60;
|
|
||||||
const bezelWidth = 12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{/* Background */}
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
{/* Subtle orange glow behind phone */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: phoneWidth * 1.4,
|
|
||||||
height: phoneHeight * 0.8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(ellipse, ${theme.colors.accent}1A 0%, transparent 70%)`,
|
|
||||||
filter: "blur(60px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
|
|
||||||
{/* Caption badge */}
|
|
||||||
{caption && (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
paddingTop: 100,
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: captionOpacity,
|
|
||||||
transform: `translateY(${captionTranslateY}px)`,
|
|
||||||
background: "rgba(0, 0, 0, 0.55)",
|
|
||||||
backdropFilter: "blur(20px)",
|
|
||||||
WebkitBackdropFilter: "blur(20px)",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: "14px 32px",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{caption}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Phone device */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `translateY(${translateY}px) scale(${scaleValue})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showFrame ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: phoneWidth,
|
|
||||||
height: phoneHeight,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: cornerRadius,
|
|
||||||
padding: bezelWidth,
|
|
||||||
boxShadow: `
|
|
||||||
0 50px 100px rgba(0, 0, 0, 0.5),
|
|
||||||
0 20px 40px rgba(0, 0, 0, 0.3),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Dynamic Island */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: bezelWidth + 15,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: 120,
|
|
||||||
height: 36,
|
|
||||||
background: "#000",
|
|
||||||
borderRadius: 18,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Screen content */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
borderRadius: cornerRadius - bezelWidth,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: theme.colors.background,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{assetKey ? (
|
|
||||||
<ScreenVideo
|
|
||||||
assetKey={assetKey}
|
|
||||||
startFromFrame={startFromFrame}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Placeholder />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Home indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: bezelWidth + 10,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: 140,
|
|
||||||
height: 5,
|
|
||||||
background: "rgba(255, 255, 255, 0.3)",
|
|
||||||
borderRadius: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* No frame - just the screen content */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: phoneWidth - 40,
|
|
||||||
height: phoneHeight - 40,
|
|
||||||
borderRadius: cornerRadius - 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
background: theme.colors.background,
|
|
||||||
boxShadow: "0 50px 100px rgba(0, 0, 0, 0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{assetKey ? (
|
|
||||||
<ScreenVideo
|
|
||||||
assetKey={assetKey}
|
|
||||||
startFromFrame={startFromFrame}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Placeholder />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type TextPunchSceneProps = {
|
|
||||||
/** The punch line text */
|
|
||||||
text?: string;
|
|
||||||
/** Optional secondary smaller text below */
|
|
||||||
subtext?: string;
|
|
||||||
/** Style variant */
|
|
||||||
variant?: "slam" | "typewriter" | "split";
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Deterministic "random" shake offsets so renders are reproducible
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const SHAKE_OFFSETS: { x: number; y: number }[] = [
|
|
||||||
{ x: -6, y: 4 },
|
|
||||||
{ x: 8, y: -5 },
|
|
||||||
{ x: -3, y: 7 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Variant: slam
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const SlamVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
|
|
||||||
text,
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
}) => {
|
|
||||||
// Main text slam spring
|
|
||||||
const slamProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 300 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const textScale = interpolate(slamProgress, [0, 1], [2.0, 1.0]);
|
|
||||||
const textOpacity = interpolate(slamProgress, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Impact frame: the frame at which scale first reaches ~1.0
|
|
||||||
// With damping 12, stiffness 300 the spring crosses 1.0 around frame 5-6
|
|
||||||
const impactFrame = 5;
|
|
||||||
|
|
||||||
// Screen shake: 3 frames starting at impact
|
|
||||||
let shakeX = 0;
|
|
||||||
let shakeY = 0;
|
|
||||||
const shakeFrame = frame - impactFrame;
|
|
||||||
if (shakeFrame >= 0 && shakeFrame < SHAKE_OFFSETS.length) {
|
|
||||||
shakeX = SHAKE_OFFSETS[shakeFrame].x;
|
|
||||||
shakeY = SHAKE_OFFSETS[shakeFrame].y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orange accent line appears after slam lands
|
|
||||||
const lineDelay = impactFrame + 4;
|
|
||||||
const lineProgress = spring({
|
|
||||||
frame: frame - lineDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Radial orange glow pulse on impact
|
|
||||||
const glowPeak = impactFrame;
|
|
||||||
const glowOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[glowPeak, glowPeak + 4, glowPeak + 14],
|
|
||||||
[0, 0.45, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
transform: `translate(${shakeX}px, ${shakeY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Radial orange glow */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 900,
|
|
||||||
height: 600,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
|
|
||||||
opacity: glowOpacity,
|
|
||||||
filter: "blur(60px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
|
|
||||||
{/* Text + underline */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 72,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: 850,
|
|
||||||
lineHeight: 1.15,
|
|
||||||
transform: `scale(${textScale})`,
|
|
||||||
opacity: textOpacity,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orange accent line */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 300,
|
|
||||||
height: 5,
|
|
||||||
marginTop: 24,
|
|
||||||
borderRadius: 3,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
transform: `scaleX(${lineScaleX})`,
|
|
||||||
transformOrigin: "left",
|
|
||||||
opacity: lineScaleX > 0.01 ? 1 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Variant: typewriter
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const TypewriterVariant: React.FC<{
|
|
||||||
text: string;
|
|
||||||
frame: number;
|
|
||||||
fps: number;
|
|
||||||
}> = ({ text, frame, fps }) => {
|
|
||||||
// ~2 chars per frame
|
|
||||||
const charsPerFrame = 2;
|
|
||||||
const visibleChars = Math.min(frame * charsPerFrame, text.length);
|
|
||||||
const typingComplete = visibleChars >= text.length;
|
|
||||||
const typingCompleteFrame = Math.ceil(text.length / charsPerFrame);
|
|
||||||
|
|
||||||
// Cursor blink: after typing completes, blink 2x then disappear
|
|
||||||
// Each blink cycle = 8 frames on, 6 frames off
|
|
||||||
const cursorVisible = (() => {
|
|
||||||
if (!typingComplete) return true; // solid while typing
|
|
||||||
const elapsed = frame - typingCompleteFrame;
|
|
||||||
if (elapsed < 0) return true;
|
|
||||||
// 2 blink cycles: on 8, off 6, on 8, off 6 = 28 frames total
|
|
||||||
const blinkCycle = 14; // 8 on + 6 off
|
|
||||||
if (elapsed >= blinkCycle * 2) return false; // disappeared
|
|
||||||
const withinCycle = elapsed % blinkCycle;
|
|
||||||
return withinCycle < 8;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Scan line effect: thin horizontal lines scrolling down slowly
|
|
||||||
const scanLineOffset = (frame * 0.5) % 8;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{/* Scan line overlay */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
backgroundImage: `repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
transparent,
|
|
||||||
transparent 6px,
|
|
||||||
rgba(255, 255, 255, 0.05) 6px,
|
|
||||||
rgba(255, 255, 255, 0.05) 8px
|
|
||||||
)`,
|
|
||||||
backgroundPosition: `0 ${scanLineOffset}px`,
|
|
||||||
pointerEvents: "none",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Text container */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: 900,
|
|
||||||
padding: "0 60px",
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: "SF Mono, Menlo, monospace",
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text.slice(0, Math.floor(visibleChars))}
|
|
||||||
</span>
|
|
||||||
{/* Cursor */}
|
|
||||||
{cursorVisible && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 4,
|
|
||||||
height: 56,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
marginLeft: 2,
|
|
||||||
verticalAlign: "bottom",
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Variant: split
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const SplitVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
|
|
||||||
text,
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
}) => {
|
|
||||||
const words = text.split(/\s+/);
|
|
||||||
const midpoint = Math.ceil(words.length / 2);
|
|
||||||
const topHalf = words.slice(0, midpoint).join(" ");
|
|
||||||
const bottomHalf = words.slice(midpoint).join(" ");
|
|
||||||
|
|
||||||
// Top half slides from left
|
|
||||||
const topProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 160 },
|
|
||||||
});
|
|
||||||
const topTranslateX = interpolate(topProgress, [0, 1], [-600, 0]);
|
|
||||||
const topOpacity = interpolate(topProgress, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bottom half slides from right, slight delay (3 frames)
|
|
||||||
const bottomProgress = spring({
|
|
||||||
frame: frame - 3,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 160 },
|
|
||||||
});
|
|
||||||
const bottomTranslateX = interpolate(bottomProgress, [0, 1], [600, 0]);
|
|
||||||
const bottomOpacity = interpolate(bottomProgress, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dividing line appears after both halves have settled
|
|
||||||
const lineDelay = 12;
|
|
||||||
const lineProgress = spring({
|
|
||||||
frame: frame - lineDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
maxWidth: 900,
|
|
||||||
padding: "0 60px",
|
|
||||||
gap: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top half */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
transform: `translateX(${topTranslateX}px)`,
|
|
||||||
opacity: topOpacity,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{topHalf}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orange dividing line */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 400,
|
|
||||||
height: 3,
|
|
||||||
marginTop: 20,
|
|
||||||
marginBottom: 20,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
transform: `scaleX(${lineScaleX})`,
|
|
||||||
transformOrigin: "center",
|
|
||||||
opacity: lineScaleX > 0.01 ? 1 : 0,
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bottom half */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
transform: `translateX(${bottomTranslateX}px)`,
|
|
||||||
opacity: bottomOpacity,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{bottomHalf}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export const TextPunchScene: React.FC<TextPunchSceneProps> = ({
|
|
||||||
text = "The spreadsheet era is over.",
|
|
||||||
subtext,
|
|
||||||
variant = "slam",
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Subtext fades in 0.4s after the main animation completes
|
|
||||||
const mainAnimationEndFrame = useMemo(() => {
|
|
||||||
switch (variant) {
|
|
||||||
case "slam":
|
|
||||||
// Slam lands ~5 frames, accent line ~9 frames → settled ~15
|
|
||||||
return 15;
|
|
||||||
case "typewriter": {
|
|
||||||
// Typing at 2 chars/frame + 2 blink cycles (28 frames)
|
|
||||||
const typingDone = Math.ceil(text.length / 2);
|
|
||||||
return typingDone;
|
|
||||||
}
|
|
||||||
case "split":
|
|
||||||
// Both halves settle ~15 frames, line at ~24
|
|
||||||
return 24;
|
|
||||||
default:
|
|
||||||
return 15;
|
|
||||||
}
|
|
||||||
}, [variant, text]);
|
|
||||||
|
|
||||||
const subtextDelay = mainAnimationEndFrame + Math.round(0.4 * fps);
|
|
||||||
const subtextOpacity = subtext
|
|
||||||
? interpolate(
|
|
||||||
frame - subtextDelay,
|
|
||||||
[0, Math.round(0.25 * fps)],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
const subtextTranslateY = subtext
|
|
||||||
? interpolate(
|
|
||||||
frame - subtextDelay,
|
|
||||||
[0, Math.round(0.25 * fps)],
|
|
||||||
[12, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill style={{ background: theme.colors.background }}>
|
|
||||||
{/* Variant content */}
|
|
||||||
{variant === "slam" && (
|
|
||||||
<SlamVariant text={text} frame={frame} fps={fps} />
|
|
||||||
)}
|
|
||||||
{variant === "typewriter" && (
|
|
||||||
<TypewriterVariant text={text} frame={frame} fps={fps} />
|
|
||||||
)}
|
|
||||||
{variant === "split" && (
|
|
||||||
<SplitVariant text={text} frame={frame} fps={fps} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtext (shared across all variants) */}
|
|
||||||
{subtext && (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "32%",
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: 800,
|
|
||||||
padding: "0 60px",
|
|
||||||
opacity: subtextOpacity,
|
|
||||||
transform: `translateY(${subtextTranslateY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{subtext}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export { HookCard } from "./HookCard";
|
|
||||||
export { ChatScene } from "./ChatScene";
|
|
||||||
export { ScreenRecScene } from "./ScreenRecScene";
|
|
||||||
export { MapScene } from "./MapScene";
|
|
||||||
export { PollScene } from "./PollScene";
|
|
||||||
export { FlexScene } from "./FlexScene";
|
|
||||||
export { TextPunchScene } from "./TextPunchScene";
|
|
||||||
export { CTAEndCard } from "./CTAEndCard";
|
|
||||||
export { KineticCaption } from "./KineticCaption";
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* Config-driven video engine types.
|
|
||||||
*
|
|
||||||
* Each video is defined by a VideoConfig that specifies its scenes,
|
|
||||||
* captions, CTA, and timing. The VideoFromConfig component reads
|
|
||||||
* these configs and renders the appropriate scene components.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SceneType =
|
|
||||||
| "HOOK"
|
|
||||||
| "CHAT"
|
|
||||||
| "SCREENREC"
|
|
||||||
| "MAP"
|
|
||||||
| "POLL"
|
|
||||||
| "FLEX"
|
|
||||||
| "TEXTPUNCH"
|
|
||||||
| "CTA";
|
|
||||||
|
|
||||||
export type CaptionLine = {
|
|
||||||
text: string;
|
|
||||||
/** Seconds from video start when this caption appears */
|
|
||||||
startSec: number;
|
|
||||||
/** Seconds from video start when this caption disappears */
|
|
||||||
endSec: number;
|
|
||||||
/** Optional emphasis style */
|
|
||||||
emphasis?: "normal" | "bold" | "highlight";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SceneConfig = {
|
|
||||||
type: SceneType;
|
|
||||||
/** Duration in seconds for this scene */
|
|
||||||
durationSec: number;
|
|
||||||
/** Scene-specific props passed to the scene component */
|
|
||||||
props?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoConfig = {
|
|
||||||
/** Unique composition ID, e.g. "V03_H01" */
|
|
||||||
id: string;
|
|
||||||
/** Base video concept, e.g. "V03" */
|
|
||||||
base: string;
|
|
||||||
/** Hook text shown in the first scene */
|
|
||||||
hook: string;
|
|
||||||
/** Ordered scene list */
|
|
||||||
scenes: SceneConfig[];
|
|
||||||
/** Kinetic caption lines */
|
|
||||||
captions: CaptionLine[];
|
|
||||||
/** CTA line (must include "Search SportsTime") */
|
|
||||||
cta: string;
|
|
||||||
/** Optional voiceover line */
|
|
||||||
vo?: string;
|
|
||||||
/** Target total length in seconds (12-18) */
|
|
||||||
targetLengthSec: number;
|
|
||||||
/** Asset keys used by this video */
|
|
||||||
assets?: {
|
|
||||||
screenrec?: string[];
|
|
||||||
overlay?: string[];
|
|
||||||
broll?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Week1Configs = VideoConfig[];
|
|
||||||
|
|
||||||
/** Asset key registry - maps logical names to file paths in public/ */
|
|
||||||
export const ASSET_KEYS = {
|
|
||||||
screenrecs: {
|
|
||||||
"date-range": "screenrecs/date-range.mp4",
|
|
||||||
"follow-team": "screenrecs/follow-team.mp4",
|
|
||||||
"by-games": "screenrecs/by-games.mp4",
|
|
||||||
"route-generated": "screenrecs/route-generated.mp4",
|
|
||||||
"poll-create": "screenrecs/poll-create.mp4",
|
|
||||||
tracker: "screenrecs/tracker.mp4",
|
|
||||||
},
|
|
||||||
overlays: {
|
|
||||||
"imessage-bg": "overlays/imessage-bg.png",
|
|
||||||
"chat-bubbles": "overlays/chat-bubbles.png",
|
|
||||||
"vote-bubbles": "overlays/vote-bubbles.png",
|
|
||||||
},
|
|
||||||
broll: {
|
|
||||||
highway: "broll/highway.mp4",
|
|
||||||
city: "broll/city.mp4",
|
|
||||||
stadium: "broll/stadium.mp4",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AssetCategory = keyof typeof ASSET_KEYS;
|
|
||||||
export type ScreenrecKey = keyof typeof ASSET_KEYS.screenrecs;
|
|
||||||
export type OverlayKey = keyof typeof ASSET_KEYS.overlays;
|
|
||||||
export type BrollKey = keyof typeof ASSET_KEYS.broll;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { registerRoot } from "remotion";
|
|
||||||
import { RemotionRoot } from "./Root";
|
|
||||||
|
|
||||||
registerRoot(RemotionRoot);
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,979 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { FilmGrain } from "../../components/shared/FilmGrain";
|
|
||||||
import { TikTokCaption } from "../../components/shared/TikTokCaption";
|
|
||||||
import type { CaptionEntry } from "../../components/shared/TikTokCaption";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Captions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const CAPTIONS: CaptionEntry[] = [
|
|
||||||
{ text: "Be honest.", startSec: 0.2, endSec: 1.2, style: "punch" },
|
|
||||||
{ text: "How many stadiums?", startSec: 1.3, endSec: 2.3, style: "shake" },
|
|
||||||
{ text: "47 and counting", startSec: 3.0, endSec: 5.0, style: "highlight" },
|
|
||||||
{ text: "Every. Single. One.", startSec: 6.0, endSec: 8.0, style: "stack" },
|
|
||||||
{ text: "Track yours", startSec: 9.0, endSec: 11.0, style: "whisper" },
|
|
||||||
{ text: "DROP YOUR NUMBER", startSec: 12.0, endSec: 14.5, style: "punch" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scene 1 : HOOK (0 - 2.5s, 75 frames)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const HookScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const WORDS = [
|
|
||||||
{ text: "Be honest.", delayFrames: 6 },
|
|
||||||
{ text: "How many stadiums", delayFrames: 30 },
|
|
||||||
{ text: "have you actually", delayFrames: 45 },
|
|
||||||
{ text: "been to?", delayFrames: 55 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Subtle background shake that decays
|
|
||||||
const shakeDecay = interpolate(frame, [0, 2 * fps], [1, 0], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
const SHAKE_OFFSETS = [
|
|
||||||
{ x: -3, y: 2 },
|
|
||||||
{ x: 4, y: -3 },
|
|
||||||
{ x: -2, y: -2 },
|
|
||||||
{ x: 3, y: 3 },
|
|
||||||
{ x: -1, y: -1 },
|
|
||||||
{ x: 2, y: 1 },
|
|
||||||
];
|
|
||||||
const shakeIdx = frame % SHAKE_OFFSETS.length;
|
|
||||||
const currentShake = SHAKE_OFFSETS[shakeIdx];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
padding: 60,
|
|
||||||
transform: `translate(${currentShake.x * shakeDecay}px, ${currentShake.y * shakeDecay}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{WORDS.map((word, i) => {
|
|
||||||
const localFrame = frame - word.delayFrames;
|
|
||||||
const slamSpring = spring({
|
|
||||||
frame: Math.max(0, localFrame),
|
|
||||||
fps,
|
|
||||||
config: { damping: 10, stiffness: 280 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = localFrame < 0 ? 0 : interpolate(slamSpring, [0, 1], [2, 1]);
|
|
||||||
const opacity = localFrame < 0
|
|
||||||
? 0
|
|
||||||
: interpolate(slamSpring, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 72,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.15,
|
|
||||||
letterSpacing: -2,
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
textShadow: `0 0 40px rgba(255, 215, 0, 0.4), 0 4px 12px rgba(0,0,0,0.8)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{word.text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilmGrain opacity={0.06} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scene 2 : THE COUNTER (2.5 - 5.5s, 90 frames)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const LEAGUE_PILLS = [
|
|
||||||
{ label: "MLB", delay: 0 },
|
|
||||||
{ label: "NFL", delay: 4 },
|
|
||||||
{ label: "NBA", delay: 8 },
|
|
||||||
{ label: "NHL", delay: 12 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CounterScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TARGET = 47;
|
|
||||||
const TOTAL = 120;
|
|
||||||
|
|
||||||
// Counter ramp: accelerate then decelerate over 2 seconds
|
|
||||||
const counterProgress = interpolate(frame, [0, 2 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
// Ease-out curve
|
|
||||||
const eased = 1 - Math.pow(1 - counterProgress, 3);
|
|
||||||
const currentCount = Math.round(eased * TARGET);
|
|
||||||
|
|
||||||
// Progress bar fill
|
|
||||||
const barProgress = interpolate(frame, [0.2 * fps, 2.2 * fps], [0, TARGET / TOTAL], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Gold glow pulse
|
|
||||||
const glowPulse = interpolate(
|
|
||||||
Math.sin(frame * 0.08),
|
|
||||||
[-1, 1],
|
|
||||||
[0.15, 0.3]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Number glow intensifies as count goes up
|
|
||||||
const numberGlow = interpolate(counterProgress, [0, 1], [20, 60]);
|
|
||||||
|
|
||||||
// Sub-text reveal
|
|
||||||
const subOpacity = interpolate(frame, [1.5 * fps, 2 * fps], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Gold radial glow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "30%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "120%",
|
|
||||||
height: "70%",
|
|
||||||
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 65%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 32,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Giant counter */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 220,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
lineHeight: 1,
|
|
||||||
letterSpacing: -8,
|
|
||||||
textShadow: `0 0 ${numberGlow}px rgba(255, 215, 0, 0.8), 0 0 ${numberGlow * 2}px rgba(255, 215, 0, 0.3)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentCount}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 900,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
background: "#1A1A1A",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: `${barProgress * 100}%`,
|
|
||||||
height: "100%",
|
|
||||||
borderRadius: 6,
|
|
||||||
background: "linear-gradient(90deg, #FFD700 0%, #FFA500 100%)",
|
|
||||||
boxShadow: "0 0 20px rgba(255, 215, 0, 0.5)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Count label */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.text,
|
|
||||||
opacity: subOpacity,
|
|
||||||
letterSpacing: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentCount} / {TOTAL} stadiums
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* League pills */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 16,
|
|
||||||
marginTop: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LEAGUE_PILLS.map((pill, i) => {
|
|
||||||
const pillSpring = spring({
|
|
||||||
frame: frame - 2 * fps - pill.delay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const pillScale = frame - 2 * fps - pill.delay < 0
|
|
||||||
? 0
|
|
||||||
: interpolate(pillSpring, [0, 1], [0.5, 1]);
|
|
||||||
const pillOpacity = frame - 2 * fps - pill.delay < 0
|
|
||||||
? 0
|
|
||||||
: interpolate(pillSpring, [0, 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
background: "rgba(255, 215, 0, 0.1)",
|
|
||||||
border: "2px solid rgba(255, 215, 0, 0.6)",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: "10px 28px",
|
|
||||||
transform: `scale(${pillScale})`,
|
|
||||||
opacity: pillOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
letterSpacing: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pill.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilmGrain opacity={0.05} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scene 3 : STADIUM MAP (5.5 - 9s, 105 frames)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const SPORTS_EMOJI = ["\u26BE", "\uD83C\uDFC8", "\uD83C\uDFC0", "\uD83C\uDFD2"];
|
|
||||||
const TOTAL_CIRCLES = 30;
|
|
||||||
const FILLED_COUNT = 24;
|
|
||||||
const COLS = 5;
|
|
||||||
const ROWS = 6;
|
|
||||||
|
|
||||||
const StadiumMapScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Circles fill one by one, 2 frames apart
|
|
||||||
const filledSoFar = Math.min(
|
|
||||||
FILLED_COUNT,
|
|
||||||
Math.max(0, Math.floor((frame - 0.3 * fps) / 2))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Gold radial glow pulse
|
|
||||||
const glowPulse = interpolate(
|
|
||||||
Math.sin(frame * 0.06),
|
|
||||||
[-1, 1],
|
|
||||||
[0.12, 0.25]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Running count text
|
|
||||||
const countSpring = spring({
|
|
||||||
frame: Math.max(0, frame - 0.3 * fps),
|
|
||||||
fps,
|
|
||||||
config: { damping: 30, stiffness: 80 },
|
|
||||||
});
|
|
||||||
const countOpacity = interpolate(frame, [0.2 * fps, 0.5 * fps], [0, 1], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Gold radial glow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "40%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "120%",
|
|
||||||
height: "70%",
|
|
||||||
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 60%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Running count in top-right */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 100,
|
|
||||||
right: 80,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
opacity: countOpacity,
|
|
||||||
textShadow: "0 0 30px rgba(255, 215, 0, 0.6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filledSoFar}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stadium grid */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 36,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: ROWS }).map((_, row) => (
|
|
||||||
<div key={row} style={{ display: "flex", gap: 48 }}>
|
|
||||||
{Array.from({ length: COLS }).map((_, col) => {
|
|
||||||
const idx = row * COLS + col;
|
|
||||||
const isFilled = idx < filledSoFar;
|
|
||||||
const isLatest = idx === filledSoFar - 1 && filledSoFar > 0;
|
|
||||||
const emojiChar = SPORTS_EMOJI[idx % SPORTS_EMOJI.length];
|
|
||||||
|
|
||||||
// Pop spring for fill
|
|
||||||
const fillSpring = isFilled
|
|
||||||
? spring({
|
|
||||||
frame: Math.max(0, frame - 0.3 * fps - idx * 2),
|
|
||||||
fps,
|
|
||||||
config: { damping: 8, stiffness: 300 },
|
|
||||||
})
|
|
||||||
: 0;
|
|
||||||
const circleScale = isFilled
|
|
||||||
? interpolate(fillSpring, [0, 1], [0.3, 1])
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
// Pulse for the latest filled circle
|
|
||||||
const pulseScale = isLatest
|
|
||||||
? 1 + 0.08 * Math.sin(frame * 0.3)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
borderRadius: 36,
|
|
||||||
background: isFilled
|
|
||||||
? theme.colors.gold
|
|
||||||
: "transparent",
|
|
||||||
border: isFilled
|
|
||||||
? "3px solid rgba(255, 215, 0, 0.9)"
|
|
||||||
: "3px solid rgba(128, 128, 128, 0.3)",
|
|
||||||
boxShadow: isFilled
|
|
||||||
? `0 0 ${isLatest ? 28 : 16}px rgba(255, 215, 0, ${isLatest ? 0.8 : 0.4})`
|
|
||||||
: "none",
|
|
||||||
transform: `scale(${circleScale * pulseScale})`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isFilled && (
|
|
||||||
<span style={{ fontSize: 28, lineHeight: 1 }}>
|
|
||||||
{emojiChar}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilmGrain opacity={0.05} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scene 4 : TRACKER APP (9 - 12s, 90 frames)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const TRACKER_ROWS = [
|
|
||||||
{ stadium: "Dodger Stadium", city: "LA", date: "Apr 12" },
|
|
||||||
{ stadium: "Fenway Park", city: "Boston", date: "May 3" },
|
|
||||||
{ stadium: "Wrigley Field", city: "Chicago", date: "May 18" },
|
|
||||||
{ stadium: "Yankee Stadium", city: "NYC", date: "Jun 7" },
|
|
||||||
{ stadium: "Oracle Park", city: "SF", date: "Jun 22" },
|
|
||||||
{ stadium: "Coors Field", city: "Denver", date: "Jul 4" },
|
|
||||||
{ stadium: "PNC Park", city: "Pittsburgh", date: "Jul 19" },
|
|
||||||
{ stadium: "Camden Yards", city: "Baltimore", date: "Aug 1" },
|
|
||||||
{ stadium: "Minute Maid Park", city: "Houston", date: "Aug 14" },
|
|
||||||
{ stadium: "T-Mobile Park", city: "Seattle", date: "Sep 2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TrackerAppScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Slow zoom drift on phone 1.0 -> 1.02
|
|
||||||
const zoomDrift = interpolate(frame, [0, 3 * fps], [1.0, 1.02], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll offset: rows slide up over time
|
|
||||||
const scrollOffset = interpolate(frame, [0.3 * fps, 2.8 * fps], [0, 280], {
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Phone entrance
|
|
||||||
const phoneSpring = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 18, stiffness: 120 },
|
|
||||||
});
|
|
||||||
const phoneScale = interpolate(phoneSpring, [0, 1], [0.85, 1]);
|
|
||||||
const phoneOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Phone frame */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 440,
|
|
||||||
height: 920,
|
|
||||||
borderRadius: 60,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
border: "4px solid #333",
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
transform: `scale(${phoneScale * zoomDrift})`,
|
|
||||||
opacity: phoneOpacity,
|
|
||||||
boxShadow: "0 40px 80px rgba(0,0,0,0.6), 0 0 60px rgba(255,215,0,0.08)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Dynamic Island */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 14,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: 130,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 18,
|
|
||||||
background: "#000",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Status bar area */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 70,
|
|
||||||
background: "rgba(10,10,10,0.95)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* App header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px 28px 12px",
|
|
||||||
background: "rgba(10,10,10,0.95)",
|
|
||||||
borderBottom: "1px solid rgba(255,215,0,0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stadium Tracker
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
47 of 120 visited
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrolling list */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
flex: 1,
|
|
||||||
height: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `translateY(-${scrollOffset}px)`,
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{TRACKER_ROWS.map((row, i) => {
|
|
||||||
const rowDelay = i * 3;
|
|
||||||
const rowSpring = spring({
|
|
||||||
frame: Math.max(0, frame - 0.2 * fps - rowDelay),
|
|
||||||
fps,
|
|
||||||
config: { damping: 20, stiffness: 180 },
|
|
||||||
});
|
|
||||||
const rowOpacity = frame - 0.2 * fps - rowDelay < 0
|
|
||||||
? 0
|
|
||||||
: interpolate(rowSpring, [0, 0.4], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const rowX = interpolate(rowSpring, [0, 1], [40, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "16px 28px",
|
|
||||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
|
||||||
opacity: rowOpacity,
|
|
||||||
transform: `translateX(${rowX}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Check badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 18,
|
|
||||||
background: "rgba(255,215,0,0.15)",
|
|
||||||
border: "2px solid rgba(255,215,0,0.5)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
marginRight: 16,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: theme.colors.gold,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"\u2713"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stadium info */}
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.stadium}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.city}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.date}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilmGrain opacity={0.04} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scene 5 : CTA (12 - 15s, 90 frames)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const CTAScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// "Drop your number." slam
|
|
||||||
const headlineSlam = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 10, stiffness: 260 },
|
|
||||||
});
|
|
||||||
const headlineScale = interpolate(headlineSlam, [0, 1], [2, 1]);
|
|
||||||
const headlineOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Search SportsTime" text
|
|
||||||
const subProgress = spring({
|
|
||||||
frame: frame - 0.8 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const subOpacity = interpolate(
|
|
||||||
frame - 0.8 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const subY = interpolate(subProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
// App icon
|
|
||||||
const iconProgress = spring({
|
|
||||||
frame: frame - 0.4 * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 140 },
|
|
||||||
});
|
|
||||||
const iconScale = interpolate(iconProgress, [0, 1], [0.4, 1]);
|
|
||||||
const iconOpacity = interpolate(
|
|
||||||
frame - 0.4 * fps,
|
|
||||||
[0, 0.15 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Subtle golden glow behind everything
|
|
||||||
const bgGlow = interpolate(frame, [0, 0.5 * fps], [0, 0.15], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search icon subtle bounce
|
|
||||||
const searchBounce = frame - 0.8 * fps > 0
|
|
||||||
? 1 + 0.03 * Math.sin((frame - 0.8 * fps) * 0.15)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background glow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "40%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "130%",
|
|
||||||
height: "60%",
|
|
||||||
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${bgGlow}) 0%, transparent 65%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 40,
|
|
||||||
padding: 60,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* App icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 28,
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
|
||||||
boxShadow: "0 16px 48px rgba(255, 107, 53, 0.4)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: iconOpacity,
|
|
||||||
transform: `scale(${iconScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="64" height="64" viewBox="0 0 100 100" fill="none">
|
|
||||||
<ellipse
|
|
||||||
cx="50"
|
|
||||||
cy="60"
|
|
||||||
rx="40"
|
|
||||||
ry="20"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 60 L10 40 Q50 10 90 40 L90 60"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="50"
|
|
||||||
y1="30"
|
|
||||||
x2="50"
|
|
||||||
y2="15"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
/>
|
|
||||||
<circle cx="50" cy="12" r="4" fill="white" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "Drop your number." */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.15,
|
|
||||||
letterSpacing: -2,
|
|
||||||
transform: `scale(${headlineScale})`,
|
|
||||||
opacity: headlineOpacity,
|
|
||||||
textShadow:
|
|
||||||
"0 0 40px rgba(255, 215, 0, 0.5), 0 4px 16px rgba(0,0,0,0.8)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Drop your number.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search CTA */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: subOpacity,
|
|
||||||
transform: `translateY(${subY}px) scale(${searchBounce})`,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Search icon */}
|
|
||||||
<svg
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(255,255,255,0.7)"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Search{" "}
|
|
||||||
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
|
|
||||||
SportsTime
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FilmGrain opacity={0.05} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main Composition
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VIDEO 1: "Stadium Count Flex"
|
|
||||||
*
|
|
||||||
* Competitive flex / brag video for TikTok.
|
|
||||||
* Gold + dark scoreboard aesthetic. ESPN stat graphics meets TikTok energy.
|
|
||||||
* 1080x1920, 30fps, 15 seconds (450 frames).
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:02.5 (0-75): HOOK - "Be honest. How many stadiums have you been to?"
|
|
||||||
* - 0:02.5-0:05.5 (75-165): THE COUNTER - Number animates 0 -> 47
|
|
||||||
* - 0:05.5-0:09 (165-270): STADIUM MAP - Grid of circles filling up
|
|
||||||
* - 0:09-0:12 (270-360): TRACKER APP - Simulated phone screen
|
|
||||||
* - 0:12-0:15 (360-450): CTA - "Drop your number."
|
|
||||||
*/
|
|
||||||
export const StadiumCountFlex: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 10;
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
hook: Math.round(2.5 * fps), // 75 frames
|
|
||||||
counter: Math.round(3 * fps), // 90 frames
|
|
||||||
stadiumMap: Math.round(3.5 * fps), // 105 frames
|
|
||||||
trackerApp: Math.round(3 * fps), // 90 frames
|
|
||||||
cta: Math.round(3 * fps), // 90 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: HOOK */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hook}>
|
|
||||||
<HookScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: THE COUNTER */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.counter}>
|
|
||||||
<CounterScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-bottom" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: STADIUM MAP */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stadiumMap}>
|
|
||||||
<StadiumMapScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: TRACKER APP */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.trackerApp}>
|
|
||||||
<TrackerAppScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: CTA */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.cta}>
|
|
||||||
<CTAScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
|
|
||||||
{/* TikTok caption overlay on top of everything */}
|
|
||||||
<TikTokCaption captions={CAPTIONS} bottomOffset={260} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type AchievementBadgeProps = {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
delay?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AchievementBadge: React.FC<AchievementBadgeProps> = ({
|
|
||||||
name,
|
|
||||||
description = "Achievement Unlocked",
|
|
||||||
delay = 0,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
const localFrame = frame - delayFrames;
|
|
||||||
|
|
||||||
if (localFrame < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Badge entrance with bounce
|
|
||||||
const entranceProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 150 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(entranceProgress, [0, 1], [0.3, 1]);
|
|
||||||
const opacity = interpolate(entranceProgress, [0, 0.5, 1], [0, 1, 1]);
|
|
||||||
|
|
||||||
// Shimmer animation
|
|
||||||
const shimmerProgress = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[fps * 0.5, fps * 1.5],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const shimmerX = interpolate(shimmerProgress, [0, 1], [-200, width + 200]);
|
|
||||||
|
|
||||||
// Glow pulse
|
|
||||||
const glowPulse = interpolate(
|
|
||||||
(localFrame % 40) / 40,
|
|
||||||
[0, 0.5, 1],
|
|
||||||
[0.3, 0.6, 0.3]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Achievement badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Outer glow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: -20,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `radial-gradient(circle, ${theme.colors.gold}${Math.round(glowPulse * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Badge background */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.gold} 0%, #B8860B 50%, ${theme.colors.gold} 100%)`,
|
|
||||||
boxShadow: `
|
|
||||||
0 10px 40px rgba(255, 215, 0, 0.4),
|
|
||||||
inset 0 -4px 10px rgba(0, 0, 0, 0.2),
|
|
||||||
inset 0 4px 10px rgba(255, 255, 255, 0.3)
|
|
||||||
`,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Shimmer overlay */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: shimmerX,
|
|
||||||
width: 100,
|
|
||||||
height: "100%",
|
|
||||||
background: `linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)`,
|
|
||||||
transform: "skewX(-20deg)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inner circle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 15,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: `linear-gradient(180deg, #1C1C1E 0%, #2C2C2E 100%)`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
border: `3px solid ${theme.colors.gold}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Trophy icon */}
|
|
||||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M12 15c3.866 0 7-3.134 7-7V4H5v4c0 3.866 3.134 7 7 7z"
|
|
||||||
fill={theme.colors.gold}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5 4H3v4c0 1.657 1.343 3 3 3V4zM19 4h2v4c0 1.657-1.343 3-3 3V4z"
|
|
||||||
fill={theme.colors.gold}
|
|
||||||
opacity={0.7}
|
|
||||||
/>
|
|
||||||
<rect x="10" y="15" width="4" height="4" fill={theme.colors.gold} />
|
|
||||||
<rect x="8" y="19" width="8" height="2" rx="1" fill={theme.colors.gold} />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Achievement text */}
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.gold,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 3,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simple text overlay for bucket list tagline
|
|
||||||
export const CollectThemAll: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const progress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
|
||||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity,
|
|
||||||
transform: `translateY(${translateY}px)`,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Collect them all.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 24,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Track every stadium. Earn every badge.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type StadiumMarker = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
name: string;
|
|
||||||
visited: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProgressMap: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
// Stadium positions (relative to container)
|
|
||||||
const stadiums: StadiumMarker[] = [
|
|
||||||
// Visited (green)
|
|
||||||
{ x: 0.15, y: 0.42, name: "LA", visited: true },
|
|
||||||
{ x: 0.22, y: 0.35, name: "SF", visited: true },
|
|
||||||
{ x: 0.28, y: 0.52, name: "Phoenix", visited: true },
|
|
||||||
{ x: 0.35, y: 0.3, name: "Denver", visited: true },
|
|
||||||
{ x: 0.48, y: 0.45, name: "Dallas", visited: true },
|
|
||||||
{ x: 0.55, y: 0.35, name: "KC", visited: true },
|
|
||||||
{ x: 0.62, y: 0.38, name: "STL", visited: true },
|
|
||||||
{ x: 0.68, y: 0.32, name: "Chicago", visited: true },
|
|
||||||
{ x: 0.72, y: 0.36, name: "Detroit", visited: true },
|
|
||||||
{ x: 0.78, y: 0.42, name: "Cleveland", visited: true },
|
|
||||||
{ x: 0.82, y: 0.38, name: "Pittsburgh", visited: true },
|
|
||||||
{ x: 0.88, y: 0.35, name: "NYC", visited: true },
|
|
||||||
// Not visited (gray)
|
|
||||||
{ x: 0.25, y: 0.28, name: "Seattle", visited: false },
|
|
||||||
{ x: 0.38, y: 0.42, name: "Houston", visited: false },
|
|
||||||
{ x: 0.52, y: 0.3, name: "Minneapolis", visited: false },
|
|
||||||
{ x: 0.58, y: 0.48, name: "Atlanta", visited: false },
|
|
||||||
{ x: 0.65, y: 0.28, name: "Milwaukee", visited: false },
|
|
||||||
{ x: 0.75, y: 0.28, name: "Toronto", visited: false },
|
|
||||||
{ x: 0.84, y: 0.32, name: "Boston", visited: false },
|
|
||||||
{ x: 0.66, y: 0.52, name: "Miami", visited: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const visitedCount = stadiums.filter((s) => s.visited).length;
|
|
||||||
const totalCount = stadiums.length;
|
|
||||||
|
|
||||||
// Animation for markers appearing
|
|
||||||
const mapEntranceProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapScale = interpolate(mapEntranceProgress, [0, 1], [0.9, 1]);
|
|
||||||
const mapOpacity = interpolate(mapEntranceProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Counter animation
|
|
||||||
const counterProgress = spring({
|
|
||||||
frame: frame - fps * 0.5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 30,
|
|
||||||
paddingTop: 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your Stadium Map
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress counter */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
gap: 8,
|
|
||||||
opacity: counterProgress,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{visitedCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 24,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
/ {totalCount}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
marginLeft: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
stadiums visited
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map container */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
position: "relative",
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 24,
|
|
||||||
overflow: "hidden",
|
|
||||||
transform: `scale(${mapScale})`,
|
|
||||||
opacity: mapOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Simplified US outline */}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 100 60"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 20,
|
|
||||||
opacity: 0.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M10 25 Q15 15 30 12 Q50 8 70 12 Q85 15 90 25 Q92 35 88 45 Q80 52 65 50 Q50 55 35 50 Q20 52 12 45 Q8 35 10 25 Z"
|
|
||||||
fill="none"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="0.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Stadium markers */}
|
|
||||||
{stadiums.map((stadium, index) => {
|
|
||||||
const markerDelay = index * 2;
|
|
||||||
const markerProgress = spring({
|
|
||||||
frame: frame - markerDelay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
|
|
||||||
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Pulse effect for visited stadiums
|
|
||||||
const pulseProgress = stadium.visited
|
|
||||||
? interpolate(
|
|
||||||
(frame + index * 10) % 60,
|
|
||||||
[0, 30, 60],
|
|
||||||
[1, 1.3, 1]
|
|
||||||
)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${stadium.x * 100}%`,
|
|
||||||
top: `${stadium.y * 100}%`,
|
|
||||||
transform: `translate(-50%, -50%) scale(${markerScale})`,
|
|
||||||
opacity: markerOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Pulse ring for visited */}
|
|
||||||
{stadium.visited && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.success,
|
|
||||||
opacity: 0.3,
|
|
||||||
transform: `translate(-50%, -50%) scale(${pulseProgress})`,
|
|
||||||
left: "50%",
|
|
||||||
top: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Marker dot */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: stadium.visited
|
|
||||||
? theme.colors.success
|
|
||||||
: theme.colors.textMuted,
|
|
||||||
border: `2px solid ${stadium.visited ? "white" : theme.colors.textMuted}`,
|
|
||||||
boxShadow: stadium.visited
|
|
||||||
? `0 2px 8px rgba(76, 175, 80, 0.5)`
|
|
||||||
: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 40,
|
|
||||||
marginTop: 24,
|
|
||||||
opacity: counterProgress,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.success,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visited
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remaining
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type Stadium = {
|
|
||||||
name: string;
|
|
||||||
city: string;
|
|
||||||
team: string;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StadiumStampProps = {
|
|
||||||
stadium: Stadium;
|
|
||||||
delay?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StadiumStamp: React.FC<StadiumStampProps> = ({
|
|
||||||
stadium,
|
|
||||||
delay = 0,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const delayFrames = delay * fps;
|
|
||||||
const localFrame = frame - delayFrames;
|
|
||||||
|
|
||||||
if (localFrame < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stamp slam animation (fast drop with bounce)
|
|
||||||
const slamProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 8, stiffness: 300 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stamp comes from above
|
|
||||||
const translateY = interpolate(slamProgress, [0, 1], [-200, 0]);
|
|
||||||
const scale = interpolate(slamProgress, [0, 0.8, 1], [1.5, 1.1, 1]);
|
|
||||||
const rotation = interpolate(slamProgress, [0, 1], [-5, 0]);
|
|
||||||
|
|
||||||
// Ink bleed effect
|
|
||||||
const inkProgress = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[5, 25],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const inkScale = interpolate(inkProgress, [0, 1], [0.8, 1.05]);
|
|
||||||
const inkOpacity = interpolate(inkProgress, [0, 0.5, 1], [0, 0.15, 0.1]);
|
|
||||||
|
|
||||||
// Opacity for initial appearance
|
|
||||||
const opacity = interpolate(localFrame, [0, 3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Ink bleed effect */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 320,
|
|
||||||
height: 320,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: stadium.color,
|
|
||||||
opacity: inkOpacity,
|
|
||||||
transform: `scale(${inkScale})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stamp */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `translateY(${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
|
|
||||||
width: 280,
|
|
||||||
height: 280,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `8px solid ${stadium.color}`,
|
|
||||||
background: "rgba(255,255,255,0.95)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 24,
|
|
||||||
boxShadow: localFrame > 5 ? `0 4px 20px rgba(0,0,0,0.2)` : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Stadium name */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: stadium.color,
|
|
||||||
textAlign: "center",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 2,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stadium.name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 80,
|
|
||||||
height: 3,
|
|
||||||
background: stadium.color,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* City */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#333",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stadium.city}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Team */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#666",
|
|
||||||
textAlign: "center",
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stadium.team}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visited badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
padding: "6px 16px",
|
|
||||||
background: stadium.color,
|
|
||||||
borderRadius: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visited
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Page flip transition for passport effect
|
|
||||||
type PassportPageProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
pageIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PassportPage: React.FC<PassportPageProps> = ({
|
|
||||||
children,
|
|
||||||
pageIndex,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
// Each page flips after its stamp animation completes
|
|
||||||
const flipDelay = pageIndex * fps * 1.2;
|
|
||||||
const localFrame = frame - flipDelay;
|
|
||||||
|
|
||||||
// Page flip animation (0 = flat, 1 = fully flipped)
|
|
||||||
const flipProgress = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[fps * 0.8, fps * 1.1],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only show page before it's fully flipped
|
|
||||||
if (flipProgress >= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3D flip effect
|
|
||||||
const rotateY = interpolate(flipProgress, [0, 1], [0, -180]);
|
|
||||||
const zIndex = 100 - pageIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
perspective: 1500,
|
|
||||||
perspectiveOrigin: "0% 50%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
transformStyle: "preserve-3d",
|
|
||||||
transformOrigin: "left center",
|
|
||||||
transform: `rotateY(${rotateY}deg)`,
|
|
||||||
zIndex,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Page front */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backfaceVisibility: "hidden",
|
|
||||||
background: "#F5F0E6", // Passport paper color
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page back (blank) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backfaceVisibility: "hidden",
|
|
||||||
transform: "rotateY(180deg)",
|
|
||||||
background: "#F5F0E6",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
Sequence,
|
|
||||||
useVideoConfig,
|
|
||||||
} from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
|
|
||||||
import { StadiumStamp, PassportPage } from "./StadiumStamp";
|
|
||||||
import { ProgressMap } from "./ProgressMap";
|
|
||||||
import { AchievementBadge, CollectThemAll } from "./AchievementBadge";
|
|
||||||
import {
|
|
||||||
GradientBackground,
|
|
||||||
LogoEndcard,
|
|
||||||
TextReveal,
|
|
||||||
theme,
|
|
||||||
} from "../../components/shared";
|
|
||||||
|
|
||||||
// Stadium data
|
|
||||||
const stamps = [
|
|
||||||
{ name: "Wrigley Field", city: "Chicago, IL", team: "Cubs", color: "#0E3386" },
|
|
||||||
{ name: "Fenway Park", city: "Boston, MA", team: "Red Sox", color: "#BD3039" },
|
|
||||||
{ name: "Dodger Stadium", city: "Los Angeles, CA", team: "Dodgers", color: "#005A9C" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video 3: "The Bucket List"
|
|
||||||
*
|
|
||||||
* Goal: Emotionally connect with stadium-chasers, highlight progress tracking
|
|
||||||
* Length: 12 seconds (360 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:02 (0-60): First stamp slams (Wrigley Field)
|
|
||||||
* - 0:02-0:04 (60-120): Page flip, second stamp (Fenway)
|
|
||||||
* - 0:04-0:06 (120-180): Third stamp (Dodger Stadium)
|
|
||||||
* - 0:06-0:08 (180-240): Progress map reveal with counter
|
|
||||||
* - 0:08-0:10 (240-300): Achievement badge unlock
|
|
||||||
* - 0:10-0:12 (300-360): Logo endcard
|
|
||||||
*/
|
|
||||||
export const TheBucketList: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 12;
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
stamp1: 1.5 * fps, // 45 frames
|
|
||||||
stamp2: 1.2 * fps, // 36 frames
|
|
||||||
stamp3: 1.2 * fps, // 36 frames
|
|
||||||
progressMap: 2.5 * fps, // 75 frames
|
|
||||||
achievement: 2.5 * fps, // 75 frames
|
|
||||||
logo: 3.1 * fps, // 93 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: First stamp - Wrigley Field */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp1}>
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: "#F5F0E6", // Passport paper color
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StadiumStamp stadium={stamps[0]} delay={0} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: 8 })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: Second stamp - Fenway Park */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp2}>
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: "#F5F0E6",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StadiumStamp stadium={stamps[1]} delay={0} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: 8 })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: Third stamp - Dodger Stadium */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp3}>
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: "#F5F0E6",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StadiumStamp stadium={stamps[2]} delay={0} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: Progress map with counter */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.progressMap}>
|
|
||||||
<ProgressMap />
|
|
||||||
{/* Counter text overlay */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 180,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextReveal
|
|
||||||
text="12 down. 8 to go."
|
|
||||||
fontSize={36}
|
|
||||||
color={theme.colors.text}
|
|
||||||
delay={0.5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: Achievement badge unlock */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.achievement}>
|
|
||||||
<AchievementBadge
|
|
||||||
name="West Coast Complete"
|
|
||||||
description="Achievement Unlocked"
|
|
||||||
delay={0}
|
|
||||||
/>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 6: Logo endcard */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
|
||||||
<LogoEndcard tagline="Your stadium passport." />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
const checklistItems = [
|
|
||||||
{ label: "Sports", icon: "🏀" },
|
|
||||||
{ label: "Dates", icon: "📅" },
|
|
||||||
{ label: "Cities", icon: "🏙️" },
|
|
||||||
{ label: "Teams", icon: "⚾" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ChecklistIntro: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{checklistItems.map((item, index) => {
|
|
||||||
// Stagger each item
|
|
||||||
const itemDelay = index * 8; // 8 frames between each
|
|
||||||
const localFrame = frame - itemDelay;
|
|
||||||
|
|
||||||
// Item entrance
|
|
||||||
const entranceProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkmark appears shortly after entrance
|
|
||||||
const checkDelay = 12;
|
|
||||||
const checkProgress = spring({
|
|
||||||
frame: localFrame - checkDelay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 200 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, 8],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const translateX = interpolate(entranceProgress, [0, 1], [-100, 0]);
|
|
||||||
const checkScale = interpolate(checkProgress, [0, 1], [0, 1]);
|
|
||||||
const checkOpacity = interpolate(checkProgress, [0, 0.5, 1], [0, 1, 1]);
|
|
||||||
|
|
||||||
// Strike-through effect
|
|
||||||
const strikeWidth = interpolate(
|
|
||||||
checkProgress,
|
|
||||||
[0.5, 1],
|
|
||||||
[0, 100],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 24,
|
|
||||||
opacity,
|
|
||||||
transform: `translateX(${translateX}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Checkbox */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `3px solid ${checkProgress > 0 ? theme.colors.success : theme.colors.textMuted}`,
|
|
||||||
background: checkProgress > 0 ? theme.colors.success : "transparent",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
transition: "background 0.1s, border-color 0.1s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Checkmark */}
|
|
||||||
<svg
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
style={{
|
|
||||||
transform: `scale(${checkScale})`,
|
|
||||||
opacity: checkOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5 12l5 5L19 7"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: checkProgress > 0.5 ? theme.colors.textMuted : theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
{/* Strike-through */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: 0,
|
|
||||||
height: 4,
|
|
||||||
width: `${strikeWidth}%`,
|
|
||||||
background: theme.colors.success,
|
|
||||||
borderRadius: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exit animation for checklist
|
|
||||||
export const ChecklistExit: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{checklistItems.map((item, index) => {
|
|
||||||
// Stagger each item exit
|
|
||||||
const itemDelay = index * 4;
|
|
||||||
const localFrame = frame - itemDelay;
|
|
||||||
|
|
||||||
const exitProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 20, stiffness: 150 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const translateX = interpolate(exitProgress, [0, 1], [0, 200]);
|
|
||||||
const opacity = interpolate(exitProgress, [0, 0.8, 1], [1, 0.5, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 24,
|
|
||||||
opacity,
|
|
||||||
transform: `translateX(${translateX}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Checkbox (checked) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `3px solid ${theme.colors.success}`,
|
|
||||||
background: theme.colors.success,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M5 12l5 5L19 7"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
// Confetti particle component
|
|
||||||
const Confetti: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
const particles = React.useMemo(() => {
|
|
||||||
const colors = [theme.colors.accent, theme.colors.secondary, theme.colors.gold, "#FF69B4"];
|
|
||||||
return Array.from({ length: 30 }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: Math.random() * width,
|
|
||||||
color: colors[Math.floor(Math.random() * colors.length)],
|
|
||||||
size: 8 + Math.random() * 12,
|
|
||||||
rotation: Math.random() * 360,
|
|
||||||
speed: 0.5 + Math.random() * 0.5,
|
|
||||||
delay: Math.random() * 10,
|
|
||||||
}));
|
|
||||||
}, [width]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{particles.map((particle) => {
|
|
||||||
const localFrame = frame - particle.delay;
|
|
||||||
if (localFrame < 0) return null;
|
|
||||||
|
|
||||||
const y = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, fps * 2],
|
|
||||||
[-50, height + 100],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
) * particle.speed;
|
|
||||||
|
|
||||||
const rotation = particle.rotation + localFrame * 3;
|
|
||||||
const opacity = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, fps * 0.5, fps * 1.5, fps * 2],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={particle.id}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: particle.x,
|
|
||||||
top: y,
|
|
||||||
width: particle.size,
|
|
||||||
height: particle.size,
|
|
||||||
background: particle.color,
|
|
||||||
borderRadius: 2,
|
|
||||||
transform: `rotate(${rotation}deg)`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ItineraryReveal: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Loading spinner phase
|
|
||||||
const loadingDuration = fps * 0.8;
|
|
||||||
const isLoading = frame < loadingDuration;
|
|
||||||
|
|
||||||
// Reveal phase
|
|
||||||
const revealProgress = spring({
|
|
||||||
frame: frame - loadingDuration,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cardScale = interpolate(revealProgress, [0, 1], [0.8, 1]);
|
|
||||||
const cardOpacity = interpolate(revealProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Confetti (appears after reveal) */}
|
|
||||||
{!isLoading && <Confetti />}
|
|
||||||
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
// Loading spinner
|
|
||||||
<LoadingSpinner frame={frame} />
|
|
||||||
) : (
|
|
||||||
// Itinerary card
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${cardScale})`,
|
|
||||||
opacity: cardOpacity,
|
|
||||||
width: "85%",
|
|
||||||
maxWidth: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 28,
|
|
||||||
padding: 36,
|
|
||||||
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Success header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.success,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M5 12l5 5L19 7"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Trip Generated!
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
West Coast Basketball Tour
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mini itinerary preview */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ day: "Day 1", city: "Los Angeles", game: "Lakers vs Celtics" },
|
|
||||||
{ day: "Day 2", city: "Phoenix", game: "Suns vs Warriors" },
|
|
||||||
{ day: "Day 3", city: "Denver", game: "Nuggets vs Heat" },
|
|
||||||
].map((item, index) => {
|
|
||||||
const itemProgress = spring({
|
|
||||||
frame: frame - loadingDuration - index * 8,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const itemOpacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
|
||||||
const itemTranslate = interpolate(itemProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
padding: 16,
|
|
||||||
background: "rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 12,
|
|
||||||
opacity: itemOpacity,
|
|
||||||
transform: `translateX(${itemTranslate}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.accent,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.day} • {item.city}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.game}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LoadingSpinner: React.FC<{ frame: number }> = ({ frame }) => {
|
|
||||||
const rotation = (frame * 8) % 360;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `4px solid ${theme.colors.textMuted}`,
|
|
||||||
borderTopColor: theme.colors.accent,
|
|
||||||
transform: `rotate(${rotation}deg)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generating your trip...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,621 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
|
||||||
|
|
||||||
type WizardStepProps = {
|
|
||||||
step: "sports" | "dates" | "regions" | "review";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WizardStep: React.FC<WizardStepProps> = ({ step }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
const contentMap = {
|
|
||||||
sports: <SportsStep />,
|
|
||||||
dates: <DatesStep />,
|
|
||||||
regions: <RegionsStep />,
|
|
||||||
review: <ReviewStep />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tapPositions = {
|
|
||||||
sports: { x: width * 0.3, y: height * 0.42 },
|
|
||||||
dates: { x: width * 0.65, y: height * 0.45 },
|
|
||||||
regions: { x: width * 0.35, y: height * 0.48 },
|
|
||||||
review: { x: width * 0.5, y: height * 0.7 },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
{contentMap[step]}
|
|
||||||
<TapIndicator
|
|
||||||
x={tapPositions[step].x}
|
|
||||||
y={tapPositions[step].y}
|
|
||||||
delay={0.5}
|
|
||||||
/>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SportsStep: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const sports = [
|
|
||||||
{ name: "MLB", color: "#002D72", selected: false },
|
|
||||||
{ name: "NBA", color: "#C9082A", selected: true },
|
|
||||||
{ name: "NFL", color: "#013369", selected: false },
|
|
||||||
{ name: "NHL", color: "#000000", selected: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 60,
|
|
||||||
paddingTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 48 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Choose Sports
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select the leagues you want to see
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sport toggles */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sports.map((sport, index) => {
|
|
||||||
const itemProgress = spring({
|
|
||||||
frame: frame - index * 5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(itemProgress, [0, 1], [0.9, 1]);
|
|
||||||
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// NBA gets selected animation
|
|
||||||
const isNBA = sport.name === "NBA";
|
|
||||||
const selectProgress = spring({
|
|
||||||
frame: frame - fps * 0.7,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSelected = isNBA ? selectProgress > 0.5 : sport.selected;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={sport.name}
|
|
||||||
style={{
|
|
||||||
background: isSelected ? sport.color : "#1C1C1E",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 32,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
border: isSelected ? `3px solid ${theme.colors.text}` : "3px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sport.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Toggle indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 60,
|
|
||||||
height: 34,
|
|
||||||
borderRadius: 17,
|
|
||||||
background: isSelected ? theme.colors.success : "rgba(255,255,255,0.2)",
|
|
||||||
padding: 3,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: isSelected ? "center" : "center",
|
|
||||||
justifyContent: isSelected ? "flex-end" : "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "white",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DatesStep: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Calendar highlight animation
|
|
||||||
const highlightProgress = spring({
|
|
||||||
frame: frame - fps * 0.5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightWidth = interpolate(highlightProgress, [0, 1], [0, 100]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 60,
|
|
||||||
paddingTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 48 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pick Your Window
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
When do you want to travel?
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mini calendar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 24,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
June 2026
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Week days header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(7, 1fr)",
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{["S", "M", "T", "W", "T", "F", "S"].map((day, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar dates */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(7, 1fr)",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Empty cells for start of month */}
|
|
||||||
{[...Array(1)].map((_, i) => (
|
|
||||||
<div key={`empty-${i}`} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Dates */}
|
|
||||||
{[...Array(30)].map((_, i) => {
|
|
||||||
const date = i + 1;
|
|
||||||
const isInRange = date >= 14 && date <= 17;
|
|
||||||
const rangeProgress = isInRange ? highlightWidth / 100 : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={date}
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
borderRadius: 25,
|
|
||||||
background: isInRange && rangeProgress > 0
|
|
||||||
? theme.colors.accent
|
|
||||||
: "transparent",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: isInRange ? 600 : 400,
|
|
||||||
color: isInRange && rangeProgress > 0
|
|
||||||
? theme.colors.text
|
|
||||||
: theme.colors.textSecondary,
|
|
||||||
opacity: isInRange ? 1 : rangeProgress > 0 ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{date}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected range indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 32,
|
|
||||||
padding: 24,
|
|
||||||
background: "rgba(255,107,53,0.1)",
|
|
||||||
borderRadius: 16,
|
|
||||||
borderLeft: `4px solid ${theme.colors.accent}`,
|
|
||||||
opacity: highlightProgress,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
June 14 – June 17, 2026
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
marginLeft: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
4 days
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RegionsStep: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width } = useVideoConfig();
|
|
||||||
|
|
||||||
const regions = [
|
|
||||||
{ name: "Northeast", selected: false },
|
|
||||||
{ name: "Southeast", selected: false },
|
|
||||||
{ name: "Midwest", selected: false },
|
|
||||||
{ name: "West Coast", selected: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 60,
|
|
||||||
paddingTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 48 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Choose Your Territory
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select regions to explore
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Region buttons */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{regions.map((region, index) => {
|
|
||||||
const itemProgress = spring({
|
|
||||||
frame: frame - index * 5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
// West Coast gets selected animation
|
|
||||||
const isWestCoast = region.name === "West Coast";
|
|
||||||
const selectProgress = spring({
|
|
||||||
frame: frame - fps * 0.7,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSelected = isWestCoast ? selectProgress > 0.5 : region.selected;
|
|
||||||
|
|
||||||
const scale = interpolate(itemProgress, [0, 1], [0.95, 1]);
|
|
||||||
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={region.name}
|
|
||||||
style={{
|
|
||||||
background: isSelected ? theme.colors.accent : "#1C1C1E",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 28,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{region.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{isSelected && (
|
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M5 12l5 5L19 7"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ReviewStep: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ label: "Games", value: "4" },
|
|
||||||
{ label: "Cities", value: "3" },
|
|
||||||
{ label: "Days", value: "6" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 60,
|
|
||||||
paddingTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 48 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Review. Confirm. Done.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your trip summary
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats cards */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 20,
|
|
||||||
marginBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stats.map((stat, index) => {
|
|
||||||
const itemProgress = spring({
|
|
||||||
frame: frame - index * 8,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(itemProgress, [0, 1], [0.8, 1]);
|
|
||||||
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stat.label}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 28,
|
|
||||||
textAlign: "center",
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stat.value}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stat.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Generate button */}
|
|
||||||
<GenerateButton />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GenerateButton: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Button appears
|
|
||||||
const buttonProgress = spring({
|
|
||||||
frame: frame - fps * 0.5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pulse effect
|
|
||||||
const pulseProgress = spring({
|
|
||||||
frame: frame - fps * 1,
|
|
||||||
fps,
|
|
||||||
config: { damping: 8, stiffness: 80 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const pulseScale = interpolate(
|
|
||||||
pulseProgress,
|
|
||||||
[0, 0.5, 1],
|
|
||||||
[1, 1.05, 1]
|
|
||||||
);
|
|
||||||
|
|
||||||
const opacity = interpolate(buttonProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 20,
|
|
||||||
opacity,
|
|
||||||
transform: `scale(${pulseScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: "28px 48px",
|
|
||||||
textAlign: "center",
|
|
||||||
boxShadow: `0 10px 40px rgba(255, 107, 53, 0.4)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate Trip
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useVideoConfig,
|
|
||||||
} from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
|
|
||||||
import { ChecklistIntro, ChecklistExit } from "./ChecklistIntro";
|
|
||||||
import { WizardStep } from "./WizardSteps";
|
|
||||||
import { ItineraryReveal } from "./ItineraryReveal";
|
|
||||||
import {
|
|
||||||
GradientBackground,
|
|
||||||
LogoEndcard,
|
|
||||||
} from "../../components/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video 2: "The Checklist"
|
|
||||||
*
|
|
||||||
* Goal: Showcase step-by-step wizard, reassure power planners
|
|
||||||
* Length: 20 seconds (600 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:02 (0-60): Checklist items check off rapidly
|
|
||||||
* - 0:02-0:05 (60-150): Sports selection step
|
|
||||||
* - 0:05-0:08 (150-240): Dates step with calendar
|
|
||||||
* - 0:08-0:11 (240-330): Regions step
|
|
||||||
* - 0:11-0:14 (330-420): Review step with generate button
|
|
||||||
* - 0:14-0:17 (420-510): Itinerary reveal with confetti
|
|
||||||
* - 0:17-0:20 (510-600): Logo endcard
|
|
||||||
*/
|
|
||||||
export const TheChecklist: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 15;
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
checklistIntro: 2 * fps, // 60 frames
|
|
||||||
checklistExit: 0.5 * fps, // 15 frames
|
|
||||||
sportsStep: 2.5 * fps, // 75 frames
|
|
||||||
datesStep: 2.5 * fps, // 75 frames
|
|
||||||
regionsStep: 2.5 * fps, // 75 frames
|
|
||||||
reviewStep: 3 * fps, // 90 frames
|
|
||||||
itineraryReveal: 3 * fps, // 90 frames
|
|
||||||
logo: 3.5 * fps, // 105 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: Checklist intro animation */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.checklistIntro}>
|
|
||||||
<ChecklistIntro />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: Checklist exit */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.checklistExit}>
|
|
||||||
<ChecklistExit />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: Sports selection */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.sportsStep}>
|
|
||||||
<WizardStep step="sports" />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-bottom" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: Dates selection */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.datesStep}>
|
|
||||||
<WizardStep step="dates" />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-bottom" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: Regions selection */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.regionsStep}>
|
|
||||||
<WizardStep step="regions" />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-bottom" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 6: Review step */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reviewStep}>
|
|
||||||
<WizardStep step="review" />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 7: Itinerary reveal with confetti */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.itineraryReveal}>
|
|
||||||
<ItineraryReveal />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 8: Logo endcard */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
|
||||||
<LogoEndcard />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
|
||||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 2: Follow Team mode selection
|
|
||||||
*
|
|
||||||
* Shows the planning mode UI inside a phone frame with "Follow Team" being selected.
|
|
||||||
* On-screen text: "Follow Team mode"
|
|
||||||
*/
|
|
||||||
export const FollowTeamScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// "Follow Team mode" on-screen text overlay (outside phone)
|
|
||||||
const labelBadgeProgress = spring({
|
|
||||||
frame: frame - 1.2 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const labelBadgeOpacity = interpolate(
|
|
||||||
frame - 1.2 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Phone frame with app UI */}
|
|
||||||
<AppScreenshot delay={0} scale={0.88}>
|
|
||||||
<MockScreen>
|
|
||||||
<FollowTeamScreenContent />
|
|
||||||
</MockScreen>
|
|
||||||
</AppScreenshot>
|
|
||||||
|
|
||||||
{/* On-screen label: "Follow Team mode" - overlaid outside phone */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 120,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: labelBadgeOpacity,
|
|
||||||
transform: `scale(${interpolate(labelBadgeProgress, [0, 1], [0.8, 1])})`,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: theme.colors.accent,
|
|
||||||
padding: "14px 36px",
|
|
||||||
borderRadius: 40,
|
|
||||||
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 30,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Follow Team mode
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Inner screen content rendered inside the phone frame */
|
|
||||||
const FollowTeamScreenContent: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const modes = [
|
|
||||||
{
|
|
||||||
name: "Explore Region",
|
|
||||||
desc: "Discover games in an area",
|
|
||||||
icon: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Follow Team",
|
|
||||||
desc: "Chase your team on the road",
|
|
||||||
icon: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Custom Trip",
|
|
||||||
desc: "Build your own route",
|
|
||||||
icon: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7",
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Header entrance
|
|
||||||
const labelProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const labelOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const labelY = interpolate(labelProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: labelOpacity,
|
|
||||||
transform: `translateY(${labelY}px)`,
|
|
||||||
marginBottom: 36,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 34,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Planning Mode
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
How do you want to plan?
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode cards */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{modes.map((mode, index) => {
|
|
||||||
const cardProgress = spring({
|
|
||||||
frame: frame - (0.2 + index * 0.12) * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(cardProgress, [0, 1], [0.92, 1]);
|
|
||||||
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Follow Team gets selected
|
|
||||||
const selectProgress = spring({
|
|
||||||
frame: frame - 0.9 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const isSelected = mode.selected && selectProgress > 0.5;
|
|
||||||
|
|
||||||
const borderColor = isSelected ? theme.colors.accent : "transparent";
|
|
||||||
const bgColor = isSelected ? `rgba(255, 107, 53, 0.12)` : "#1C1C1E";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={mode.name}
|
|
||||||
style={{
|
|
||||||
background: bgColor,
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 24,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 18,
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
border: `3px solid ${borderColor}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: isSelected
|
|
||||||
? theme.colors.accent
|
|
||||||
: "rgba(255,255,255,0.08)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke={isSelected ? "white" : theme.colors.textSecondary}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d={mode.icon} />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text */}
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: isSelected ? theme.colors.accent : theme.colors.text,
|
|
||||||
marginBottom: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode.desc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected checkmark */}
|
|
||||||
{isSelected && (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="12" r="12" fill={theme.colors.accent} />
|
|
||||||
<path
|
|
||||||
d="M7 12l3 3 7-7"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 1: Bold "HOT TAKE" hook
|
|
||||||
*
|
|
||||||
* Full-screen provocative text that grabs attention instantly.
|
|
||||||
* "If you've never done an away-game road trip... are you even a fan?"
|
|
||||||
*/
|
|
||||||
export const HotTakeScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// "HOT TAKE" badge slam
|
|
||||||
const badgeSlam = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 200 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const badgeScale = interpolate(badgeSlam, [0, 1], [3, 1]);
|
|
||||||
const badgeOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main text reveal (staggered lines)
|
|
||||||
const line1Progress = spring({
|
|
||||||
frame: frame - 0.4 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const line1Opacity = interpolate(
|
|
||||||
frame - 0.4 * fps,
|
|
||||||
[0, 0.25 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const line1Y = interpolate(line1Progress, [0, 1], [40, 0]);
|
|
||||||
|
|
||||||
const line2Progress = spring({
|
|
||||||
frame: frame - 0.7 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const line2Opacity = interpolate(
|
|
||||||
frame - 0.7 * fps,
|
|
||||||
[0, 0.25 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const line2Y = interpolate(line2Progress, [0, 1], [40, 0]);
|
|
||||||
|
|
||||||
// Emphasis pulse on "are you even a fan?"
|
|
||||||
const emphasisPulse = spring({
|
|
||||||
frame: frame - 1.2 * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 8, stiffness: 150 },
|
|
||||||
});
|
|
||||||
const emphasisScale = interpolate(emphasisPulse, [0, 0.5, 1], [0.8, 1.08, 1]);
|
|
||||||
|
|
||||||
// Subtle background pulse
|
|
||||||
const bgPulse = interpolate(
|
|
||||||
frame,
|
|
||||||
[0, 0.15 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Red flash on slam */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `radial-gradient(ellipse at center, rgba(255, 50, 50, ${0.15 * bgPulse}) 0%, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Content container */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 40,
|
|
||||||
padding: 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* HOT TAKE badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: badgeOpacity,
|
|
||||||
transform: `scale(${badgeScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#FF2222",
|
|
||||||
padding: "16px 48px",
|
|
||||||
borderRadius: 12,
|
|
||||||
boxShadow: "0 8px 32px rgba(255, 34, 34, 0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 52,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: 6,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
HOT TAKE
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main provocative text */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
marginTop: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: line1Opacity,
|
|
||||||
transform: `translateY(${line1Y}px)`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 46,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
If you've never done an{"\n"}away-game road trip...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: line2Opacity,
|
|
||||||
transform: `translateY(${line2Y}px) scale(${emphasisScale})`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 54,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
are you even a fan?
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 5: "This is your weekend." reaction + CTA
|
|
||||||
*
|
|
||||||
* Emotional payoff moment with the CTA:
|
|
||||||
* "Search SportsTime and run your team's road stretch."
|
|
||||||
*/
|
|
||||||
export const ReactionScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// "This is your weekend." text slam
|
|
||||||
const headlineSlam = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 160 },
|
|
||||||
});
|
|
||||||
const headlineScale = interpolate(headlineSlam, [0, 1], [1.5, 1]);
|
|
||||||
const headlineOpacity = interpolate(frame, [0, 0.2 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// VO text line
|
|
||||||
const voProgress = spring({
|
|
||||||
frame: frame - 0.8 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const voOpacity = interpolate(
|
|
||||||
frame - 0.8 * fps,
|
|
||||||
[0, 0.25 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const voY = interpolate(voProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
// CTA entrance
|
|
||||||
const ctaProgress = spring({
|
|
||||||
frame: frame - 1.5 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const ctaOpacity = interpolate(
|
|
||||||
frame - 1.5 * fps,
|
|
||||||
[0, 0.25 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const ctaScale = interpolate(ctaProgress, [0, 1], [0.85, 1]);
|
|
||||||
|
|
||||||
// App icon entrance
|
|
||||||
const iconProgress = spring({
|
|
||||||
frame: frame - 1.8 * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 100 },
|
|
||||||
});
|
|
||||||
const iconScale = interpolate(iconProgress, [0, 1], [0.5, 1]);
|
|
||||||
const iconOpacity = interpolate(
|
|
||||||
frame - 1.8 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wordmark
|
|
||||||
const wordmarkProgress = spring({
|
|
||||||
frame: frame - 2.1 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const wordmarkOpacity = interpolate(
|
|
||||||
frame - 2.1 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Background glow
|
|
||||||
const glowOpacity = interpolate(
|
|
||||||
frame,
|
|
||||||
[0, 0.5 * fps],
|
|
||||||
[0, 0.12],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background accent glow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "25%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "140%",
|
|
||||||
height: "60%",
|
|
||||||
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
|
|
||||||
opacity: glowOpacity,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 48,
|
|
||||||
padding: 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* "This is your weekend." */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: headlineOpacity,
|
|
||||||
transform: `scale(${headlineScale})`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 64,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.2,
|
|
||||||
letterSpacing: -2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This is your{"\n"}weekend.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VO callout text */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: voOpacity,
|
|
||||||
transform: `translateY(${voY}px)`,
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
maxWidth: 800,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Do at least one away-game run{"\n"}this season.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App icon + wordmark */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 20,
|
|
||||||
marginTop: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* App Icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: 28,
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
|
||||||
boxShadow: `0 16px 48px rgba(255, 107, 53, 0.4)`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: iconOpacity,
|
|
||||||
transform: `scale(${iconScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<ellipse
|
|
||||||
cx="50"
|
|
||||||
cy="60"
|
|
||||||
rx="40"
|
|
||||||
ry="20"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 60 L10 40 Q50 10 90 40 L90 60"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="50"
|
|
||||||
y1="30"
|
|
||||||
x2="50"
|
|
||||||
y2="15"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="3"
|
|
||||||
/>
|
|
||||||
<circle cx="50" cy="12" r="4" fill="white" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SportsTime wordmark */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: wordmarkOpacity,
|
|
||||||
transform: `translateY(${interpolate(wordmarkProgress, [0, 1], [10, 0])}px)`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SportsTime
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA text */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: ctaOpacity,
|
|
||||||
transform: `scale(${ctaScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(255, 255, 255, 0.08)",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: "20px 40px",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Search{" "}
|
|
||||||
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
|
|
||||||
SportsTime
|
|
||||||
</span>{" "}
|
|
||||||
on the App Store
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 3: Road games surfaced
|
|
||||||
*
|
|
||||||
* Shows upcoming away games inside a phone frame, cards sliding in.
|
|
||||||
* On-screen text: "Plan it in seconds"
|
|
||||||
*/
|
|
||||||
|
|
||||||
type GameCard = {
|
|
||||||
opponent: string;
|
|
||||||
opponentColor: string;
|
|
||||||
date: string;
|
|
||||||
venue: string;
|
|
||||||
city: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROAD_GAMES: GameCard[] = [
|
|
||||||
{
|
|
||||||
opponent: "@ Dodgers",
|
|
||||||
opponentColor: "#005A9C",
|
|
||||||
date: "Fri, Jun 12",
|
|
||||||
venue: "Dodger Stadium",
|
|
||||||
city: "Los Angeles, CA",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
opponent: "@ Giants",
|
|
||||||
opponentColor: "#FD5A1E",
|
|
||||||
date: "Sun, Jun 14",
|
|
||||||
venue: "Oracle Park",
|
|
||||||
city: "San Francisco, CA",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
opponent: "@ Padres",
|
|
||||||
opponentColor: "#2F241D",
|
|
||||||
date: "Tue, Jun 16",
|
|
||||||
venue: "Petco Park",
|
|
||||||
city: "San Diego, CA",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
opponent: "@ D-backs",
|
|
||||||
opponentColor: "#A71930",
|
|
||||||
date: "Thu, Jun 18",
|
|
||||||
venue: "Chase Field",
|
|
||||||
city: "Phoenix, AZ",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RoadGamesScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// "Plan it in seconds" label overlay (outside phone)
|
|
||||||
const planLabelProgress = spring({
|
|
||||||
frame: frame - 2 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const planLabelOpacity = interpolate(
|
|
||||||
frame - 2 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Phone frame with app UI */}
|
|
||||||
<AppScreenshot delay={0} scale={0.88}>
|
|
||||||
<MockScreen>
|
|
||||||
<RoadGamesScreenContent />
|
|
||||||
</MockScreen>
|
|
||||||
</AppScreenshot>
|
|
||||||
|
|
||||||
{/* "Plan it in seconds" label - overlaid outside phone */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 120,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: planLabelOpacity,
|
|
||||||
transform: `scale(${interpolate(planLabelProgress, [0, 1], [0.8, 1])})`,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: theme.colors.accent,
|
|
||||||
padding: "14px 36px",
|
|
||||||
borderRadius: 40,
|
|
||||||
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 30,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Plan it in seconds
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Inner screen content rendered inside the phone frame */
|
|
||||||
const RoadGamesScreenContent: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Header entrance
|
|
||||||
const headerProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const headerOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const headerY = interpolate(headerProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: headerOpacity,
|
|
||||||
transform: `translateY(${headerY}px)`,
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Team badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: "#C9082A",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ATL
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Braves Road Games
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
June 2026 away stretch
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Game cards */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ROAD_GAMES.map((game, index) => {
|
|
||||||
const cardDelay = 0.3 + index * 0.2;
|
|
||||||
const cardProgress = spring({
|
|
||||||
frame: frame - cardDelay * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const translateX = interpolate(cardProgress, [0, 1], [300, 0]);
|
|
||||||
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={game.venue}
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 22,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
transform: `translateX(${translateX}px)`,
|
|
||||||
opacity,
|
|
||||||
borderLeft: `4px solid ${game.opponentColor}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Date block */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
minWidth: 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.date.split(", ")[0]}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.date.split(" ")[1].replace(",", "")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 2,
|
|
||||||
height: 40,
|
|
||||||
background: "rgba(255,255,255,0.1)",
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Game info */}
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.opponent}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 15,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.venue}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.city}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 4: Route + itinerary shown
|
|
||||||
*
|
|
||||||
* Animated route line connecting cities with itinerary card, inside phone frame.
|
|
||||||
* Shows the full trip: LA -> SF -> SD -> PHX
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Stop = {
|
|
||||||
city: string;
|
|
||||||
abbr: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
game: string;
|
|
||||||
date: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STOPS: Stop[] = [
|
|
||||||
{ city: "Los Angeles", abbr: "LAX", x: 200, y: 340, game: "vs Dodgers", date: "Jun 12" },
|
|
||||||
{ city: "San Francisco", abbr: "SFO", x: 160, y: 160, game: "vs Giants", date: "Jun 14" },
|
|
||||||
{ city: "San Diego", abbr: "SAN", x: 280, y: 440, game: "vs Padres", date: "Jun 16" },
|
|
||||||
{ city: "Phoenix", abbr: "PHX", x: 550, y: 380, game: "vs D-backs", date: "Jun 18" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RouteItineraryScene: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Phone frame with app UI */}
|
|
||||||
<AppScreenshot delay={0} scale={0.88}>
|
|
||||||
<MockScreen>
|
|
||||||
<RouteScreenContent />
|
|
||||||
</MockScreen>
|
|
||||||
</AppScreenshot>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Inner screen content rendered inside the phone frame */
|
|
||||||
const RouteScreenContent: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Route line draw progress
|
|
||||||
const lineProgress = interpolate(
|
|
||||||
frame,
|
|
||||||
[0.2 * fps, 1.8 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Itinerary card at bottom
|
|
||||||
const cardProgress = spring({
|
|
||||||
frame: frame - 2 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const cardY = interpolate(cardProgress, [0, 1], [150, 0]);
|
|
||||||
const cardOpacity = interpolate(
|
|
||||||
frame - 2 * fps,
|
|
||||||
[0, 0.3 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build SVG path segments
|
|
||||||
const pathSegments: string[] = [];
|
|
||||||
for (let i = 0; i < STOPS.length - 1; i++) {
|
|
||||||
const from = STOPS[i];
|
|
||||||
const to = STOPS[i + 1];
|
|
||||||
if (i === 0) {
|
|
||||||
pathSegments.push(`M ${from.x} ${from.y}`);
|
|
||||||
}
|
|
||||||
const cx = (from.x + to.x) / 2;
|
|
||||||
const cy = Math.min(from.y, to.y) - 40;
|
|
||||||
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
|
|
||||||
}
|
|
||||||
const fullPath = pathSegments.join(" ");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
||||||
{/* Map area */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
height: "60%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Subtle map grid */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: "50px 50px",
|
|
||||||
borderRadius: 16,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Route line SVG */}
|
|
||||||
<svg
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
viewBox="0 0 700 550"
|
|
||||||
style={{ position: "absolute", inset: 0 }}
|
|
||||||
>
|
|
||||||
{/* Route line background (dimmed) */}
|
|
||||||
<path
|
|
||||||
d={fullPath}
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(255, 107, 53, 0.15)"
|
|
||||||
strokeWidth="4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Route line animated */}
|
|
||||||
<path
|
|
||||||
d={fullPath}
|
|
||||||
fill="none"
|
|
||||||
stroke={theme.colors.accent}
|
|
||||||
strokeWidth="4"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray="2000"
|
|
||||||
strokeDashoffset={2000 * (1 - lineProgress)}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Stop markers */}
|
|
||||||
{STOPS.map((stop, index) => {
|
|
||||||
const markerDelay = 0.3 + index * 0.4;
|
|
||||||
const markerProgress = spring({
|
|
||||||
frame: frame - markerDelay * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 180 },
|
|
||||||
});
|
|
||||||
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
|
|
||||||
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
const leftPct = (stop.x / 700) * 100;
|
|
||||||
const topPct = (stop.y / 550) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stop.abbr}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${leftPct}%`,
|
|
||||||
top: `${topPct}%`,
|
|
||||||
transform: `translate(-50%, -50%) scale(${markerScale})`,
|
|
||||||
opacity: markerOpacity,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Ping ring */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `2px solid ${theme.colors.accent}`,
|
|
||||||
opacity: 0.3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Marker dot */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.secondary,
|
|
||||||
border: "3px solid white",
|
|
||||||
boxShadow: `0 4px 12px rgba(78, 205, 196, 0.5)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* City label */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textShadow: "0 2px 8px rgba(0,0,0,0.8)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stop.city}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Itinerary card at bottom */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 20,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
opacity: cardOpacity,
|
|
||||||
transform: `translateY(${cardY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 24,
|
|
||||||
border: `2px solid rgba(255, 107, 53, 0.3)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Card header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
West Coast Run
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 15,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
7 days
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div style={{ display: "flex", gap: 16 }}>
|
|
||||||
{[
|
|
||||||
{ label: "Games", value: "4" },
|
|
||||||
{ label: "Cities", value: "4" },
|
|
||||||
{ label: "Miles", value: "1,240" },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} style={{ flex: 1, textAlign: "center" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stat.value}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stat.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, useVideoConfig } from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
|
|
||||||
import { HotTakeScene } from "./HotTakeScene";
|
|
||||||
import { FollowTeamScene } from "./FollowTeamScene";
|
|
||||||
import { RoadGamesScene } from "./RoadGamesScene";
|
|
||||||
import { RouteItineraryScene } from "./RouteItineraryScene";
|
|
||||||
import { ReactionScene } from "./ReactionScene";
|
|
||||||
import { GradientBackground } from "../../components/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V02: "The Fan Test" (Viral)
|
|
||||||
*
|
|
||||||
* Hook: "If you've never done an away-game road trip... are you even a fan?"
|
|
||||||
* Concept: Identity challenge -> Follow Team demo.
|
|
||||||
* Length: 18 seconds (540 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:03 (0-90): Bold HOT TAKE hook
|
|
||||||
* - 0:03-0:06 (90-180): Follow Team mode selection
|
|
||||||
* - 0:06-0:10 (180-300): Road games surfaced
|
|
||||||
* - 0:10-0:14 (300-420): Route + itinerary shown
|
|
||||||
* - 0:14-0:18 (420-540): "This is your weekend." reaction + CTA
|
|
||||||
*
|
|
||||||
* On-screen text: "HOT TAKE", "Follow Team mode", "Plan it in seconds"
|
|
||||||
* CTA: "Search SportsTime and run your team's road stretch."
|
|
||||||
* Why it performs: debate comments + identity trigger.
|
|
||||||
*/
|
|
||||||
export const TheFanTest: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 12;
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
hotTake: 3 * fps, // 90 frames
|
|
||||||
followTeam: 3 * fps, // 90 frames
|
|
||||||
roadGames: 4 * fps, // 120 frames
|
|
||||||
routeItinerary: 4 * fps, // 120 frames
|
|
||||||
reaction: 4 * fps, // 120 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: HOT TAKE hook */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hotTake}>
|
|
||||||
<HotTakeScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: Follow Team mode selection */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.followTeam}>
|
|
||||||
<FollowTeamScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-right" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: Road games surfaced */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.roadGames}>
|
|
||||||
<RoadGamesScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: Route + itinerary */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.routeItinerary}>
|
|
||||||
<RouteItineraryScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: Reaction + CTA */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reaction}>
|
|
||||||
<ReactionScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 2: "All talk" - the chat dies
|
|
||||||
*
|
|
||||||
* Shows the last few messages then silence.
|
|
||||||
* Typing indicator appears... then vanishes.
|
|
||||||
* Bold overlay: "All talk."
|
|
||||||
*/
|
|
||||||
export const AllTalkScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Typing indicator dots animation
|
|
||||||
const typingAppear = interpolate(
|
|
||||||
frame,
|
|
||||||
[0.2 * fps, 0.4 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const typingDisappear = interpolate(
|
|
||||||
frame,
|
|
||||||
[1 * fps, 1.2 * fps],
|
|
||||||
[1, 0],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const typingOpacity = typingAppear * typingDisappear;
|
|
||||||
|
|
||||||
// Dot bounce cycle (frame-based, not CSS)
|
|
||||||
const dotBounce = (dotIndex: number) => {
|
|
||||||
const cycle = ((frame + dotIndex * 4) % 18) / 18;
|
|
||||||
return interpolate(cycle, [0, 0.5, 1], [0, -6, 0]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// "All talk." slam
|
|
||||||
const slamDelay = 1.3 * fps;
|
|
||||||
const slamProgress = spring({
|
|
||||||
frame: frame - slamDelay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const slamScale = interpolate(slamProgress, [0, 1], [2.5, 1]);
|
|
||||||
const slamOpacity = interpolate(
|
|
||||||
frame - slamDelay,
|
|
||||||
[0, 0.1 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill style={{ background: "#000000" }}>
|
|
||||||
{/* Stale chat messages (static, from previous scene context) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "120px 24px 20px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Last visible messages */}
|
|
||||||
<ChatBubble sender="Sam" color="#AF52DE" text="Maybe June?" isMe={false} />
|
|
||||||
<ChatBubble sender="Jake" color="#34C759" text="Or July" isMe={false} />
|
|
||||||
<ChatBubble sender="Mike" color="#FF9500" text="Whatever works" isMe={false} />
|
|
||||||
<ChatBubble sender="" color="#007AFF" text="Ok so..." isMe={true} />
|
|
||||||
|
|
||||||
{/* Typing indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
opacity: typingOpacity,
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#2C2C2E",
|
|
||||||
borderRadius: 20,
|
|
||||||
borderBottomLeftRadius: 6,
|
|
||||||
padding: "14px 20px",
|
|
||||||
display: "flex",
|
|
||||||
gap: 5,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "rgba(255,255,255,0.4)",
|
|
||||||
transform: `translateY(${dotBounce(i)}px)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "All talk." overlay slam */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: slamOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${slamScale})`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 80,
|
|
||||||
fontWeight: 900,
|
|
||||||
color: "white",
|
|
||||||
letterSpacing: -3,
|
|
||||||
textShadow: "0 4px 40px rgba(0,0,0,0.8)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
All talk.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Simple static chat bubble (no animation) */
|
|
||||||
const ChatBubble: React.FC<{
|
|
||||||
sender: string;
|
|
||||||
color: string;
|
|
||||||
text: string;
|
|
||||||
isMe: boolean;
|
|
||||||
}> = ({ sender, color, text, isMe }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: isMe ? "flex-end" : "flex-start",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isMe && sender && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 13,
|
|
||||||
color,
|
|
||||||
marginBottom: 2,
|
|
||||||
marginLeft: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sender}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: isMe ? "#007AFF" : "#2C2C2E",
|
|
||||||
borderRadius: 20,
|
|
||||||
borderBottomRightRadius: isMe ? 6 : 20,
|
|
||||||
borderBottomLeftRadius: isMe ? 20 : 6,
|
|
||||||
padding: "12px 18px",
|
|
||||||
maxWidth: "75%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: "white",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 6: CTA ending
|
|
||||||
*
|
|
||||||
* Clean branded ending.
|
|
||||||
* "If your group chat is all talk → SportsTime"
|
|
||||||
*/
|
|
||||||
export const CTAScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Text line 1
|
|
||||||
const line1Progress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const line1Opacity = interpolate(frame, [0, 0.25 * fps], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
const line1Y = interpolate(line1Progress, [0, 1], [30, 0]);
|
|
||||||
|
|
||||||
// Arrow / divider
|
|
||||||
const arrowProgress = spring({
|
|
||||||
frame: frame - 0.5 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const arrowOpacity = interpolate(
|
|
||||||
frame - 0.5 * fps,
|
|
||||||
[0, 0.15 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
const arrowScale = interpolate(arrowProgress, [0, 1], [0.5, 1]);
|
|
||||||
|
|
||||||
// App icon + name
|
|
||||||
const brandProgress = spring({
|
|
||||||
frame: frame - 0.8 * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 100 },
|
|
||||||
});
|
|
||||||
const brandScale = interpolate(brandProgress, [0, 1], [0.6, 1]);
|
|
||||||
const brandOpacity = interpolate(
|
|
||||||
frame - 0.8 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Search CTA
|
|
||||||
const ctaProgress = spring({
|
|
||||||
frame: frame - 1.2 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const ctaOpacity = interpolate(
|
|
||||||
frame - 1.2 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Accent glow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "30%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "130%",
|
|
||||||
height: "50%",
|
|
||||||
background: `radial-gradient(ellipse, ${theme.colors.accent}18 0%, transparent 70%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 36,
|
|
||||||
padding: 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Line 1: "If your group chat is all talk" */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: line1Opacity,
|
|
||||||
transform: `translateY(${line1Y}px)`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
If your group chat{"\n"}is all talk
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: arrowOpacity,
|
|
||||||
transform: `scale(${arrowScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M12 5v14m0 0l-6-6m6 6l6-6"
|
|
||||||
stroke={theme.colors.accent}
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App icon + name */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
opacity: brandOpacity,
|
|
||||||
transform: `scale(${brandScale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
borderRadius: 24,
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
|
||||||
boxShadow: "0 12px 40px rgba(255, 107, 53, 0.4)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="56" height="56" viewBox="0 0 100 100" fill="none">
|
|
||||||
<ellipse cx="50" cy="60" rx="40" ry="20" stroke="white" strokeWidth="4" fill="none" />
|
|
||||||
<path d="M10 60 L10 40 Q50 10 90 40 L90 60" stroke="white" strokeWidth="4" fill="none" />
|
|
||||||
<line x1="50" y1="30" x2="50" y2="15" stroke="white" strokeWidth="3" />
|
|
||||||
<circle cx="50" cy="12" r="4" fill="white" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 44,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
letterSpacing: -1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SportsTime
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: ctaOpacity,
|
|
||||||
transform: `translateY(${interpolate(ctaProgress, [0, 1], [15, 0])}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(255, 255, 255, 0.08)",
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: "16px 36px",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.12)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Search{" "}
|
|
||||||
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
|
|
||||||
SportsTime
|
|
||||||
</span>{" "}
|
|
||||||
on the App Store
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 1: iMessage group chat blowing up
|
|
||||||
*
|
|
||||||
* Messages stack in rapid succession with spring pop-ins.
|
|
||||||
* Overlay: "Every group chat ever"
|
|
||||||
* Fast zooms on the chat as messages fly in.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Message = {
|
|
||||||
sender: string;
|
|
||||||
color: string;
|
|
||||||
text: string;
|
|
||||||
isMe: boolean;
|
|
||||||
delaySeconds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MESSAGES: Message[] = [
|
|
||||||
{ sender: "Jake", color: "#34C759", text: "We should do a baseball trip", isMe: false, delaySeconds: 0.15 },
|
|
||||||
{ sender: "Mike", color: "#FF9500", text: "I'm down", isMe: false, delaySeconds: 0.5 },
|
|
||||||
{ sender: "Sam", color: "#AF52DE", text: "Same", isMe: false, delaySeconds: 0.75 },
|
|
||||||
{ sender: "You", color: "#007AFF", text: "Let's goooo", isMe: true, delaySeconds: 1.0 },
|
|
||||||
{ sender: "Jake", color: "#34C759", text: "When tho", isMe: false, delaySeconds: 1.4 },
|
|
||||||
{ sender: "Mike", color: "#FF9500", text: "idk", isMe: false, delaySeconds: 1.7 },
|
|
||||||
{ sender: "Sam", color: "#AF52DE", text: "Maybe June?", isMe: false, delaySeconds: 2.0 },
|
|
||||||
{ sender: "Jake", color: "#34C759", text: "Or July", isMe: false, delaySeconds: 2.25 },
|
|
||||||
{ sender: "Mike", color: "#FF9500", text: "Whatever works", isMe: false, delaySeconds: 2.45 },
|
|
||||||
{ sender: "You", color: "#007AFF", text: "Ok so...", isMe: true, delaySeconds: 2.7 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ChatScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Subtle zoom-in on the whole chat
|
|
||||||
const chatZoom = interpolate(frame, [0, 3.5 * fps], [1, 1.04], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Overlay text: "Every group chat ever"
|
|
||||||
const overlayProgress = spring({
|
|
||||||
frame: frame - 0.3 * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 180 },
|
|
||||||
});
|
|
||||||
const overlayOpacity = interpolate(
|
|
||||||
frame - 0.3 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill style={{ background: "#000000" }}>
|
|
||||||
{/* iMessage-style chat background */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
transform: `scale(${chatZoom})`,
|
|
||||||
transformOrigin: "center 40%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Status bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px 32px 12px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontFamily: theme.fonts.text, fontSize: 16, color: "white" }}>
|
|
||||||
9:41
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
||||||
<div style={{ width: 16, height: 10, border: "1.5px solid white", borderRadius: 2 }}>
|
|
||||||
<div style={{ width: "70%", height: "100%", background: "white", borderRadius: 1 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 32px 16px",
|
|
||||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Group avatar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
background: "linear-gradient(135deg, #34C759, #007AFF)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 18 }}>4</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
The Boys
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: "rgba(255,255,255,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
4 people
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "20px 24px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{MESSAGES.map((msg, index) => {
|
|
||||||
const msgDelay = msg.delaySeconds * fps;
|
|
||||||
const msgProgress = spring({
|
|
||||||
frame: frame - msgDelay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 14, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
|
|
||||||
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
if (frame < msgDelay) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: msg.isMe ? "flex-end" : "flex-start",
|
|
||||||
transform: `scale(${msgScale})`,
|
|
||||||
opacity: msgOpacity,
|
|
||||||
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Sender name (not for "me") */}
|
|
||||||
{!msg.isMe && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 13,
|
|
||||||
color: msg.color,
|
|
||||||
marginBottom: 2,
|
|
||||||
marginLeft: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.sender}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: msg.isMe ? "#007AFF" : "#2C2C2E",
|
|
||||||
borderRadius: 20,
|
|
||||||
borderBottomRightRadius: msg.isMe ? 6 : 20,
|
|
||||||
borderBottomLeftRadius: msg.isMe ? 20 : 6,
|
|
||||||
padding: "12px 18px",
|
|
||||||
maxWidth: "75%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: "white",
|
|
||||||
lineHeight: 1.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overlay: "Every group chat ever" */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 100,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: overlayOpacity,
|
|
||||||
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(0, 0, 0, 0.85)",
|
|
||||||
backdropFilter: "blur(10px)",
|
|
||||||
padding: "16px 40px",
|
|
||||||
borderRadius: 16,
|
|
||||||
border: "1px solid rgba(255,255,255,0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "white",
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Every group chat ever
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 5: Poll it. Done.
|
|
||||||
*
|
|
||||||
* First half: poll appears in group chat
|
|
||||||
* Votes come in rapidly with spring animations
|
|
||||||
* Overlay: "Poll it. Done."
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Vote = {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
delaySeconds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VOTES: Vote[] = [
|
|
||||||
{ name: "Jake", color: "#34C759", delaySeconds: 0.6 },
|
|
||||||
{ name: "Mike", color: "#FF9500", delaySeconds: 0.85 },
|
|
||||||
{ name: "Sam", color: "#AF52DE", delaySeconds: 1.05 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PollScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Poll card entrance
|
|
||||||
const pollEntrance = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const pollScale = interpolate(pollEntrance, [0, 1], [0.7, 1]);
|
|
||||||
const pollOpacity = interpolate(pollEntrance, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// Overlay: "Poll it. Done."
|
|
||||||
const overlayDelay = 1.5 * fps;
|
|
||||||
const overlayProgress = spring({
|
|
||||||
frame: frame - overlayDelay,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 200 },
|
|
||||||
});
|
|
||||||
const overlayScale = interpolate(overlayProgress, [0, 1], [2, 1]);
|
|
||||||
const overlayOpacity = interpolate(
|
|
||||||
frame - overlayDelay,
|
|
||||||
[0, 0.1 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vote count animation
|
|
||||||
const voteCount = VOTES.filter(
|
|
||||||
(v) => frame >= v.delaySeconds * fps
|
|
||||||
).length;
|
|
||||||
const totalVoters = VOTES.length + 1; // +1 for "You"
|
|
||||||
|
|
||||||
// Progress bar width
|
|
||||||
const yesWidth = interpolate(
|
|
||||||
(voteCount + 1) / totalVoters,
|
|
||||||
[0, 1],
|
|
||||||
[0, 100]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill style={{ background: "#000000" }}>
|
|
||||||
{/* Chat context */}
|
|
||||||
<div style={{ padding: "120px 24px 0" }}>
|
|
||||||
{/* "You" sent the poll */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "flex-end",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#007AFF",
|
|
||||||
borderRadius: 20,
|
|
||||||
borderBottomRightRadius: 6,
|
|
||||||
padding: "12px 18px",
|
|
||||||
maxWidth: "75%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontFamily: theme.fonts.text, fontSize: 20, color: "white" }}>
|
|
||||||
I made us a trip. Vote
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Poll card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${pollScale})`,
|
|
||||||
opacity: pollOpacity,
|
|
||||||
transformOrigin: "left top",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 20,
|
|
||||||
padding: 28,
|
|
||||||
border: "1px solid rgba(255,255,255,0.1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Poll header */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 10,
|
|
||||||
background: `linear-gradient(135deg, ${theme.colors.accent}, ${theme.colors.accentDark})`,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="3" y="3" width="7" height="10" rx="1" fill="white" />
|
|
||||||
<rect x="14" y="7" width="7" height="14" rx="1" fill="white" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 700, color: theme.colors.text }}>
|
|
||||||
SportsTime Trip
|
|
||||||
</div>
|
|
||||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 14, color: theme.colors.textSecondary }}>
|
|
||||||
West Coast Baseball Run
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trip details */}
|
|
||||||
<div style={{ marginBottom: 20, padding: "14px 16px", background: "rgba(255,255,255,0.04)", borderRadius: 12 }}>
|
|
||||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 16, color: theme.colors.text, marginBottom: 4 }}>
|
|
||||||
Jun 12–18 · 4 games · 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 4: Routes generated
|
|
||||||
*
|
|
||||||
* Route auto-generates inside phone frame.
|
|
||||||
* Map with animated route line + game cards appear.
|
|
||||||
* Overlay: "Real route. Real games."
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Stop = {
|
|
||||||
city: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STOPS: Stop[] = [
|
|
||||||
{ city: "LA", x: 180, y: 300 },
|
|
||||||
{ city: "SF", x: 140, y: 130 },
|
|
||||||
{ city: "SD", x: 240, y: 400 },
|
|
||||||
{ city: "PHX", x: 480, y: 340 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RouteScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Overlay text
|
|
||||||
const overlayProgress = spring({
|
|
||||||
frame: frame - 0.3 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const overlayOpacity = interpolate(
|
|
||||||
frame - 0.3 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Overlay: "Real route. Real games." */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 80,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: overlayOpacity,
|
|
||||||
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(0, 0, 0, 0.85)",
|
|
||||||
padding: "14px 36px",
|
|
||||||
borderRadius: 14,
|
|
||||||
border: "1px solid rgba(255,255,255,0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 34,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Real route. Real games.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phone with route */}
|
|
||||||
<div style={{ marginTop: 40 }}>
|
|
||||||
<AppScreenshot delay={0} scale={0.82}>
|
|
||||||
<MockScreen>
|
|
||||||
<RouteContent />
|
|
||||||
</MockScreen>
|
|
||||||
</AppScreenshot>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Route map + game list inside phone */
|
|
||||||
const RouteContent: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Route draw
|
|
||||||
const lineProgress = interpolate(
|
|
||||||
frame,
|
|
||||||
[0.15 * fps, 1.5 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const pathSegments: string[] = [];
|
|
||||||
for (let i = 0; i < STOPS.length - 1; i++) {
|
|
||||||
const from = STOPS[i];
|
|
||||||
const to = STOPS[i + 1];
|
|
||||||
if (i === 0) pathSegments.push(`M ${from.x} ${from.y}`);
|
|
||||||
const cx = (from.x + to.x) / 2;
|
|
||||||
const cy = Math.min(from.y, to.y) - 30;
|
|
||||||
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
|
|
||||||
}
|
|
||||||
const fullPath = pathSegments.join(" ");
|
|
||||||
|
|
||||||
// Game cards
|
|
||||||
const games = [
|
|
||||||
{ team: "@ Dodgers", venue: "Dodger Stadium", date: "Jun 12", color: "#005A9C" },
|
|
||||||
{ team: "@ Giants", venue: "Oracle Park", date: "Jun 14", color: "#FD5A1E" },
|
|
||||||
{ team: "@ Padres", venue: "Petco Park", date: "Jun 16", color: "#2F241D" },
|
|
||||||
{ team: "@ D-backs", venue: "Chase Field", date: "Jun 18", color: "#A71930" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
||||||
{/* Map section (top 45%) */}
|
|
||||||
<div style={{ position: "absolute", top: 0, left: 8, right: 8, height: "45%" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: "40px 40px",
|
|
||||||
borderRadius: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 600 480" style={{ position: "absolute", inset: 0 }}>
|
|
||||||
<path d={fullPath} fill="none" stroke="rgba(255,107,53,0.15)" strokeWidth="3.5" strokeLinecap="round" />
|
|
||||||
<path
|
|
||||||
d={fullPath}
|
|
||||||
fill="none"
|
|
||||||
stroke={theme.colors.accent}
|
|
||||||
strokeWidth="3.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray="1500"
|
|
||||||
strokeDashoffset={1500 * (1 - lineProgress)}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{STOPS.map((stop, index) => {
|
|
||||||
const markerProgress = spring({
|
|
||||||
frame: frame - (0.2 + index * 0.35) * fps,
|
|
||||||
fps,
|
|
||||||
config: { damping: 12, stiffness: 180 },
|
|
||||||
});
|
|
||||||
const leftPct = (stop.x / 600) * 100;
|
|
||||||
const topPct = (stop.y / 480) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={stop.city}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: `${leftPct}%`,
|
|
||||||
top: `${topPct}%`,
|
|
||||||
transform: `translate(-50%, -50%) scale(${interpolate(markerProgress, [0, 1], [0, 1])})`,
|
|
||||||
opacity: interpolate(markerProgress, [0, 1], [0, 1]),
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.secondary,
|
|
||||||
border: "2.5px solid white",
|
|
||||||
boxShadow: "0 2px 8px rgba(78,205,196,0.5)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
textShadow: "0 1px 6px rgba(0,0,0,0.8)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{stop.city}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Game cards (bottom 55%) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 12,
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
top: "46%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your Games
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{games.map((game, index) => {
|
|
||||||
const cardDelay = 1.2 + index * 0.18;
|
|
||||||
const cardProgress = spring({
|
|
||||||
frame: frame - cardDelay * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const translateX = interpolate(cardProgress, [0, 1], [250, 0]);
|
|
||||||
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={game.venue}
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: "14px 18px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 14,
|
|
||||||
transform: `translateX(${translateX}px)`,
|
|
||||||
opacity,
|
|
||||||
borderLeft: `4px solid ${game.color}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 50, textAlign: "center" }}>
|
|
||||||
<div style={{ fontFamily: theme.fonts.display, fontSize: 16, fontWeight: 700, color: theme.colors.text }}>
|
|
||||||
{game.date.split(" ")[1]}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 11, color: theme.colors.textMuted }}>
|
|
||||||
{game.date.split(" ")[0]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ width: 1.5, height: 32, background: "rgba(255,255,255,0.08)" }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontFamily: theme.fonts.display, fontSize: 17, fontWeight: 700, color: theme.colors.text }}>
|
|
||||||
{game.team}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 13, color: theme.colors.textSecondary }}>
|
|
||||||
{game.venue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
|
||||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scene 3: "So I planned it myself"
|
|
||||||
*
|
|
||||||
* User opens SportsTime, builds trip.
|
|
||||||
* Shows date range + sport selection inside phone frame.
|
|
||||||
* Overlay: "So I planned it myself"
|
|
||||||
*/
|
|
||||||
export const SolutionScene: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Overlay text entrance
|
|
||||||
const overlayProgress = spring({
|
|
||||||
frame: frame - 0.3 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const overlayOpacity = interpolate(
|
|
||||||
frame - 0.3 * fps,
|
|
||||||
[0, 0.2 * fps],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Overlay: "So I planned it myself" */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 80,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: overlayOpacity,
|
|
||||||
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "rgba(0, 0, 0, 0.85)",
|
|
||||||
padding: "14px 36px",
|
|
||||||
borderRadius: 14,
|
|
||||||
border: "1px solid rgba(255,255,255,0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 34,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
So I planned it myself
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Phone with trip builder */}
|
|
||||||
<div style={{ marginTop: 40 }}>
|
|
||||||
<AppScreenshot delay={0} scale={0.82}>
|
|
||||||
<MockScreen>
|
|
||||||
<TripBuilderContent />
|
|
||||||
</MockScreen>
|
|
||||||
</AppScreenshot>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tap on the date range */}
|
|
||||||
<TapIndicator x={540} y={680} delay={0.8} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Trip builder screen content */
|
|
||||||
const TripBuilderContent: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const sports = [
|
|
||||||
{ name: "MLB", color: "#002D72", emoji: "\u26be" },
|
|
||||||
{ name: "NBA", color: "#C9082A", emoji: "\ud83c\udfc0" },
|
|
||||||
{ name: "NFL", color: "#013369", emoji: "\ud83c\udfc8" },
|
|
||||||
{ name: "NHL", color: "#000000", emoji: "\ud83c\udfd2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// MLB gets selected
|
|
||||||
const mlbSelect = spring({
|
|
||||||
frame: frame - 0.6 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
const mlbSelected = mlbSelect > 0.5;
|
|
||||||
|
|
||||||
// Date range highlight
|
|
||||||
const dateHighlight = spring({
|
|
||||||
frame: frame - 1.2 * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 12 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 28 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Trip
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 17,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What do you want to see?
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sport chips */}
|
|
||||||
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
|
|
||||||
{sports.map((sport, i) => {
|
|
||||||
const chipProgress = spring({
|
|
||||||
frame: frame - i * 4,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
const isSelected = sport.name === "MLB" && mlbSelected;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={sport.name}
|
|
||||||
style={{
|
|
||||||
background: isSelected ? sport.color : "#1C1C1E",
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: "14px 22px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
opacity: interpolate(chipProgress, [0, 1], [0, 1]),
|
|
||||||
transform: `scale(${interpolate(chipProgress, [0, 1], [0.9, 1])})`,
|
|
||||||
border: isSelected ? "2px solid white" : "2px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 20 }}>{sport.emoji}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sport.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date range section */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 22,
|
|
||||||
marginBottom: 20,
|
|
||||||
border: `2px solid ${dateHighlight > 0.5 ? theme.colors.accent : "transparent"}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
marginBottom: 10,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Travel Dates
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: dateHighlight > 0.5 ? theme.colors.accent : theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Jun 12 – Jun 18
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 15,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
7 days
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke={theme.colors.textSecondary} strokeWidth="2" />
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10" stroke={theme.colors.textSecondary} strokeWidth="2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Region */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 22,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
marginBottom: 10,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Region
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
West Coast
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AbsoluteFill, useVideoConfig } from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
|
|
||||||
import { ChatScene } from "./ChatScene";
|
|
||||||
import { AllTalkScene } from "./AllTalkScene";
|
|
||||||
import { SolutionScene } from "./SolutionScene";
|
|
||||||
import { RouteScene } from "./RouteScene";
|
|
||||||
import { PollScene } from "./PollScene";
|
|
||||||
import { CTAScene } from "./CTAScene";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* V03: "The Group Chat" (Viral)
|
|
||||||
*
|
|
||||||
* Hook: iMessage group chat blowing up with "we should do a trip" energy
|
|
||||||
* Problem: Nobody actually plans it → "All talk."
|
|
||||||
* Solution: One person opens SportsTime → plans it → polls the group
|
|
||||||
* Length: 16 seconds (480 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Timing breakdown:
|
|
||||||
* - 0:00-0:03.5 (0-105): Group chat messages flying in
|
|
||||||
* - 0:03.5-0:05.5 (105-165): Chat dies, typing stops, "All talk."
|
|
||||||
* - 0:05.5-0:08.5 (165-255): Opens SportsTime, builds trip, "So I planned it myself"
|
|
||||||
* - 0:08.5-0:11.5 (255-345): Route generates, games appear, "Real route. Real games."
|
|
||||||
* - 0:11.5-0:14 (345-420): Poll sent to group, votes flood in, "Poll it. Done."
|
|
||||||
* - 0:14-0:16 (420-480): CTA: "If your group chat is all talk → SportsTime"
|
|
||||||
*/
|
|
||||||
export const TheGroupChat: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION = 10; // 10 frame transitions (snappy for TikTok pace)
|
|
||||||
|
|
||||||
// TransitionSeries subtracts transition duration from total.
|
|
||||||
// 5 transitions × 10 frames = 50 frames overlap.
|
|
||||||
// Scene sum must be 480 + 50 = 530 to fill 16s composition.
|
|
||||||
const SCENES = {
|
|
||||||
chat: 115, // ~3.8s - group chat chaos
|
|
||||||
allTalk: 70, // ~2.3s - chat dies, "All talk."
|
|
||||||
solution: 100, // ~3.3s - opens SportsTime, builds trip
|
|
||||||
route: 95, // ~3.2s - route generates, games appear
|
|
||||||
poll: 85, // ~2.8s - poll sent, votes flood in
|
|
||||||
cta: 65, // ~2.2s - CTA ending
|
|
||||||
}; // Total: 530 - 50 = 480 frames = 16s
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: Group chat chaos */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENES.chat}>
|
|
||||||
<ChatScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: "All talk." */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENES.allTalk}>
|
|
||||||
<AllTalkScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: "So I planned it myself" */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENES.solution}>
|
|
||||||
<SolutionScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-right" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: "Real route. Real games." */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENES.route}>
|
|
||||||
<RouteScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: "Poll it. Done." */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENES.poll}>
|
|
||||||
<PollScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 6: CTA */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENES.cta}>
|
|
||||||
<CTAScene />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
Easing,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
|
||||||
|
|
||||||
export const ExportTrigger: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
// Menu appearance
|
|
||||||
const menuProgress = spring({
|
|
||||||
frame: frame - fps * 0.5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const menuOpacity = interpolate(menuProgress, [0, 1], [0, 1]);
|
|
||||||
const menuTranslateY = interpolate(menuProgress, [0, 1], [100, 0]);
|
|
||||||
|
|
||||||
// PDF option highlight
|
|
||||||
const highlightProgress = spring({
|
|
||||||
frame: frame - fps * 1,
|
|
||||||
fps,
|
|
||||||
config: { damping: 20, stiffness: 150 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 40,
|
|
||||||
paddingTop: 80,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Mock trip detail view */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 24,
|
|
||||||
marginBottom: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
West Coast Tour
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
June 14-17, 2026 • 4 cities • 3 games
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share button area */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
marginBottom: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "14px 24px",
|
|
||||||
background: theme.colors.accent,
|
|
||||||
borderRadius: 14,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
|
||||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Share
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share menu (slides up) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: "#2C2C2E",
|
|
||||||
borderTopLeftRadius: 24,
|
|
||||||
borderTopRightRadius: 24,
|
|
||||||
padding: 24,
|
|
||||||
transform: `translateY(${menuTranslateY}px)`,
|
|
||||||
opacity: menuOpacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 5,
|
|
||||||
background: "rgba(255,255,255,0.3)",
|
|
||||||
borderRadius: 3,
|
|
||||||
margin: "0 auto 24px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Share Trip
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share options */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
||||||
{[
|
|
||||||
{ icon: "📄", label: "Export PDF", highlight: true },
|
|
||||||
{ icon: "📱", label: "Share Link", highlight: false },
|
|
||||||
{ icon: "📋", label: "Copy Details", highlight: false },
|
|
||||||
].map((option, index) => {
|
|
||||||
const isHighlighted = option.highlight && highlightProgress > 0.5;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
padding: 16,
|
|
||||||
background: isHighlighted
|
|
||||||
? theme.colors.accent
|
|
||||||
: "rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 14,
|
|
||||||
border: isHighlighted
|
|
||||||
? `2px solid ${theme.colors.text}`
|
|
||||||
: "2px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 24 }}>{option.icon}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.text,
|
|
||||||
fontWeight: isHighlighted ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tap indicators */}
|
|
||||||
<TapIndicator x={width - 100} y={185} delay={0} />
|
|
||||||
<TapIndicator x={width / 2} y={height - 200} delay={1} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PDFIconFly: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, height } = useVideoConfig();
|
|
||||||
|
|
||||||
// Icon materializes
|
|
||||||
const materializeProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pulse
|
|
||||||
const pulseProgress = spring({
|
|
||||||
frame: frame - fps * 0.3,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 200 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const pulseScale = interpolate(pulseProgress, [0, 0.5, 1], [1, 1.15, 1]);
|
|
||||||
|
|
||||||
// Fly away
|
|
||||||
const flyProgress = interpolate(
|
|
||||||
frame,
|
|
||||||
[fps * 0.6, fps * 1.2],
|
|
||||||
[0, 1],
|
|
||||||
{
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
easing: Easing.in(Easing.quad),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const translateY = interpolate(flyProgress, [0, 1], [0, -height]);
|
|
||||||
const scale = interpolate(materializeProgress, [0, 1], [0.5, 1]) * pulseScale;
|
|
||||||
const opacity = interpolate(flyProgress, [0, 0.8, 1], [1, 1, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `translateY(${translateY}px) scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* PDF Icon */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 140,
|
|
||||||
height: 180,
|
|
||||||
background: "white",
|
|
||||||
borderRadius: 12,
|
|
||||||
position: "relative",
|
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Folded corner */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
background: "#E0E0E0",
|
|
||||||
clipPath: "polygon(100% 0, 100% 100%, 0 100%)",
|
|
||||||
borderBottomLeftRadius: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* PDF badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 20,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
padding: "12px 0",
|
|
||||||
background: theme.colors.accent,
|
|
||||||
borderRadius: 8,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
PDF
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content lines */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "40px 20px 0",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 8,
|
|
||||||
background: "#DDD",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "80%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 8,
|
|
||||||
background: "#DDD",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "60%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 8,
|
|
||||||
background: "#DDD",
|
|
||||||
borderRadius: 4,
|
|
||||||
width: "70%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type PDFPage = {
|
|
||||||
title: string;
|
|
||||||
icon: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pages: PDFPage[] = [
|
|
||||||
{ title: "Day-by-Day", icon: "📅" },
|
|
||||||
{ title: "Route Map", icon: "🗺️" },
|
|
||||||
{ title: "Cover", icon: "🏟️" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PDFDocumentFan: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Desk surface */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: "70%",
|
|
||||||
background: "linear-gradient(180deg, #2C2420 0%, #1A1714 100%)",
|
|
||||||
borderTop: "3px solid rgba(255,255,255,0.05)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Wood grain texture (subtle) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
opacity: 0.1,
|
|
||||||
background: `repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
transparent 40px,
|
|
||||||
rgba(255,255,255,0.03) 40px,
|
|
||||||
rgba(255,255,255,0.03) 42px
|
|
||||||
)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PDF Pages fanning out */}
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
perspective: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: 500,
|
|
||||||
height: 650,
|
|
||||||
marginTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pages.map((page, index) => {
|
|
||||||
// Stagger each page
|
|
||||||
const pageDelay = (pages.length - 1 - index) * 8;
|
|
||||||
const localFrame = frame - pageDelay;
|
|
||||||
|
|
||||||
const pageProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fan out rotation and position
|
|
||||||
const baseRotation = (index - 1) * -8; // -8, 0, 8 degrees
|
|
||||||
const rotation = interpolate(pageProgress, [0, 1], [0, baseRotation]);
|
|
||||||
const translateX = interpolate(pageProgress, [0, 1], [0, (index - 1) * 60]);
|
|
||||||
const translateZ = interpolate(pageProgress, [0, 1], [0, -index * 5]);
|
|
||||||
|
|
||||||
// Land on desk
|
|
||||||
const landProgress = spring({
|
|
||||||
frame: localFrame - 10,
|
|
||||||
fps,
|
|
||||||
config: { damping: 25, stiffness: 150 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropY = interpolate(landProgress, [0, 1], [-200, 0]);
|
|
||||||
const scale = interpolate(pageProgress, [0, 1], [0.9, 1]);
|
|
||||||
const opacity = interpolate(pageProgress, [0, 0.3, 1], [0, 1, 1]);
|
|
||||||
|
|
||||||
// Shadow grows as page lands
|
|
||||||
const shadowOpacity = interpolate(landProgress, [0, 1], [0, 0.3]);
|
|
||||||
const shadowBlur = interpolate(landProgress, [0, 1], [10, 30]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
transformStyle: "preserve-3d",
|
|
||||||
transform: `
|
|
||||||
translateX(${translateX}px)
|
|
||||||
translateY(${dropY}px)
|
|
||||||
translateZ(${translateZ}px)
|
|
||||||
rotateZ(${rotation}deg)
|
|
||||||
scale(${scale})
|
|
||||||
`,
|
|
||||||
opacity,
|
|
||||||
zIndex: pages.length - index,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Shadow */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 20,
|
|
||||||
background: "black",
|
|
||||||
borderRadius: 16,
|
|
||||||
filter: `blur(${shadowBlur}px)`,
|
|
||||||
opacity: shadowOpacity,
|
|
||||||
transform: "translateY(20px)",
|
|
||||||
zIndex: -1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Page */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
background: "white",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 32,
|
|
||||||
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Page header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 24,
|
|
||||||
paddingBottom: 16,
|
|
||||||
borderBottom: "2px solid #EEE",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 32 }}>{page.icon}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{page.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page content placeholder */}
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{index === 2 ? (
|
|
||||||
// Cover page
|
|
||||||
<CoverPageContent />
|
|
||||||
) : index === 1 ? (
|
|
||||||
// Map page
|
|
||||||
<MapPageContent />
|
|
||||||
) : (
|
|
||||||
// Day-by-day page
|
|
||||||
<DayByDayContent />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SportsTime branding */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: "1px solid #EEE",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SportsTime
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#999",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Page {pages.length - index}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CoverPageContent: React.FC = () => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
height: "100%",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
borderRadius: 20,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 48 }}>🏟️</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#333",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
West Coast Tour
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: "#666",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
June 14-17, 2026
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MapPageContent: React.FC = () => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
background: "#F0F0F0",
|
|
||||||
borderRadius: 12,
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Simplified map */}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 100 80"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Route line */}
|
|
||||||
<path
|
|
||||||
d="M20 30 L40 35 L60 25 L80 40"
|
|
||||||
fill="none"
|
|
||||||
stroke={theme.colors.accent}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
/>
|
|
||||||
{/* City markers */}
|
|
||||||
{[
|
|
||||||
{ x: 20, y: 30 },
|
|
||||||
{ x: 40, y: 35 },
|
|
||||||
{ x: 60, y: 25 },
|
|
||||||
{ x: 80, y: 40 },
|
|
||||||
].map((pos, i) => (
|
|
||||||
<circle
|
|
||||||
key={i}
|
|
||||||
cx={pos.x}
|
|
||||||
cy={pos.y}
|
|
||||||
r="4"
|
|
||||||
fill={theme.colors.secondary}
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DayByDayContent: React.FC = () => (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
||||||
{["Day 1 - Los Angeles", "Day 2 - Phoenix", "Day 3 - Denver"].map(
|
|
||||||
(day, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
padding: 12,
|
|
||||||
background: "#F8F8F8",
|
|
||||||
borderRadius: 8,
|
|
||||||
borderLeft: `3px solid ${theme.colors.accent}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#666",
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Game @ 7:00 PM • Stadium details
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useVideoConfig,
|
|
||||||
} from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
|
|
||||||
import { ExportTrigger, PDFIconFly } from "./ExportAnimation";
|
|
||||||
import { PDFDocumentFan } from "./PDFDocumentFan";
|
|
||||||
import {
|
|
||||||
GradientBackground,
|
|
||||||
LogoEndcard,
|
|
||||||
TextReveal,
|
|
||||||
theme,
|
|
||||||
} from "../../components/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video 5: "The Handoff"
|
|
||||||
*
|
|
||||||
* Goal: Showcase PDF export, deliver tangible output promise
|
|
||||||
* Length: 10 seconds (300 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:02 (0-60): Export trigger (share button tap)
|
|
||||||
* - 0:02-0:04 (60-120): PDF icon materializes and flies
|
|
||||||
* - 0:04-0:07 (120-210): PDF pages fan out on desk
|
|
||||||
* - 0:07-0:10 (210-300): Logo endcard
|
|
||||||
*/
|
|
||||||
export const TheHandoff: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 10;
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
exportTrigger: 2 * fps, // 60 frames
|
|
||||||
pdfFly: 1.5 * fps, // 45 frames
|
|
||||||
documentFan: 3.5 * fps, // 105 frames
|
|
||||||
logo: 3 * fps, // 90 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: Export trigger */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.exportTrigger}>
|
|
||||||
<ExportTrigger />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: PDF icon materializes and flies */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pdfFly}>
|
|
||||||
<PDFIconFly />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: PDF pages fan out */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.documentFan}>
|
|
||||||
<PDFDocumentFan />
|
|
||||||
{/* Text overlay */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 80,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextReveal
|
|
||||||
text="Print it. Share it. Live it."
|
|
||||||
fontSize={36}
|
|
||||||
delay={0.5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: Logo endcard */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
|
||||||
<LogoEndcard tagline="Your trip, anywhere." />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
export const GameCardReveal: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const cardScale = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${cardScale})`,
|
|
||||||
opacity,
|
|
||||||
width: 700,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 32,
|
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Game card header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
MLB • Yankee Stadium
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
7:05 PM
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Teams */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Away team */}
|
|
||||||
<div style={{ flex: 1, textAlign: "center" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "#BD3039",
|
|
||||||
margin: "0 auto 16px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
BOS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Red Sox
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VS */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
VS
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Home team */}
|
|
||||||
<div style={{ flex: 1, textAlign: "center" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "#003087",
|
|
||||||
margin: "0 auto 16px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
NYY
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Yankees
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 24,
|
|
||||||
paddingTop: 24,
|
|
||||||
borderTop: "1px solid rgba(255,255,255,0.1)",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Saturday, June 14, 2026
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
interpolate,
|
|
||||||
Easing,
|
|
||||||
continueRender,
|
|
||||||
delayRender,
|
|
||||||
} from "remotion";
|
|
||||||
import mapboxgl, { Map } from "mapbox-gl";
|
|
||||||
import * as turf from "@turf/turf";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
// Stadium coordinates: NYC (Yankee Stadium) → Chicago (Wrigley) → Denver (Coors) → LA (Dodger Stadium)
|
|
||||||
const stadiumCoordinates: [number, number][] = [
|
|
||||||
[-73.9262, 40.8296], // Yankee Stadium, NYC
|
|
||||||
[-87.6555, 41.9484], // Wrigley Field, Chicago
|
|
||||||
[-104.9942, 39.7559], // Coors Field, Denver
|
|
||||||
[-118.24, 34.0739], // Dodger Stadium, LA
|
|
||||||
];
|
|
||||||
|
|
||||||
const stadiumNames = ["NYC", "Chicago", "Denver", "Los Angeles"];
|
|
||||||
|
|
||||||
// Only render map if token is available
|
|
||||||
const MAPBOX_TOKEN = process.env.REMOTION_MAPBOX_TOKEN;
|
|
||||||
|
|
||||||
type MapSceneProps = {
|
|
||||||
animationDuration?: number; // in seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MapScene: React.FC<MapSceneProps> = ({
|
|
||||||
animationDuration = 2,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [handle] = useState(() => delayRender("Loading map..."));
|
|
||||||
const [map, setMap] = useState<Map | null>(null);
|
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
|
||||||
|
|
||||||
// Calculate route progress
|
|
||||||
const progress = interpolate(
|
|
||||||
frame,
|
|
||||||
[0, animationDuration * fps],
|
|
||||||
[0, 1],
|
|
||||||
{
|
|
||||||
easing: Easing.inOut(Easing.sin),
|
|
||||||
extrapolateLeft: "clamp",
|
|
||||||
extrapolateRight: "clamp",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize map
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current || !MAPBOX_TOKEN) {
|
|
||||||
// If no token, continue render without map
|
|
||||||
continueRender(handle);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
mapboxgl.accessToken = MAPBOX_TOKEN;
|
|
||||||
|
|
||||||
const _map = new Map({
|
|
||||||
container: ref.current,
|
|
||||||
zoom: 3.5,
|
|
||||||
center: [-98, 39], // Center of US
|
|
||||||
pitch: 0,
|
|
||||||
bearing: 0,
|
|
||||||
style: "mapbox://styles/mapbox/dark-v11",
|
|
||||||
interactive: false,
|
|
||||||
fadeDuration: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
_map.on("style.load", () => {
|
|
||||||
// Add route line source
|
|
||||||
_map.addSource("route", {
|
|
||||||
type: "geojson",
|
|
||||||
data: {
|
|
||||||
type: "Feature",
|
|
||||||
properties: {},
|
|
||||||
geometry: {
|
|
||||||
type: "LineString",
|
|
||||||
coordinates: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add route line layer
|
|
||||||
_map.addLayer({
|
|
||||||
id: "route-line",
|
|
||||||
type: "line",
|
|
||||||
source: "route",
|
|
||||||
paint: {
|
|
||||||
"line-color": theme.colors.mapLine,
|
|
||||||
"line-width": 6,
|
|
||||||
"line-opacity": 0.9,
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
"line-cap": "round",
|
|
||||||
"line-join": "round",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add stadium markers source
|
|
||||||
_map.addSource("stadiums", {
|
|
||||||
type: "geojson",
|
|
||||||
data: {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: stadiumCoordinates.map((coord, i) => ({
|
|
||||||
type: "Feature" as const,
|
|
||||||
properties: { name: stadiumNames[i], index: i },
|
|
||||||
geometry: { type: "Point" as const, coordinates: coord },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add stadium markers layer (initially hidden, revealed as route progresses)
|
|
||||||
_map.addLayer({
|
|
||||||
id: "stadium-markers",
|
|
||||||
type: "circle",
|
|
||||||
source: "stadiums",
|
|
||||||
paint: {
|
|
||||||
"circle-radius": 12,
|
|
||||||
"circle-color": theme.colors.mapMarker,
|
|
||||||
"circle-stroke-width": 3,
|
|
||||||
"circle-stroke-color": "#FFFFFF",
|
|
||||||
"circle-opacity": 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add stadium labels
|
|
||||||
_map.addLayer({
|
|
||||||
id: "stadium-labels",
|
|
||||||
type: "symbol",
|
|
||||||
source: "stadiums",
|
|
||||||
layout: {
|
|
||||||
"text-field": ["get", "name"],
|
|
||||||
"text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
|
|
||||||
"text-size": 16,
|
|
||||||
"text-offset": [0, 1.5],
|
|
||||||
"text-anchor": "top",
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
"text-color": "#FFFFFF",
|
|
||||||
"text-halo-color": "#000000",
|
|
||||||
"text-halo-width": 2,
|
|
||||||
"text-opacity": 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
_map.on("load", () => {
|
|
||||||
continueRender(handle);
|
|
||||||
setMap(_map);
|
|
||||||
setMapLoaded(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Don't remove map - causes issues with Remotion
|
|
||||||
};
|
|
||||||
}, [handle]);
|
|
||||||
|
|
||||||
// Animate route and markers
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map || !mapLoaded) return;
|
|
||||||
|
|
||||||
const animHandle = delayRender("Animating route...");
|
|
||||||
|
|
||||||
// Create the route line using turf for geodesic path
|
|
||||||
const routeLine = turf.lineString(stadiumCoordinates);
|
|
||||||
const routeDistance = turf.length(routeLine);
|
|
||||||
|
|
||||||
// Calculate current distance along route
|
|
||||||
const currentDistance = Math.max(0.001, routeDistance * progress);
|
|
||||||
|
|
||||||
// Slice the line to current progress
|
|
||||||
const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
|
|
||||||
|
|
||||||
// Update route line
|
|
||||||
const routeSource = map.getSource("route") as mapboxgl.GeoJSONSource;
|
|
||||||
if (routeSource) {
|
|
||||||
routeSource.setData(slicedLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate which stadiums should be visible
|
|
||||||
const totalSegments = stadiumCoordinates.length - 1;
|
|
||||||
const currentSegment = progress * totalSegments;
|
|
||||||
|
|
||||||
// Update marker opacities based on route progress
|
|
||||||
stadiumCoordinates.forEach((_, index) => {
|
|
||||||
const segmentProgress = index === 0 ? 0 : index / totalSegments;
|
|
||||||
const shouldShow = progress >= segmentProgress;
|
|
||||||
const opacity = shouldShow ? 1 : 0;
|
|
||||||
|
|
||||||
// Use filter to show/hide markers based on index
|
|
||||||
map.setPaintProperty("stadium-markers", "circle-opacity", [
|
|
||||||
"case",
|
|
||||||
["<=", ["get", "index"], Math.floor(currentSegment) + (progress > 0 ? 1 : 0) - 1],
|
|
||||||
1,
|
|
||||||
["==", ["get", "index"], 0],
|
|
||||||
progress > 0.01 ? 1 : 0,
|
|
||||||
0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
map.setPaintProperty("stadium-labels", "text-opacity", [
|
|
||||||
"case",
|
|
||||||
["<=", ["get", "index"], Math.floor(currentSegment) + (progress > 0 ? 1 : 0) - 1],
|
|
||||||
1,
|
|
||||||
["==", ["get", "index"], 0],
|
|
||||||
progress > 0.01 ? 1 : 0,
|
|
||||||
0,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
map.once("idle", () => continueRender(animHandle));
|
|
||||||
}, [map, mapLoaded, progress]);
|
|
||||||
|
|
||||||
const style: React.CSSProperties = useMemo(
|
|
||||||
() => ({
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
position: "absolute",
|
|
||||||
}),
|
|
||||||
[width, height]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fallback if no Mapbox token
|
|
||||||
if (!MAPBOX_TOKEN) {
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: "#1a1a2e",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FallbackMapAnimation progress={progress} />
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div ref={ref} style={style} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback SVG map animation when Mapbox token is not available
|
|
||||||
const FallbackMapAnimation: React.FC<{ progress: number }> = ({ progress }) => {
|
|
||||||
const { width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
// Simplified US map points
|
|
||||||
const points = [
|
|
||||||
{ x: width * 0.8, y: height * 0.35, name: "NYC" },
|
|
||||||
{ x: width * 0.55, y: height * 0.35, name: "Chicago" },
|
|
||||||
{ x: width * 0.4, y: height * 0.4, name: "Denver" },
|
|
||||||
{ x: width * 0.2, y: height * 0.45, name: "Los Angeles" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Calculate which segments to show
|
|
||||||
const totalSegments = points.length - 1;
|
|
||||||
const currentProgress = progress * totalSegments;
|
|
||||||
|
|
||||||
// Build path
|
|
||||||
let pathD = `M ${points[0].x} ${points[0].y}`;
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
if (currentProgress >= i - 1) {
|
|
||||||
const segmentProgress = Math.min(1, currentProgress - (i - 1));
|
|
||||||
const prevPoint = points[i - 1];
|
|
||||||
const currPoint = points[i];
|
|
||||||
const x = prevPoint.x + (currPoint.x - prevPoint.x) * segmentProgress;
|
|
||||||
const y = prevPoint.y + (currPoint.y - prevPoint.y) * segmentProgress;
|
|
||||||
pathD += ` L ${x} ${y}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
style={{ position: "absolute" }}
|
|
||||||
>
|
|
||||||
{/* Background US shape (simplified) */}
|
|
||||||
<ellipse
|
|
||||||
cx={width * 0.5}
|
|
||||||
cy={height * 0.45}
|
|
||||||
rx={width * 0.4}
|
|
||||||
ry={height * 0.25}
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(255,255,255,0.1)"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Route line */}
|
|
||||||
<path
|
|
||||||
d={pathD}
|
|
||||||
fill="none"
|
|
||||||
stroke={theme.colors.mapLine}
|
|
||||||
strokeWidth="6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Stadium markers */}
|
|
||||||
{points.map((point, index) => {
|
|
||||||
const segmentThreshold = index === 0 ? 0.01 : index / totalSegments;
|
|
||||||
const isVisible = progress >= segmentThreshold;
|
|
||||||
const opacity = isVisible ? 1 : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={index} opacity={opacity}>
|
|
||||||
<circle
|
|
||||||
cx={point.x}
|
|
||||||
cy={point.y}
|
|
||||||
r="16"
|
|
||||||
fill={theme.colors.mapMarker}
|
|
||||||
stroke="#FFFFFF"
|
|
||||||
strokeWidth="3"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={point.x}
|
|
||||||
y={point.y + 35}
|
|
||||||
fill="#FFFFFF"
|
|
||||||
fontSize="20"
|
|
||||||
fontFamily="system-ui"
|
|
||||||
fontWeight="bold"
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
{point.name}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type TimelineDay = {
|
|
||||||
day: string;
|
|
||||||
date: string;
|
|
||||||
city: string;
|
|
||||||
game?: {
|
|
||||||
teams: string;
|
|
||||||
time: string;
|
|
||||||
sport: string;
|
|
||||||
};
|
|
||||||
isTravel?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const itinerary: TimelineDay[] = [
|
|
||||||
{
|
|
||||||
day: "Day 1",
|
|
||||||
date: "Jun 14",
|
|
||||||
city: "New York",
|
|
||||||
game: { teams: "Red Sox @ Yankees", time: "7:05 PM", sport: "MLB" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: "Day 2",
|
|
||||||
date: "Jun 15",
|
|
||||||
city: "Chicago",
|
|
||||||
isTravel: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: "Day 3",
|
|
||||||
date: "Jun 16",
|
|
||||||
city: "Chicago",
|
|
||||||
game: { teams: "Cardinals @ Cubs", time: "2:20 PM", sport: "MLB" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: "Day 4",
|
|
||||||
date: "Jun 17",
|
|
||||||
city: "Denver",
|
|
||||||
game: { teams: "Dodgers @ Rockies", time: "6:40 PM", sport: "MLB" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TimelineSlide: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 60,
|
|
||||||
paddingTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your Trip
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 22,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
4 days • 4 cities • 3 games
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
|
||||||
{itinerary.map((day, index) => {
|
|
||||||
const itemDelay = index * 0.1;
|
|
||||||
const itemProgress = spring({
|
|
||||||
frame: frame - itemDelay * fps,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(
|
|
||||||
frame - itemDelay * fps,
|
|
||||||
[0, fps * 0.2],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const translateX = interpolate(itemProgress, [0, 1], [50, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "stretch",
|
|
||||||
gap: 20,
|
|
||||||
opacity,
|
|
||||||
transform: `translateX(${translateX}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Timeline indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
width: 60,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: day.game
|
|
||||||
? theme.colors.accent
|
|
||||||
: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{index < itinerary.length - 1 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
width: 2,
|
|
||||||
background: "rgba(255,255,255,0.2)",
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 24,
|
|
||||||
borderLeft: day.game
|
|
||||||
? `4px solid ${theme.colors.accent}`
|
|
||||||
: "4px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
marginBottom: day.game ? 12 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day.day}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day.date}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.secondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day.city}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{day.game && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day.game.teams}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
background: "rgba(255,107,53,0.2)",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day.game.sport}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day.game.time}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{day.isTravel && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M12 2L4 12h3v8h10v-8h3L12 2z"
|
|
||||||
fill={theme.colors.textMuted}
|
|
||||||
transform="rotate(90 12 12)"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Travel day
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
Sequence,
|
|
||||||
useVideoConfig,
|
|
||||||
} from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
|
|
||||||
import { MapScene } from "./MapScene";
|
|
||||||
import { GameCardReveal } from "./GameCardReveal";
|
|
||||||
import { TimelineSlide } from "./TimelineSlide";
|
|
||||||
import {
|
|
||||||
GradientBackground,
|
|
||||||
TextReveal,
|
|
||||||
LogoEndcard,
|
|
||||||
theme,
|
|
||||||
} from "../../components/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video 1: "The Route"
|
|
||||||
*
|
|
||||||
* Goal: Establish brand identity and core value proposition
|
|
||||||
* Length: 15 seconds (450 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:02 (0-60): Map animation with route drawing
|
|
||||||
* - 0:02-0:05 (60-150): Zoom into game card
|
|
||||||
* - 0:05-0:08 (150-240): Timeline view slide in
|
|
||||||
* - 0:08-0:11 (240-330): Text: "Your entire trip. One tap."
|
|
||||||
* - 0:11-0:15 (330-450): Logo endcard
|
|
||||||
*/
|
|
||||||
export const TheRoute: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
map: 2.5 * fps, // 75 frames
|
|
||||||
gameCard: 2.5 * fps, // 75 frames
|
|
||||||
timeline: 3 * fps, // 90 frames
|
|
||||||
tagline: 3 * fps, // 90 frames
|
|
||||||
logo: 4 * fps, // 120 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: Map with route animation */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.map}>
|
|
||||||
<MapScene animationDuration={2} />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: Game card reveal */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.gameCard}>
|
|
||||||
<GameCardReveal />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-right" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: Timeline view */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.timeline}>
|
|
||||||
<TimelineSlide />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: Tagline */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.tagline}>
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextReveal
|
|
||||||
text="Your entire trip. One tap."
|
|
||||||
fontSize={56}
|
|
||||||
/>
|
|
||||||
<div style={{ position: "absolute", bottom: 200 }}>
|
|
||||||
<TextReveal
|
|
||||||
text="Plan it. See it. Live it."
|
|
||||||
fontSize={32}
|
|
||||||
color={theme.colors.textSecondary}
|
|
||||||
delay={0.5}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: Logo endcard */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
|
||||||
<LogoEndcard showAppStoreBadge />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type Message = {
|
|
||||||
text: string;
|
|
||||||
sender: string;
|
|
||||||
color: string;
|
|
||||||
side: "left" | "right";
|
|
||||||
};
|
|
||||||
|
|
||||||
const messages: Message[] = [
|
|
||||||
{ text: "Lakers game?", sender: "Mike", color: "#552583", side: "left" },
|
|
||||||
{ text: "Dodgers?", sender: "Sarah", color: "#005A9C", side: "right" },
|
|
||||||
{ text: "Both??", sender: "Jake", color: "#007AFF", side: "left" },
|
|
||||||
{ text: "When though", sender: "Sarah", color: "#005A9C", side: "right" },
|
|
||||||
{ text: "idk June maybe", sender: "Mike", color: "#552583", side: "left" },
|
|
||||||
{ text: "too many options 😩", sender: "Jake", color: "#007AFF", side: "right" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ChatBubbles: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 40,
|
|
||||||
paddingTop: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header - looks like a group chat */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
marginBottom: 40,
|
|
||||||
paddingBottom: 20,
|
|
||||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Avatar stack */}
|
|
||||||
<div style={{ display: "flex", marginLeft: 20 }}>
|
|
||||||
{["#FF6B6B", "#4ECDC4", "#45B7D1"].map((color, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: color,
|
|
||||||
border: `3px solid ${theme.colors.background}`,
|
|
||||||
marginLeft: i > 0 ? -15 : 0,
|
|
||||||
zIndex: 3 - i,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
LA Trip Planning
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
3 people
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.map((message, index) => {
|
|
||||||
// Stagger each message
|
|
||||||
const messageDelay = index * 6;
|
|
||||||
const localFrame = frame - messageDelay;
|
|
||||||
|
|
||||||
const entranceProgress = spring({
|
|
||||||
frame: localFrame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, 5],
|
|
||||||
[0, 1],
|
|
||||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
const translateX = message.side === "left"
|
|
||||||
? interpolate(entranceProgress, [0, 1], [-50, 0])
|
|
||||||
: interpolate(entranceProgress, [0, 1], [50, 0]);
|
|
||||||
|
|
||||||
const scale = interpolate(entranceProgress, [0, 1], [0.8, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: message.side === "left" ? "flex-start" : "flex-end",
|
|
||||||
opacity,
|
|
||||||
transform: `translateX(${translateX}px) scale(${scale})`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: "75%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: message.side === "left" ? "flex-start" : "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Sender name */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
marginBottom: 4,
|
|
||||||
marginLeft: message.side === "left" ? 12 : 0,
|
|
||||||
marginRight: message.side === "right" ? 12 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.sender}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Bubble */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: message.side === "left" ? "#2C2C2E" : message.color,
|
|
||||||
padding: "14px 20px",
|
|
||||||
borderRadius: 22,
|
|
||||||
borderBottomLeftRadius: message.side === "left" ? 6 : 22,
|
|
||||||
borderBottomRightRadius: message.side === "right" ? 6 : 22,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// "Stop the chaos" text overlay
|
|
||||||
export const StopTheChaos: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const progress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
|
||||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity,
|
|
||||||
transform: `translateY(${translateY}px)`,
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stop the chaos.
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
type PollOption = {
|
|
||||||
game: string;
|
|
||||||
league: string;
|
|
||||||
votes: { name: string; avatar: string }[];
|
|
||||||
percentage: number;
|
|
||||||
isWinner?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pollOptions: PollOption[] = [
|
|
||||||
{
|
|
||||||
game: "Lakers vs Celtics",
|
|
||||||
league: "NBA",
|
|
||||||
votes: [
|
|
||||||
{ name: "Mike", avatar: "#FF6B6B" },
|
|
||||||
{ name: "Sarah", avatar: "#4ECDC4" },
|
|
||||||
{ name: "Jake", avatar: "#45B7D1" },
|
|
||||||
],
|
|
||||||
percentage: 100,
|
|
||||||
isWinner: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
game: "Dodgers vs Giants",
|
|
||||||
league: "MLB",
|
|
||||||
votes: [
|
|
||||||
{ name: "Sarah", avatar: "#4ECDC4" },
|
|
||||||
],
|
|
||||||
percentage: 33,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PollCreation: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const entranceProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(entranceProgress, [0, 1], [0.95, 1]);
|
|
||||||
const opacity = interpolate(entranceProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 40,
|
|
||||||
paddingTop: 80,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Poll card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 32,
|
|
||||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
LA Trip - Which games?
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Vote for your favorites
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pollOptions.map((option, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 20,
|
|
||||||
border: `2px solid ${option.isWinner ? theme.colors.accent : "transparent"}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
background: "rgba(255,107,53,0.2)",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginRight: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.league}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 20,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.game}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote checkbox */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 8,
|
|
||||||
border: `2px solid ${theme.colors.textMuted}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Share button */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: theme.colors.accent,
|
|
||||||
borderRadius: 14,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Share Poll
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PollVoting: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
padding: 40,
|
|
||||||
paddingTop: 80,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
LA Trip - Which games?
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
3 votes cast
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Options with votes */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pollOptions.map((option, index) => {
|
|
||||||
// Animate votes appearing
|
|
||||||
const voteDelay = index * fps * 0.3;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.05)",
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 20,
|
|
||||||
border: option.isWinner
|
|
||||||
? `2px solid ${theme.colors.accent}`
|
|
||||||
: "2px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Game info */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
background: "rgba(255,107,53,0.2)",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: 6,
|
|
||||||
marginRight: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.league}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.game}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{option.isWinner && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.success,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✓ Winner
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<PollProgressBar
|
|
||||||
percentage={option.percentage}
|
|
||||||
delay={voteDelay}
|
|
||||||
isWinner={option.isWinner}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Voter avatars */}
|
|
||||||
<VoterAvatars
|
|
||||||
votes={option.votes}
|
|
||||||
startDelay={voteDelay + fps * 0.2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type PollProgressBarProps = {
|
|
||||||
percentage: number;
|
|
||||||
delay: number;
|
|
||||||
isWinner?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PollProgressBar: React.FC<PollProgressBarProps> = ({
|
|
||||||
percentage,
|
|
||||||
delay,
|
|
||||||
isWinner,
|
|
||||||
}) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const progress = spring({
|
|
||||||
frame: frame - delay,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const width = interpolate(progress, [0, 1], [0, percentage]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 8,
|
|
||||||
background: "rgba(255,255,255,0.1)",
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${width}%`,
|
|
||||||
background: isWinner
|
|
||||||
? theme.colors.accent
|
|
||||||
: theme.colors.secondary,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type VoterAvatarsProps = {
|
|
||||||
votes: { name: string; avatar: string }[];
|
|
||||||
startDelay: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VoterAvatars: React.FC<VoterAvatarsProps> = ({ votes, startDelay }) => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<div style={{ display: "flex", marginLeft: 8 }}>
|
|
||||||
{votes.map((vote, i) => {
|
|
||||||
const avatarProgress = spring({
|
|
||||||
frame: frame - startDelay - i * 5,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.snappy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = interpolate(avatarProgress, [0, 1], [0, 1]);
|
|
||||||
const opacity = interpolate(avatarProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: vote.avatar,
|
|
||||||
border: `2px solid ${theme.colors.background}`,
|
|
||||||
marginLeft: i > 0 ? -10 : 0,
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
opacity,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "white",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{vote.name[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{votes.length} vote{votes.length > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useCurrentFrame,
|
|
||||||
useVideoConfig,
|
|
||||||
spring,
|
|
||||||
interpolate,
|
|
||||||
} from "remotion";
|
|
||||||
import { theme } from "../../components/shared/theme";
|
|
||||||
|
|
||||||
// Confetti particles
|
|
||||||
const Confetti: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps, width, height } = useVideoConfig();
|
|
||||||
|
|
||||||
const particles = React.useMemo(() => {
|
|
||||||
const colors = [theme.colors.accent, theme.colors.secondary, theme.colors.gold, "#9B59B6"];
|
|
||||||
return Array.from({ length: 25 }, (_, i) => ({
|
|
||||||
id: i,
|
|
||||||
x: Math.random() * width,
|
|
||||||
color: colors[Math.floor(Math.random() * colors.length)],
|
|
||||||
size: 6 + Math.random() * 10,
|
|
||||||
rotation: Math.random() * 360,
|
|
||||||
speed: 0.4 + Math.random() * 0.4,
|
|
||||||
delay: Math.random() * 8,
|
|
||||||
}));
|
|
||||||
}, [width]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{particles.map((particle) => {
|
|
||||||
const localFrame = frame - particle.delay;
|
|
||||||
if (localFrame < 0) return null;
|
|
||||||
|
|
||||||
const y = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, fps * 2],
|
|
||||||
[-50, height + 100],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
) * particle.speed;
|
|
||||||
|
|
||||||
const rotation = particle.rotation + localFrame * 4;
|
|
||||||
const opacity = interpolate(
|
|
||||||
localFrame,
|
|
||||||
[0, fps * 0.3, fps * 1.5, fps * 2],
|
|
||||||
[0, 1, 1, 0],
|
|
||||||
{ extrapolateRight: "clamp" }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={particle.id}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: particle.x,
|
|
||||||
top: y,
|
|
||||||
width: particle.size,
|
|
||||||
height: particle.size,
|
|
||||||
background: particle.color,
|
|
||||||
borderRadius: 2,
|
|
||||||
transform: `rotate(${rotation}deg)`,
|
|
||||||
opacity,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ResultsReveal: React.FC = () => {
|
|
||||||
const frame = useCurrentFrame();
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
// Winner card entrance
|
|
||||||
const cardProgress = spring({
|
|
||||||
frame,
|
|
||||||
fps,
|
|
||||||
config: { damping: 15, stiffness: 120 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const cardScale = interpolate(cardProgress, [0, 1], [0.8, 1]);
|
|
||||||
const cardOpacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
|
||||||
|
|
||||||
// "Democracy wins" text entrance
|
|
||||||
const textProgress = spring({
|
|
||||||
frame: frame - fps * 0.6,
|
|
||||||
fps,
|
|
||||||
config: theme.animation.smooth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const textOpacity = interpolate(textProgress, [0, 1], [0, 1]);
|
|
||||||
const textY = interpolate(textProgress, [0, 1], [20, 0]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Confetti */}
|
|
||||||
<Confetti />
|
|
||||||
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Winner card */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform: `scale(${cardScale})`,
|
|
||||||
opacity: cardOpacity,
|
|
||||||
width: "90%",
|
|
||||||
maxWidth: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#1C1C1E",
|
|
||||||
borderRadius: 28,
|
|
||||||
padding: 40,
|
|
||||||
border: `3px solid ${theme.colors.accent}`,
|
|
||||||
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.3)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Winner badge */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: theme.colors.accent,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
|
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.accent,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Winner
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Winning game */}
|
|
||||||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colors.textMuted,
|
|
||||||
background: "rgba(255,107,53,0.2)",
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
NBA
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: theme.colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Lakers vs Celtics
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote count */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Voter avatars */}
|
|
||||||
<div style={{ display: "flex" }}>
|
|
||||||
{["#FF6B6B", "#4ECDC4", "#45B7D1"].map((color, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: color,
|
|
||||||
border: `2px solid #1C1C1E`,
|
|
||||||
marginLeft: i > 0 ? -10 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.text,
|
|
||||||
fontSize: 18,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
3 votes • 100%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* "Democracy wins" text */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 150,
|
|
||||||
opacity: textOpacity,
|
|
||||||
transform: `translateY(${textY}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: theme.fonts.display,
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: theme.colors.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Democracy wins.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
AbsoluteFill,
|
|
||||||
useVideoConfig,
|
|
||||||
} from "remotion";
|
|
||||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
|
||||||
import { fade } from "@remotion/transitions/fade";
|
|
||||||
import { slide } from "@remotion/transitions/slide";
|
|
||||||
|
|
||||||
import { ChatBubbles, StopTheChaos } from "./ChatBubbles";
|
|
||||||
import { PollCreation, PollVoting } from "./PollAnimation";
|
|
||||||
import { ResultsReveal } from "./ResultsReveal";
|
|
||||||
import {
|
|
||||||
GradientBackground,
|
|
||||||
LogoEndcard,
|
|
||||||
TextReveal,
|
|
||||||
theme,
|
|
||||||
} from "../../components/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Video 4: "The Squad"
|
|
||||||
*
|
|
||||||
* Goal: Promote group polling feature, position app for friend group planning
|
|
||||||
* Length: 18 seconds (540 frames at 30fps)
|
|
||||||
*
|
|
||||||
* Scene breakdown:
|
|
||||||
* - 0:00-0:02 (0-60): Chat bubbles chaos
|
|
||||||
* - 0:02-0:05 (60-150): "Stop the chaos" + Poll creation
|
|
||||||
* - 0:05-0:09 (150-270): Votes animating in
|
|
||||||
* - 0:09-0:13 (270-390): Results reveal with confetti
|
|
||||||
* - 0:13-0:16 (390-480): Trip planned text
|
|
||||||
* - 0:16-0:18 (480-540): Logo endcard
|
|
||||||
*/
|
|
||||||
export const TheSquad: React.FC = () => {
|
|
||||||
const { fps } = useVideoConfig();
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 12;
|
|
||||||
|
|
||||||
const SCENE_DURATIONS = {
|
|
||||||
chatBubbles: 2.5 * fps, // 75 frames
|
|
||||||
stopChaos: 1 * fps, // 30 frames
|
|
||||||
pollCreation: 2 * fps, // 60 frames
|
|
||||||
pollVoting: 3 * fps, // 90 frames
|
|
||||||
results: 3.5 * fps, // 105 frames
|
|
||||||
tripPlanned: 2.5 * fps, // 75 frames
|
|
||||||
logo: 3.5 * fps, // 105 frames
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AbsoluteFill>
|
|
||||||
<GradientBackground />
|
|
||||||
|
|
||||||
<TransitionSeries>
|
|
||||||
{/* Scene 1: Chat bubbles chaos */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.chatBubbles}>
|
|
||||||
<ChatBubbles />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 2: "Stop the chaos" */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stopChaos}>
|
|
||||||
<StopTheChaos />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 3: Poll creation UI */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pollCreation}>
|
|
||||||
<PollCreation />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={slide({ direction: "from-right" })}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 4: Votes animating in */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pollVoting}>
|
|
||||||
<PollVoting />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 5: Results reveal with confetti */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.results}>
|
|
||||||
<ResultsReveal />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 6: Trip planned text */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.tripPlanned}>
|
|
||||||
<AbsoluteFill
|
|
||||||
style={{
|
|
||||||
background: theme.colors.background,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<TextReveal
|
|
||||||
text="Trip planned."
|
|
||||||
fontSize={48}
|
|
||||||
/>
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<TextReveal
|
|
||||||
text="Friends aligned."
|
|
||||||
fontSize={48}
|
|
||||||
color={theme.colors.accent}
|
|
||||||
delay={0.3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AbsoluteFill>
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
|
|
||||||
<TransitionSeries.Transition
|
|
||||||
presentation={fade()}
|
|
||||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scene 7: Logo endcard */}
|
|
||||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
|
||||||
<LogoEndcard tagline="Plan together." />
|
|
||||||
</TransitionSeries.Sequence>
|
|
||||||
</TransitionSeries>
|
|
||||||
</AbsoluteFill>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*", "remotion.config.ts"],
|
|
||||||
"exclude": ["node_modules", "out"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user