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:
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";
|
||||
|
||||
Reference in New Issue
Block a user