feat: add marketing video mode and Remotion marketing video project

Add debug-only Marketing Video Mode toggle that enables hands-free
screen recording across the app: auto-scrolling Featured Trips carousel,
auto-filling trip wizard, smooth trip detail scrolling via CADisplayLink,
and trip options auto-sort with scroll.

Add Remotion marketing video project with 6 scene compositions using
image sequences extracted from screen recordings, varied phone entrance
animations, and deduped frames for smooth playback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-13 12:07:35 -06:00
parent 67965cbac6
commit 5f5b137e64
655 changed files with 4008 additions and 63 deletions

View File

@@ -0,0 +1,57 @@
import React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { THEME } from "../theme";
type AnimatedCounterProps = {
from: number;
to: number;
startFrame?: number;
fontSize?: number;
color?: string;
suffix?: string;
};
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({
from,
to,
startFrame = 0,
fontSize = 140,
color = THEME.colors.white,
suffix = "",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame: frame - startFrame,
fps,
config: { damping: 30, mass: 1, stiffness: 80 },
});
const value = Math.round(interpolate(progress, [0, 1], [from, to]));
const scale = spring({
frame: frame - startFrame,
fps,
config: { damping: 10, mass: 0.4, stiffness: 200 },
});
const scaleValue = interpolate(scale, [0, 1], [0.5, 1]);
return (
<div
style={{
fontFamily: THEME.font.heading,
fontSize,
fontWeight: 900,
color,
textShadow: THEME.textShadow,
transform: `scale(${scaleValue})`,
textAlign: "center",
}}
>
{value}
{suffix}
</div>
);
};

View File

@@ -0,0 +1,71 @@
import React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { THEME } from "../theme";
type KineticTextProps = {
words: string[];
fontSize?: number;
color?: string;
delay?: number;
staggerFrames?: number;
textShadow?: string;
textAlign?: "center" | "left" | "right";
lineHeight?: number;
};
export const KineticText: React.FC<KineticTextProps> = ({
words,
fontSize = 72,
color = THEME.colors.white,
delay = 0,
staggerFrames = 3,
textShadow = THEME.textShadow,
textAlign = "center",
lineHeight = 1.2,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: textAlign === "center" ? "center" : "flex-start",
gap: `0 ${fontSize * 0.25}px`,
fontFamily: THEME.font.heading,
fontSize,
fontWeight: 800,
color,
textShadow,
lineHeight,
}}
>
{words.map((word, i) => {
const wordDelay = delay + i * staggerFrames;
const entrance = spring({
frame: frame - wordDelay,
fps,
config: { damping: 14, mass: 0.6, stiffness: 140 },
});
const opacity = interpolate(entrance, [0, 1], [0, 1]);
const translateY = interpolate(entrance, [0, 1], [30, 0]);
return (
<span
key={`${word}-${i}`}
style={{
display: "inline-block",
opacity,
transform: `translateY(${translateY}px)`,
}}
>
{word}
</span>
);
})}
</div>
);
};

View File

