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

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

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

View File

@@ -0,0 +1,43 @@
import React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
/**
* Subtle film grain overlay that makes videos feel organic/real.
* Uses deterministic noise per frame for reproducible renders.
*/
export const FilmGrain: React.FC<{ opacity?: number }> = ({
opacity = 0.04,
}) => {
const frame = useCurrentFrame();
// Shift the noise pattern each frame using a CSS trick
const offsetX = ((frame * 73) % 200) - 100;
const offsetY = ((frame * 47) % 200) - 100;
return (
<AbsoluteFill
style={{
pointerEvents: "none",
mixBlendMode: "overlay",
opacity,
}}
>
<svg width="100%" height="100%">
<filter id={`grain-${frame % 10}`}>
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
seed={frame}
stitchTiles="stitch"
/>
</filter>
<rect
width="100%"
height="100%"
filter={`url(#grain-${frame % 10})`}
transform={`translate(${offsetX}, ${offsetY})`}
/>
</svg>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,306 @@
import React from "react";
import {
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { theme } from "./theme";
/**
* TikTok-native kinetic caption system.
*
* Unlike generic subtitle overlays, each style mimics real TikTok
* caption patterns: punch zooms, word pops, highlight boxes, etc.
*/
type CaptionEntry = {
text: string;
startSec: number;
endSec: number;
style?: "punch" | "highlight" | "stack" | "whisper" | "shake";
};
type TikTokCaptionProps = {
captions: CaptionEntry[];
/** Vertical position from bottom (px) */
bottomOffset?: number;
};
export const TikTokCaption: React.FC<TikTokCaptionProps> = ({
captions,
bottomOffset = 280,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentSec = frame / fps;
const active = captions.find(
(c) => currentSec >= c.startSec && currentSec < c.endSec
);
if (!active) return null;
const startFrame = active.startSec * fps;
const endFrame = active.endSec * fps;
const localFrame = frame - startFrame;
const style = active.style || "punch";
const exitOpacity = interpolate(
frame,
[endFrame - 4, endFrame],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
style={{
position: "absolute",
bottom: bottomOffset,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
opacity: exitOpacity,
zIndex: 100,
}}
>
{style === "punch" && (
<PunchCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "highlight" && (
<HighlightCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "stack" && (
<StackCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "whisper" && (
<WhisperCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
{style === "shake" && (
<ShakeCaption text={active.text} localFrame={localFrame} fps={fps} />
)}
</div>
);
};
/** Punch zoom in - text scales from big to normal with impact */
const PunchCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const prog = spring({
frame: localFrame,
fps,
config: { damping: 10, stiffness: 280 },
});
const scale = interpolate(prog, [0, 1], [1.8, 1]);
const opacity = interpolate(prog, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 52,
fontWeight: 900,
color: "white",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: -1,
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
transform: `scale(${scale})`,
opacity,
maxWidth: 900,
lineHeight: 1.15,
}}
>
{text}
</span>
);
};
/** Highlight box - text with colored background box that wipes in */
const HighlightCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const boxProg = spring({
frame: localFrame,
fps,
config: { damping: 15, stiffness: 200 },
});
const textProg = spring({
frame: localFrame - 3,
fps,
config: { damping: 20, stiffness: 180 },
});
return (
<div style={{ position: "relative", display: "inline-block" }}>
<div
style={{
position: "absolute",
inset: "-8px -20px",
background: theme.colors.accent,
borderRadius: 8,
transform: `scaleX(${boxProg})`,
transformOrigin: "left",
}}
/>
<span
style={{
position: "relative",
fontFamily: theme.fonts.display,
fontSize: 46,
fontWeight: 800,
color: "white",
opacity: interpolate(textProg, [0, 1], [0, 1]),
letterSpacing: -0.5,
textShadow: "0 2px 8px rgba(0,0,0,0.3)",
}}
>
{text}
</span>
</div>
);
};
/** Stack - words stack vertically, each popping in */
const StackCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const words = text.split(" ");
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
{words.map((word, i) => {
const delay = i * 2;
const prog = spring({
frame: localFrame - delay,
fps,
config: { damping: 12, stiffness: 250 },
});
return (
<span
key={i}
style={{
fontFamily: theme.fonts.display,
fontSize: 56,
fontWeight: 900,
color: "white",
textTransform: "uppercase",
letterSpacing: 2,
transform: `scale(${interpolate(prog, [0, 1], [0.5, 1])}) translateY(${interpolate(prog, [0, 1], [20, 0])}px)`,
opacity: interpolate(prog, [0, 0.5], [0, 1], {
extrapolateRight: "clamp",
}),
textShadow:
"0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
lineHeight: 1.0,
}}
>
{word}
</span>
);
})}
</div>
);
};
/** Whisper - small italic text that fades in gently */
const WhisperCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const opacity = interpolate(localFrame, [0, fps * 0.3], [0, 1], {
extrapolateRight: "clamp",
});
return (
<span
style={{
fontFamily: theme.fonts.text,
fontSize: 32,
fontWeight: 400,
fontStyle: "italic",
color: "rgba(255,255,255,0.8)",
textAlign: "center",
opacity,
letterSpacing: 1,
textShadow: "0 2px 12px rgba(0,0,0,0.6)",
}}
>
{text}
</span>
);
};
/** Shake - text shakes briefly on entrance then settles */
const ShakeCaption: React.FC<{
text: string;
localFrame: number;
fps: number;
}> = ({ text, localFrame, fps }) => {
const prog = spring({
frame: localFrame,
fps,
config: { damping: 8, stiffness: 300 },
});
// Shake offsets that decay over ~5 frames
const shakeIntensity = interpolate(localFrame, [0, 5], [8, 0], {
extrapolateRight: "clamp",
});
const OFFSETS = [
{ x: -1, y: 1 },
{ x: 1, y: -1 },
{ x: -1, y: -1 },
{ x: 1, y: 1 },
{ x: 0, y: -1 },
];
const offset = OFFSETS[localFrame % OFFSETS.length];
const sx = offset.x * shakeIntensity;
const sy = offset.y * shakeIntensity;
return (
<span
style={{
fontFamily: theme.fonts.display,
fontSize: 50,
fontWeight: 900,
color: "white",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: -0.5,
transform: `translate(${sx}px, ${sy}px) scale(${interpolate(prog, [0, 1], [1.3, 1])})`,
opacity: interpolate(prog, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
}),
textShadow: "0 4px 20px rgba(0,0,0,0.8), 0 2px 4px rgba(0,0,0,0.9)",
maxWidth: 900,
lineHeight: 1.15,
}}
>
{text}
</span>
);
};
export type { CaptionEntry };

View File

@@ -4,3 +4,6 @@ export { TextReveal, TextRevealMultiline, HighlightText } from "./TextReveal";
export { TapIndicator, SwipeIndicator } from "./TapIndicator";
export { AppScreenshot, MockScreen } from "./AppScreenshot";
export { GradientBackground, GridBackground, GlowBackground } from "./Background";
export { FilmGrain } from "./FilmGrain";
export { TikTokCaption } from "./TikTokCaption";
export type { CaptionEntry } from "./TikTokCaption";