Files
Sportstime/marketing-videos/src/videos/TheGroupChat/RouteScene.tsx
Trey t 8e937a5646 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>
2026-02-06 09:36:34 -06:00

291 lines
8.4 KiB
TypeScript

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>
);
};