@@ -0,0 +1,230 @@
import React from "react";
import { Img, staticFile, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { THEME } from "../theme";
type PhoneMockupProps = {
screenshot: string;
screenshotB?: string;
crossfadeAt?: number;
enterFrom?: "bottom" | "right" | "left" | "top" | "fade" | "scale";
delay?: number;
scale?: number;
sequenceFolder?: string;
sequenceFrameCount?: number;
sequenceStartAt?: number;
sequenceSpeed?: number;
};
// iPhone frame dimensions — iphone.png is the bezel/frame image.
// The screen area sits inside the frame with insets.
const FRAME_WIDTH = 433;
const FRAME_HEIGHT = 885;
const SCREEN_TOP = 22;
const SCREEN_LEFT = 23;
const SCREEN_WIDTH = 386;
const SCREEN_HEIGHT = 842;
const SCREEN_BORDER_RADIUS = 37;
// CSS fallback frame (used when iphone.png is missing)
const CSSFrame: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
style={{
width: FRAME_WIDTH,
height: FRAME_HEIGHT,
borderRadius: 52,
background: "#1a1a1a",
padding: 18,
boxShadow: "0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.1)",
position: "relative",
overflow: "hidden",
}}
>
{/* Dynamic Island */}
<div
style={{
position: "absolute",
top: 26,
left: "50%",
transform: "translateX(-50%)",
width: 120,
height: 34,
borderRadius: 17,
background: "#000",
zIndex: 10,
}}
/>
<div
style={{
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
borderRadius: SCREEN_BORDER_RADIUS,
overflow: "hidden",
}}
>
{children}
</div>
</div>
);
// Image-based frame using iphone.png overlay
const ImageFrame: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ position: "relative", width: FRAME_WIDTH, height: FRAME_HEIGHT }}>
<div
style={{
position: "absolute",
top: SCREEN_TOP,
left: SCREEN_LEFT,
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
borderRadius: SCREEN_BORDER_RADIUS,
overflow: "hidden",
}}
>
{children}
</div>
<Img
src={staticFile("iphone.png")}
style={{
position: "absolute",
top: 0,
left: 0,
width: FRAME_WIDTH,
height: FRAME_HEIGHT,
pointerEvents: "none",
}}
/>
</div>
);
// Set to true once you place iphone.png in public/
const USE_IMAGE_FRAME = true;
export const PhoneMockup: React.FC<PhoneMockupProps> = ({
screenshot,
screenshotB,
crossfadeAt = 35,
enterFrom = "bottom",
delay = 8,
scale = 1.0,
sequenceFolder,
sequenceFrameCount = 0,
sequenceStartAt = 15,
sequenceSpeed = 1,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entrance = spring({
frame: frame - delay,
fps,
config: THEME.spring,
});
const translateY =
enterFrom === "bottom"
? interpolate(entrance, [0, 1], [400, 0])
: enterFrom === "top"
? interpolate(entrance, [0, 1], [-400, 0])
: 0;
const translateX =
enterFrom === "right"
? interpolate(entrance, [0, 1], [400, 0])
: enterFrom === "left"
? interpolate(entrance, [0, 1], [-400, 0])
: 0;
const entranceOpacity =
enterFrom === "fade" || enterFrom === "scale"
? interpolate(entrance, [0, 1], [0, 1])
: 1;
const entranceScale =
enterFrom === "scale"
? interpolate(entrance, [0, 1], [0.3, 1])
: 1;
// Crossfade between two screenshots if screenshotB is provided
const crossfadeProgress = screenshotB
? spring({
frame: frame - crossfadeAt,
fps,
config: { damping: 20, mass: 0.8, stiffness: 100 },
})
: 0;
const bOpacity = interpolate(crossfadeProgress, [0, 1], [0, 1]);
// Image sequence: pick the right frame based on current time
const sequenceFrameIndex = sequenceFolder && sequenceFrameCount > 0
? Math.min(
Math.floor(Math.max(0, frame - sequenceStartAt) * sequenceSpeed),
sequenceFrameCount - 1
)
: -1;
const sequenceSrc = sequenceFrameIndex >= 0
? staticFile(`screenshots/${sequenceFolder}/frame-${String(sequenceFrameIndex + 1).padStart(3, "0")}.jpg`)
: null;
const screenContent = (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Base static screenshot — always rendered to prevent flash */}
<Img
src={staticFile(`screenshots/${screenshot}`)}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{screenshotB && (
<Img
src={staticFile(`screenshots/${screenshotB}`)}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: bOpacity,
}}
/>
)}
{/* Sequence frame layered on top — uses CSS backgroundImage to avoid flash */}
{sequenceSrc && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundImage: `url(${sequenceSrc})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
</div>
);
const Frame = USE_IMAGE_FRAME ? ImageFrame : CSSFrame;
return (
<div
style={{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale * entranceScale})`,
opacity: entranceOpacity,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Frame>{screenContent}</Frame>
</div>
);
};