feat: fix travel placement bug, add theme-based alternate icons, fix animated background crash
- Fix repeat-city travel placement: use stop indices instead of global city name matching so Follow Team trips with repeat cities show travel correctly - Add TravelPlacement helper and regression tests (7 tests) - Add alternate app icons for each theme, auto-switch on theme change - Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20) - Add marketing video configs, engine, and new video components - Add docs and data exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,26 +13,26 @@
|
||||
"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: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"
|
||||
},
|
||||
|
||||
39
marketing-videos/public/ASSET_README.md
Normal file
39
marketing-videos/public/ASSET_README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
0
marketing-videos/public/broll/.gitkeep
Normal file
0
marketing-videos/public/broll/.gitkeep
Normal file
0
marketing-videos/public/overlays/.gitkeep
Normal file
0
marketing-videos/public/overlays/.gitkeep
Normal file
0
marketing-videos/public/screenrecs/.gitkeep
Normal file
0
marketing-videos/public/screenrecs/.gitkeep
Normal file
78
marketing-videos/scripts/render-week1.sh
Executable file
78
marketing-videos/scripts/render-week1.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/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)"
|
||||
@@ -8,11 +8,24 @@ import { TheSquad } from "./videos/TheSquad";
|
||||
import { TheHandoff } from "./videos/TheHandoff";
|
||||
import { TheFanTest } from "./videos/TheFanTest";
|
||||
import { TheGroupChat } from "./videos/TheGroupChat";
|
||||
import { StadiumCountFlex } from "./videos/StadiumCountFlex";
|
||||
import { GroupChatChaos } from "./videos/GroupChatChaos";
|
||||
import { LocalCityRoute } from "./videos/LocalCityRoute";
|
||||
import { SpreadsheetEra } from "./videos/SpreadsheetEra";
|
||||
import { AwayGameTake } from "./videos/AwayGameTake";
|
||||
|
||||
import { VideoFromConfig } from "./engine";
|
||||
import type { VideoConfig } from "./engine";
|
||||
import week1Configs from "./configs/week1.json";
|
||||
|
||||
/**
|
||||
* Wrapper component that receives config via defaultProps.
|
||||
* Avoids inline arrow functions in Composition component prop.
|
||||
*/
|
||||
const ConfigVideo: React.FC<{ config: VideoConfig }> = ({ config }) => {
|
||||
return <VideoFromConfig config={config} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* SportsTime Marketing Videos
|
||||
*
|
||||
@@ -101,13 +114,58 @@ export const RemotionRoot: React.FC = () => {
|
||||
/>
|
||||
</Folder>
|
||||
|
||||
{/* Hand-crafted TikTok/Reels - unique visual identity per video */}
|
||||
<Folder name="TikTok-Originals">
|
||||
<Composition
|
||||
id="StadiumCountFlex"
|
||||
component={StadiumCountFlex}
|
||||
durationInFrames={15 * FPS}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<Composition
|
||||
id="GroupChatChaos"
|
||||
component={GroupChatChaos}
|
||||
durationInFrames={16 * FPS}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<Composition
|
||||
id="LocalCityRoute"
|
||||
component={LocalCityRoute}
|
||||
durationInFrames={14 * FPS}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<Composition
|
||||
id="SpreadsheetEra"
|
||||
component={SpreadsheetEra}
|
||||
durationInFrames={15 * FPS}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<Composition
|
||||
id="AwayGameTake"
|
||||
component={AwayGameTake}
|
||||
durationInFrames={15 * FPS}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
</Folder>
|
||||
|
||||
{/* Week 1: 20 config-driven TikTok/Reels videos */}
|
||||
<Folder name="Week1-Reels">
|
||||
{configs.map((config) => (
|
||||
<Composition
|
||||
key={config.id}
|
||||
id={config.id}
|
||||
component={() => <VideoFromConfig config={config} />}
|
||||
component={ConfigVideo}
|
||||
defaultProps={{ config }}
|
||||
durationInFrames={Math.round(config.targetLengthSec * FPS)}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
|
||||
43
marketing-videos/src/components/shared/FilmGrain.tsx
Normal file
43
marketing-videos/src/components/shared/FilmGrain.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame } from "remotion";
|
||||
|
||||
/**
|
||||
* Subtle film grain overlay that makes videos feel organic/real.
|
||||
* Uses deterministic noise per frame for reproducible renders.
|
||||
*/
|
||||
export const FilmGrain: React.FC<{ opacity?: number }> = ({
|
||||
opacity = 0.04,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
// Shift the noise pattern each frame using a CSS trick
|
||||
const offsetX = ((frame * 73) % 200) - 100;
|
||||
const offsetY = ((frame * 47) % 200) - 100;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
pointerEvents: "none",
|
||||
mixBlendMode: "overlay",
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<svg width="100%" height="100%">
|
||||
<filter id={`grain-${frame % 10}`}>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.65"
|
||||
numOctaves="3"
|
||||
seed={frame}
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
filter={`url(#grain-${frame % 10})`}
|
||||
transform={`translate(${offsetX}, ${offsetY})`}
|
||||
/>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
306
marketing-videos/src/components/shared/TikTokCaption.tsx
Normal file
306
marketing-videos/src/components/shared/TikTokCaption.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "./theme";
|
||||
|
||||
/**
|
||||
* TikTok-native kinetic caption system.
|
||||
*
|
||||
* Unlike generic subtitle overlays, each style mimics real TikTok
|
||||
* caption patterns: punch zooms, word pops, highlight boxes, etc.
|
||||
*/
|
||||
|
||||
type CaptionEntry = {
|
||||
text: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
style?: "punch" | "highlight" | "stack" | "whisper" | "shake";
|
||||
};
|
||||
|
||||
type TikTokCaptionProps = {
|
||||
captions: CaptionEntry[];
|
||||
/** Vertical position from bottom (px) */
|
||||
bottomOffset?: number;
|
||||
};
|
||||
|
||||
export const TikTokCaption: React.FC<TikTokCaptionProps> = ({
|
||||
captions,
|
||||
bottomOffset = 280,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const currentSec = frame / fps;
|
||||
const active = captions.find(
|
||||
(c) => currentSec >= c.startSec && currentSec < c.endSec
|
||||
);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
const startFrame = active.startSec * fps;
|
||||
const endFrame = active.endSec * fps;
|
||||
const localFrame = frame - startFrame;
|
||||
const style = active.style || "punch";
|
||||
|
||||
const exitOpacity = interpolate(
|
||||
frame,
|
||||
[endFrame - 4, endFrame],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: bottomOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "none",
|
||||
opacity: exitOpacity,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
{style === "punch" && (
|
||||
<PunchCaption text={active.text} localFrame={localFrame} fps={fps} />
|
||||
)}
|
||||
{style === "highlight" && (
|
||||
<HighlightCaption text={active.text} localFrame={localFrame} fps={fps} />
|
||||
)}
|
||||
{style === "stack" && (
|
||||
<StackCaption text={active.text} localFrame={localFrame} fps={fps} />
|
||||
)}
|
||||
{style === "whisper" && (
|
||||
<WhisperCaption text={active.text} localFrame={localFrame} fps={fps} />
|
||||
)}
|
||||
{style === "shake" && (
|
||||
<ShakeCaption text={active.text} localFrame={localFrame} fps={fps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Punch zoom in - text scales from big to normal with impact */
|
||||
const PunchCaption: React.FC<{
|
||||
text: string;
|
||||
localFrame: number;
|
||||
fps: number;
|
||||
}> = ({ text, localFrame, fps }) => {
|
||||
const prog = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 280 },
|
||||
});
|
||||
|
||||
const scale = interpolate(prog, [0, 1], [1.8, 1]);
|
||||
const opacity = interpolate(prog, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 52,
|
||||
fontWeight: 900,
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: -1,
|
||||
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
maxWidth: 900,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Highlight box - text with colored background box that wipes in */
|
||||
const HighlightCaption: React.FC<{
|
||||
text: string;
|
||||
localFrame: number;
|
||||
fps: number;
|
||||
}> = ({ text, localFrame, fps }) => {
|
||||
const boxProg = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 200 },
|
||||
});
|
||||
const textProg = spring({
|
||||
frame: localFrame - 3,
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 180 },
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "-8px -20px",
|
||||
background: theme.colors.accent,
|
||||
borderRadius: 8,
|
||||
transform: `scaleX(${boxProg})`,
|
||||
transformOrigin: "left",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 46,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
opacity: interpolate(textProg, [0, 1], [0, 1]),
|
||||
letterSpacing: -0.5,
|
||||
textShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Stack - words stack vertically, each popping in */
|
||||
const StackCaption: React.FC<{
|
||||
text: string;
|
||||
localFrame: number;
|
||||
fps: number;
|
||||
}> = ({ text, localFrame, fps }) => {
|
||||
const words = text.split(" ");
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{words.map((word, i) => {
|
||||
const delay = i * 2;
|
||||
const prog = spring({
|
||||
frame: localFrame - delay,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 250 },
|
||||
});
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 56,
|
||||
fontWeight: 900,
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 2,
|
||||
transform: `scale(${interpolate(prog, [0, 1], [0.5, 1])}) translateY(${interpolate(prog, [0, 1], [20, 0])}px)`,
|
||||
opacity: interpolate(prog, [0, 0.5], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
textShadow:
|
||||
"0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
|
||||
lineHeight: 1.0,
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Whisper - small italic text that fades in gently */
|
||||
const WhisperCaption: React.FC<{
|
||||
text: string;
|
||||
localFrame: number;
|
||||
fps: number;
|
||||
}> = ({ text, localFrame, fps }) => {
|
||||
const opacity = interpolate(localFrame, [0, fps * 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 400,
|
||||
fontStyle: "italic",
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
textAlign: "center",
|
||||
opacity,
|
||||
letterSpacing: 1,
|
||||
textShadow: "0 2px 12px rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Shake - text shakes briefly on entrance then settles */
|
||||
const ShakeCaption: React.FC<{
|
||||
text: string;
|
||||
localFrame: number;
|
||||
fps: number;
|
||||
}> = ({ text, localFrame, fps }) => {
|
||||
const prog = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 8, stiffness: 300 },
|
||||
});
|
||||
|
||||
// Shake offsets that decay over ~5 frames
|
||||
const shakeIntensity = interpolate(localFrame, [0, 5], [8, 0], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const OFFSETS = [
|
||||
{ x: -1, y: 1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: -1, y: -1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
const offset = OFFSETS[localFrame % OFFSETS.length];
|
||||
const sx = offset.x * shakeIntensity;
|
||||
const sy = offset.y * shakeIntensity;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 50,
|
||||
fontWeight: 900,
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: -0.5,
|
||||
transform: `translate(${sx}px, ${sy}px) scale(${interpolate(prog, [0, 1], [1.3, 1])})`,
|
||||
opacity: interpolate(prog, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
|
||||
maxWidth: 900,
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type { CaptionEntry };
|
||||
@@ -4,3 +4,6 @@ export { TextReveal, TextRevealMultiline, HighlightText } from "./TextReveal";
|
||||
export { TapIndicator, SwipeIndicator } from "./TapIndicator";
|
||||
export { AppScreenshot, MockScreen } from "./AppScreenshot";
|
||||
export { GradientBackground, GridBackground, GlowBackground } from "./Background";
|
||||
export { FilmGrain } from "./FilmGrain";
|
||||
export { TikTokCaption } from "./TikTokCaption";
|
||||
export type { CaptionEntry } from "./TikTokCaption";
|
||||
|
||||
430
marketing-videos/src/configs/week1.json
Normal file
430
marketing-videos/src/configs/week1.json
Normal file
@@ -0,0 +1,430 @@
|
||||
[
|
||||
{
|
||||
"id": "V03-H01",
|
||||
"base": "V03",
|
||||
"hook": "NOBODY planned it. So I made a poll.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "Every group chat ever" } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Built the trip in 20 sec" } },
|
||||
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Dallas", "x": 45, "y": 55 }, { "name": "Houston", "x": 50, "y": 70 }, { "name": "San Antonio", "x": 38, "y": 72 }] } },
|
||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which trip?", "options": [{ "label": "Texas Triangle", "votes": 4, "emoji": "🤠" }, { "label": "East Coast", "votes": 2, "emoji": "🗽" }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Nobody was planning it", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "So I opened SportsTime", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
||||
{ "text": "Built a route in 20 seconds", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
|
||||
{ "text": "Dropped the poll", "startSec": 8.0, "endSec": 9.8, "emphasis": "normal" },
|
||||
{ "text": "Trip booked by lunch", "startSec": 10.3, "endSec": 12.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Nobody plans it. So I did.",
|
||||
"targetLengthSec": 15,
|
||||
"assets": { "screenrec": ["route-generated"], "overlay": ["chat-bubbles", "vote-bubbles"] }
|
||||
},
|
||||
{
|
||||
"id": "V10-H01",
|
||||
"base": "V10",
|
||||
"hook": "If your group chat needs 200 messages… use this.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "200 messages later…", "groupName": "Trip Planning 🏈", "groupSize": 5 } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "One app. One route." } },
|
||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Dallas or Houston first?", "options": [{ "label": "Dallas first", "votes": 3, "emoji": "⭐" }, { "label": "Houston first", "votes": 2, "emoji": "🚀" }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Your group chat after someone says 'trip'", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
|
||||
{ "text": "200 messages… zero plans", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
|
||||
{ "text": "Or just use this", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "Route built. Poll sent.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "Trip decided in 3 minutes", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Two hundred messages or one app. Your call.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V03-H02",
|
||||
"base": "V03",
|
||||
"hook": "Every sports trip starts with 'we should do this' 🤡",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "🤡" } },
|
||||
{ "type": "CHAT", "durationSec": 3.0, "props": { "overlayText": "The cycle continues…", "messages": [{ "sender": "Jake", "text": "We should do a baseball trip", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "Bro YES", "isMe": false, "delaySec": 0.5 }, { "sender": "Sam", "text": "When?", "isMe": false, "delaySec": 0.8 }, { "sender": "Jake", "text": "Idk someone plan it", "isMe": false, "delaySec": 1.2 }, { "sender": "You", "text": "Fine. I'll do it.", "isMe": true, "delaySec": 1.7 }] } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Picked dates + sports" } },
|
||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "Which route?", "options": [{ "label": "LA → SD", "votes": 3, "emoji": "🌴" }, { "label": "NY → BOS", "votes": 2, "emoji": "🗽" }, { "label": "CHI → DET", "votes": 1, "emoji": "🌊" }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Every. Single. Time.", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "'Someone plan it'", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
|
||||
{ "text": "Fine. I opened SportsTime.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "3 options. 1 poll.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "Trip planned in 2 min", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Every trip starts the same way. End it different.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V10-H02",
|
||||
"base": "V10",
|
||||
"hook": "3 trip options. 1 vote. Done.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.0 },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Generated 3 routes" } },
|
||||
{ "type": "POLL", "durationSec": 3.0, "props": { "question": "Pick our trip", "options": [{ "label": "Texas Triangle 🤠", "votes": 4, "emoji": "🤠" }, { "label": "Cali Coast 🌴", "votes": 3, "emoji": "🌴" }, { "label": "Northeast 🗽", "votes": 1, "emoji": "🗽" }] } },
|
||||
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "Group chat: decided", "messages": [{ "sender": "Jake", "text": "Texas it is!!", "isMe": false, "delaySec": 0.2 }, { "sender": "Mike", "text": "LETS GO 🤠", "isMe": false, "delaySec": 0.5 }, { "sender": "You", "text": "Booked ✅", "isMe": true, "delaySec": 0.9 }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "3 trip options", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
|
||||
{ "text": "Generated in seconds", "startSec": 2.3, "endSec": 4.3, "emphasis": "normal" },
|
||||
{ "text": "1 vote to decide", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "Done. Trip booked.", "startSec": 7.5, "endSec": 9.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Three options. One vote. Done.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V03-H03",
|
||||
"base": "V03",
|
||||
"hook": "All talk → I shipped the itinerary.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "CHAT", "durationSec": 2.5, "props": { "overlayText": "3 months of 'we should'", "messages": [{ "sender": "Jake", "text": "We should do a trip", "isMe": false, "delaySec": 0.15 }, { "sender": "Mike", "text": "For sure", "isMe": false, "delaySec": 0.4 }, { "sender": "Sam", "text": "Down", "isMe": false, "delaySec": 0.6 }] } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "So I just did it" } },
|
||||
{ "type": "MAP", "durationSec": 2.5, "props": { "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 54, "y": 30 }, { "name": "Detroit", "x": 62, "y": 33 }] } },
|
||||
{ "type": "POLL", "durationSec": 2.5, "props": { "question": "We going?", "options": [{ "label": "YES 🔥", "votes": 4, "emoji": "🔥" }, { "label": "Can't make it", "votes": 0, "emoji": "😢" }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "3 months of all talk", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
|
||||
{ "text": "'We should do a trip' — nobody moves", "startSec": 2.8, "endSec": 4.8, "emphasis": "bold" },
|
||||
{ "text": "So I opened SportsTime", "startSec": 5.3, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "Built the route, mapped the games", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "Shipped the itinerary", "startSec": 10.0, "endSec": 12.0, "emphasis": "bold" },
|
||||
{ "text": "4-0 vote. We're going.", "startSec": 12.5, "endSec": 14.0, "emphasis": "highlight" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "All talk. Until I shipped the itinerary.",
|
||||
"targetLengthSec": 15
|
||||
},
|
||||
{
|
||||
"id": "V17-H01",
|
||||
"base": "V17",
|
||||
"hook": "The spreadsheet era is over.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Pick dates → pick sports" } },
|
||||
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "No spreadsheet needed.", "variant": "slam" } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Route + games auto-matched" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Stop planning trips in spreadsheets", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "Pick your dates and sports", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
||||
{ "text": "No spreadsheet needed", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
|
||||
{ "text": "Route + games auto-matched", "startSec": 8.0, "endSec": 10.5, "emphasis": "normal" },
|
||||
{ "text": "Done in 20 seconds", "startSec": 11.0, "endSec": 12.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Spreadsheets are over. This is how you plan now.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V17-H02",
|
||||
"base": "V17",
|
||||
"hook": "I used to waste 2 hours. Now it's 20 seconds.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "2 hours → 20 seconds", "variant": "split" } },
|
||||
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/by-games.mp4", "caption": "The whole flow" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Planning a sports trip used to take hours", "startSec": 0.3, "endSec": 2.3, "emphasis": "normal" },
|
||||
{ "text": "2 hours → 20 seconds", "startSec": 2.8, "endSec": 4.5, "emphasis": "highlight" },
|
||||
{ "text": "Pick sports. Pick dates. Get a route.", "startSec": 5.0, "endSec": 7.5, "emphasis": "normal" },
|
||||
{ "text": "That's literally it", "startSec": 8.0, "endSec": 9.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Two hours of Googling or twenty seconds. You pick.",
|
||||
"targetLengthSec": 12
|
||||
},
|
||||
{
|
||||
"id": "V06-H01",
|
||||
"base": "V06",
|
||||
"hook": "Plan a multi-game weekend in 3 taps.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.0 },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/date-range.mp4", "caption": "Tap 1: Dates" } },
|
||||
{ "type": "TEXTPUNCH", "durationSec": 1.5, "props": { "text": "3 taps. Multiple games.", "variant": "slam" } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.0, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Tap 3: Route" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Multi-game weekend?", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
|
||||
{ "text": "Tap 1: Pick your dates", "startSec": 2.3, "endSec": 4.0, "emphasis": "normal" },
|
||||
{ "text": "Tap 2: Pick your sports", "startSec": 4.3, "endSec": 5.5, "emphasis": "normal" },
|
||||
{ "text": "Tap 3: Route generated", "startSec": 6.0, "endSec": 8.0, "emphasis": "highlight" },
|
||||
{ "text": "That easy", "startSec": 8.5, "endSec": 10.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Three taps. Multi-game weekend. Planned.",
|
||||
"targetLengthSec": 12
|
||||
},
|
||||
{
|
||||
"id": "V08-H01",
|
||||
"base": "V08",
|
||||
"hook": "Already driving? Add games without chaos.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "SCREENREC", "durationSec": 4.0, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Add a game stop mid-drive" } },
|
||||
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Rerouted", "cities": [{ "name": "Start", "x": 30, "y": 40 }, { "name": "Game Stop", "x": 50, "y": 50 }, { "name": "Destination", "x": 70, "y": 40 }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Already on the road?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "Add a game stop without the chaos", "startSec": 2.8, "endSec": 5.0, "emphasis": "normal" },
|
||||
{ "text": "Route adjusts automatically", "startSec": 5.5, "endSec": 7.5, "emphasis": "highlight" },
|
||||
{ "text": "Detour = more games", "startSec": 8.0, "endSec": 10.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Mid-drive game stop. No chaos.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V05-LA-01",
|
||||
"base": "V05",
|
||||
"hook": "LA this weekend? Here's a REAL 2-game run.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim", "cities": [{ "name": "Dodger Stadium", "x": 40, "y": 45 }, { "name": "Angel Stadium", "x": 55, "y": 60 }] } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Full weekend itinerary" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "LA this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
||||
{ "text": "Dodgers Friday → Angels Saturday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
||||
{ "text": "30 min drive between stadiums", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "2-game weekend locked", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "SportsTime planned it all", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "LA two-game weekend. Planned in seconds.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V05-NY-01",
|
||||
"base": "V05",
|
||||
"hook": "NYC this weekend? You can hit 2 games.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NYC → Jersey", "cities": [{ "name": "Yankee Stadium", "x": 48, "y": 35 }, { "name": "MetLife Stadium", "x": 42, "y": 42 }] } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Weekend mapped out" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "NYC this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
||||
{ "text": "Yankees Saturday → Giants Sunday", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
||||
{ "text": "Both games. One weekend.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "Route optimized automatically", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "That's the move", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "NYC two-gamer. Hit both without the hassle.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V05-TX-01",
|
||||
"base": "V05",
|
||||
"hook": "Texas road trip? Dallas → Houston + games.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Texas Triangle", "cities": [{ "name": "Dallas", "x": 45, "y": 38 }, { "name": "Austin", "x": 42, "y": 58 }, { "name": "Houston", "x": 55, "y": 60 }] } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "3 cities. 4 games." } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Texas road trip?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
||||
{ "text": "Dallas → Austin → Houston", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
||||
{ "text": "4 games along the route", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "Cowboys. Longhorns. Astros. Rockets.", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "All planned in one app", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Texas Triangle. Four games. One route.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V05-CA-01",
|
||||
"base": "V05",
|
||||
"hook": "California weekend? SF → Sac → Bay games.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "NorCal Run", "cities": [{ "name": "San Francisco", "x": 20, "y": 42 }, { "name": "Sacramento", "x": 30, "y": 35 }, { "name": "Oakland", "x": 22, "y": 44 }] } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/route-generated.mp4", "caption": "Bay Area weekend" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "California this weekend?", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
||||
{ "text": "SF → Sacramento → back to the Bay", "startSec": 2.5, "endSec": 4.5, "emphasis": "normal" },
|
||||
{ "text": "Warriors. Kings. Giants.", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "3 games in one weekend", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "Route mapped automatically", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "NorCal three-game run. SportsTime planned it.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V08-LA-01",
|
||||
"base": "V08",
|
||||
"hook": "LA → San Diego drive? Add a game stop.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Found a game on the way" } },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "LA → Anaheim → San Diego", "cities": [{ "name": "LA", "x": 25, "y": 35 }, { "name": "Anaheim", "x": 35, "y": 48 }, { "name": "San Diego", "x": 30, "y": 70 }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Driving LA to San Diego?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "There's a game on the way", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
||||
{ "text": "Angels game in Anaheim = perfect pit stop", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
|
||||
{ "text": "Then Padres in SD", "startSec": 8.0, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "2 games. 1 drive.", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "LA to San Diego. Two games along the way.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V04-H01",
|
||||
"base": "V04",
|
||||
"hook": "My friend: 4 stadiums. Me: 27.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 27, "totalStadiums": 120, "caption": "And counting" } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Track every stadium" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "My friend: 4 stadiums", "startSec": 0.3, "endSec": 1.5, "emphasis": "normal" },
|
||||
{ "text": "Me: 27 and counting", "startSec": 1.8, "endSec": 3.5, "emphasis": "highlight" },
|
||||
{ "text": "Every stadium tracked", "startSec": 4.0, "endSec": 6.0, "emphasis": "normal" },
|
||||
{ "text": "MLB. NFL. NBA. NHL.", "startSec": 6.5, "endSec": 8.0, "emphasis": "bold" },
|
||||
{ "text": "What's your count?", "startSec": 8.5, "endSec": 10.0, "emphasis": "normal" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Four stadiums? That's cute.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V20-H01",
|
||||
"base": "V20",
|
||||
"hook": "No tracking = no bucket list.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 15, "totalStadiums": 120, "caption": "Start tracking" } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/tracker.mp4", "caption": "Every visit logged" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "You've been to how many stadiums?", "startSec": 0.3, "endSec": 2.0, "emphasis": "normal" },
|
||||
{ "text": "But you're not tracking them?", "startSec": 2.3, "endSec": 4.0, "emphasis": "bold" },
|
||||
{ "text": "Start your stadium bucket list", "startSec": 4.5, "endSec": 6.5, "emphasis": "highlight" },
|
||||
{ "text": "Every visit. Every league.", "startSec": 7.0, "endSec": 9.0, "emphasis": "normal" },
|
||||
{ "text": "How many can you hit?", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "If you're not tracking, it doesn't count.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V14-H01",
|
||||
"base": "V14",
|
||||
"hook": "Trying to hit every stadium before 35.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 42, "totalStadiums": 120, "caption": "42 down, 78 to go" } },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Next trip: Midwest", "cities": [{ "name": "Chicago", "x": 55, "y": 35 }, { "name": "Milwaukee", "x": 53, "y": 30 }, { "name": "Minneapolis", "x": 45, "y": 22 }, { "name": "Kansas City", "x": 42, "y": 45 }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Every stadium before 35", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "42 down. 78 to go.", "startSec": 2.8, "endSec": 4.5, "emphasis": "normal" },
|
||||
{ "text": "Planning the next run", "startSec": 5.0, "endSec": 7.0, "emphasis": "highlight" },
|
||||
{ "text": "Midwest loop: 4 new stadiums", "startSec": 7.5, "endSec": 9.5, "emphasis": "normal" },
|
||||
{ "text": "The bucket list is real", "startSec": 10.0, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Every stadium before thirty-five. Clock's ticking.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V04-H02",
|
||||
"base": "V04",
|
||||
"hook": "Drop your stadium count. I'll wait.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5, "props": { "emoji": "👀" } },
|
||||
{ "type": "FLEX", "durationSec": 3.5, "props": { "stadiumCount": 31, "totalStadiums": 120, "caption": "What's yours?" } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Drop your stadium count", "startSec": 0.3, "endSec": 1.8, "emphasis": "bold" },
|
||||
{ "text": "I'll wait 👀", "startSec": 2.0, "endSec": 3.5, "emphasis": "normal" },
|
||||
{ "text": "Mine: 31 across 4 leagues", "startSec": 3.8, "endSec": 5.5, "emphasis": "highlight" },
|
||||
{ "text": "Track yours", "startSec": 5.8, "endSec": 7.0, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Drop your count. I'll wait.",
|
||||
"targetLengthSec": 12
|
||||
},
|
||||
{
|
||||
"id": "V02-H01",
|
||||
"base": "V02",
|
||||
"hook": "If you've never done an away-game trip…",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team on the road" } },
|
||||
{ "type": "MAP", "durationSec": 3.5, "props": { "caption": "Away game route", "cities": [{ "name": "Home", "x": 35, "y": 40 }, { "name": "Away Game 1", "x": 55, "y": 35 }, { "name": "Away Game 2", "x": 70, "y": 42 }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Never done an away-game trip?", "startSec": 0.3, "endSec": 2.3, "emphasis": "bold" },
|
||||
{ "text": "Follow your team on the road", "startSec": 2.8, "endSec": 4.8, "emphasis": "normal" },
|
||||
{ "text": "SportsTime finds the games + builds the route", "startSec": 5.3, "endSec": 7.5, "emphasis": "highlight" },
|
||||
{ "text": "New city. Your team. Road trip.", "startSec": 8.0, "endSec": 10.0, "emphasis": "normal" },
|
||||
{ "text": "Start with one trip", "startSec": 10.5, "endSec": 11.5, "emphasis": "bold" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "If you've never done an away game trip, start here.",
|
||||
"targetLengthSec": 13
|
||||
},
|
||||
{
|
||||
"id": "V19-H01",
|
||||
"base": "V19",
|
||||
"hook": "Hot take: away games are the real fandom.",
|
||||
"scenes": [
|
||||
{ "type": "HOOK", "durationSec": 2.5 },
|
||||
{ "type": "TEXTPUNCH", "durationSec": 2.0, "props": { "text": "Home games are easy mode.", "variant": "slam" } },
|
||||
{ "type": "SCREENREC", "durationSec": 3.5, "props": { "assetKey": "screenrecs/follow-team.mp4", "caption": "Follow your team anywhere" } },
|
||||
{ "type": "MAP", "durationSec": 3.0, "props": { "caption": "Away game road trip", "cities": [{ "name": "Your City", "x": 30, "y": 45 }, { "name": "Rival City", "x": 60, "y": 35 }, { "name": "Another Stop", "x": 75, "y": 50 }] } },
|
||||
{ "type": "CTA", "durationSec": 2.0 }
|
||||
],
|
||||
"captions": [
|
||||
{ "text": "Hot take incoming 🔥", "startSec": 0.3, "endSec": 2.0, "emphasis": "bold" },
|
||||
{ "text": "Home games are easy mode", "startSec": 2.5, "endSec": 4.3, "emphasis": "highlight" },
|
||||
{ "text": "Real fans follow the team on the road", "startSec": 4.8, "endSec": 6.8, "emphasis": "normal" },
|
||||
{ "text": "Plan the away game trip", "startSec": 7.3, "endSec": 9.0, "emphasis": "normal" },
|
||||
{ "text": "Route + games + drive times", "startSec": 9.5, "endSec": 11.0, "emphasis": "bold" },
|
||||
{ "text": "Prove you're a real one", "startSec": 11.5, "endSec": 13.0, "emphasis": "highlight" }
|
||||
],
|
||||
"cta": "Search SportsTime on the App Store",
|
||||
"vo": "Away games are the real fandom. Prove it.",
|
||||
"targetLengthSec": 15
|
||||
}
|
||||
]
|
||||
180
marketing-videos/src/engine/VideoFromConfig.tsx
Normal file
180
marketing-videos/src/engine/VideoFromConfig.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useVideoConfig } from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
import type { VideoConfig, SceneConfig } from "./types";
|
||||
import {
|
||||
HookCard,
|
||||
ChatScene,
|
||||
ScreenRecScene,
|
||||
MapScene,
|
||||
PollScene,
|
||||
FlexScene,
|
||||
TextPunchScene,
|
||||
CTAEndCard,
|
||||
KineticCaption,
|
||||
} from "./scenes";
|
||||
|
||||
type VideoFromConfigProps = {
|
||||
config: VideoConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Config-driven video renderer.
|
||||
*
|
||||
* Reads a VideoConfig and renders each scene in order using TransitionSeries.
|
||||
* Kinetic captions overlay on top of all scenes.
|
||||
*/
|
||||
export const VideoFromConfig: React.FC<VideoFromConfigProps> = ({ config }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const TRANSITION_FRAMES = 8; // Snappy transitions for TikTok pace
|
||||
|
||||
const renderScene = (scene: SceneConfig, index: number) => {
|
||||
const props = scene.props || {};
|
||||
|
||||
switch (scene.type) {
|
||||
case "HOOK":
|
||||
return <HookCard hookText={config.hook} emoji={props.emoji as string} />;
|
||||
|
||||
case "CHAT":
|
||||
return (
|
||||
<ChatScene
|
||||
groupName={props.groupName as string}
|
||||
groupSize={props.groupSize as number}
|
||||
messages={props.messages as any[]}
|
||||
overlayText={props.overlayText as string}
|
||||
/>
|
||||
);
|
||||
|
||||
case "SCREENREC":
|
||||
return (
|
||||
<ScreenRecScene
|
||||
assetKey={props.assetKey as string}
|
||||
caption={props.caption as string}
|
||||
showFrame={props.showFrame as boolean}
|
||||
phoneScale={props.phoneScale as number}
|
||||
videoStartSec={props.videoStartSec as number}
|
||||
/>
|
||||
);
|
||||
|
||||
case "MAP":
|
||||
return (
|
||||
<MapScene
|
||||
cities={props.cities as any[]}
|
||||
caption={props.caption as string}
|
||||
routeColor={props.routeColor as string}
|
||||
/>
|
||||
);
|
||||
|
||||
case "POLL":
|
||||
return (
|
||||
<PollScene
|
||||
question={props.question as string}
|
||||
options={props.options as any[]}
|
||||
caption={props.caption as string}
|
||||
/>
|
||||
);
|
||||
|
||||
case "FLEX":
|
||||
return (
|
||||
<FlexScene
|
||||
stadiumCount={props.stadiumCount as number}
|
||||
totalStadiums={props.totalStadiums as number}
|
||||
caption={props.caption as string}
|
||||
leagues={props.leagues as string[]}
|
||||
/>
|
||||
);
|
||||
|
||||
case "TEXTPUNCH":
|
||||
return (
|
||||
<TextPunchScene
|
||||
text={props.text as string}
|
||||
subtext={props.subtext as string}
|
||||
variant={props.variant as "slam" | "typewriter" | "split"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "CTA":
|
||||
return (
|
||||
<CTAEndCard
|
||||
ctaText={config.cta}
|
||||
tagline={props.tagline as string}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: "#0A0A0A",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "red", fontSize: 32 }}>
|
||||
Unknown scene: {scene.type}
|
||||
</span>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Choose transition type based on scene pair
|
||||
const getTransition = (fromType: SceneConfig["type"], toType: SceneConfig["type"]) => {
|
||||
// Slide in for screen recordings (feels like opening an app)
|
||||
if (toType === "SCREENREC") {
|
||||
return slide({ direction: "from-right" });
|
||||
}
|
||||
// Slide for map reveals
|
||||
if (toType === "MAP") {
|
||||
return slide({ direction: "from-bottom" });
|
||||
}
|
||||
// Default: fade
|
||||
return fade();
|
||||
};
|
||||
|
||||
const scenes = config.scenes;
|
||||
const numTransitions = scenes.length - 1;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Scene layer with transitions */}
|
||||
<TransitionSeries>
|
||||
{scenes.map((scene, index) => {
|
||||
const durationFrames = Math.round(scene.durationSec * fps);
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
elements.push(
|
||||
<TransitionSeries.Sequence
|
||||
key={`scene-${index}`}
|
||||
durationInFrames={durationFrames}
|
||||
>
|
||||
{renderScene(scene, index)}
|
||||
</TransitionSeries.Sequence>
|
||||
);
|
||||
|
||||
// Add transition between scenes (not after the last one)
|
||||
if (index < numTransitions) {
|
||||
const nextScene = scenes[index + 1];
|
||||
elements.push(
|
||||
<TransitionSeries.Transition
|
||||
key={`trans-${index}`}
|
||||
presentation={getTransition(scene.type, nextScene.type)}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_FRAMES })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
})}
|
||||
</TransitionSeries>
|
||||
|
||||
{/* Caption overlay on top of everything */}
|
||||
{config.captions.length > 0 && (
|
||||
<KineticCaption captions={config.captions} />
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
9
marketing-videos/src/engine/index.ts
Normal file
9
marketing-videos/src/engine/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { VideoFromConfig } from "./VideoFromConfig";
|
||||
export type {
|
||||
VideoConfig,
|
||||
SceneConfig,
|
||||
SceneType,
|
||||
CaptionLine,
|
||||
Week1Configs,
|
||||
} from "./types";
|
||||
export { ASSET_KEYS } from "./types";
|
||||
273
marketing-videos/src/engine/scenes/CTAEndCard.tsx
Normal file
273
marketing-videos/src/engine/scenes/CTAEndCard.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type CTAEndCardProps = {
|
||||
/** CTA text line (should include "Search SportsTime") */
|
||||
ctaText?: string;
|
||||
/** Optional tagline above CTA */
|
||||
tagline?: string;
|
||||
};
|
||||
|
||||
export const CTAEndCard: React.FC<CTAEndCardProps> = ({
|
||||
ctaText = "Search SportsTime on the App Store",
|
||||
tagline,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// --- App icon animation (scale + bounce) ---
|
||||
const iconSpring = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
});
|
||||
|
||||
const iconScale = interpolate(iconSpring, [0, 1], [0.3, 1]);
|
||||
const iconOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
|
||||
|
||||
// --- Wordmark fades in with the icon ---
|
||||
const wordmarkOpacity = interpolate(iconSpring, [0, 1], [0, 1]);
|
||||
|
||||
// --- Tagline animation (0.4s delay) ---
|
||||
const taglineDelay = Math.round(0.4 * fps);
|
||||
const taglineSpring = spring({
|
||||
frame: frame - taglineDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const taglineOpacity = interpolate(taglineSpring, [0, 1], [0, 1]);
|
||||
const taglineTranslateY = interpolate(taglineSpring, [0, 1], [20, 0]);
|
||||
|
||||
// --- Search bar animation (0.5s delay) ---
|
||||
const searchBarDelay = Math.round(0.5 * fps);
|
||||
const searchBarSpring = spring({
|
||||
frame: frame - searchBarDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const searchBarOpacity = interpolate(searchBarSpring, [0, 1], [0, 1]);
|
||||
const searchBarTranslateY = interpolate(searchBarSpring, [0, 1], [30, 0]);
|
||||
|
||||
// --- Pulse/glow on search bar border (breathing orange glow) ---
|
||||
const pulseSpeed = 1.5; // seconds per cycle
|
||||
const pulseCycle = (frame / fps) * (1 / pulseSpeed) * Math.PI * 2;
|
||||
const pulseIntensity = (Math.sin(pulseCycle) + 1) / 2; // 0 to 1
|
||||
const glowAlpha = interpolate(pulseIntensity, [0, 1], [0.1, 0.5]);
|
||||
const borderAlpha = interpolate(pulseIntensity, [0, 1], [0.2, 0.5]);
|
||||
|
||||
// --- "Available on the App Store" text (0.7s delay) ---
|
||||
const availableDelay = Math.round(0.7 * fps);
|
||||
const availableSpring = spring({
|
||||
frame: frame - availableDelay,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const availableOpacity = interpolate(availableSpring, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Background gradient */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center content: icon + wordmark + tagline */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* App icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 40,
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
||||
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.4)`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
transform: `scale(${iconScale})`,
|
||||
opacity: iconOpacity,
|
||||
}}
|
||||
>
|
||||
{/* Simple stadium SVG icon: ellipse base + arch + flag */}
|
||||
<svg
|
||||
width={100}
|
||||
height={100}
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
>
|
||||
{/* Stadium base ellipse */}
|
||||
<ellipse
|
||||
cx={50}
|
||||
cy={70}
|
||||
rx={36}
|
||||
ry={12}
|
||||
stroke="white"
|
||||
strokeWidth={3.5}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Stadium arch */}
|
||||
<path
|
||||
d="M 20 70 Q 20 28 50 28 Q 80 28 80 70"
|
||||
stroke="white"
|
||||
strokeWidth={3.5}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Flag pole */}
|
||||
<line
|
||||
x1={50}
|
||||
y1={28}
|
||||
x2={50}
|
||||
y2={12}
|
||||
stroke="white"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Flag */}
|
||||
<path
|
||||
d="M 50 12 L 62 17 L 50 22"
|
||||
stroke="white"
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* "SportsTime" wordmark */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: -2,
|
||||
marginTop: 32,
|
||||
opacity: wordmarkOpacity,
|
||||
}}
|
||||
>
|
||||
SportsTime
|
||||
</div>
|
||||
|
||||
{/* Tagline (optional) */}
|
||||
{tagline && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 36,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: 16,
|
||||
opacity: taglineOpacity,
|
||||
transform: `translateY(${taglineTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
{tagline}
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* CTA search bar - positioned near bottom */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 200,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
opacity: searchBarOpacity,
|
||||
transform: `translateY(${searchBarTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Search bar container */}
|
||||
<div
|
||||
style={{
|
||||
width: 800,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
border: `1.5px solid rgba(255, 255, 255, ${borderAlpha})`,
|
||||
boxShadow: `0 0 ${interpolate(pulseIntensity, [0, 1], [10, 30])}px rgba(255, 107, 53, ${glowAlpha})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: 28,
|
||||
paddingRight: 28,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Search icon (magnifying glass SVG) */}
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<circle
|
||||
cx={10.5}
|
||||
cy={10.5}
|
||||
r={7}
|
||||
stroke={theme.colors.textSecondary}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<line
|
||||
x1={15.5}
|
||||
y1={15.5}
|
||||
x2={21}
|
||||
y2={21}
|
||||
stroke={theme.colors.textSecondary}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* CTA text */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 28,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{ctaText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* "Available on the App Store" text below search bar */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
fontWeight: 400,
|
||||
color: theme.colors.textMuted,
|
||||
opacity: availableOpacity,
|
||||
}}
|
||||
>
|
||||
Available on the App Store
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
423
marketing-videos/src/engine/scenes/ChatScene.tsx
Normal file
423
marketing-videos/src/engine/scenes/ChatScene.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type ChatMessage = {
|
||||
sender: string;
|
||||
text: string;
|
||||
isMe: boolean;
|
||||
delaySec: number;
|
||||
};
|
||||
|
||||
type ChatSceneProps = {
|
||||
/** Group name shown in header */
|
||||
groupName?: string;
|
||||
/** Number of people in group */
|
||||
groupSize?: number;
|
||||
/** Messages to display */
|
||||
messages?: ChatMessage[];
|
||||
/** Overlay caption text shown at top */
|
||||
overlayText?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_MESSAGES: ChatMessage[] = [
|
||||
{ sender: "Jake", text: "We should do a baseball trip", isMe: false, delaySec: 0.15 },
|
||||
{ sender: "Mike", text: "I'm down", isMe: false, delaySec: 0.5 },
|
||||
{ sender: "Sam", text: "Same", isMe: false, delaySec: 0.75 },
|
||||
{ sender: "You", text: "Let's goooo", isMe: true, delaySec: 1.0 },
|
||||
{ sender: "Jake", text: "When tho", isMe: false, delaySec: 1.4 },
|
||||
{ sender: "Mike", text: "idk lol", isMe: false, delaySec: 1.7 },
|
||||
{ sender: "Sam", text: "Maybe June?", isMe: false, delaySec: 2.0 },
|
||||
{ sender: "Jake", text: "Or July", isMe: false, delaySec: 2.25 },
|
||||
];
|
||||
|
||||
const SENDER_COLORS = ["#34C759", "#FF9500", "#AF52DE", "#FF2D55"];
|
||||
|
||||
/**
|
||||
* Assigns a stable color to each unique sender name (excluding "me" messages).
|
||||
*/
|
||||
const buildSenderColorMap = (messages: ChatMessage[]): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
let colorIndex = 0;
|
||||
for (const msg of messages) {
|
||||
if (!msg.isMe && !(msg.sender in map)) {
|
||||
map[msg.sender] = SENDER_COLORS[colorIndex % SENDER_COLORS.length];
|
||||
colorIndex++;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* Typing indicator with 3 bouncing dots.
|
||||
*/
|
||||
const TypingIndicator: React.FC<{ frame: number; fps: number }> = ({ frame, fps }) => {
|
||||
const dots = [0, 1, 2];
|
||||
// Each dot bounces on a ~0.6s cycle, staggered by ~0.15s
|
||||
const cycleDuration = 0.6 * fps;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#2C2C2E",
|
||||
borderRadius: 20,
|
||||
borderBottomLeftRadius: 6,
|
||||
padding: "14px 20px",
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{dots.map((i) => {
|
||||
const stagger = i * 0.15 * fps;
|
||||
const cycleFrame = (frame + stagger) % cycleDuration;
|
||||
const bounceProgress = Math.sin((cycleFrame / cycleDuration) * Math.PI);
|
||||
const translateY = interpolate(bounceProgress, [0, 1], [0, -8]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
background: "rgba(255, 255, 255, 0.4)",
|
||||
transform: `translateY(${translateY}px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatScene: React.FC<ChatSceneProps> = ({
|
||||
groupName = "The Boys",
|
||||
groupSize = 4,
|
||||
messages = DEFAULT_MESSAGES,
|
||||
overlayText,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const senderColorMap = buildSenderColorMap(messages);
|
||||
|
||||
// Subtle zoom-in over the full scene duration
|
||||
const chatZoom = interpolate(frame, [0, durationInFrames], [1.0, 1.04], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Determine when the last message appears to show typing indicator after
|
||||
const lastMessageDelaySec = messages.length > 0
|
||||
? Math.max(...messages.map((m) => m.delaySec))
|
||||
: 0;
|
||||
const typingStartFrame = (lastMessageDelaySec + 0.4) * fps;
|
||||
|
||||
const typingProgress = spring({
|
||||
frame: frame - typingStartFrame,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 200 },
|
||||
});
|
||||
const typingScale = interpolate(typingProgress, [0, 1], [0.3, 1]);
|
||||
const typingOpacity = interpolate(typingProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Overlay text animation (springs in at 0.3s)
|
||||
const overlayDelay = 0.3 * fps;
|
||||
const overlayProgress = spring({
|
||||
frame: frame - overlayDelay,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 180 },
|
||||
});
|
||||
const overlayOpacity = interpolate(
|
||||
frame - overlayDelay,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
const overlayScale = interpolate(overlayProgress, [0, 1], [0.85, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#000000" }}>
|
||||
{/* Zoomable chat container */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
transform: `scale(${chatZoom})`,
|
||||
transformOrigin: "center 40%",
|
||||
}}
|
||||
>
|
||||
{/* Status bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 32px 12px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
9:41
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
{/* Signal bars */}
|
||||
<div style={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
|
||||
{[6, 8, 10, 12].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 3,
|
||||
height: h,
|
||||
background: "white",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Battery icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 12,
|
||||
border: "1.5px solid white",
|
||||
borderRadius: 3,
|
||||
marginLeft: 4,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "70%",
|
||||
height: "100%",
|
||||
background: "white",
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -5,
|
||||
top: 3,
|
||||
width: 3,
|
||||
height: 6,
|
||||
background: "white",
|
||||
borderRadius: "0 2px 2px 0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 32px 16px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Group avatar with gradient */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
background: "linear-gradient(135deg, #34C759, #007AFF)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{groupSize}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
{groupSize} people
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{messages.map((msg, index) => {
|
||||
const msgDelayFrames = msg.delaySec * fps;
|
||||
const msgProgress = spring({
|
||||
frame: frame - msgDelayFrames,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 200 },
|
||||
});
|
||||
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
|
||||
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
|
||||
|
||||
if (frame < msgDelayFrames) return null;
|
||||
|
||||
const senderColor = msg.isMe ? "#007AFF" : (senderColorMap[msg.sender] || SENDER_COLORS[0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: msg.isMe ? "flex-end" : "flex-start",
|
||||
transform: `scale(${msgScale})`,
|
||||
opacity: msgOpacity,
|
||||
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
|
||||
}}
|
||||
>
|
||||
{/* Sender name (only for non-"me" messages) */}
|
||||
{!msg.isMe && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 13,
|
||||
color: senderColor,
|
||||
marginBottom: 2,
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
{msg.sender}
|
||||
</span>
|
||||
)}
|
||||
{/* Message bubble */}
|
||||
<div
|
||||
style={{
|
||||
background: msg.isMe ? "#007AFF" : "#2C2C2E",
|
||||
borderRadius: 20,
|
||||
borderBottomRightRadius: msg.isMe ? 6 : 20,
|
||||
borderBottomLeftRadius: msg.isMe ? 20 : 6,
|
||||
padding: "12px 18px",
|
||||
maxWidth: "75%",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: "white",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{msg.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Typing indicator (appears after last message) */}
|
||||
{frame >= typingStartFrame && (
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${typingScale})`,
|
||||
opacity: typingOpacity,
|
||||
transformOrigin: "left bottom",
|
||||
}}
|
||||
>
|
||||
<TypingIndicator frame={frame} fps={fps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay text pill badge */}
|
||||
{overlayText && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: overlayOpacity,
|
||||
transform: `scale(${overlayScale})`,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
backdropFilter: "blur(16px)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
padding: "16px 40px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 36,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
{overlayText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
346
marketing-videos/src/engine/scenes/FlexScene.tsx
Normal file
346
marketing-videos/src/engine/scenes/FlexScene.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { GradientBackground } from "../../components/shared/Background";
|
||||
|
||||
type FlexSceneProps = {
|
||||
/** Number of stadiums visited */
|
||||
stadiumCount?: number;
|
||||
/** Total stadiums possible */
|
||||
totalStadiums?: number;
|
||||
/** Caption text */
|
||||
caption?: string;
|
||||
/** League labels to show */
|
||||
leagues?: string[];
|
||||
};
|
||||
|
||||
export const FlexScene: React.FC<FlexSceneProps> = ({
|
||||
stadiumCount = 27,
|
||||
totalStadiums = 120,
|
||||
caption,
|
||||
leagues = ["MLB", "NFL", "NBA", "NHL"],
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// --- Stadium icon dots (arc above counter) ---
|
||||
const totalDots = 8;
|
||||
const visitedDots = Math.round(
|
||||
(stadiumCount / totalStadiums) * totalDots
|
||||
);
|
||||
|
||||
const dotsAppearFrame = Math.round(0.2 * fps);
|
||||
const dotsOpacity = interpolate(
|
||||
frame,
|
||||
[dotsAppearFrame, dotsAppearFrame + Math.round(0.3 * fps)],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// --- Counter animation ---
|
||||
// Count up over ~1.5s
|
||||
const countDuration = Math.round(1.5 * fps);
|
||||
const rawCount = interpolate(frame, [0, countDuration], [0, stadiumCount], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const displayCount = Math.round(rawCount);
|
||||
|
||||
// Counter scale spring
|
||||
const counterScaleProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.bouncy,
|
||||
});
|
||||
const counterScale = interpolate(
|
||||
counterScaleProgress,
|
||||
[0, 1],
|
||||
[0.5, 1]
|
||||
);
|
||||
|
||||
// --- Progress bar ---
|
||||
// Starts animating after counter reaches target
|
||||
const progressBarDelay = countDuration + Math.round(0.1 * fps);
|
||||
const progressBarProgress = spring({
|
||||
frame: frame - progressBarDelay,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const progressBarWidth = interpolate(
|
||||
progressBarProgress,
|
||||
[0, 1],
|
||||
[0, (stadiumCount / totalStadiums) * 100]
|
||||
);
|
||||
|
||||
// --- League badges ---
|
||||
// Spring in staggered, starting 0.3s after progress bar delay
|
||||
const badgesBaseDelay = progressBarDelay + Math.round(0.3 * fps);
|
||||
const badgeStaggerFrames = Math.round(0.1 * fps);
|
||||
|
||||
// --- Caption animation ---
|
||||
const captionDelay = Math.round(0.15 * fps);
|
||||
const captionProgress = spring({
|
||||
frame: frame - captionDelay,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const captionOpacity = interpolate(
|
||||
frame - captionDelay,
|
||||
[0, Math.round(fps * 0.25)],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Background */}
|
||||
<GradientBackground animate />
|
||||
|
||||
{/* Subtle gold radial glow behind counter */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 800,
|
||||
height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse, ${theme.colors.gold}1A 0%, transparent 70%)`,
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Caption badge at top */}
|
||||
{caption && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
paddingTop: 120,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: captionOpacity,
|
||||
transform: `translateY(${captionTranslateY}px)`,
|
||||
background: "rgba(0, 0, 0, 0.55)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
borderRadius: 999,
|
||||
padding: "14px 36px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
{/* Stadium icon dots arc */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 400,
|
||||
height: 60,
|
||||
marginBottom: 24,
|
||||
opacity: dotsOpacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: totalDots }).map((_, i) => {
|
||||
// Arrange in a subtle arc
|
||||
const angle =
|
||||
Math.PI + ((i / (totalDots - 1)) * Math.PI);
|
||||
const arcWidth = 180;
|
||||
const arcHeight = 30;
|
||||
const cx = 200 + Math.cos(angle) * arcWidth;
|
||||
const cy = 50 + Math.sin(angle) * arcHeight;
|
||||
const isVisited = i < visitedDots;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: cx - 8,
|
||||
top: cy - 8,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
background: isVisited
|
||||
? theme.colors.accent
|
||||
: "transparent",
|
||||
border: isVisited
|
||||
? `2px solid ${theme.colors.accent}`
|
||||
: "2px solid rgba(255, 255, 255, 0.25)",
|
||||
boxShadow: isVisited
|
||||
? `0 0 8px ${theme.colors.accent}80`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Large counter number */}
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${counterScale})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 160,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.gold,
|
||||
textShadow: `0 0 40px ${theme.colors.gold}66, 0 0 80px ${theme.colors.gold}33`,
|
||||
lineHeight: 1,
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
letterSpacing: -4,
|
||||
}}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* "/ totalStadiums stadiums" subtitle */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 36,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: 8,
|
||||
fontWeight: 500,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
/ {totalStadiums} stadiums
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
style={{
|
||||
width: 800,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
background: "#2C2C2E",
|
||||
marginTop: 40,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${progressBarWidth}%`,
|
||||
height: "100%",
|
||||
borderRadius: 8,
|
||||
background: `linear-gradient(90deg, ${theme.colors.accent}, ${theme.colors.gold})`,
|
||||
boxShadow: `0 0 12px ${theme.colors.accent}66`,
|
||||
transition: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* League badges row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
marginTop: 40,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{leagues.map((league, index) => {
|
||||
const badgeDelay = badgesBaseDelay + index * badgeStaggerFrames;
|
||||
const badgeProgress = spring({
|
||||
frame: frame - badgeDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const badgeScale = interpolate(
|
||||
badgeProgress,
|
||||
[0, 1],
|
||||
[0.3, 1]
|
||||
);
|
||||
const badgeOpacity = interpolate(
|
||||
badgeProgress,
|
||||
[0, 1],
|
||||
[0, 1]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={league}
|
||||
style={{
|
||||
opacity: badgeOpacity,
|
||||
transform: `scale(${badgeScale})`,
|
||||
background: "#2C2C2E",
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
||||
borderRadius: 20,
|
||||
padding: "8px 20px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{league}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
180
marketing-videos/src/engine/scenes/HookCard.tsx
Normal file
180
marketing-videos/src/engine/scenes/HookCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { GradientBackground } from "../../components/shared/Background";
|
||||
|
||||
type HookCardProps = {
|
||||
hookText: string;
|
||||
/** Optional emoji shown above the hook text */
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
export const HookCard: React.FC<HookCardProps> = ({ hookText, emoji }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const words = hookText.split(/\s+/);
|
||||
const staggerFrames = Math.round(0.06 * fps);
|
||||
|
||||
// Calculate when the last word finishes appearing (for the underline timing)
|
||||
const lastWordStartFrame = (words.length - 1) * staggerFrames;
|
||||
|
||||
// Emoji spring - appears at frame 3
|
||||
const emojiProgress = spring({
|
||||
frame: frame - 3,
|
||||
fps,
|
||||
config: theme.animation.bouncy,
|
||||
});
|
||||
|
||||
const emojiScale = interpolate(emojiProgress, [0, 1], [0.3, 2]);
|
||||
const emojiOpacity = interpolate(emojiProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Underline wipe - starts after all words have appeared, with a small buffer
|
||||
const underlineDelay = lastWordStartFrame + Math.round(fps * 0.3);
|
||||
const underlineProgress = spring({
|
||||
frame: frame - underlineDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const underlineScaleX = interpolate(underlineProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Dark gradient background */}
|
||||
<GradientBackground />
|
||||
|
||||
{/* Radial orange glow behind text */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 900,
|
||||
height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse, ${theme.colors.accent}26 0%, transparent 70%)`,
|
||||
filter: "blur(40px)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Main content container */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
maxWidth: 900,
|
||||
padding: "0 40px",
|
||||
}}
|
||||
>
|
||||
{/* Optional emoji */}
|
||||
{emoji && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 64,
|
||||
marginBottom: 32,
|
||||
transform: `scale(${emojiScale})`,
|
||||
opacity: emojiOpacity,
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hook text with word-by-word reveal */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
gap: "0 16px",
|
||||
lineHeight: 1.25,
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
{words.map((word, index) => {
|
||||
const wordDelay = index * staggerFrames;
|
||||
|
||||
const wordProgress = spring({
|
||||
frame: frame - wordDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const wordOpacity = interpolate(
|
||||
frame - wordDelay,
|
||||
[0, Math.round(fps * 0.15)],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const wordScale = interpolate(
|
||||
wordProgress,
|
||||
[0, 1],
|
||||
[0.7, 1.0]
|
||||
);
|
||||
|
||||
const wordTranslateY = interpolate(
|
||||
wordProgress,
|
||||
[0, 1],
|
||||
[40, 0]
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 64,
|
||||
fontWeight: 800,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
opacity: wordOpacity,
|
||||
transform: `scale(${wordScale}) translateY(${wordTranslateY}px)`,
|
||||
display: "inline-block",
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Orange underline wipe */}
|
||||
<div
|
||||
style={{
|
||||
width: 300,
|
||||
height: 5,
|
||||
marginTop: 20,
|
||||
borderRadius: 3,
|
||||
background: theme.colors.accent,
|
||||
transform: `scaleX(${underlineScaleX})`,
|
||||
transformOrigin: "left",
|
||||
opacity: underlineScaleX > 0 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
103
marketing-videos/src/engine/scenes/KineticCaption.tsx
Normal file
103
marketing-videos/src/engine/scenes/KineticCaption.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import type { CaptionLine } from "../types";
|
||||
|
||||
type KineticCaptionProps = {
|
||||
captions: CaptionLine[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Kinetic caption overlay that renders on top of all scenes.
|
||||
* Captions appear/disappear based on their startSec/endSec timing.
|
||||
* Each caption pops in with a spring and has bold/highlight styling options.
|
||||
*/
|
||||
export const KineticCaption: React.FC<KineticCaptionProps> = ({ captions }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Find the currently active caption
|
||||
const currentSec = frame / fps;
|
||||
const activeCaptions = captions.filter(
|
||||
(c) => currentSec >= c.startSec && currentSec < c.endSec
|
||||
);
|
||||
|
||||
if (activeCaptions.length === 0) return null;
|
||||
|
||||
const caption = activeCaptions[activeCaptions.length - 1];
|
||||
const captionStartFrame = caption.startSec * fps;
|
||||
const captionEndFrame = caption.endSec * fps;
|
||||
const localFrame = frame - captionStartFrame;
|
||||
|
||||
// Entrance spring
|
||||
const enterProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 200 },
|
||||
});
|
||||
|
||||
const scale = interpolate(enterProgress, [0, 1], [0.7, 1]);
|
||||
const enterOpacity = interpolate(enterProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Exit fade (last 5 frames before endSec)
|
||||
const exitOpacity = interpolate(
|
||||
frame,
|
||||
[captionEndFrame - 5, captionEndFrame],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const opacity = Math.min(enterOpacity, exitOpacity);
|
||||
|
||||
const isHighlight = caption.emphasis === "highlight";
|
||||
const isBold = caption.emphasis === "bold" || isHighlight;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
paddingBottom: 220,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
maxWidth: 900,
|
||||
padding: "16px 36px",
|
||||
borderRadius: 16,
|
||||
background: isHighlight
|
||||
? "rgba(255, 107, 53, 0.9)"
|
||||
: "rgba(0, 0, 0, 0.8)",
|
||||
backdropFilter: "blur(8px)",
|
||||
border: isHighlight
|
||||
? "none"
|
||||
: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 38,
|
||||
fontWeight: isBold ? 800 : 600,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: -0.5,
|
||||
textShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{caption.text}
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
402
marketing-videos/src/engine/scenes/MapScene.tsx
Normal file
402
marketing-videos/src/engine/scenes/MapScene.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type MapCity = {
|
||||
name: string;
|
||||
x: number; // percentage position 0-100
|
||||
y: number; // percentage position 0-100
|
||||
};
|
||||
|
||||
type MapSceneProps = {
|
||||
/** Cities to show on the map */
|
||||
cities?: MapCity[];
|
||||
/** Caption text at top */
|
||||
caption?: string;
|
||||
/** Route line color override */
|
||||
routeColor?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CITIES: MapCity[] = [
|
||||
{ name: "Dallas", x: 45, y: 55 },
|
||||
{ name: "Houston", x: 50, y: 70 },
|
||||
{ name: "San Antonio", x: 40, y: 72 },
|
||||
];
|
||||
|
||||
export const MapScene: React.FC<MapSceneProps> = ({
|
||||
cities = DEFAULT_CITIES,
|
||||
caption,
|
||||
routeColor = theme.colors.mapLine,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
// Map area: center 80% of screen
|
||||
const mapLeft = width * 0.1;
|
||||
const mapTop = height * 0.1;
|
||||
const mapWidth = width * 0.8;
|
||||
const mapHeight = height * 0.8;
|
||||
|
||||
// Convert percentage coords to pixel coords within the map area
|
||||
const points = cities.map((city) => ({
|
||||
name: city.name,
|
||||
px: mapLeft + (city.x / 100) * mapWidth,
|
||||
py: mapTop + (city.y / 100) * mapHeight,
|
||||
}));
|
||||
|
||||
// Timing: each segment takes ~0.5s to draw, staggered
|
||||
const segmentDurationFrames = Math.round(0.5 * fps);
|
||||
|
||||
// Calculate total path length and per-segment lengths
|
||||
const segmentLengths: number[] = [];
|
||||
let totalPathLength = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const dx = points[i].px - points[i - 1].px;
|
||||
const dy = points[i].py - points[i - 1].py;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
segmentLengths.push(len);
|
||||
totalPathLength += len;
|
||||
}
|
||||
|
||||
// Build the SVG path d attribute (straight lines between cities)
|
||||
const pathD = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.px} ${p.py}`)
|
||||
.join(" ");
|
||||
|
||||
// Calculate cumulative lengths for each segment start
|
||||
const cumulativeLengths: number[] = [0];
|
||||
for (let i = 0; i < segmentLengths.length; i++) {
|
||||
cumulativeLengths.push(cumulativeLengths[i] + segmentLengths[i]);
|
||||
}
|
||||
|
||||
// Route draw animation: each segment draws over segmentDurationFrames, staggered
|
||||
const getSegmentDrawProgress = (segmentIndex: number): number => {
|
||||
const segmentStartFrame = segmentIndex * segmentDurationFrames;
|
||||
return interpolate(
|
||||
frame,
|
||||
[segmentStartFrame, segmentStartFrame + segmentDurationFrames],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
};
|
||||
|
||||
// Calculate overall drawn length for strokeDashoffset
|
||||
let drawnLength = 0;
|
||||
for (let i = 0; i < segmentLengths.length; i++) {
|
||||
const segProgress = getSegmentDrawProgress(i);
|
||||
drawnLength += segmentLengths[i] * segProgress;
|
||||
}
|
||||
|
||||
const strokeDashoffset = totalPathLength - drawnLength;
|
||||
|
||||
// Determine when each city's route segment completes (for marker appearance)
|
||||
// City 0 appears when route starts drawing (frame ~0)
|
||||
// City i appears when segment i-1 finishes drawing
|
||||
const getCityAppearFrame = (cityIndex: number): number => {
|
||||
if (cityIndex === 0) return 0;
|
||||
return cityIndex * segmentDurationFrames;
|
||||
};
|
||||
|
||||
// Traveling dot: appears after entire route is drawn, moves along path
|
||||
const routeCompleteFrame =
|
||||
(points.length - 1) * segmentDurationFrames;
|
||||
const travelDotDelay = routeCompleteFrame + Math.round(fps * 0.3);
|
||||
const travelDotDuration = Math.round(fps * 1.5);
|
||||
|
||||
const travelDotProgress = interpolate(
|
||||
frame,
|
||||
[travelDotDelay, travelDotDelay + travelDotDuration],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Calculate traveling dot position along the path
|
||||
const travelDistance = travelDotProgress * totalPathLength;
|
||||
let travelDotX = points[0].px;
|
||||
let travelDotY = points[0].py;
|
||||
|
||||
if (travelDotProgress > 0) {
|
||||
let accumulated = 0;
|
||||
for (let i = 0; i < segmentLengths.length; i++) {
|
||||
if (accumulated + segmentLengths[i] >= travelDistance) {
|
||||
const segFraction =
|
||||
(travelDistance - accumulated) / segmentLengths[i];
|
||||
travelDotX =
|
||||
points[i].px + (points[i + 1].px - points[i].px) * segFraction;
|
||||
travelDotY =
|
||||
points[i].py + (points[i + 1].py - points[i].py) * segFraction;
|
||||
break;
|
||||
}
|
||||
accumulated += segmentLengths[i];
|
||||
}
|
||||
}
|
||||
|
||||
const travelDotOpacity = interpolate(
|
||||
travelDotProgress,
|
||||
[0, 0.02, 0.98, 1],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Caption animation
|
||||
const captionProgress = caption
|
||||
? spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
})
|
||||
: 0;
|
||||
const captionOpacity = interpolate(captionProgress, [0, 1], [0, 1]);
|
||||
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
|
||||
|
||||
// Pulse animation for markers (repeating)
|
||||
const pulseFrame = frame % Math.round(fps * 1.5);
|
||||
const pulseScale = interpolate(
|
||||
pulseFrame,
|
||||
[0, Math.round(fps * 1.5)],
|
||||
[1, 2.5],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
const pulseOpacity = interpolate(
|
||||
pulseFrame,
|
||||
[0, Math.round(fps * 1.5)],
|
||||
[0.6, 0],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
{/* Subtle dark grid pattern */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "60px 60px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* SVG map layer */}
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Route glow (blurred duplicate path) */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={routeColor}
|
||||
strokeWidth={12}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={totalPathLength}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
opacity={0.2}
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
|
||||
{/* Route line (dashed, animated) */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={routeColor}
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={totalPathLength}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
|
||||
{/* City markers */}
|
||||
{points.map((point, index) => {
|
||||
const appearFrame = getCityAppearFrame(index);
|
||||
|
||||
const markerSpring = spring({
|
||||
frame: frame - appearFrame,
|
||||
fps,
|
||||
config: theme.animation.bouncy,
|
||||
});
|
||||
|
||||
const markerScale = interpolate(
|
||||
markerSpring,
|
||||
[0, 1],
|
||||
[0, 1]
|
||||
);
|
||||
|
||||
const markerOpacity = interpolate(
|
||||
markerSpring,
|
||||
[0, 0.3],
|
||||
[0, 1],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Label appears slightly after the marker
|
||||
const labelDelay = appearFrame + Math.round(fps * 0.15);
|
||||
const labelSpring = spring({
|
||||
frame: frame - labelDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const labelOpacity = interpolate(
|
||||
labelSpring,
|
||||
[0, 1],
|
||||
[0, 1]
|
||||
);
|
||||
const labelTranslateY = interpolate(
|
||||
labelSpring,
|
||||
[0, 1],
|
||||
[10, 0]
|
||||
);
|
||||
|
||||
// Only show pulse after marker is fully visible
|
||||
const showPulse = markerSpring > 0.95;
|
||||
|
||||
return (
|
||||
<g key={index}>
|
||||
{/* Pulse ring (repeating, expanding, fading) */}
|
||||
{showPulse && (
|
||||
<circle
|
||||
cx={point.px}
|
||||
cy={point.py}
|
||||
r={24}
|
||||
fill="none"
|
||||
stroke={theme.colors.mapMarker}
|
||||
strokeWidth={2}
|
||||
opacity={pulseOpacity}
|
||||
transform={`translate(${point.px * (1 - pulseScale)}, ${point.py * (1 - pulseScale)}) scale(${pulseScale})`}
|
||||
style={{ transformOrigin: `${point.px}px ${point.py}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Outer ring */}
|
||||
<circle
|
||||
cx={point.px}
|
||||
cy={point.py}
|
||||
r={24}
|
||||
fill="transparent"
|
||||
stroke={theme.colors.mapMarker}
|
||||
strokeWidth={3}
|
||||
opacity={markerOpacity}
|
||||
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
|
||||
/>
|
||||
|
||||
{/* Inner dot */}
|
||||
<circle
|
||||
cx={point.px}
|
||||
cy={point.py}
|
||||
r={10}
|
||||
fill={theme.colors.mapMarker}
|
||||
opacity={markerOpacity}
|
||||
transform={`translate(${point.px * (1 - markerScale)}, ${point.py * (1 - markerScale)}) scale(${markerScale})`}
|
||||
/>
|
||||
|
||||
{/* City name label */}
|
||||
<text
|
||||
x={point.px}
|
||||
y={point.py + 44}
|
||||
fill={theme.colors.text}
|
||||
fontSize={28}
|
||||
fontWeight={700}
|
||||
fontFamily={theme.fonts.display}
|
||||
textAnchor="middle"
|
||||
opacity={labelOpacity}
|
||||
style={{
|
||||
filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.8))",
|
||||
}}
|
||||
>
|
||||
<tspan dy={labelTranslateY}>{point.name}</tspan>
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Traveling dot along the route */}
|
||||
{travelDotProgress > 0 && travelDotProgress < 1 && (
|
||||
<>
|
||||
{/* Glow behind traveling dot */}
|
||||
<circle
|
||||
cx={travelDotX}
|
||||
cy={travelDotY}
|
||||
r={18}
|
||||
fill={routeColor}
|
||||
opacity={travelDotOpacity * 0.3}
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
{/* Traveling dot */}
|
||||
<circle
|
||||
cx={travelDotX}
|
||||
cy={travelDotY}
|
||||
r={6}
|
||||
fill={routeColor}
|
||||
opacity={travelDotOpacity}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Caption pill badge at top */}
|
||||
{caption && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: captionOpacity,
|
||||
transform: `translateY(${captionTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.6)",
|
||||
backdropFilter: "blur(16px)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
padding: "14px 36px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: theme.fontSizes.body,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
408
marketing-videos/src/engine/scenes/PollScene.tsx
Normal file
408
marketing-videos/src/engine/scenes/PollScene.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { GradientBackground } from "../../components/shared/Background";
|
||||
|
||||
type PollOption = {
|
||||
label: string;
|
||||
votes: number;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
type PollSceneProps = {
|
||||
/** Poll question */
|
||||
question?: string;
|
||||
/** Poll options with vote counts */
|
||||
options?: PollOption[];
|
||||
/** Caption text at top */
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_QUESTION = "Which trip are we doing?";
|
||||
const DEFAULT_OPTIONS: PollOption[] = [
|
||||
{ label: "Dallas \u2192 Houston", votes: 3, emoji: "\uD83E\uDD20" },
|
||||
{ label: "NYC \u2192 Boston", votes: 2, emoji: "\uD83D\uDDFD" },
|
||||
{ label: "LA \u2192 San Diego", votes: 1, emoji: "\uD83C\uDF34" },
|
||||
];
|
||||
|
||||
const VOTER_NAMES = ["Jake", "Mike", "Sam", "Alex", "Chris"];
|
||||
|
||||
const FILL_COLORS = [
|
||||
theme.colors.accent,
|
||||
"#FF8F5E",
|
||||
"#FFB088",
|
||||
];
|
||||
|
||||
const getFillColor = (index: number): string => {
|
||||
if (index < FILL_COLORS.length) return FILL_COLORS[index];
|
||||
return FILL_COLORS[FILL_COLORS.length - 1];
|
||||
};
|
||||
|
||||
export const PollScene: React.FC<PollSceneProps> = ({
|
||||
question = DEFAULT_QUESTION,
|
||||
options = DEFAULT_OPTIONS,
|
||||
caption,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const maxVotes = Math.max(...options.map((o) => o.votes));
|
||||
|
||||
// Card entrance - slides up and fades in
|
||||
const cardEntrance = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const cardTranslateY = interpolate(cardEntrance, [0, 1], [80, 0]);
|
||||
const cardOpacity = interpolate(cardEntrance, [0, 1], [0, 1]);
|
||||
|
||||
// Caption entrance
|
||||
const captionEntrance = spring({
|
||||
frame: frame - 3,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const captionOpacity = interpolate(captionEntrance, [0, 1], [0, 1]);
|
||||
const captionTranslateY = interpolate(captionEntrance, [0, 1], [-20, 0]);
|
||||
|
||||
// Question text entrance
|
||||
const questionEntrance = spring({
|
||||
frame: frame - Math.round(fps * 0.15),
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const questionOpacity = interpolate(questionEntrance, [0, 1], [0, 1]);
|
||||
|
||||
// Bar fill delay base (after card is in)
|
||||
const barStartFrame = Math.round(fps * 0.4);
|
||||
|
||||
// Calculate when all bars are done filling for the "Poll sent" badge
|
||||
const lastBarStartFrame = barStartFrame + (options.length - 1) * Math.round(fps * 0.2);
|
||||
const barFillDuration = Math.round(fps * 0.5);
|
||||
const allBarsDoneFrame = lastBarStartFrame + barFillDuration + Math.round(fps * 0.3);
|
||||
|
||||
// "Poll sent" badge
|
||||
const pollSentEntrance = spring({
|
||||
frame: frame - allBarsDoneFrame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const pollSentOpacity = interpolate(pollSentEntrance, [0, 1], [0, 1]);
|
||||
const pollSentScale = interpolate(pollSentEntrance, [0, 1], [0.5, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<GradientBackground />
|
||||
|
||||
{/* Caption at top */}
|
||||
{caption && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 120,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
zIndex: 10,
|
||||
opacity: captionOpacity,
|
||||
transform: `translateY(${captionTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 32px",
|
||||
borderRadius: 100,
|
||||
background: "rgba(255, 255, 255, 0.08)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Poll card centered vertically */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 900,
|
||||
borderRadius: 24,
|
||||
padding: 48,
|
||||
background: "#1C1C1E",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
opacity: cardOpacity,
|
||||
transform: `translateY(${cardTranslateY}px)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 32,
|
||||
}}
|
||||
>
|
||||
{/* Question */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 36,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
opacity: questionOpacity,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const staggerDelay = barStartFrame + index * Math.round(fps * 0.2);
|
||||
|
||||
const fillProgress = spring({
|
||||
frame: frame - staggerDelay,
|
||||
fps,
|
||||
config: { damping: 30, stiffness: 120 },
|
||||
});
|
||||
|
||||
const fillWidth = interpolate(
|
||||
fillProgress,
|
||||
[0, 1],
|
||||
[0, (option.votes / maxVotes) * 100]
|
||||
);
|
||||
|
||||
// Vote count fades in after bar fills
|
||||
const voteCountDelay = staggerDelay + Math.round(fps * 0.3);
|
||||
const voteCountEntrance = spring({
|
||||
frame: frame - voteCountDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const voteCountOpacity = interpolate(
|
||||
voteCountEntrance,
|
||||
[0, 1],
|
||||
[0, 1]
|
||||
);
|
||||
|
||||
const isWinner = option.votes === maxVotes;
|
||||
const fillColor = getFillColor(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: 72,
|
||||
borderRadius: 16,
|
||||
background: "#2C2C2E",
|
||||
overflow: "hidden",
|
||||
boxShadow: isWinner && fillProgress > 0.8
|
||||
? `0 0 24px ${fillColor}40`
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{/* Fill bar */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: `${fillWidth}%`,
|
||||
borderRadius: 16,
|
||||
background: fillColor,
|
||||
transition: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Label and emoji */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 24px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{option.emoji && (
|
||||
<span style={{ fontSize: 28 }}>{option.emoji}</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Vote count */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
opacity: voteCountOpacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{option.votes} {option.votes === 1 ? "vote" : "votes"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "Poll sent" badge below the card */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, ${cardTranslateY + 240}px) scale(${pollSentScale})`,
|
||||
opacity: pollSentOpacity,
|
||||
marginTop: options.length * 46 + 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "12px 28px",
|
||||
borderRadius: 100,
|
||||
background: `${theme.colors.success}20`,
|
||||
border: `1px solid ${theme.colors.success}40`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{"\u2713"}</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.success,
|
||||
}}
|
||||
>
|
||||
Poll sent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Vote notification pills - bottom right */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 160,
|
||||
right: 60,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{VOTER_NAMES.map((name, index) => {
|
||||
const notifStartFrame =
|
||||
barStartFrame + index * Math.round(fps * 0.3);
|
||||
const notifEndFrame = notifStartFrame + Math.round(fps * 1.0);
|
||||
|
||||
const enterProgress = spring({
|
||||
frame: frame - notifStartFrame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const exitProgress = spring({
|
||||
frame: frame - notifEndFrame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const notifOpacity = interpolate(
|
||||
enterProgress,
|
||||
[0, 1],
|
||||
[0, 1]
|
||||
) * interpolate(exitProgress, [0, 1], [1, 0]);
|
||||
|
||||
const notifTranslateX = interpolate(
|
||||
enterProgress,
|
||||
[0, 1],
|
||||
[60, 0]
|
||||
);
|
||||
|
||||
const notifScale = interpolate(enterProgress, [0, 1], [0.7, 1]);
|
||||
|
||||
if (notifOpacity <= 0.01) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
padding: "10px 22px",
|
||||
borderRadius: 100,
|
||||
background: "rgba(255, 255, 255, 0.1)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
opacity: notifOpacity,
|
||||
transform: `translateX(${notifTranslateX}px) scale(${notifScale})`,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{name} voted!
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
318
marketing-videos/src/engine/scenes/ScreenRecScene.tsx
Normal file
318
marketing-videos/src/engine/scenes/ScreenRecScene.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Video,
|
||||
staticFile,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { GradientBackground } from "../../components/shared/Background";
|
||||
|
||||
type ScreenRecSceneProps = {
|
||||
/** Asset key for the screen recording, e.g. "screenrecs/date_range.mp4" */
|
||||
assetKey?: string;
|
||||
/** Caption text overlay at top */
|
||||
caption?: string;
|
||||
/** Whether to show the phone device frame */
|
||||
showFrame?: boolean;
|
||||
/** Scale of the phone (default 0.85) */
|
||||
phoneScale?: number;
|
||||
/** Optional start time offset in seconds for the video */
|
||||
videoStartSec?: number;
|
||||
};
|
||||
|
||||
const Placeholder: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: `linear-gradient(135deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 50%, ${theme.colors.accent}22 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{/* Play icon triangle */}
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "40px solid rgba(255, 255, 255, 0.6)",
|
||||
borderTop: "24px solid transparent",
|
||||
borderBottom: "24px solid transparent",
|
||||
marginLeft: 10,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: theme.fontSizes.body,
|
||||
color: theme.colors.textSecondary,
|
||||
fontWeight: 500,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Screen Recording
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScreenVideo: React.FC<{
|
||||
assetKey: string;
|
||||
startFromFrame: number;
|
||||
}> = ({ assetKey, startFromFrame }) => {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
if (hasError) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
|
||||
try {
|
||||
const src = staticFile(assetKey);
|
||||
return (
|
||||
<Video
|
||||
src={src}
|
||||
startFrom={startFromFrame}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
return <Placeholder />;
|
||||
}
|
||||
};
|
||||
|
||||
export const ScreenRecScene: React.FC<ScreenRecSceneProps> = ({
|
||||
assetKey,
|
||||
caption,
|
||||
showFrame = true,
|
||||
phoneScale = 0.85,
|
||||
videoStartSec = 0,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
const startFromFrame = Math.round(videoStartSec * fps);
|
||||
|
||||
// --- Phone entrance animations ---
|
||||
|
||||
// Slide up from below
|
||||
const slideProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const translateY = interpolate(slideProgress, [0, 1], [200, 0]);
|
||||
|
||||
// Scale up
|
||||
const scaleProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const scaleValue = interpolate(scaleProgress, [0, 1], [0.92, 1.0]) * phoneScale;
|
||||
|
||||
// --- Caption animation (0.15s delay) ---
|
||||
const captionDelayFrames = Math.round(0.15 * fps);
|
||||
|
||||
const captionProgress = spring({
|
||||
frame: frame - captionDelayFrames,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const captionOpacity = interpolate(
|
||||
frame - captionDelayFrames,
|
||||
[0, Math.round(fps * 0.25)],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const captionTranslateY = interpolate(captionProgress, [0, 1], [-30, 0]);
|
||||
|
||||
// --- Phone dimensions ---
|
||||
const phoneWidth = width * 0.75;
|
||||
const phoneHeight = height * 0.8;
|
||||
const cornerRadius = 60;
|
||||
const bezelWidth = 12;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Background */}
|
||||
<GradientBackground />
|
||||
|
||||
{/* Subtle orange glow behind phone */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: phoneWidth * 1.4,
|
||||
height: phoneHeight * 0.8,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse, ${theme.colors.accent}1A 0%, transparent 70%)`,
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Caption badge */}
|
||||
{caption && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
paddingTop: 100,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: captionOpacity,
|
||||
transform: `translateY(${captionTranslateY}px)`,
|
||||
background: "rgba(0, 0, 0, 0.55)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
borderRadius: 24,
|
||||
padding: "14px 32px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{/* Phone device */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${translateY}px) scale(${scaleValue})`,
|
||||
}}
|
||||
>
|
||||
{showFrame ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: phoneWidth,
|
||||
height: phoneHeight,
|
||||
background: "#1C1C1E",
|
||||
borderRadius: cornerRadius,
|
||||
padding: bezelWidth,
|
||||
boxShadow: `
|
||||
0 50px 100px rgba(0, 0, 0, 0.5),
|
||||
0 20px 40px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{/* Dynamic Island */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: bezelWidth + 15,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 120,
|
||||
height: 36,
|
||||
background: "#000",
|
||||
borderRadius: 18,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screen content */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: cornerRadius - bezelWidth,
|
||||
overflow: "hidden",
|
||||
background: theme.colors.background,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{assetKey ? (
|
||||
<ScreenVideo
|
||||
assetKey={assetKey}
|
||||
startFromFrame={startFromFrame}
|
||||
/>
|
||||
) : (
|
||||
<Placeholder />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Home indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: bezelWidth + 10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 140,
|
||||
height: 5,
|
||||
background: "rgba(255, 255, 255, 0.3)",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* No frame - just the screen content */
|
||||
<div
|
||||
style={{
|
||||
width: phoneWidth - 40,
|
||||
height: phoneHeight - 40,
|
||||
borderRadius: cornerRadius - 20,
|
||||
overflow: "hidden",
|
||||
background: theme.colors.background,
|
||||
boxShadow: "0 50px 100px rgba(0, 0, 0, 0.5)",
|
||||
}}
|
||||
>
|
||||
{assetKey ? (
|
||||
<ScreenVideo
|
||||
assetKey={assetKey}
|
||||
startFromFrame={startFromFrame}
|
||||
/>
|
||||
) : (
|
||||
<Placeholder />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
463
marketing-videos/src/engine/scenes/TextPunchScene.tsx
Normal file
463
marketing-videos/src/engine/scenes/TextPunchScene.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type TextPunchSceneProps = {
|
||||
/** The punch line text */
|
||||
text?: string;
|
||||
/** Optional secondary smaller text below */
|
||||
subtext?: string;
|
||||
/** Style variant */
|
||||
variant?: "slam" | "typewriter" | "split";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic "random" shake offsets so renders are reproducible
|
||||
// ---------------------------------------------------------------------------
|
||||
const SHAKE_OFFSETS: { x: number; y: number }[] = [
|
||||
{ x: -6, y: 4 },
|
||||
{ x: 8, y: -5 },
|
||||
{ x: -3, y: 7 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant: slam
|
||||
// ---------------------------------------------------------------------------
|
||||
const SlamVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
|
||||
text,
|
||||
frame,
|
||||
fps,
|
||||
}) => {
|
||||
// Main text slam spring
|
||||
const slamProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 300 },
|
||||
});
|
||||
|
||||
const textScale = interpolate(slamProgress, [0, 1], [2.0, 1.0]);
|
||||
const textOpacity = interpolate(slamProgress, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Impact frame: the frame at which scale first reaches ~1.0
|
||||
// With damping 12, stiffness 300 the spring crosses 1.0 around frame 5-6
|
||||
const impactFrame = 5;
|
||||
|
||||
// Screen shake: 3 frames starting at impact
|
||||
let shakeX = 0;
|
||||
let shakeY = 0;
|
||||
const shakeFrame = frame - impactFrame;
|
||||
if (shakeFrame >= 0 && shakeFrame < SHAKE_OFFSETS.length) {
|
||||
shakeX = SHAKE_OFFSETS[shakeFrame].x;
|
||||
shakeY = SHAKE_OFFSETS[shakeFrame].y;
|
||||
}
|
||||
|
||||
// Orange accent line appears after slam lands
|
||||
const lineDelay = impactFrame + 4;
|
||||
const lineProgress = spring({
|
||||
frame: frame - lineDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Radial orange glow pulse on impact
|
||||
const glowPeak = impactFrame;
|
||||
const glowOpacity = interpolate(
|
||||
frame,
|
||||
[glowPeak, glowPeak + 4, glowPeak + 14],
|
||||
[0, 0.45, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
transform: `translate(${shakeX}px, ${shakeY}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Radial orange glow */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 900,
|
||||
height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
|
||||
opacity: glowOpacity,
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Text + underline */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 72,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
maxWidth: 850,
|
||||
lineHeight: 1.15,
|
||||
transform: `scale(${textScale})`,
|
||||
opacity: textOpacity,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
{/* Orange accent line */}
|
||||
<div
|
||||
style={{
|
||||
width: 300,
|
||||
height: 5,
|
||||
marginTop: 24,
|
||||
borderRadius: 3,
|
||||
background: theme.colors.accent,
|
||||
transform: `scaleX(${lineScaleX})`,
|
||||
transformOrigin: "left",
|
||||
opacity: lineScaleX > 0.01 ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant: typewriter
|
||||
// ---------------------------------------------------------------------------
|
||||
const TypewriterVariant: React.FC<{
|
||||
text: string;
|
||||
frame: number;
|
||||
fps: number;
|
||||
}> = ({ text, frame, fps }) => {
|
||||
// ~2 chars per frame
|
||||
const charsPerFrame = 2;
|
||||
const visibleChars = Math.min(frame * charsPerFrame, text.length);
|
||||
const typingComplete = visibleChars >= text.length;
|
||||
const typingCompleteFrame = Math.ceil(text.length / charsPerFrame);
|
||||
|
||||
// Cursor blink: after typing completes, blink 2x then disappear
|
||||
// Each blink cycle = 8 frames on, 6 frames off
|
||||
const cursorVisible = (() => {
|
||||
if (!typingComplete) return true; // solid while typing
|
||||
const elapsed = frame - typingCompleteFrame;
|
||||
if (elapsed < 0) return true;
|
||||
// 2 blink cycles: on 8, off 6, on 8, off 6 = 28 frames total
|
||||
const blinkCycle = 14; // 8 on + 6 off
|
||||
if (elapsed >= blinkCycle * 2) return false; // disappeared
|
||||
const withinCycle = elapsed % blinkCycle;
|
||||
return withinCycle < 8;
|
||||
})();
|
||||
|
||||
// Scan line effect: thin horizontal lines scrolling down slowly
|
||||
const scanLineOffset = (frame * 0.5) % 8;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Scan line overlay */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
rgba(255, 255, 255, 0.05) 6px,
|
||||
rgba(255, 255, 255, 0.05) 8px
|
||||
)`,
|
||||
backgroundPosition: `0 ${scanLineOffset}px`,
|
||||
pointerEvents: "none",
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Text container */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 900,
|
||||
padding: "0 60px",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "SF Mono, Menlo, monospace",
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: -0.5,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{text.slice(0, Math.floor(visibleChars))}
|
||||
</span>
|
||||
{/* Cursor */}
|
||||
{cursorVisible && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 4,
|
||||
height: 56,
|
||||
background: theme.colors.accent,
|
||||
marginLeft: 2,
|
||||
verticalAlign: "bottom",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant: split
|
||||
// ---------------------------------------------------------------------------
|
||||
const SplitVariant: React.FC<{ text: string; frame: number; fps: number }> = ({
|
||||
text,
|
||||
frame,
|
||||
fps,
|
||||
}) => {
|
||||
const words = text.split(/\s+/);
|
||||
const midpoint = Math.ceil(words.length / 2);
|
||||
const topHalf = words.slice(0, midpoint).join(" ");
|
||||
const bottomHalf = words.slice(midpoint).join(" ");
|
||||
|
||||
// Top half slides from left
|
||||
const topProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 160 },
|
||||
});
|
||||
const topTranslateX = interpolate(topProgress, [0, 1], [-600, 0]);
|
||||
const topOpacity = interpolate(topProgress, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Bottom half slides from right, slight delay (3 frames)
|
||||
const bottomProgress = spring({
|
||||
frame: frame - 3,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 160 },
|
||||
});
|
||||
const bottomTranslateX = interpolate(bottomProgress, [0, 1], [600, 0]);
|
||||
const bottomOpacity = interpolate(bottomProgress, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Dividing line appears after both halves have settled
|
||||
const lineDelay = 12;
|
||||
const lineProgress = spring({
|
||||
frame: frame - lineDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const lineScaleX = interpolate(lineProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
maxWidth: 900,
|
||||
padding: "0 60px",
|
||||
gap: 0,
|
||||
}}
|
||||
>
|
||||
{/* Top half */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 64,
|
||||
fontWeight: 800,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
transform: `translateX(${topTranslateX}px)`,
|
||||
opacity: topOpacity,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{topHalf}
|
||||
</div>
|
||||
|
||||
{/* Orange dividing line */}
|
||||
<div
|
||||
style={{
|
||||
width: 400,
|
||||
height: 3,
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
background: theme.colors.accent,
|
||||
transform: `scaleX(${lineScaleX})`,
|
||||
transformOrigin: "center",
|
||||
opacity: lineScaleX > 0.01 ? 1 : 0,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom half */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 64,
|
||||
fontWeight: 800,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
transform: `translateX(${bottomTranslateX}px)`,
|
||||
opacity: bottomOpacity,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{bottomHalf}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
export const TextPunchScene: React.FC<TextPunchSceneProps> = ({
|
||||
text = "The spreadsheet era is over.",
|
||||
subtext,
|
||||
variant = "slam",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Subtext fades in 0.4s after the main animation completes
|
||||
const mainAnimationEndFrame = useMemo(() => {
|
||||
switch (variant) {
|
||||
case "slam":
|
||||
// Slam lands ~5 frames, accent line ~9 frames → settled ~15
|
||||
return 15;
|
||||
case "typewriter": {
|
||||
// Typing at 2 chars/frame + 2 blink cycles (28 frames)
|
||||
const typingDone = Math.ceil(text.length / 2);
|
||||
return typingDone;
|
||||
}
|
||||
case "split":
|
||||
// Both halves settle ~15 frames, line at ~24
|
||||
return 24;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
}, [variant, text]);
|
||||
|
||||
const subtextDelay = mainAnimationEndFrame + Math.round(0.4 * fps);
|
||||
const subtextOpacity = subtext
|
||||
? interpolate(
|
||||
frame - subtextDelay,
|
||||
[0, Math.round(0.25 * fps)],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
)
|
||||
: 0;
|
||||
const subtextTranslateY = subtext
|
||||
? interpolate(
|
||||
frame - subtextDelay,
|
||||
[0, Math.round(0.25 * fps)],
|
||||
[12, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: theme.colors.background }}>
|
||||
{/* Variant content */}
|
||||
{variant === "slam" && (
|
||||
<SlamVariant text={text} frame={frame} fps={fps} />
|
||||
)}
|
||||
{variant === "typewriter" && (
|
||||
<TypewriterVariant text={text} frame={frame} fps={fps} />
|
||||
)}
|
||||
{variant === "split" && (
|
||||
<SplitVariant text={text} frame={frame} fps={fps} />
|
||||
)}
|
||||
|
||||
{/* Subtext (shared across all variants) */}
|
||||
{subtext && (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "32%",
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: "center",
|
||||
maxWidth: 800,
|
||||
padding: "0 60px",
|
||||
opacity: subtextOpacity,
|
||||
transform: `translateY(${subtextTranslateY}px)`,
|
||||
}}
|
||||
>
|
||||
{subtext}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
9
marketing-videos/src/engine/scenes/index.ts
Normal file
9
marketing-videos/src/engine/scenes/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { HookCard } from "./HookCard";
|
||||
export { ChatScene } from "./ChatScene";
|
||||
export { ScreenRecScene } from "./ScreenRecScene";
|
||||
export { MapScene } from "./MapScene";
|
||||
export { PollScene } from "./PollScene";
|
||||
export { FlexScene } from "./FlexScene";
|
||||
export { TextPunchScene } from "./TextPunchScene";
|
||||
export { CTAEndCard } from "./CTAEndCard";
|
||||
export { KineticCaption } from "./KineticCaption";
|
||||
89
marketing-videos/src/engine/types.ts
Normal file
89
marketing-videos/src/engine/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Config-driven video engine types.
|
||||
*
|
||||
* Each video is defined by a VideoConfig that specifies its scenes,
|
||||
* captions, CTA, and timing. The VideoFromConfig component reads
|
||||
* these configs and renders the appropriate scene components.
|
||||
*/
|
||||
|
||||
export type SceneType =
|
||||
| "HOOK"
|
||||
| "CHAT"
|
||||
| "SCREENREC"
|
||||
| "MAP"
|
||||
| "POLL"
|
||||
| "FLEX"
|
||||
| "TEXTPUNCH"
|
||||
| "CTA";
|
||||
|
||||
export type CaptionLine = {
|
||||
text: string;
|
||||
/** Seconds from video start when this caption appears */
|
||||
startSec: number;
|
||||
/** Seconds from video start when this caption disappears */
|
||||
endSec: number;
|
||||
/** Optional emphasis style */
|
||||
emphasis?: "normal" | "bold" | "highlight";
|
||||
};
|
||||
|
||||
export type SceneConfig = {
|
||||
type: SceneType;
|
||||
/** Duration in seconds for this scene */
|
||||
durationSec: number;
|
||||
/** Scene-specific props passed to the scene component */
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type VideoConfig = {
|
||||
/** Unique composition ID, e.g. "V03_H01" */
|
||||
id: string;
|
||||
/** Base video concept, e.g. "V03" */
|
||||
base: string;
|
||||
/** Hook text shown in the first scene */
|
||||
hook: string;
|
||||
/** Ordered scene list */
|
||||
scenes: SceneConfig[];
|
||||
/** Kinetic caption lines */
|
||||
captions: CaptionLine[];
|
||||
/** CTA line (must include "Search SportsTime") */
|
||||
cta: string;
|
||||
/** Optional voiceover line */
|
||||
vo?: string;
|
||||
/** Target total length in seconds (12-18) */
|
||||
targetLengthSec: number;
|
||||
/** Asset keys used by this video */
|
||||
assets?: {
|
||||
screenrec?: string[];
|
||||
overlay?: string[];
|
||||
broll?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type Week1Configs = VideoConfig[];
|
||||
|
||||
/** Asset key registry - maps logical names to file paths in public/ */
|
||||
export const ASSET_KEYS = {
|
||||
screenrecs: {
|
||||
"date-range": "screenrecs/date-range.mp4",
|
||||
"follow-team": "screenrecs/follow-team.mp4",
|
||||
"by-games": "screenrecs/by-games.mp4",
|
||||
"route-generated": "screenrecs/route-generated.mp4",
|
||||
"poll-create": "screenrecs/poll-create.mp4",
|
||||
tracker: "screenrecs/tracker.mp4",
|
||||
},
|
||||
overlays: {
|
||||
"imessage-bg": "overlays/imessage-bg.png",
|
||||
"chat-bubbles": "overlays/chat-bubbles.png",
|
||||
"vote-bubbles": "overlays/vote-bubbles.png",
|
||||
},
|
||||
broll: {
|
||||
highway: "broll/highway.mp4",
|
||||
city: "broll/city.mp4",
|
||||
stadium: "broll/stadium.mp4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AssetCategory = keyof typeof ASSET_KEYS;
|
||||
export type ScreenrecKey = keyof typeof ASSET_KEYS.screenrecs;
|
||||
export type OverlayKey = keyof typeof ASSET_KEYS.overlays;
|
||||
export type BrollKey = keyof typeof ASSET_KEYS.broll;
|
||||
1325
marketing-videos/src/videos/AwayGameTake/index.tsx
Normal file
1325
marketing-videos/src/videos/AwayGameTake/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1365
marketing-videos/src/videos/GroupChatChaos/index.tsx
Normal file
1365
marketing-videos/src/videos/GroupChatChaos/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1111
marketing-videos/src/videos/LocalCityRoute/index.tsx
Normal file
1111
marketing-videos/src/videos/LocalCityRoute/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1373
marketing-videos/src/videos/SpreadsheetEra/index.tsx
Normal file
1373
marketing-videos/src/videos/SpreadsheetEra/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
979
marketing-videos/src/videos/StadiumCountFlex/index.tsx
Normal file
979
marketing-videos/src/videos/StadiumCountFlex/index.tsx
Normal file
@@ -0,0 +1,979 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { FilmGrain } from "../../components/shared/FilmGrain";
|
||||
import { TikTokCaption } from "../../components/shared/TikTokCaption";
|
||||
import type { CaptionEntry } from "../../components/shared/TikTokCaption";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAPTIONS: CaptionEntry[] = [
|
||||
{ text: "Be honest.", startSec: 0.2, endSec: 1.2, style: "punch" },
|
||||
{ text: "How many stadiums?", startSec: 1.3, endSec: 2.3, style: "shake" },
|
||||
{ text: "47 and counting", startSec: 3.0, endSec: 5.0, style: "highlight" },
|
||||
{ text: "Every. Single. One.", startSec: 6.0, endSec: 8.0, style: "stack" },
|
||||
{ text: "Track yours", startSec: 9.0, endSec: 11.0, style: "whisper" },
|
||||
{ text: "DROP YOUR NUMBER", startSec: 12.0, endSec: 14.5, style: "punch" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene 1 : HOOK (0 - 2.5s, 75 frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HookScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const WORDS = [
|
||||
{ text: "Be honest.", delayFrames: 6 },
|
||||
{ text: "How many stadiums", delayFrames: 30 },
|
||||
{ text: "have you actually", delayFrames: 45 },
|
||||
{ text: "been to?", delayFrames: 55 },
|
||||
];
|
||||
|
||||
// Subtle background shake that decays
|
||||
const shakeDecay = interpolate(frame, [0, 2 * fps], [1, 0], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const SHAKE_OFFSETS = [
|
||||
{ x: -3, y: 2 },
|
||||
{ x: 4, y: -3 },
|
||||
{ x: -2, y: -2 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: -1, y: -1 },
|
||||
{ x: 2, y: 1 },
|
||||
];
|
||||
const shakeIdx = frame % SHAKE_OFFSETS.length;
|
||||
const currentShake = SHAKE_OFFSETS[shakeIdx];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: 60,
|
||||
transform: `translate(${currentShake.x * shakeDecay}px, ${currentShake.y * shakeDecay}px)`,
|
||||
}}
|
||||
>
|
||||
{WORDS.map((word, i) => {
|
||||
const localFrame = frame - word.delayFrames;
|
||||
const slamSpring = spring({
|
||||
frame: Math.max(0, localFrame),
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 280 },
|
||||
});
|
||||
|
||||
const scale = localFrame < 0 ? 0 : interpolate(slamSpring, [0, 1], [2, 1]);
|
||||
const opacity = localFrame < 0
|
||||
? 0
|
||||
: interpolate(slamSpring, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 72,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.gold,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: -2,
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
textShadow: `0 0 40px rgba(255, 215, 0, 0.4), 0 4px 12px rgba(0,0,0,0.8)`,
|
||||
}}
|
||||
>
|
||||
{word.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<FilmGrain opacity={0.06} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene 2 : THE COUNTER (2.5 - 5.5s, 90 frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LEAGUE_PILLS = [
|
||||
{ label: "MLB", delay: 0 },
|
||||
{ label: "NFL", delay: 4 },
|
||||
{ label: "NBA", delay: 8 },
|
||||
{ label: "NHL", delay: 12 },
|
||||
];
|
||||
|
||||
const CounterScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TARGET = 47;
|
||||
const TOTAL = 120;
|
||||
|
||||
// Counter ramp: accelerate then decelerate over 2 seconds
|
||||
const counterProgress = interpolate(frame, [0, 2 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
// Ease-out curve
|
||||
const eased = 1 - Math.pow(1 - counterProgress, 3);
|
||||
const currentCount = Math.round(eased * TARGET);
|
||||
|
||||
// Progress bar fill
|
||||
const barProgress = interpolate(frame, [0.2 * fps, 2.2 * fps], [0, TARGET / TOTAL], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Gold glow pulse
|
||||
const glowPulse = interpolate(
|
||||
Math.sin(frame * 0.08),
|
||||
[-1, 1],
|
||||
[0.15, 0.3]
|
||||
);
|
||||
|
||||
// Number glow intensifies as count goes up
|
||||
const numberGlow = interpolate(counterProgress, [0, 1], [20, 60]);
|
||||
|
||||
// Sub-text reveal
|
||||
const subOpacity = interpolate(frame, [1.5 * fps, 2 * fps], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Gold radial glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "30%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "120%",
|
||||
height: "70%",
|
||||
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 65%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 32,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Giant counter */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 220,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.gold,
|
||||
lineHeight: 1,
|
||||
letterSpacing: -8,
|
||||
textShadow: `0 0 ${numberGlow}px rgba(255, 215, 0, 0.8), 0 0 ${numberGlow * 2}px rgba(255, 215, 0, 0.3)`,
|
||||
}}
|
||||
>
|
||||
{currentCount}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
style={{
|
||||
width: 900,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
background: "#1A1A1A",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${barProgress * 100}%`,
|
||||
height: "100%",
|
||||
borderRadius: 6,
|
||||
background: "linear-gradient(90deg, #FFD700 0%, #FFA500 100%)",
|
||||
boxShadow: "0 0 20px rgba(255, 215, 0, 0.5)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Count label */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.text,
|
||||
opacity: subOpacity,
|
||||
letterSpacing: 2,
|
||||
}}
|
||||
>
|
||||
{currentCount} / {TOTAL} stadiums
|
||||
</div>
|
||||
|
||||
{/* League pills */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{LEAGUE_PILLS.map((pill, i) => {
|
||||
const pillSpring = spring({
|
||||
frame: frame - 2 * fps - pill.delay,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 200 },
|
||||
});
|
||||
const pillScale = frame - 2 * fps - pill.delay < 0
|
||||
? 0
|
||||
: interpolate(pillSpring, [0, 1], [0.5, 1]);
|
||||
const pillOpacity = frame - 2 * fps - pill.delay < 0
|
||||
? 0
|
||||
: interpolate(pillSpring, [0, 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
background: "rgba(255, 215, 0, 0.1)",
|
||||
border: "2px solid rgba(255, 215, 0, 0.6)",
|
||||
borderRadius: 24,
|
||||
padding: "10px 28px",
|
||||
transform: `scale(${pillScale})`,
|
||||
opacity: pillOpacity,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.gold,
|
||||
letterSpacing: 2,
|
||||
}}
|
||||
>
|
||||
{pill.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilmGrain opacity={0.05} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene 3 : STADIUM MAP (5.5 - 9s, 105 frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPORTS_EMOJI = ["\u26BE", "\uD83C\uDFC8", "\uD83C\uDFC0", "\uD83C\uDFD2"];
|
||||
const TOTAL_CIRCLES = 30;
|
||||
const FILLED_COUNT = 24;
|
||||
const COLS = 5;
|
||||
const ROWS = 6;
|
||||
|
||||
const StadiumMapScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Circles fill one by one, 2 frames apart
|
||||
const filledSoFar = Math.min(
|
||||
FILLED_COUNT,
|
||||
Math.max(0, Math.floor((frame - 0.3 * fps) / 2))
|
||||
);
|
||||
|
||||
// Gold radial glow pulse
|
||||
const glowPulse = interpolate(
|
||||
Math.sin(frame * 0.06),
|
||||
[-1, 1],
|
||||
[0.12, 0.25]
|
||||
);
|
||||
|
||||
// Running count text
|
||||
const countSpring = spring({
|
||||
frame: Math.max(0, frame - 0.3 * fps),
|
||||
fps,
|
||||
config: { damping: 30, stiffness: 80 },
|
||||
});
|
||||
const countOpacity = interpolate(frame, [0.2 * fps, 0.5 * fps], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Gold radial glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "120%",
|
||||
height: "70%",
|
||||
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${glowPulse}) 0%, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Running count in top-right */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
right: 80,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 64,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.gold,
|
||||
opacity: countOpacity,
|
||||
textShadow: "0 0 30px rgba(255, 215, 0, 0.6)",
|
||||
}}
|
||||
>
|
||||
{filledSoFar}
|
||||
</div>
|
||||
|
||||
{/* Stadium grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 36,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: ROWS }).map((_, row) => (
|
||||
<div key={row} style={{ display: "flex", gap: 48 }}>
|
||||
{Array.from({ length: COLS }).map((_, col) => {
|
||||
const idx = row * COLS + col;
|
||||
const isFilled = idx < filledSoFar;
|
||||
const isLatest = idx === filledSoFar - 1 && filledSoFar > 0;
|
||||
const emojiChar = SPORTS_EMOJI[idx % SPORTS_EMOJI.length];
|
||||
|
||||
// Pop spring for fill
|
||||
const fillSpring = isFilled
|
||||
? spring({
|
||||
frame: Math.max(0, frame - 0.3 * fps - idx * 2),
|
||||
fps,
|
||||
config: { damping: 8, stiffness: 300 },
|
||||
})
|
||||
: 0;
|
||||
const circleScale = isFilled
|
||||
? interpolate(fillSpring, [0, 1], [0.3, 1])
|
||||
: 1;
|
||||
|
||||
// Pulse for the latest filled circle
|
||||
const pulseScale = isLatest
|
||||
? 1 + 0.08 * Math.sin(frame * 0.3)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
background: isFilled
|
||||
? theme.colors.gold
|
||||
: "transparent",
|
||||
border: isFilled
|
||||
? "3px solid rgba(255, 215, 0, 0.9)"
|
||||
: "3px solid rgba(128, 128, 128, 0.3)",
|
||||
boxShadow: isFilled
|
||||
? `0 0 ${isLatest ? 28 : 16}px rgba(255, 215, 0, ${isLatest ? 0.8 : 0.4})`
|
||||
: "none",
|
||||
transform: `scale(${circleScale * pulseScale})`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isFilled && (
|
||||
<span style={{ fontSize: 28, lineHeight: 1 }}>
|
||||
{emojiChar}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FilmGrain opacity={0.05} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene 4 : TRACKER APP (9 - 12s, 90 frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TRACKER_ROWS = [
|
||||
{ stadium: "Dodger Stadium", city: "LA", date: "Apr 12" },
|
||||
{ stadium: "Fenway Park", city: "Boston", date: "May 3" },
|
||||
{ stadium: "Wrigley Field", city: "Chicago", date: "May 18" },
|
||||
{ stadium: "Yankee Stadium", city: "NYC", date: "Jun 7" },
|
||||
{ stadium: "Oracle Park", city: "SF", date: "Jun 22" },
|
||||
{ stadium: "Coors Field", city: "Denver", date: "Jul 4" },
|
||||
{ stadium: "PNC Park", city: "Pittsburgh", date: "Jul 19" },
|
||||
{ stadium: "Camden Yards", city: "Baltimore", date: "Aug 1" },
|
||||
{ stadium: "Minute Maid Park", city: "Houston", date: "Aug 14" },
|
||||
{ stadium: "T-Mobile Park", city: "Seattle", date: "Sep 2" },
|
||||
];
|
||||
|
||||
const TrackerAppScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Slow zoom drift on phone 1.0 -> 1.02
|
||||
const zoomDrift = interpolate(frame, [0, 3 * fps], [1.0, 1.02], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Scroll offset: rows slide up over time
|
||||
const scrollOffset = interpolate(frame, [0.3 * fps, 2.8 * fps], [0, 280], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Phone entrance
|
||||
const phoneSpring = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 120 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneSpring, [0, 1], [0.85, 1]);
|
||||
const phoneOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Phone frame */}
|
||||
<div
|
||||
style={{
|
||||
width: 440,
|
||||
height: 920,
|
||||
borderRadius: 60,
|
||||
background: "#1C1C1E",
|
||||
border: "4px solid #333",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
transform: `scale(${phoneScale * zoomDrift})`,
|
||||
opacity: phoneOpacity,
|
||||
boxShadow: "0 40px 80px rgba(0,0,0,0.6), 0 0 60px rgba(255,215,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{/* Dynamic Island */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 14,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 130,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
background: "#000",
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status bar area */}
|
||||
<div
|
||||
style={{
|
||||
height: 70,
|
||||
background: "rgba(10,10,10,0.95)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* App header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 28px 12px",
|
||||
background: "rgba(10,10,10,0.95)",
|
||||
borderBottom: "1px solid rgba(255,215,0,0.15)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 26,
|
||||
fontWeight: 800,
|
||||
color: theme.colors.gold,
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
Stadium Tracker
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
47 of 120 visited
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrolling list */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
height: 700,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(-${scrollOffset}px)`,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{TRACKER_ROWS.map((row, i) => {
|
||||
const rowDelay = i * 3;
|
||||
const rowSpring = spring({
|
||||
frame: Math.max(0, frame - 0.2 * fps - rowDelay),
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 180 },
|
||||
});
|
||||
const rowOpacity = frame - 0.2 * fps - rowDelay < 0
|
||||
? 0
|
||||
: interpolate(rowSpring, [0, 0.4], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const rowX = interpolate(rowSpring, [0, 1], [40, 0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "16px 28px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
opacity: rowOpacity,
|
||||
transform: `translateX(${rowX}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Check badge */}
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
background: "rgba(255,215,0,0.15)",
|
||||
border: "2px solid rgba(255,215,0,0.5)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 16,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: theme.colors.gold,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{"\u2713"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stadium info */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{row.stadium}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{row.city}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{row.date}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilmGrain opacity={0.04} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene 5 : CTA (12 - 15s, 90 frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CTAScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// "Drop your number." slam
|
||||
const headlineSlam = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 260 },
|
||||
});
|
||||
const headlineScale = interpolate(headlineSlam, [0, 1], [2, 1]);
|
||||
const headlineOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// "Search SportsTime" text
|
||||
const subProgress = spring({
|
||||
frame: frame - 0.8 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const subOpacity = interpolate(
|
||||
frame - 0.8 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const subY = interpolate(subProgress, [0, 1], [20, 0]);
|
||||
|
||||
// App icon
|
||||
const iconProgress = spring({
|
||||
frame: frame - 0.4 * fps,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 140 },
|
||||
});
|
||||
const iconScale = interpolate(iconProgress, [0, 1], [0.4, 1]);
|
||||
const iconOpacity = interpolate(
|
||||
frame - 0.4 * fps,
|
||||
[0, 0.15 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Subtle golden glow behind everything
|
||||
const bgGlow = interpolate(frame, [0, 0.5 * fps], [0, 0.15], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Search icon subtle bounce
|
||||
const searchBounce = frame - 0.8 * fps > 0
|
||||
? 1 + 0.03 * Math.sin((frame - 0.8 * fps) * 0.15)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Background glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "130%",
|
||||
height: "60%",
|
||||
background: `radial-gradient(ellipse, rgba(255, 215, 0, ${bgGlow}) 0%, transparent 65%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 40,
|
||||
padding: 60,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* App icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 28,
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
||||
boxShadow: "0 16px 48px rgba(255, 107, 53, 0.4)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: iconOpacity,
|
||||
transform: `scale(${iconScale})`,
|
||||
}}
|
||||
>
|
||||
<svg width="64" height="64" viewBox="0 0 100 100" fill="none">
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="60"
|
||||
rx="40"
|
||||
ry="20"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10 60 L10 40 Q50 10 90 40 L90 60"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1="50"
|
||||
y1="30"
|
||||
x2="50"
|
||||
y2="15"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle cx="50" cy="12" r="4" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* "Drop your number." */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 64,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.gold,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: -2,
|
||||
transform: `scale(${headlineScale})`,
|
||||
opacity: headlineOpacity,
|
||||
textShadow:
|
||||
"0 0 40px rgba(255, 215, 0, 0.5), 0 4px 16px rgba(0,0,0,0.8)",
|
||||
}}
|
||||
>
|
||||
Drop your number.
|
||||
</div>
|
||||
|
||||
{/* Search CTA */}
|
||||
<div
|
||||
style={{
|
||||
opacity: subOpacity,
|
||||
transform: `translateY(${subY}px) scale(${searchBounce})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{/* Search icon */}
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.7)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 36,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
Search{" "}
|
||||
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
|
||||
SportsTime
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilmGrain opacity={0.05} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* VIDEO 1: "Stadium Count Flex"
|
||||
*
|
||||
* Competitive flex / brag video for TikTok.
|
||||
* Gold + dark scoreboard aesthetic. ESPN stat graphics meets TikTok energy.
|
||||
* 1080x1920, 30fps, 15 seconds (450 frames).
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:02.5 (0-75): HOOK - "Be honest. How many stadiums have you been to?"
|
||||
* - 0:02.5-0:05.5 (75-165): THE COUNTER - Number animates 0 -> 47
|
||||
* - 0:05.5-0:09 (165-270): STADIUM MAP - Grid of circles filling up
|
||||
* - 0:09-0:12 (270-360): TRACKER APP - Simulated phone screen
|
||||
* - 0:12-0:15 (360-450): CTA - "Drop your number."
|
||||
*/
|
||||
export const StadiumCountFlex: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION_DURATION = 10;
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
hook: Math.round(2.5 * fps), // 75 frames
|
||||
counter: Math.round(3 * fps), // 90 frames
|
||||
stadiumMap: Math.round(3.5 * fps), // 105 frames
|
||||
trackerApp: Math.round(3 * fps), // 90 frames
|
||||
cta: Math.round(3 * fps), // 90 frames
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: HOOK */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hook}>
|
||||
<HookScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: THE COUNTER */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.counter}>
|
||||
<CounterScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-bottom" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: STADIUM MAP */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stadiumMap}>
|
||||
<StadiumMapScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: TRACKER APP */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.trackerApp}>
|
||||
<TrackerAppScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: CTA */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.cta}>
|
||||
<CTAScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
|
||||
{/* TikTok caption overlay on top of everything */}
|
||||
<TikTokCaption captions={CAPTIONS} bottomOffset={260} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
274
marketing-videos/src/videos/TheFanTest/FollowTeamScene.tsx
Normal file
274
marketing-videos/src/videos/TheFanTest/FollowTeamScene.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
||||
|
||||
/**
|
||||
* Scene 2: Follow Team mode selection
|
||||
*
|
||||
* Shows the planning mode UI inside a phone frame with "Follow Team" being selected.
|
||||
* On-screen text: "Follow Team mode"
|
||||
*/
|
||||
export const FollowTeamScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// "Follow Team mode" on-screen text overlay (outside phone)
|
||||
const labelBadgeProgress = spring({
|
||||
frame: frame - 1.2 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const labelBadgeOpacity = interpolate(
|
||||
frame - 1.2 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Phone frame with app UI */}
|
||||
<AppScreenshot delay={0} scale={0.88}>
|
||||
<MockScreen>
|
||||
<FollowTeamScreenContent />
|
||||
</MockScreen>
|
||||
</AppScreenshot>
|
||||
|
||||
{/* On-screen label: "Follow Team mode" - overlaid outside phone */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 120,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: labelBadgeOpacity,
|
||||
transform: `scale(${interpolate(labelBadgeProgress, [0, 1], [0.8, 1])})`,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: theme.colors.accent,
|
||||
padding: "14px 36px",
|
||||
borderRadius: 40,
|
||||
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Follow Team mode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Inner screen content rendered inside the phone frame */
|
||||
const FollowTeamScreenContent: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const modes = [
|
||||
{
|
||||
name: "Explore Region",
|
||||
desc: "Discover games in an area",
|
||||
icon: "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5",
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
name: "Follow Team",
|
||||
desc: "Chase your team on the road",
|
||||
icon: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: "Custom Trip",
|
||||
desc: "Build your own route",
|
||||
icon: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7",
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Header entrance
|
||||
const labelProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const labelOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const labelY = interpolate(labelProgress, [0, 1], [20, 0]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
opacity: labelOpacity,
|
||||
transform: `translateY(${labelY}px)`,
|
||||
marginBottom: 36,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 34,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Planning Mode
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
How do you want to plan?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode cards */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{modes.map((mode, index) => {
|
||||
const cardProgress = spring({
|
||||
frame: frame - (0.2 + index * 0.12) * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const scale = interpolate(cardProgress, [0, 1], [0.92, 1]);
|
||||
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Follow Team gets selected
|
||||
const selectProgress = spring({
|
||||
frame: frame - 0.9 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const isSelected = mode.selected && selectProgress > 0.5;
|
||||
|
||||
const borderColor = isSelected ? theme.colors.accent : "transparent";
|
||||
const bgColor = isSelected ? `rgba(255, 107, 53, 0.12)` : "#1C1C1E";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mode.name}
|
||||
style={{
|
||||
background: bgColor,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 18,
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
border: `3px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: isSelected
|
||||
? theme.colors.accent
|
||||
: "rgba(255,255,255,0.08)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={isSelected ? "white" : theme.colors.textSecondary}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d={mode.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: isSelected ? theme.colors.accent : theme.colors.text,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{mode.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{mode.desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected checkmark */}
|
||||
{isSelected && (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="12" fill={theme.colors.accent} />
|
||||
<path
|
||||
d="M7 12l3 3 7-7"
|
||||
stroke="white"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
175
marketing-videos/src/videos/TheFanTest/HotTakeScene.tsx
Normal file
175
marketing-videos/src/videos/TheFanTest/HotTakeScene.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
/**
|
||||
* Scene 1: Bold "HOT TAKE" hook
|
||||
*
|
||||
* Full-screen provocative text that grabs attention instantly.
|
||||
* "If you've never done an away-game road trip... are you even a fan?"
|
||||
*/
|
||||
export const HotTakeScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// "HOT TAKE" badge slam
|
||||
const badgeSlam = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 200 },
|
||||
});
|
||||
|
||||
const badgeScale = interpolate(badgeSlam, [0, 1], [3, 1]);
|
||||
const badgeOpacity = interpolate(frame, [0, 0.15 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Main text reveal (staggered lines)
|
||||
const line1Progress = spring({
|
||||
frame: frame - 0.4 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const line1Opacity = interpolate(
|
||||
frame - 0.4 * fps,
|
||||
[0, 0.25 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const line1Y = interpolate(line1Progress, [0, 1], [40, 0]);
|
||||
|
||||
const line2Progress = spring({
|
||||
frame: frame - 0.7 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const line2Opacity = interpolate(
|
||||
frame - 0.7 * fps,
|
||||
[0, 0.25 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const line2Y = interpolate(line2Progress, [0, 1], [40, 0]);
|
||||
|
||||
// Emphasis pulse on "are you even a fan?"
|
||||
const emphasisPulse = spring({
|
||||
frame: frame - 1.2 * fps,
|
||||
fps,
|
||||
config: { damping: 8, stiffness: 150 },
|
||||
});
|
||||
const emphasisScale = interpolate(emphasisPulse, [0, 0.5, 1], [0.8, 1.08, 1]);
|
||||
|
||||
// Subtle background pulse
|
||||
const bgPulse = interpolate(
|
||||
frame,
|
||||
[0, 0.15 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Red flash on slam */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at center, rgba(255, 50, 50, ${0.15 * bgPulse}) 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content container */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 40,
|
||||
padding: 60,
|
||||
}}
|
||||
>
|
||||
{/* HOT TAKE badge */}
|
||||
<div
|
||||
style={{
|
||||
opacity: badgeOpacity,
|
||||
transform: `scale(${badgeScale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#FF2222",
|
||||
padding: "16px 48px",
|
||||
borderRadius: 12,
|
||||
boxShadow: "0 8px 32px rgba(255, 34, 34, 0.5)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 52,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: 6,
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
HOT TAKE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main provocative text */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: line1Opacity,
|
||||
transform: `translateY(${line1Y}px)`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 46,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
If you've never done an{"\n"}away-game road trip...
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: line2Opacity,
|
||||
transform: `translateY(${line2Y}px) scale(${emphasisScale})`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 54,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.accent,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
are you even a fan?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
269
marketing-videos/src/videos/TheFanTest/ReactionScene.tsx
Normal file
269
marketing-videos/src/videos/TheFanTest/ReactionScene.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
/**
|
||||
* Scene 5: "This is your weekend." reaction + CTA
|
||||
*
|
||||
* Emotional payoff moment with the CTA:
|
||||
* "Search SportsTime and run your team's road stretch."
|
||||
*/
|
||||
export const ReactionScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// "This is your weekend." text slam
|
||||
const headlineSlam = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 160 },
|
||||
});
|
||||
const headlineScale = interpolate(headlineSlam, [0, 1], [1.5, 1]);
|
||||
const headlineOpacity = interpolate(frame, [0, 0.2 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// VO text line
|
||||
const voProgress = spring({
|
||||
frame: frame - 0.8 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const voOpacity = interpolate(
|
||||
frame - 0.8 * fps,
|
||||
[0, 0.25 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const voY = interpolate(voProgress, [0, 1], [20, 0]);
|
||||
|
||||
// CTA entrance
|
||||
const ctaProgress = spring({
|
||||
frame: frame - 1.5 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const ctaOpacity = interpolate(
|
||||
frame - 1.5 * fps,
|
||||
[0, 0.25 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const ctaScale = interpolate(ctaProgress, [0, 1], [0.85, 1]);
|
||||
|
||||
// App icon entrance
|
||||
const iconProgress = spring({
|
||||
frame: frame - 1.8 * fps,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
});
|
||||
const iconScale = interpolate(iconProgress, [0, 1], [0.5, 1]);
|
||||
const iconOpacity = interpolate(
|
||||
frame - 1.8 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Wordmark
|
||||
const wordmarkProgress = spring({
|
||||
frame: frame - 2.1 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const wordmarkOpacity = interpolate(
|
||||
frame - 2.1 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Background glow
|
||||
const glowOpacity = interpolate(
|
||||
frame,
|
||||
[0, 0.5 * fps],
|
||||
[0, 0.12],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Background accent glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "25%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "140%",
|
||||
height: "60%",
|
||||
background: `radial-gradient(ellipse, ${theme.colors.accent} 0%, transparent 70%)`,
|
||||
opacity: glowOpacity,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 48,
|
||||
padding: 60,
|
||||
}}
|
||||
>
|
||||
{/* "This is your weekend." */}
|
||||
<div
|
||||
style={{
|
||||
opacity: headlineOpacity,
|
||||
transform: `scale(${headlineScale})`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 64,
|
||||
fontWeight: 900,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: -2,
|
||||
}}
|
||||
>
|
||||
This is your{"\n"}weekend.
|
||||
</div>
|
||||
|
||||
{/* VO callout text */}
|
||||
<div
|
||||
style={{
|
||||
opacity: voOpacity,
|
||||
transform: `translateY(${voY}px)`,
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 26,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.5,
|
||||
maxWidth: 800,
|
||||
}}
|
||||
>
|
||||
Do at least one away-game run{"\n"}this season.
|
||||
</div>
|
||||
|
||||
{/* App icon + wordmark */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 20,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
{/* App Icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 28,
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
||||
boxShadow: `0 16px 48px rgba(255, 107, 53, 0.4)`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: iconOpacity,
|
||||
transform: `scale(${iconScale})`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
>
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="60"
|
||||
rx="40"
|
||||
ry="20"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10 60 L10 40 Q50 10 90 40 L90 60"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1="50"
|
||||
y1="30"
|
||||
x2="50"
|
||||
y2="15"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle cx="50" cy="12" r="4" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* SportsTime wordmark */}
|
||||
<div
|
||||
style={{
|
||||
opacity: wordmarkOpacity,
|
||||
transform: `translateY(${interpolate(wordmarkProgress, [0, 1], [10, 0])}px)`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
SportsTime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA text */}
|
||||
<div
|
||||
style={{
|
||||
opacity: ctaOpacity,
|
||||
transform: `scale(${ctaScale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: 16,
|
||||
padding: "20px 40px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Search{" "}
|
||||
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
|
||||
SportsTime
|
||||
</span>{" "}
|
||||
on the App Store
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
321
marketing-videos/src/videos/TheFanTest/RoadGamesScene.tsx
Normal file
321
marketing-videos/src/videos/TheFanTest/RoadGamesScene.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
||||
|
||||
/**
|
||||
* Scene 3: Road games surfaced
|
||||
*
|
||||
* Shows upcoming away games inside a phone frame, cards sliding in.
|
||||
* On-screen text: "Plan it in seconds"
|
||||
*/
|
||||
|
||||
type GameCard = {
|
||||
opponent: string;
|
||||
opponentColor: string;
|
||||
date: string;
|
||||
venue: string;
|
||||
city: string;
|
||||
};
|
||||
|
||||
const ROAD_GAMES: GameCard[] = [
|
||||
{
|
||||
opponent: "@ Dodgers",
|
||||
opponentColor: "#005A9C",
|
||||
date: "Fri, Jun 12",
|
||||
venue: "Dodger Stadium",
|
||||
city: "Los Angeles, CA",
|
||||
},
|
||||
{
|
||||
opponent: "@ Giants",
|
||||
opponentColor: "#FD5A1E",
|
||||
date: "Sun, Jun 14",
|
||||
venue: "Oracle Park",
|
||||
city: "San Francisco, CA",
|
||||
},
|
||||
{
|
||||
opponent: "@ Padres",
|
||||
opponentColor: "#2F241D",
|
||||
date: "Tue, Jun 16",
|
||||
venue: "Petco Park",
|
||||
city: "San Diego, CA",
|
||||
},
|
||||
{
|
||||
opponent: "@ D-backs",
|
||||
opponentColor: "#A71930",
|
||||
date: "Thu, Jun 18",
|
||||
venue: "Chase Field",
|
||||
city: "Phoenix, AZ",
|
||||
},
|
||||
];
|
||||
|
||||
export const RoadGamesScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// "Plan it in seconds" label overlay (outside phone)
|
||||
const planLabelProgress = spring({
|
||||
frame: frame - 2 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const planLabelOpacity = interpolate(
|
||||
frame - 2 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Phone frame with app UI */}
|
||||
<AppScreenshot delay={0} scale={0.88}>
|
||||
<MockScreen>
|
||||
<RoadGamesScreenContent />
|
||||
</MockScreen>
|
||||
</AppScreenshot>
|
||||
|
||||
{/* "Plan it in seconds" label - overlaid outside phone */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 120,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: planLabelOpacity,
|
||||
transform: `scale(${interpolate(planLabelProgress, [0, 1], [0.8, 1])})`,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: theme.colors.accent,
|
||||
padding: "14px 36px",
|
||||
borderRadius: 40,
|
||||
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Plan it in seconds
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Inner screen content rendered inside the phone frame */
|
||||
const RoadGamesScreenContent: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Header entrance
|
||||
const headerProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const headerOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const headerY = interpolate(headerProgress, [0, 1], [20, 0]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
opacity: headerOpacity,
|
||||
transform: `translateY(${headerY}px)`,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
{/* Team badge */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: "#C9082A",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 16,
|
||||
fontWeight: 900,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
ATL
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Braves Road Games
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
June 2026 away stretch
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game cards */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{ROAD_GAMES.map((game, index) => {
|
||||
const cardDelay = 0.3 + index * 0.2;
|
||||
const cardProgress = spring({
|
||||
frame: frame - cardDelay * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const translateX = interpolate(cardProgress, [0, 1], [300, 0]);
|
||||
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={game.venue}
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
opacity,
|
||||
borderLeft: `4px solid ${game.opponentColor}`,
|
||||
}}
|
||||
>
|
||||
{/* Date block */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 13,
|
||||
color: theme.colors.textMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{game.date.split(", ")[0]}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{game.date.split(" ")[1].replace(",", "")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
style={{
|
||||
width: 2,
|
||||
height: 40,
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Game info */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 3,
|
||||
}}
|
||||
>
|
||||
{game.opponent}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 15,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{game.venue}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 13,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{game.city}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
301
marketing-videos/src/videos/TheFanTest/RouteItineraryScene.tsx
Normal file
301
marketing-videos/src/videos/TheFanTest/RouteItineraryScene.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
||||
|
||||
/**
|
||||
* Scene 4: Route + itinerary shown
|
||||
*
|
||||
* Animated route line connecting cities with itinerary card, inside phone frame.
|
||||
* Shows the full trip: LA -> SF -> SD -> PHX
|
||||
*/
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
abbr: string;
|
||||
x: number;
|
||||
y: number;
|
||||
game: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const STOPS: Stop[] = [
|
||||
{ city: "Los Angeles", abbr: "LAX", x: 200, y: 340, game: "vs Dodgers", date: "Jun 12" },
|
||||
{ city: "San Francisco", abbr: "SFO", x: 160, y: 160, game: "vs Giants", date: "Jun 14" },
|
||||
{ city: "San Diego", abbr: "SAN", x: 280, y: 440, game: "vs Padres", date: "Jun 16" },
|
||||
{ city: "Phoenix", abbr: "PHX", x: 550, y: 380, game: "vs D-backs", date: "Jun 18" },
|
||||
];
|
||||
|
||||
export const RouteItineraryScene: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Phone frame with app UI */}
|
||||
<AppScreenshot delay={0} scale={0.88}>
|
||||
<MockScreen>
|
||||
<RouteScreenContent />
|
||||
</MockScreen>
|
||||
</AppScreenshot>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Inner screen content rendered inside the phone frame */
|
||||
const RouteScreenContent: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Route line draw progress
|
||||
const lineProgress = interpolate(
|
||||
frame,
|
||||
[0.2 * fps, 1.8 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Itinerary card at bottom
|
||||
const cardProgress = spring({
|
||||
frame: frame - 2 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const cardY = interpolate(cardProgress, [0, 1], [150, 0]);
|
||||
const cardOpacity = interpolate(
|
||||
frame - 2 * fps,
|
||||
[0, 0.3 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Build SVG path segments
|
||||
const pathSegments: string[] = [];
|
||||
for (let i = 0; i < STOPS.length - 1; i++) {
|
||||
const from = STOPS[i];
|
||||
const to = STOPS[i + 1];
|
||||
if (i === 0) {
|
||||
pathSegments.push(`M ${from.x} ${from.y}`);
|
||||
}
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = Math.min(from.y, to.y) - 40;
|
||||
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
|
||||
}
|
||||
const fullPath = pathSegments.join(" ");
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
{/* Map area */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 12,
|
||||
right: 12,
|
||||
height: "60%",
|
||||
}}
|
||||
>
|
||||
{/* Subtle map grid */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "50px 50px",
|
||||
borderRadius: 16,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Route line SVG */}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 700 550"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
>
|
||||
{/* Route line background (dimmed) */}
|
||||
<path
|
||||
d={fullPath}
|
||||
fill="none"
|
||||
stroke="rgba(255, 107, 53, 0.15)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Route line animated */}
|
||||
<path
|
||||
d={fullPath}
|
||||
fill="none"
|
||||
stroke={theme.colors.accent}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="2000"
|
||||
strokeDashoffset={2000 * (1 - lineProgress)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Stop markers */}
|
||||
{STOPS.map((stop, index) => {
|
||||
const markerDelay = 0.3 + index * 0.4;
|
||||
const markerProgress = spring({
|
||||
frame: frame - markerDelay * fps,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 180 },
|
||||
});
|
||||
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
|
||||
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
|
||||
|
||||
const leftPct = (stop.x / 700) * 100;
|
||||
const topPct = (stop.y / 550) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stop.abbr}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
transform: `translate(-50%, -50%) scale(${markerScale})`,
|
||||
opacity: markerOpacity,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{/* Ping ring */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${theme.colors.accent}`,
|
||||
opacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
{/* Marker dot */}
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.secondary,
|
||||
border: "3px solid white",
|
||||
boxShadow: `0 4px 12px rgba(78, 205, 196, 0.5)`,
|
||||
}}
|
||||
/>
|
||||
{/* City label */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
textShadow: "0 2px 8px rgba(0,0,0,0.8)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{stop.city}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Itinerary card at bottom */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: 16,
|
||||
right: 16,
|
||||
opacity: cardOpacity,
|
||||
transform: `translateY(${cardY}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
border: `2px solid rgba(255, 107, 53, 0.3)`,
|
||||
}}
|
||||
>
|
||||
{/* Card header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
West Coast Run
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 15,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
7 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
{[
|
||||
{ label: "Games", value: "4" },
|
||||
{ label: "Cities", value: "4" },
|
||||
{ label: "Miles", value: "1,240" },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} style={{ flex: 1, textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
marketing-videos/src/videos/TheFanTest/index.tsx
Normal file
97
marketing-videos/src/videos/TheFanTest/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useVideoConfig } from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
import { HotTakeScene } from "./HotTakeScene";
|
||||
import { FollowTeamScene } from "./FollowTeamScene";
|
||||
import { RoadGamesScene } from "./RoadGamesScene";
|
||||
import { RouteItineraryScene } from "./RouteItineraryScene";
|
||||
import { ReactionScene } from "./ReactionScene";
|
||||
import { GradientBackground } from "../../components/shared";
|
||||
|
||||
/**
|
||||
* V02: "The Fan Test" (Viral)
|
||||
*
|
||||
* Hook: "If you've never done an away-game road trip... are you even a fan?"
|
||||
* Concept: Identity challenge -> Follow Team demo.
|
||||
* Length: 18 seconds (540 frames at 30fps)
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:03 (0-90): Bold HOT TAKE hook
|
||||
* - 0:03-0:06 (90-180): Follow Team mode selection
|
||||
* - 0:06-0:10 (180-300): Road games surfaced
|
||||
* - 0:10-0:14 (300-420): Route + itinerary shown
|
||||
* - 0:14-0:18 (420-540): "This is your weekend." reaction + CTA
|
||||
*
|
||||
* On-screen text: "HOT TAKE", "Follow Team mode", "Plan it in seconds"
|
||||
* CTA: "Search SportsTime and run your team's road stretch."
|
||||
* Why it performs: debate comments + identity trigger.
|
||||
*/
|
||||
export const TheFanTest: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION_DURATION = 12;
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
hotTake: 3 * fps, // 90 frames
|
||||
followTeam: 3 * fps, // 90 frames
|
||||
roadGames: 4 * fps, // 120 frames
|
||||
routeItinerary: 4 * fps, // 120 frames
|
||||
reaction: 4 * fps, // 120 frames
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<GradientBackground />
|
||||
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: HOT TAKE hook */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.hotTake}>
|
||||
<HotTakeScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: Follow Team mode selection */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.followTeam}>
|
||||
<FollowTeamScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-right" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: Road games surfaced */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.roadGames}>
|
||||
<RoadGamesScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: Route + itinerary */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.routeItinerary}>
|
||||
<RouteItineraryScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: Reaction + CTA */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reaction}>
|
||||
<ReactionScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
189
marketing-videos/src/videos/TheGroupChat/AllTalkScene.tsx
Normal file
189
marketing-videos/src/videos/TheGroupChat/AllTalkScene.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
/**
|
||||
* Scene 2: "All talk" - the chat dies
|
||||
*
|
||||
* Shows the last few messages then silence.
|
||||
* Typing indicator appears... then vanishes.
|
||||
* Bold overlay: "All talk."
|
||||
*/
|
||||
export const AllTalkScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Typing indicator dots animation
|
||||
const typingAppear = interpolate(
|
||||
frame,
|
||||
[0.2 * fps, 0.4 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const typingDisappear = interpolate(
|
||||
frame,
|
||||
[1 * fps, 1.2 * fps],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const typingOpacity = typingAppear * typingDisappear;
|
||||
|
||||
// Dot bounce cycle (frame-based, not CSS)
|
||||
const dotBounce = (dotIndex: number) => {
|
||||
const cycle = ((frame + dotIndex * 4) % 18) / 18;
|
||||
return interpolate(cycle, [0, 0.5, 1], [0, -6, 0]);
|
||||
};
|
||||
|
||||
// "All talk." slam
|
||||
const slamDelay = 1.3 * fps;
|
||||
const slamProgress = spring({
|
||||
frame: frame - slamDelay,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 200 },
|
||||
});
|
||||
const slamScale = interpolate(slamProgress, [0, 1], [2.5, 1]);
|
||||
const slamOpacity = interpolate(
|
||||
frame - slamDelay,
|
||||
[0, 0.1 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#000000" }}>
|
||||
{/* Stale chat messages (static, from previous scene context) */}
|
||||
<div
|
||||
style={{
|
||||
padding: "120px 24px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* Last visible messages */}
|
||||
<ChatBubble sender="Sam" color="#AF52DE" text="Maybe June?" isMe={false} />
|
||||
<ChatBubble sender="Jake" color="#34C759" text="Or July" isMe={false} />
|
||||
<ChatBubble sender="Mike" color="#FF9500" text="Whatever works" isMe={false} />
|
||||
<ChatBubble sender="" color="#007AFF" text="Ok so..." isMe={true} />
|
||||
|
||||
{/* Typing indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
opacity: typingOpacity,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#2C2C2E",
|
||||
borderRadius: 20,
|
||||
borderBottomLeftRadius: 6,
|
||||
padding: "14px 20px",
|
||||
display: "flex",
|
||||
gap: 5,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: "rgba(255,255,255,0.4)",
|
||||
transform: `translateY(${dotBounce(i)}px)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "All talk." overlay slam */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity: slamOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${slamScale})`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 80,
|
||||
fontWeight: 900,
|
||||
color: "white",
|
||||
letterSpacing: -3,
|
||||
textShadow: "0 4px 40px rgba(0,0,0,0.8)",
|
||||
}}
|
||||
>
|
||||
All talk.
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Simple static chat bubble (no animation) */
|
||||
const ChatBubble: React.FC<{
|
||||
sender: string;
|
||||
color: string;
|
||||
text: string;
|
||||
isMe: boolean;
|
||||
}> = ({ sender, color, text, isMe }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: isMe ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
{!isMe && sender && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 13,
|
||||
color,
|
||||
marginBottom: 2,
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
{sender}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
background: isMe ? "#007AFF" : "#2C2C2E",
|
||||
borderRadius: 20,
|
||||
borderBottomRightRadius: isMe ? 6 : 20,
|
||||
borderBottomLeftRadius: isMe ? 20 : 6,
|
||||
padding: "12px 18px",
|
||||
maxWidth: "75%",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: "white",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
215
marketing-videos/src/videos/TheGroupChat/CTAScene.tsx
Normal file
215
marketing-videos/src/videos/TheGroupChat/CTAScene.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
/**
|
||||
* Scene 6: CTA ending
|
||||
*
|
||||
* Clean branded ending.
|
||||
* "If your group chat is all talk → SportsTime"
|
||||
*/
|
||||
export const CTAScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Text line 1
|
||||
const line1Progress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const line1Opacity = interpolate(frame, [0, 0.25 * fps], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const line1Y = interpolate(line1Progress, [0, 1], [30, 0]);
|
||||
|
||||
// Arrow / divider
|
||||
const arrowProgress = spring({
|
||||
frame: frame - 0.5 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const arrowOpacity = interpolate(
|
||||
frame - 0.5 * fps,
|
||||
[0, 0.15 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const arrowScale = interpolate(arrowProgress, [0, 1], [0.5, 1]);
|
||||
|
||||
// App icon + name
|
||||
const brandProgress = spring({
|
||||
frame: frame - 0.8 * fps,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
});
|
||||
const brandScale = interpolate(brandProgress, [0, 1], [0.6, 1]);
|
||||
const brandOpacity = interpolate(
|
||||
frame - 0.8 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Search CTA
|
||||
const ctaProgress = spring({
|
||||
frame: frame - 1.2 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const ctaOpacity = interpolate(
|
||||
frame - 1.2 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Accent glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "30%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "130%",
|
||||
height: "50%",
|
||||
background: `radial-gradient(ellipse, ${theme.colors.accent}18 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 36,
|
||||
padding: 60,
|
||||
}}
|
||||
>
|
||||
{/* Line 1: "If your group chat is all talk" */}
|
||||
<div
|
||||
style={{
|
||||
opacity: line1Opacity,
|
||||
transform: `translateY(${line1Y}px)`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
If your group chat{"\n"}is all talk
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div
|
||||
style={{
|
||||
opacity: arrowOpacity,
|
||||
transform: `scale(${arrowScale})`,
|
||||
}}
|
||||
>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 5v14m0 0l-6-6m6 6l6-6"
|
||||
stroke={theme.colors.accent}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* App icon + name */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
opacity: brandOpacity,
|
||||
transform: `scale(${brandScale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 24,
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
||||
boxShadow: "0 12px 40px rgba(255, 107, 53, 0.4)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="56" height="56" viewBox="0 0 100 100" fill="none">
|
||||
<ellipse cx="50" cy="60" rx="40" ry="20" stroke="white" strokeWidth="4" fill="none" />
|
||||
<path d="M10 60 L10 40 Q50 10 90 40 L90 60" stroke="white" strokeWidth="4" fill="none" />
|
||||
<line x1="50" y1="30" x2="50" y2="15" stroke="white" strokeWidth="3" />
|
||||
<circle cx="50" cy="12" r="4" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 44,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
SportsTime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div
|
||||
style={{
|
||||
opacity: ctaOpacity,
|
||||
transform: `translateY(${interpolate(ctaProgress, [0, 1], [15, 0])}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: 14,
|
||||
padding: "16px 36px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Search{" "}
|
||||
<span style={{ color: theme.colors.accent, fontWeight: 700 }}>
|
||||
SportsTime
|
||||
</span>{" "}
|
||||
on the App Store
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
254
marketing-videos/src/videos/TheGroupChat/ChatScene.tsx
Normal file
254
marketing-videos/src/videos/TheGroupChat/ChatScene.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
/**
|
||||
* Scene 1: iMessage group chat blowing up
|
||||
*
|
||||
* Messages stack in rapid succession with spring pop-ins.
|
||||
* Overlay: "Every group chat ever"
|
||||
* Fast zooms on the chat as messages fly in.
|
||||
*/
|
||||
|
||||
type Message = {
|
||||
sender: string;
|
||||
color: string;
|
||||
text: string;
|
||||
isMe: boolean;
|
||||
delaySeconds: number;
|
||||
};
|
||||
|
||||
const MESSAGES: Message[] = [
|
||||
{ sender: "Jake", color: "#34C759", text: "We should do a baseball trip", isMe: false, delaySeconds: 0.15 },
|
||||
{ sender: "Mike", color: "#FF9500", text: "I'm down", isMe: false, delaySeconds: 0.5 },
|
||||
{ sender: "Sam", color: "#AF52DE", text: "Same", isMe: false, delaySeconds: 0.75 },
|
||||
{ sender: "You", color: "#007AFF", text: "Let's goooo", isMe: true, delaySeconds: 1.0 },
|
||||
{ sender: "Jake", color: "#34C759", text: "When tho", isMe: false, delaySeconds: 1.4 },
|
||||
{ sender: "Mike", color: "#FF9500", text: "idk", isMe: false, delaySeconds: 1.7 },
|
||||
{ sender: "Sam", color: "#AF52DE", text: "Maybe June?", isMe: false, delaySeconds: 2.0 },
|
||||
{ sender: "Jake", color: "#34C759", text: "Or July", isMe: false, delaySeconds: 2.25 },
|
||||
{ sender: "Mike", color: "#FF9500", text: "Whatever works", isMe: false, delaySeconds: 2.45 },
|
||||
{ sender: "You", color: "#007AFF", text: "Ok so...", isMe: true, delaySeconds: 2.7 },
|
||||
];
|
||||
|
||||
export const ChatScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Subtle zoom-in on the whole chat
|
||||
const chatZoom = interpolate(frame, [0, 3.5 * fps], [1, 1.04], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Overlay text: "Every group chat ever"
|
||||
const overlayProgress = spring({
|
||||
frame: frame - 0.3 * fps,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 180 },
|
||||
});
|
||||
const overlayOpacity = interpolate(
|
||||
frame - 0.3 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#000000" }}>
|
||||
{/* iMessage-style chat background */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
transform: `scale(${chatZoom})`,
|
||||
transformOrigin: "center 40%",
|
||||
}}
|
||||
>
|
||||
{/* Status bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 32px 12px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: theme.fonts.text, fontSize: 16, color: "white" }}>
|
||||
9:41
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<div style={{ width: 16, height: 10, border: "1.5px solid white", borderRadius: 2 }}>
|
||||
<div style={{ width: "70%", height: "100%", background: "white", borderRadius: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 32px 16px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Group avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
background: "linear-gradient(135deg, #34C759, #007AFF)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>4</span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
The Boys
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
4 people
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 24px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{MESSAGES.map((msg, index) => {
|
||||
const msgDelay = msg.delaySeconds * fps;
|
||||
const msgProgress = spring({
|
||||
frame: frame - msgDelay,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 200 },
|
||||
});
|
||||
const msgScale = interpolate(msgProgress, [0, 1], [0.3, 1]);
|
||||
const msgOpacity = interpolate(msgProgress, [0, 1], [0, 1]);
|
||||
|
||||
if (frame < msgDelay) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: msg.isMe ? "flex-end" : "flex-start",
|
||||
transform: `scale(${msgScale})`,
|
||||
opacity: msgOpacity,
|
||||
transformOrigin: msg.isMe ? "right bottom" : "left bottom",
|
||||
}}
|
||||
>
|
||||
{/* Sender name (not for "me") */}
|
||||
{!msg.isMe && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 13,
|
||||
color: msg.color,
|
||||
marginBottom: 2,
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
{msg.sender}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
background: msg.isMe ? "#007AFF" : "#2C2C2E",
|
||||
borderRadius: 20,
|
||||
borderBottomRightRadius: msg.isMe ? 6 : 20,
|
||||
borderBottomLeftRadius: msg.isMe ? 20 : 6,
|
||||
padding: "12px 18px",
|
||||
maxWidth: "75%",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: "white",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{msg.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay: "Every group chat ever" */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: overlayOpacity,
|
||||
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.85)",
|
||||
backdropFilter: "blur(10px)",
|
||||
padding: "16px 40px",
|
||||
borderRadius: 16,
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 36,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
Every group chat ever
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
332
marketing-videos/src/videos/TheGroupChat/PollScene.tsx
Normal file
332
marketing-videos/src/videos/TheGroupChat/PollScene.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
/**
|
||||
* Scene 5: Poll it. Done.
|
||||
*
|
||||
* First half: poll appears in group chat
|
||||
* Votes come in rapidly with spring animations
|
||||
* Overlay: "Poll it. Done."
|
||||
*/
|
||||
|
||||
type Vote = {
|
||||
name: string;
|
||||
color: string;
|
||||
delaySeconds: number;
|
||||
};
|
||||
|
||||
const VOTES: Vote[] = [
|
||||
{ name: "Jake", color: "#34C759", delaySeconds: 0.6 },
|
||||
{ name: "Mike", color: "#FF9500", delaySeconds: 0.85 },
|
||||
{ name: "Sam", color: "#AF52DE", delaySeconds: 1.05 },
|
||||
];
|
||||
|
||||
export const PollScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Poll card entrance
|
||||
const pollEntrance = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const pollScale = interpolate(pollEntrance, [0, 1], [0.7, 1]);
|
||||
const pollOpacity = interpolate(pollEntrance, [0, 1], [0, 1]);
|
||||
|
||||
// Overlay: "Poll it. Done."
|
||||
const overlayDelay = 1.5 * fps;
|
||||
const overlayProgress = spring({
|
||||
frame: frame - overlayDelay,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 200 },
|
||||
});
|
||||
const overlayScale = interpolate(overlayProgress, [0, 1], [2, 1]);
|
||||
const overlayOpacity = interpolate(
|
||||
frame - overlayDelay,
|
||||
[0, 0.1 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Vote count animation
|
||||
const voteCount = VOTES.filter(
|
||||
(v) => frame >= v.delaySeconds * fps
|
||||
).length;
|
||||
const totalVoters = VOTES.length + 1; // +1 for "You"
|
||||
|
||||
// Progress bar width
|
||||
const yesWidth = interpolate(
|
||||
(voteCount + 1) / totalVoters,
|
||||
[0, 1],
|
||||
[0, 100]
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#000000" }}>
|
||||
{/* Chat context */}
|
||||
<div style={{ padding: "120px 24px 0" }}>
|
||||
{/* "You" sent the poll */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#007AFF",
|
||||
borderRadius: 20,
|
||||
borderBottomRightRadius: 6,
|
||||
padding: "12px 18px",
|
||||
maxWidth: "75%",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: theme.fonts.text, fontSize: 20, color: "white" }}>
|
||||
I made us a trip. Vote
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Poll card */}
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${pollScale})`,
|
||||
opacity: pollOpacity,
|
||||
transformOrigin: "left top",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 20,
|
||||
padding: 28,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
}}
|
||||
>
|
||||
{/* Poll header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent}, ${theme.colors.accentDark})`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="10" rx="1" fill="white" />
|
||||
<rect x="14" y="7" width="7" height="14" rx="1" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 700, color: theme.colors.text }}>
|
||||
SportsTime Trip
|
||||
</div>
|
||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 14, color: theme.colors.textSecondary }}>
|
||||
West Coast Baseball Run
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip details */}
|
||||
<div style={{ marginBottom: 20, padding: "14px 16px", background: "rgba(255,255,255,0.04)", borderRadius: 12 }}>
|
||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 16, color: theme.colors.text, marginBottom: 4 }}>
|
||||
Jun 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>
|
||||
);
|
||||
};
|
||||
290
marketing-videos/src/videos/TheGroupChat/RouteScene.tsx
Normal file
290
marketing-videos/src/videos/TheGroupChat/RouteScene.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
||||
|
||||
/**
|
||||
* Scene 4: Routes generated
|
||||
*
|
||||
* Route auto-generates inside phone frame.
|
||||
* Map with animated route line + game cards appear.
|
||||
* Overlay: "Real route. Real games."
|
||||
*/
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const STOPS: Stop[] = [
|
||||
{ city: "LA", x: 180, y: 300 },
|
||||
{ city: "SF", x: 140, y: 130 },
|
||||
{ city: "SD", x: 240, y: 400 },
|
||||
{ city: "PHX", x: 480, y: 340 },
|
||||
];
|
||||
|
||||
export const RouteScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Overlay text
|
||||
const overlayProgress = spring({
|
||||
frame: frame - 0.3 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const overlayOpacity = interpolate(
|
||||
frame - 0.3 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Overlay: "Real route. Real games." */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: overlayOpacity,
|
||||
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.85)",
|
||||
padding: "14px 36px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 34,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Real route. Real games.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone with route */}
|
||||
<div style={{ marginTop: 40 }}>
|
||||
<AppScreenshot delay={0} scale={0.82}>
|
||||
<MockScreen>
|
||||
<RouteContent />
|
||||
</MockScreen>
|
||||
</AppScreenshot>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Route map + game list inside phone */
|
||||
const RouteContent: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Route draw
|
||||
const lineProgress = interpolate(
|
||||
frame,
|
||||
[0.15 * fps, 1.5 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const pathSegments: string[] = [];
|
||||
for (let i = 0; i < STOPS.length - 1; i++) {
|
||||
const from = STOPS[i];
|
||||
const to = STOPS[i + 1];
|
||||
if (i === 0) pathSegments.push(`M ${from.x} ${from.y}`);
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = Math.min(from.y, to.y) - 30;
|
||||
pathSegments.push(`Q ${cx} ${cy} ${to.x} ${to.y}`);
|
||||
}
|
||||
const fullPath = pathSegments.join(" ");
|
||||
|
||||
// Game cards
|
||||
const games = [
|
||||
{ team: "@ Dodgers", venue: "Dodger Stadium", date: "Jun 12", color: "#005A9C" },
|
||||
{ team: "@ Giants", venue: "Oracle Park", date: "Jun 14", color: "#FD5A1E" },
|
||||
{ team: "@ Padres", venue: "Petco Park", date: "Jun 16", color: "#2F241D" },
|
||||
{ team: "@ D-backs", venue: "Chase Field", date: "Jun 18", color: "#A71930" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
{/* Map section (top 45%) */}
|
||||
<div style={{ position: "absolute", top: 0, left: 8, right: 8, height: "45%" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "40px 40px",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg width="100%" height="100%" viewBox="0 0 600 480" style={{ position: "absolute", inset: 0 }}>
|
||||
<path d={fullPath} fill="none" stroke="rgba(255,107,53,0.15)" strokeWidth="3.5" strokeLinecap="round" />
|
||||
<path
|
||||
d={fullPath}
|
||||
fill="none"
|
||||
stroke={theme.colors.accent}
|
||||
strokeWidth="3.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="1500"
|
||||
strokeDashoffset={1500 * (1 - lineProgress)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{STOPS.map((stop, index) => {
|
||||
const markerProgress = spring({
|
||||
frame: frame - (0.2 + index * 0.35) * fps,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 180 },
|
||||
});
|
||||
const leftPct = (stop.x / 600) * 100;
|
||||
const topPct = (stop.y / 480) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stop.city}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
transform: `translate(-50%, -50%) scale(${interpolate(markerProgress, [0, 1], [0, 1])})`,
|
||||
opacity: interpolate(markerProgress, [0, 1], [0, 1]),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.secondary,
|
||||
border: "2.5px solid white",
|
||||
boxShadow: "0 2px 8px rgba(78,205,196,0.5)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
textShadow: "0 1px 6px rgba(0,0,0,0.8)",
|
||||
}}
|
||||
>
|
||||
{stop.city}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Game cards (bottom 55%) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: "46%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Your Games
|
||||
</div>
|
||||
|
||||
{games.map((game, index) => {
|
||||
const cardDelay = 1.2 + index * 0.18;
|
||||
const cardProgress = spring({
|
||||
frame: frame - cardDelay * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const translateX = interpolate(cardProgress, [0, 1], [250, 0]);
|
||||
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={game.venue}
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 14,
|
||||
padding: "14px 18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
opacity,
|
||||
borderLeft: `4px solid ${game.color}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 50, textAlign: "center" }}>
|
||||
<div style={{ fontFamily: theme.fonts.display, fontSize: 16, fontWeight: 700, color: theme.colors.text }}>
|
||||
{game.date.split(" ")[1]}
|
||||
</div>
|
||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 11, color: theme.colors.textMuted }}>
|
||||
{game.date.split(" ")[0]}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 1.5, height: 32, background: "rgba(255,255,255,0.08)" }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: theme.fonts.display, fontSize: 17, fontWeight: 700, color: theme.colors.text }}>
|
||||
{game.team}
|
||||
</div>
|
||||
<div style={{ fontFamily: theme.fonts.text, fontSize: 13, color: theme.colors.textSecondary }}>
|
||||
{game.venue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
281
marketing-videos/src/videos/TheGroupChat/SolutionScene.tsx
Normal file
281
marketing-videos/src/videos/TheGroupChat/SolutionScene.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
||||
|
||||
/**
|
||||
* Scene 3: "So I planned it myself"
|
||||
*
|
||||
* User opens SportsTime, builds trip.
|
||||
* Shows date range + sport selection inside phone frame.
|
||||
* Overlay: "So I planned it myself"
|
||||
*/
|
||||
export const SolutionScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Overlay text entrance
|
||||
const overlayProgress = spring({
|
||||
frame: frame - 0.3 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const overlayOpacity = interpolate(
|
||||
frame - 0.3 * fps,
|
||||
[0, 0.2 * fps],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Overlay: "So I planned it myself" */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
opacity: overlayOpacity,
|
||||
transform: `scale(${interpolate(overlayProgress, [0, 1], [0.85, 1])})`,
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.85)",
|
||||
padding: "14px 36px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 34,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
So I planned it myself
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone with trip builder */}
|
||||
<div style={{ marginTop: 40 }}>
|
||||
<AppScreenshot delay={0} scale={0.82}>
|
||||
<MockScreen>
|
||||
<TripBuilderContent />
|
||||
</MockScreen>
|
||||
</AppScreenshot>
|
||||
</div>
|
||||
|
||||
{/* Tap on the date range */}
|
||||
<TapIndicator x={540} y={680} delay={0.8} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Trip builder screen content */
|
||||
const TripBuilderContent: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const sports = [
|
||||
{ name: "MLB", color: "#002D72", emoji: "\u26be" },
|
||||
{ name: "NBA", color: "#C9082A", emoji: "\ud83c\udfc0" },
|
||||
{ name: "NFL", color: "#013369", emoji: "\ud83c\udfc8" },
|
||||
{ name: "NHL", color: "#000000", emoji: "\ud83c\udfd2" },
|
||||
];
|
||||
|
||||
// MLB gets selected
|
||||
const mlbSelect = spring({
|
||||
frame: frame - 0.6 * fps,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
const mlbSelected = mlbSelect > 0.5;
|
||||
|
||||
// Date range highlight
|
||||
const dateHighlight = spring({
|
||||
frame: frame - 1.2 * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
New Trip
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 17,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
What do you want to see?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sport chips */}
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
|
||||
{sports.map((sport, i) => {
|
||||
const chipProgress = spring({
|
||||
frame: frame - i * 4,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
const isSelected = sport.name === "MLB" && mlbSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sport.name}
|
||||
style={{
|
||||
background: isSelected ? sport.color : "#1C1C1E",
|
||||
borderRadius: 14,
|
||||
padding: "14px 22px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
opacity: interpolate(chipProgress, [0, 1], [0, 1]),
|
||||
transform: `scale(${interpolate(chipProgress, [0, 1], [0.9, 1])})`,
|
||||
border: isSelected ? "2px solid white" : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20 }}>{sport.emoji}</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{sport.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Date range section */}
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
marginBottom: 20,
|
||||
border: `2px solid ${dateHighlight > 0.5 ? theme.colors.accent : "transparent"}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
marginBottom: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Travel Dates
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: dateHighlight > 0.5 ? theme.colors.accent : theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Jun 12 – Jun 18
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 15,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
7 days
|
||||
</div>
|
||||
</div>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke={theme.colors.textSecondary} strokeWidth="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" stroke={theme.colors.textSecondary} strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" stroke={theme.colors.textSecondary} strokeWidth="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region */}
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 16,
|
||||
padding: 22,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
marginBottom: 10,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Region
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
West Coast
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
marketing-videos/src/videos/TheGroupChat/index.tsx
Normal file
107
marketing-videos/src/videos/TheGroupChat/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useVideoConfig } from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
import { ChatScene } from "./ChatScene";
|
||||
import { AllTalkScene } from "./AllTalkScene";
|
||||
import { SolutionScene } from "./SolutionScene";
|
||||
import { RouteScene } from "./RouteScene";
|
||||
import { PollScene } from "./PollScene";
|
||||
import { CTAScene } from "./CTAScene";
|
||||
|
||||
/**
|
||||
* V03: "The Group Chat" (Viral)
|
||||
*
|
||||
* Hook: iMessage group chat blowing up with "we should do a trip" energy
|
||||
* Problem: Nobody actually plans it → "All talk."
|
||||
* Solution: One person opens SportsTime → plans it → polls the group
|
||||
* Length: 16 seconds (480 frames at 30fps)
|
||||
*
|
||||
* Timing breakdown:
|
||||
* - 0:00-0:03.5 (0-105): Group chat messages flying in
|
||||
* - 0:03.5-0:05.5 (105-165): Chat dies, typing stops, "All talk."
|
||||
* - 0:05.5-0:08.5 (165-255): Opens SportsTime, builds trip, "So I planned it myself"
|
||||
* - 0:08.5-0:11.5 (255-345): Route generates, games appear, "Real route. Real games."
|
||||
* - 0:11.5-0:14 (345-420): Poll sent to group, votes flood in, "Poll it. Done."
|
||||
* - 0:14-0:16 (420-480): CTA: "If your group chat is all talk → SportsTime"
|
||||
*/
|
||||
export const TheGroupChat: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION = 10; // 10 frame transitions (snappy for TikTok pace)
|
||||
|
||||
// TransitionSeries subtracts transition duration from total.
|
||||
// 5 transitions × 10 frames = 50 frames overlap.
|
||||
// Scene sum must be 480 + 50 = 530 to fill 16s composition.
|
||||
const SCENES = {
|
||||
chat: 115, // ~3.8s - group chat chaos
|
||||
allTalk: 70, // ~2.3s - chat dies, "All talk."
|
||||
solution: 100, // ~3.3s - opens SportsTime, builds trip
|
||||
route: 95, // ~3.2s - route generates, games appear
|
||||
poll: 85, // ~2.8s - poll sent, votes flood in
|
||||
cta: 65, // ~2.2s - CTA ending
|
||||
}; // Total: 530 - 50 = 480 frames = 16s
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: Group chat chaos */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.chat}>
|
||||
<ChatScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: "All talk." */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.allTalk}>
|
||||
<AllTalkScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: "So I planned it myself" */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.solution}>
|
||||
<SolutionScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-right" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: "Real route. Real games." */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.route}>
|
||||
<RouteScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: "Poll it. Done." */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.poll}>
|
||||
<PollScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION })}
|
||||
/>
|
||||
|
||||
{/* Scene 6: CTA */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.cta}>
|
||||
<CTAScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user