- 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>
291 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
};
|