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:
17
marketing-videos/src/Root.tsx
Normal file
17
marketing-videos/src/Root.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import { Composition } from "remotion";
|
||||
import { AppStorePreview } from "./compositions/AppStorePreview";
|
||||
import { WIDTH, HEIGHT, FPS, TOTAL_DURATION } from "./theme";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="app-store-preview"
|
||||
component={AppStorePreview}
|
||||
durationInFrames={TOTAL_DURATION}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
);
|
||||
};
|
||||
57
marketing-videos/src/components/AnimatedCounter.tsx
Normal file
57
marketing-videos/src/components/AnimatedCounter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
marketing-videos/src/components/KineticText.tsx
Normal file
71
marketing-videos/src/components/KineticText.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
230
marketing-videos/src/components/PhoneMockup.tsx
Normal file
230
marketing-videos/src/components/PhoneMockup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
marketing-videos/src/compositions/AppStorePreview.tsx
Normal file
55
marketing-videos/src/compositions/AppStorePreview.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
import { SCENES, TRANSITION_DURATION } from "../theme";
|
||||
import { HeroScene } from "../scenes/HeroScene";
|
||||
import { SearchScene } from "../scenes/SearchScene";
|
||||
import { CustomizeScene } from "../scenes/CustomizeScene";
|
||||
import { GroupScene } from "../scenes/GroupScene";
|
||||
import { ItineraryScene } from "../scenes/ItineraryScene";
|
||||
import { CTAScene } from "../scenes/CTAScene";
|
||||
|
||||
export const AppStorePreview: React.FC = () => {
|
||||
const transition = {
|
||||
presentation: slide({ direction: "from-right" }),
|
||||
timing: linearTiming({ durationInFrames: TRANSITION_DURATION }),
|
||||
};
|
||||
|
||||
return (
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.hero.durationInFrames}>
|
||||
<HeroScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition {...transition} />
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.customize.durationInFrames}>
|
||||
<CustomizeScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition {...transition} />
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.search.durationInFrames}>
|
||||
<SearchScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition {...transition} />
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.group.durationInFrames}>
|
||||
<GroupScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition {...transition} />
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.itinerary.durationInFrames}>
|
||||
<ItineraryScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition {...transition} />
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={SCENES.cta.durationInFrames}>
|
||||
<CTAScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
);
|
||||
};
|
||||
4
marketing-videos/src/index.ts
Normal file
4
marketing-videos/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
70
marketing-videos/src/scenes/CTAScene.tsx
Normal file
70
marketing-videos/src/scenes/CTAScene.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Img, staticFile, spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { THEME } from "../theme";
|
||||
import { KineticText } from "../components/KineticText";
|
||||
|
||||
export const CTAScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Background pulse
|
||||
const pulse = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 20, mass: 1, stiffness: 60 },
|
||||
});
|
||||
const bg = interpolate(pulse, [0, 1], [0, 8]);
|
||||
|
||||
// App icon entrance
|
||||
const iconEntrance = spring({
|
||||
frame: frame - 25,
|
||||
fps,
|
||||
config: THEME.spring,
|
||||
});
|
||||
const iconScale = interpolate(iconEntrance, [0, 1], [0, 1]);
|
||||
const iconOpacity = interpolate(iconEntrance, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at 50% 40%, hsl(0, 72%, ${45 + bg}%) 0%, ${THEME.colors.cta} 70%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 50px",
|
||||
gap: 50,
|
||||
}}
|
||||
>
|
||||
{/* Headline */}
|
||||
<KineticText
|
||||
words={["The", "Game's", "Just", "the", "Start."]}
|
||||
fontSize={78}
|
||||
delay={4}
|
||||
staggerFrames={4}
|
||||
/>
|
||||
|
||||
{/* App name + icon */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 20,
|
||||
transform: `scale(${iconScale})`,
|
||||
opacity: iconOpacity,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("appIcon.png")}
|
||||
style={{
|
||||
width: 360,
|
||||
height: 360,
|
||||
borderRadius: 80,
|
||||
boxShadow: "0 10px 40px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
70
marketing-videos/src/scenes/CustomizeScene.tsx
Normal file
70
marketing-videos/src/scenes/CustomizeScene.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { THEME } from "../theme";
|
||||
import { KineticText } from "../components/KineticText";
|
||||
import { PhoneMockup } from "../components/PhoneMockup";
|
||||
|
||||
export const CustomizeScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Subtle shine sweep
|
||||
const shine = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 25, mass: 1, stiffness: 50 },
|
||||
});
|
||||
const shineX = interpolate(shine, [0, 1], [-200, 1200]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${THEME.colors.customize} 0%, #F59E0B 50%, ${THEME.colors.customize} 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 50px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Shine overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: shineX,
|
||||
width: 200,
|
||||
height: "100%",
|
||||
background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent)",
|
||||
transform: "skewX(-15deg)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Headline */}
|
||||
<div style={{ marginBottom: 50, maxWidth: 900, zIndex: 1 }}>
|
||||
<KineticText
|
||||
words={["Your", "Trip.", "Your", "Rules."]}
|
||||
fontSize={80}
|
||||
color={THEME.colors.textDark}
|
||||
delay={4}
|
||||
textShadow={THEME.textShadowLight}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone mockup */}
|
||||
<div style={{ zIndex: 1 }}>
|
||||
<PhoneMockup
|
||||
screenshot="screen-3-wizard.png"
|
||||
enterFrom="scale"
|
||||
delay={10}
|
||||
scale={1.05}
|
||||
sequenceFolder="plan-trip-sequence"
|
||||
sequenceFrameCount={156}
|
||||
sequenceStartAt={18}
|
||||
sequenceSpeed={2.5}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
72
marketing-videos/src/scenes/GroupScene.tsx
Normal file
72
marketing-videos/src/scenes/GroupScene.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { THEME } from "../theme";
|
||||
import { KineticText } from "../components/KineticText";
|
||||
import { PhoneMockup } from "../components/PhoneMockup";
|
||||
|
||||
export const GroupScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Floating circles background
|
||||
const float1 = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 40, mass: 2, stiffness: 30 },
|
||||
});
|
||||
const y1 = interpolate(float1, [0, 1], [0, -20]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at 30% 70%, #60A5FA 0%, ${THEME.colors.group} 50%, #2563EB 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 50px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Decorative circles */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 120,
|
||||
right: -60,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
transform: `translateY(${y1}px)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 200,
|
||||
left: -40,
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
transform: `translateY(${-y1}px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Headline */}
|
||||
<div style={{ marginBottom: 50, maxWidth: 900, zIndex: 1 }}>
|
||||
<KineticText
|
||||
words={["Vote", "on", "games", "with", "your", "crew"]}
|
||||
fontSize={74}
|
||||
delay={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone mockup */}
|
||||
<div style={{ zIndex: 1 }}>
|
||||
<PhoneMockup screenshot="screen-4-poll-a.png" screenshotB="screen-4-poll-b.png" crossfadeAt={35} enterFrom="left" delay={10} scale={1.05} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
51
marketing-videos/src/scenes/HeroScene.tsx
Normal file
51
marketing-videos/src/scenes/HeroScene.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { THEME } from "../theme";
|
||||
import { KineticText } from "../components/KineticText";
|
||||
import { PhoneMockup } from "../components/PhoneMockup";
|
||||
|
||||
export const HeroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Subtle background gradient pulse
|
||||
const pulse = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 30, mass: 1, stiffness: 40 },
|
||||
});
|
||||
const bgLightness = interpolate(pulse, [0, 1], [0, 3]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at 50% 30%, hsl(153, 42%, ${28 + bgLightness}%) 0%, ${THEME.colors.hero} 70%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 50px",
|
||||
}}
|
||||
>
|
||||
{/* Headline */}
|
||||
<div style={{ marginBottom: 50, maxWidth: 900 }}>
|
||||
<KineticText
|
||||
words={["Plan", "the", "ultimate", "sports", "road", "trip"]}
|
||||
fontSize={76}
|
||||
delay={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone mockup with scrolling home screen recording */}
|
||||
<PhoneMockup
|
||||
screenshot="screen-1-home.png"
|
||||
enterFrom="fade"
|
||||
delay={4}
|
||||
scale={1.05}
|
||||
sequenceFolder="home-sequence"
|
||||
sequenceFrameCount={92}
|
||||
sequenceStartAt={18}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
54
marketing-videos/src/scenes/ItineraryScene.tsx
Normal file
54
marketing-videos/src/scenes/ItineraryScene.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { THEME } from "../theme";
|
||||
import { KineticText } from "../components/KineticText";
|
||||
import { PhoneMockup } from "../components/PhoneMockup";
|
||||
|
||||
export const ItineraryScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Gentle warm glow
|
||||
const glow = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 40, mass: 2, stiffness: 20 },
|
||||
});
|
||||
const glowSize = interpolate(glow, [0, 1], [40, 55]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(ellipse at 50% 50%, #FFFBEB ${glowSize}%, ${THEME.colors.itinerary} 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 50px",
|
||||
}}
|
||||
>
|
||||
{/* Headline */}
|
||||
<div style={{ marginBottom: 50, maxWidth: 900 }}>
|
||||
<KineticText
|
||||
words={["Beautiful", "itineraries,", "one", "tap"]}
|
||||
fontSize={74}
|
||||
color={THEME.colors.textDark}
|
||||
delay={4}
|
||||
textShadow="none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone mockup */}
|
||||
<PhoneMockup
|
||||
screenshot="screen-5-itinerary.png"
|
||||
enterFrom="bottom"
|
||||
delay={10}
|
||||
scale={1.05}
|
||||
sequenceFolder="trip-sequence"
|
||||
sequenceFrameCount={244}
|
||||
sequenceStartAt={18}
|
||||
sequenceSpeed={2.5}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
53
marketing-videos/src/scenes/SearchScene.tsx
Normal file
53
marketing-videos/src/scenes/SearchScene.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, spring, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { THEME } from "../theme";
|
||||
import { KineticText } from "../components/KineticText";
|
||||
import { PhoneMockup } from "../components/PhoneMockup";
|
||||
|
||||
export const SearchScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Animated split position
|
||||
const splitProgress = spring({
|
||||
frame: frame - 2,
|
||||
fps,
|
||||
config: { damping: 18, mass: 0.8, stiffness: 100 },
|
||||
});
|
||||
const splitY = interpolate(splitProgress, [0, 1], [100, 55]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${THEME.colors.search} 0%, ${THEME.colors.search} ${splitY}%, ${THEME.colors.searchDark} ${splitY}%, ${THEME.colors.searchDark} 100%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 50px",
|
||||
}}
|
||||
>
|
||||
{/* Headline */}
|
||||
<div style={{ marginBottom: 50, maxWidth: 900 }}>
|
||||
<KineticText
|
||||
words={["Tap.", "Search.", "Go."]}
|
||||
fontSize={84}
|
||||
delay={4}
|
||||
staggerFrames={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone mockup */}
|
||||
<PhoneMockup
|
||||
screenshot="screen-2-search.png"
|
||||
enterFrom="right"
|
||||
delay={10}
|
||||
scale={1.05}
|
||||
sequenceFolder="all-trips-sequence"
|
||||
sequenceFrameCount={126}
|
||||
sequenceStartAt={18}
|
||||
sequenceSpeed={2.5}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
40
marketing-videos/src/theme.ts
Normal file
40
marketing-videos/src/theme.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const THEME = {
|
||||
colors: {
|
||||
hero: "#2D6A4F",
|
||||
search: "#F97316",
|
||||
searchDark: "#1a1a2e",
|
||||
customize: "#EAB308",
|
||||
group: "#3B82F6",
|
||||
itinerary: "#FFF8E7",
|
||||
cta: "#DC2626",
|
||||
white: "#FFFFFF",
|
||||
black: "#000000",
|
||||
textDark: "#1a1a2e",
|
||||
},
|
||||
spring: {
|
||||
damping: 12,
|
||||
mass: 0.5,
|
||||
stiffness: 120,
|
||||
},
|
||||
textShadow: "0 4px 20px rgba(0, 0, 0, 0.4)",
|
||||
textShadowLight: "0 2px 10px rgba(0, 0, 0, 0.15)",
|
||||
font: {
|
||||
heading: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
body: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const SCENES = {
|
||||
hero: { durationInFrames: 80 },
|
||||
search: { durationInFrames: 80 },
|
||||
customize: { durationInFrames: 80 },
|
||||
group: { durationInFrames: 80 },
|
||||
itinerary: { durationInFrames: 80 },
|
||||
cta: { durationInFrames: 95 },
|
||||
} as const;
|
||||
|
||||
export const TRANSITION_DURATION = 9;
|
||||
export const FPS = 30;
|
||||
export const WIDTH = 1080;
|
||||
export const HEIGHT = 1920;
|
||||
export const TOTAL_DURATION = 450;
|
||||
Reference in New Issue
Block a user