chore: remove scraper, add docs, add marketing-videos gitignore
- Remove Scripts/ directory (scraper no longer needed) - Add themed background documentation to CLAUDE.md - Add .gitignore for marketing-videos to prevent node_modules tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
75
marketing-videos/src/Root.tsx
Normal file
75
marketing-videos/src/Root.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Composition, Folder } from "remotion";
|
||||
|
||||
import { TheRoute } from "./videos/TheRoute";
|
||||
import { TheChecklist } from "./videos/TheChecklist";
|
||||
import { TheBucketList } from "./videos/TheBucketList";
|
||||
import { TheSquad } from "./videos/TheSquad";
|
||||
import { TheHandoff } from "./videos/TheHandoff";
|
||||
|
||||
/**
|
||||
* SportsTime Marketing Videos
|
||||
*
|
||||
* All videos are portrait format (1080x1920) at 30fps
|
||||
* Designed for App Store, YouTube, Instagram, and TikTok
|
||||
*/
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
const FPS = 30;
|
||||
const WIDTH = 1080;
|
||||
const HEIGHT = 1920;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Folder name="SportsTime-Marketing">
|
||||
{/* Video 1: The Route - Map animation showcasing trip planning */}
|
||||
<Composition
|
||||
id="TheRoute"
|
||||
component={TheRoute}
|
||||
durationInFrames={15 * FPS} // 15 seconds = 450 frames
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
|
||||
{/* Video 2: The Checklist - Wizard walkthrough */}
|
||||
<Composition
|
||||
id="TheChecklist"
|
||||
component={TheChecklist}
|
||||
durationInFrames={20 * FPS} // 20 seconds = 600 frames
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
|
||||
{/* Video 3: The Bucket List - Stadium progress tracking */}
|
||||
<Composition
|
||||
id="TheBucketList"
|
||||
component={TheBucketList}
|
||||
durationInFrames={12 * FPS} // 12 seconds = 360 frames
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
|
||||
{/* Video 4: The Squad - Group polling feature */}
|
||||
<Composition
|
||||
id="TheSquad"
|
||||
component={TheSquad}
|
||||
durationInFrames={18 * FPS} // 18 seconds = 540 frames
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
|
||||
{/* Video 5: The Handoff - PDF export showcase */}
|
||||
<Composition
|
||||
id="TheHandoff"
|
||||
component={TheHandoff}
|
||||
durationInFrames={10 * FPS} // 10 seconds = 300 frames
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
</Folder>
|
||||
</>
|
||||
);
|
||||
};
|
||||
185
marketing-videos/src/components/shared/AppScreenshot.tsx
Normal file
185
marketing-videos/src/components/shared/AppScreenshot.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Img,
|
||||
staticFile,
|
||||
} from "remotion";
|
||||
import { theme } from "./theme";
|
||||
|
||||
type AppScreenshotProps = {
|
||||
src?: string;
|
||||
delay?: number;
|
||||
scale?: number;
|
||||
showDeviceFrame?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AppScreenshot: React.FC<AppScreenshotProps> = ({
|
||||
src,
|
||||
delay = 0,
|
||||
scale = 0.85,
|
||||
showDeviceFrame = true,
|
||||
children,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
|
||||
const progress = spring({
|
||||
frame: frame - delayFrames,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const opacity = interpolate(
|
||||
frame - delayFrames,
|
||||
[0, fps * 0.3],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const scaleValue = interpolate(progress, [0, 1], [0.9, 1]) * scale;
|
||||
|
||||
if (frame < delayFrames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phoneWidth = width * 0.75;
|
||||
const phoneHeight = height * 0.8;
|
||||
const cornerRadius = 60;
|
||||
const bezelWidth = 12;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `scale(${scaleValue})`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{showDeviceFrame ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: phoneWidth,
|
||||
height: phoneHeight,
|
||||
background: "#1C1C1E",
|
||||
borderRadius: cornerRadius,
|
||||
padding: bezelWidth,
|
||||
boxShadow: `
|
||||
0 50px 100px rgba(0, 0, 0, 0.5),
|
||||
0 20px 40px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1)
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{/* Dynamic Island */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: bezelWidth + 15,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 120,
|
||||
height: 36,
|
||||
background: "#000",
|
||||
borderRadius: 18,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Screen content */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: cornerRadius - bezelWidth,
|
||||
overflow: "hidden",
|
||||
background: theme.colors.background,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<Img
|
||||
src={staticFile(src)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Home indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: bezelWidth + 10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 140,
|
||||
height: 5,
|
||||
background: "rgba(255, 255, 255, 0.3)",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: phoneWidth - 40,
|
||||
height: phoneHeight - 40,
|
||||
borderRadius: cornerRadius - 20,
|
||||
overflow: "hidden",
|
||||
background: theme.colors.background,
|
||||
boxShadow: `0 30px 80px rgba(0, 0, 0, 0.4)`,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<Img
|
||||
src={staticFile(src)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock screen content components
|
||||
type MockScreenProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MockScreen: React.FC<MockScreenProps> = ({ children }) => {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: theme.spacing.lg,
|
||||
paddingTop: 80, // Account for Dynamic Island
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
79
marketing-videos/src/components/shared/Background.tsx
Normal file
79
marketing-videos/src/components/shared/Background.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
||||
import { theme } from "./theme";
|
||||
|
||||
type GradientBackgroundProps = {
|
||||
animate?: boolean;
|
||||
};
|
||||
|
||||
export const GradientBackground: React.FC<GradientBackgroundProps> = ({
|
||||
animate = false,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const gradientAngle = animate
|
||||
? interpolate(frame, [0, 300], [180, 200], { extrapolateRight: "clamp" })
|
||||
: 180;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(${gradientAngle}deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Subtle grid pattern background
|
||||
export const GridBackground: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Radial glow background
|
||||
type GlowBackgroundProps = {
|
||||
color?: string;
|
||||
intensity?: number;
|
||||
};
|
||||
|
||||
export const GlowBackground: React.FC<GlowBackgroundProps> = ({
|
||||
color = theme.colors.accent,
|
||||
intensity = 0.15,
|
||||
}) => {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "30%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "150%",
|
||||
height: "80%",
|
||||
background: `radial-gradient(ellipse, ${color}${Math.round(intensity * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
203
marketing-videos/src/components/shared/LogoEndcard.tsx
Normal file
203
marketing-videos/src/components/shared/LogoEndcard.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "./theme";
|
||||
|
||||
type LogoEndcardProps = {
|
||||
tagline?: string;
|
||||
showAppStoreBadge?: boolean;
|
||||
};
|
||||
|
||||
export const LogoEndcard: React.FC<LogoEndcardProps> = ({
|
||||
tagline,
|
||||
showAppStoreBadge = false,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Logo entrance with slight bounce
|
||||
const logoScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
});
|
||||
|
||||
const logoOpacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Tagline entrance (delayed)
|
||||
const taglineProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
delay: fps * 0.4,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const taglineOpacity = interpolate(
|
||||
frame,
|
||||
[fps * 0.4, fps * 0.6],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const taglineY = interpolate(taglineProgress, [0, 1], [20, 0]);
|
||||
|
||||
// App Store badge entrance (delayed further)
|
||||
const badgeProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
delay: fps * 0.7,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const badgeOpacity = interpolate(
|
||||
frame,
|
||||
[fps * 0.7, fps * 0.9],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${theme.colors.backgroundGradientStart} 0%, ${theme.colors.backgroundGradientEnd} 100%)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* App Icon */}
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${logoScale})`,
|
||||
opacity: logoOpacity,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing.lg,
|
||||
}}
|
||||
>
|
||||
{/* Icon placeholder - rounded square with gradient */}
|
||||
<div
|
||||
style={{
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 40,
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
||||
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.4)`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* Simple stadium icon representation */}
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
>
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="60"
|
||||
rx="40"
|
||||
ry="20"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M10 60 L10 40 Q50 10 90 40 L90 60"
|
||||
stroke="white"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1="50"
|
||||
y1="30"
|
||||
x2="50"
|
||||
y2="15"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle cx="50" cy="12" r="4" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Wordmark */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: theme.fontSizes.hero,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
letterSpacing: -2,
|
||||
}}
|
||||
>
|
||||
SportsTime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
{tagline && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: showAppStoreBadge ? 280 : 200,
|
||||
opacity: taglineOpacity,
|
||||
transform: `translateY(${taglineY}px)`,
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: theme.fontSizes.subtitle,
|
||||
fontWeight: 500,
|
||||
color: theme.colors.textSecondary,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{tagline}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* App Store Badge */}
|
||||
{showAppStoreBadge && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 120,
|
||||
opacity: badgeOpacity,
|
||||
transform: `scale(${badgeProgress})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 32px",
|
||||
background: theme.colors.text,
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="black">
|
||||
<path d="M16 2C8.268 2 2 8.268 2 16s6.268 14 14 14 14-6.268 14-14S23.732 2 16 2zm5.788 20.683c-.314.467-.782.7-1.404.7-.311 0-.622-.078-.933-.233l-3.451-1.867-3.451 1.867c-.311.155-.622.233-.933.233-.622 0-1.09-.233-1.404-.7-.314-.467-.389-1.012-.225-1.635l.933-3.917-3.062-2.567c-.467-.389-.7-.856-.7-1.4 0-.544.233-1.012.7-1.4l3.062-2.567-.933-3.917c-.164-.623-.089-1.168.225-1.635.314-.467.782-.7 1.404-.7.311 0 .622.078.933.233L16 5.142l3.451-1.867c.311-.155.622-.233.933-.233.622 0 1.09.233 1.404.7.314.467.389 1.012.225 1.635l-.933 3.917 3.062 2.567c.467.388.7.856.7 1.4 0 .544-.233 1.011-.7 1.4l-3.062 2.567.933 3.917c.164.623.089 1.168-.225 1.635z" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
Download on the App Store
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
173
marketing-videos/src/components/shared/TapIndicator.tsx
Normal file
173
marketing-videos/src/components/shared/TapIndicator.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "./theme";
|
||||
|
||||
type TapIndicatorProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
delay?: number;
|
||||
showRipple?: boolean;
|
||||
};
|
||||
|
||||
export const TapIndicator: React.FC<TapIndicatorProps> = ({
|
||||
x,
|
||||
y,
|
||||
delay = 0,
|
||||
showRipple = true,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
const localFrame = frame - delayFrames;
|
||||
|
||||
if (localFrame < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Finger appears
|
||||
const fingerProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
// Finger presses down
|
||||
const pressProgress = spring({
|
||||
frame: localFrame - fps * 0.2,
|
||||
fps,
|
||||
config: { damping: 30, stiffness: 300 },
|
||||
});
|
||||
|
||||
const fingerScale = interpolate(fingerProgress, [0, 1], [0.5, 1]);
|
||||
const fingerOpacity = interpolate(fingerProgress, [0, 1], [0, 1]);
|
||||
const pressScale = interpolate(pressProgress, [0, 1], [1, 0.9]);
|
||||
|
||||
// Ripple effect
|
||||
const rippleProgress = interpolate(
|
||||
localFrame,
|
||||
[fps * 0.25, fps * 0.7],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const rippleScale = interpolate(rippleProgress, [0, 1], [0.5, 2]);
|
||||
const rippleOpacity = interpolate(rippleProgress, [0, 0.3, 1], [0, 0.5, 0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{/* Ripple */}
|
||||
{showRipple && rippleProgress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.accent,
|
||||
opacity: rippleOpacity,
|
||||
transform: `translate(-50%, -50%) scale(${rippleScale})`,
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finger circle */}
|
||||
<div
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: "50%",
|
||||
background: `rgba(255, 255, 255, 0.9)`,
|
||||
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3)`,
|
||||
opacity: fingerOpacity,
|
||||
transform: `scale(${fingerScale * pressScale})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Swipe indicator for tutorial-style animations
|
||||
type SwipeIndicatorProps = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const SwipeIndicator: React.FC<SwipeIndicatorProps> = ({
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
delay = 0,
|
||||
duration = 0.8,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
const durationFrames = duration * fps;
|
||||
const localFrame = frame - delayFrames;
|
||||
|
||||
if (localFrame < 0 || localFrame > durationFrames + fps * 0.3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progress = interpolate(
|
||||
localFrame,
|
||||
[0, durationFrames],
|
||||
[0, 1],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const x = interpolate(progress, [0, 1], [startX, endX]);
|
||||
const y = interpolate(progress, [0, 1], [startY, endY]);
|
||||
|
||||
const opacity = interpolate(
|
||||
localFrame,
|
||||
[0, fps * 0.1, durationFrames, durationFrames + fps * 0.2],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
pointerEvents: "none",
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: "50%",
|
||||
background: `rgba(255, 255, 255, 0.9)`,
|
||||
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
188
marketing-videos/src/components/shared/TextReveal.tsx
Normal file
188
marketing-videos/src/components/shared/TextReveal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "./theme";
|
||||
|
||||
type TextRevealProps = {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontWeight?: number;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
delay?: number;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const TextReveal: React.FC<TextRevealProps> = ({
|
||||
text,
|
||||
fontSize = theme.fontSizes.title,
|
||||
color = theme.colors.text,
|
||||
fontWeight = 700,
|
||||
textAlign = "center",
|
||||
delay = 0,
|
||||
style = {},
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
|
||||
// Spring for smooth entrance
|
||||
const progress = spring({
|
||||
frame: frame - delayFrames,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const opacity = interpolate(
|
||||
frame - delayFrames,
|
||||
[0, fps * 0.3],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
||||
|
||||
if (frame < delayFrames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
color,
|
||||
textAlign,
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
letterSpacing: -1,
|
||||
lineHeight: 1.2,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Multi-line version with staggered reveal
|
||||
type TextRevealMultilineProps = {
|
||||
lines: string[];
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontWeight?: number;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
staggerDelay?: number;
|
||||
startDelay?: number;
|
||||
lineHeight?: number;
|
||||
};
|
||||
|
||||
export const TextRevealMultiline: React.FC<TextRevealMultilineProps> = ({
|
||||
lines,
|
||||
fontSize = theme.fontSizes.title,
|
||||
color = theme.colors.text,
|
||||
fontWeight = 700,
|
||||
textAlign = "center",
|
||||
staggerDelay = 0.1,
|
||||
startDelay = 0,
|
||||
lineHeight = 1.3,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: textAlign === "center" ? "center" : textAlign === "right" ? "flex-end" : "flex-start",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<TextReveal
|
||||
key={index}
|
||||
text={line}
|
||||
fontSize={fontSize}
|
||||
color={color}
|
||||
fontWeight={fontWeight}
|
||||
textAlign={textAlign}
|
||||
delay={startDelay + index * staggerDelay}
|
||||
style={{ lineHeight }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Highlight style text (with background)
|
||||
type HighlightTextProps = {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export const HighlightText: React.FC<HighlightTextProps> = ({
|
||||
text,
|
||||
fontSize = theme.fontSizes.subtitle,
|
||||
delay = 0,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
|
||||
const progress = spring({
|
||||
frame: frame - delayFrames,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const scaleX = interpolate(progress, [0, 1], [0, 1]);
|
||||
const textOpacity = interpolate(
|
||||
frame - delayFrames,
|
||||
[fps * 0.15, fps * 0.3],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
if (frame < delayFrames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", display: "inline-block" }}>
|
||||
{/* Background highlight */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: -12,
|
||||
right: -12,
|
||||
bottom: 0,
|
||||
background: theme.colors.accent,
|
||||
borderRadius: 8,
|
||||
transform: `scaleX(${scaleX})`,
|
||||
transformOrigin: "left",
|
||||
}}
|
||||
/>
|
||||
{/* Text */}
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
opacity: textOpacity,
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
marketing-videos/src/components/shared/index.ts
Normal file
6
marketing-videos/src/components/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { theme } from "./theme";
|
||||
export { LogoEndcard } from "./LogoEndcard";
|
||||
export { TextReveal, TextRevealMultiline, HighlightText } from "./TextReveal";
|
||||
export { TapIndicator, SwipeIndicator } from "./TapIndicator";
|
||||
export { AppScreenshot, MockScreen } from "./AppScreenshot";
|
||||
export { GradientBackground, GridBackground, GlowBackground } from "./Background";
|
||||
47
marketing-videos/src/components/shared/theme.ts
Normal file
47
marketing-videos/src/components/shared/theme.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// SportsTime Marketing Video Theme
|
||||
export const theme = {
|
||||
colors: {
|
||||
background: "#0A0A0A",
|
||||
backgroundGradientStart: "#0A0A0A",
|
||||
backgroundGradientEnd: "#1A1A2E",
|
||||
accent: "#FF6B35",
|
||||
accentDark: "#E55A25",
|
||||
secondary: "#4ECDC4",
|
||||
secondaryDark: "#3DBDB5",
|
||||
text: "#FFFFFF",
|
||||
textSecondary: "#B0B0B0",
|
||||
textMuted: "#6B6B6B",
|
||||
success: "#4CAF50",
|
||||
gold: "#FFD700",
|
||||
mapLine: "#FF6B35",
|
||||
mapMarker: "#4ECDC4",
|
||||
},
|
||||
fonts: {
|
||||
display: "SF Pro Display, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
text: "SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
},
|
||||
fontSizes: {
|
||||
hero: 72,
|
||||
headline: 56,
|
||||
title: 48,
|
||||
subtitle: 36,
|
||||
body: 28,
|
||||
caption: 20,
|
||||
},
|
||||
spacing: {
|
||||
xs: 8,
|
||||
sm: 16,
|
||||
md: 24,
|
||||
lg: 40,
|
||||
xl: 64,
|
||||
xxl: 96,
|
||||
},
|
||||
animation: {
|
||||
smooth: { damping: 200 },
|
||||
snappy: { damping: 20, stiffness: 200 },
|
||||
bouncy: { damping: 8 },
|
||||
heavy: { damping: 15, stiffness: 80, mass: 2 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Theme = typeof theme;
|
||||
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);
|
||||
236
marketing-videos/src/videos/TheBucketList/AchievementBadge.tsx
Normal file
236
marketing-videos/src/videos/TheBucketList/AchievementBadge.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type AchievementBadgeProps = {
|
||||
name: string;
|
||||
description?: string;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export const AchievementBadge: React.FC<AchievementBadgeProps> = ({
|
||||
name,
|
||||
description = "Achievement Unlocked",
|
||||
delay = 0,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
const localFrame = frame - delayFrames;
|
||||
|
||||
if (localFrame < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Badge entrance with bounce
|
||||
const entranceProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 150 },
|
||||
});
|
||||
|
||||
const scale = interpolate(entranceProgress, [0, 1], [0.3, 1]);
|
||||
const opacity = interpolate(entranceProgress, [0, 0.5, 1], [0, 1, 1]);
|
||||
|
||||
// Shimmer animation
|
||||
const shimmerProgress = interpolate(
|
||||
localFrame,
|
||||
[fps * 0.5, fps * 1.5],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const shimmerX = interpolate(shimmerProgress, [0, 1], [-200, width + 200]);
|
||||
|
||||
// Glow pulse
|
||||
const glowPulse = interpolate(
|
||||
(localFrame % 40) / 40,
|
||||
[0, 0.5, 1],
|
||||
[0.3, 0.6, 0.3]
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 32,
|
||||
}}
|
||||
>
|
||||
{/* Achievement badge */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 200,
|
||||
height: 200,
|
||||
}}
|
||||
>
|
||||
{/* Outer glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: -20,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${theme.colors.gold}${Math.round(glowPulse * 255).toString(16).padStart(2, "0")} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Badge background */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
borderRadius: "50%",
|
||||
background: `linear-gradient(135deg, ${theme.colors.gold} 0%, #B8860B 50%, ${theme.colors.gold} 100%)`,
|
||||
boxShadow: `
|
||||
0 10px 40px rgba(255, 215, 0, 0.4),
|
||||
inset 0 -4px 10px rgba(0, 0, 0, 0.2),
|
||||
inset 0 4px 10px rgba(255, 255, 255, 0.3)
|
||||
`,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Shimmer overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: shimmerX,
|
||||
width: 100,
|
||||
height: "100%",
|
||||
background: `linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)`,
|
||||
transform: "skewX(-20deg)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inner circle */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 15,
|
||||
borderRadius: "50%",
|
||||
background: `linear-gradient(180deg, #1C1C1E 0%, #2C2C2E 100%)`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
border: `3px solid ${theme.colors.gold}`,
|
||||
}}
|
||||
>
|
||||
{/* Trophy icon */}
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 15c3.866 0 7-3.134 7-7V4H5v4c0 3.866 3.134 7 7 7z"
|
||||
fill={theme.colors.gold}
|
||||
/>
|
||||
<path
|
||||
d="M5 4H3v4c0 1.657 1.343 3 3 3V4zM19 4h2v4c0 1.657-1.343 3-3 3V4z"
|
||||
fill={theme.colors.gold}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<rect x="10" y="15" width="4" height="4" fill={theme.colors.gold} />
|
||||
<rect x="8" y="19" width="8" height="2" rx="1" fill={theme.colors.gold} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Achievement text */}
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.gold,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 3,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 40,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple text overlay for bucket list tagline
|
||||
export const CollectThemAll: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const progress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Collect them all.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 24,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Track every stadium. Earn every badge.
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
284
marketing-videos/src/videos/TheBucketList/ProgressMap.tsx
Normal file
284
marketing-videos/src/videos/TheBucketList/ProgressMap.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type StadiumMarker = {
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
visited: boolean;
|
||||
};
|
||||
|
||||
export const ProgressMap: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
// Stadium positions (relative to container)
|
||||
const stadiums: StadiumMarker[] = [
|
||||
// Visited (green)
|
||||
{ x: 0.15, y: 0.42, name: "LA", visited: true },
|
||||
{ x: 0.22, y: 0.35, name: "SF", visited: true },
|
||||
{ x: 0.28, y: 0.52, name: "Phoenix", visited: true },
|
||||
{ x: 0.35, y: 0.3, name: "Denver", visited: true },
|
||||
{ x: 0.48, y: 0.45, name: "Dallas", visited: true },
|
||||
{ x: 0.55, y: 0.35, name: "KC", visited: true },
|
||||
{ x: 0.62, y: 0.38, name: "STL", visited: true },
|
||||
{ x: 0.68, y: 0.32, name: "Chicago", visited: true },
|
||||
{ x: 0.72, y: 0.36, name: "Detroit", visited: true },
|
||||
{ x: 0.78, y: 0.42, name: "Cleveland", visited: true },
|
||||
{ x: 0.82, y: 0.38, name: "Pittsburgh", visited: true },
|
||||
{ x: 0.88, y: 0.35, name: "NYC", visited: true },
|
||||
// Not visited (gray)
|
||||
{ x: 0.25, y: 0.28, name: "Seattle", visited: false },
|
||||
{ x: 0.38, y: 0.42, name: "Houston", visited: false },
|
||||
{ x: 0.52, y: 0.3, name: "Minneapolis", visited: false },
|
||||
{ x: 0.58, y: 0.48, name: "Atlanta", visited: false },
|
||||
{ x: 0.65, y: 0.28, name: "Milwaukee", visited: false },
|
||||
{ x: 0.75, y: 0.28, name: "Toronto", visited: false },
|
||||
{ x: 0.84, y: 0.32, name: "Boston", visited: false },
|
||||
{ x: 0.66, y: 0.52, name: "Miami", visited: false },
|
||||
];
|
||||
|
||||
const visitedCount = stadiums.filter((s) => s.visited).length;
|
||||
const totalCount = stadiums.length;
|
||||
|
||||
// Animation for markers appearing
|
||||
const mapEntranceProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const mapScale = interpolate(mapEntranceProgress, [0, 1], [0.9, 1]);
|
||||
const mapOpacity = interpolate(mapEntranceProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Counter animation
|
||||
const counterProgress = spring({
|
||||
frame: frame - fps * 0.5,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 30,
|
||||
paddingTop: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 36,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Your Stadium Map
|
||||
</div>
|
||||
|
||||
{/* Progress counter */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: 8,
|
||||
opacity: counterProgress,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
{visitedCount}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 24,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
/ {totalCount}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: theme.colors.textSecondary,
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
stadiums visited
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map container */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
transform: `scale(${mapScale})`,
|
||||
opacity: mapOpacity,
|
||||
}}
|
||||
>
|
||||
{/* Simplified US outline */}
|
||||
<svg
|
||||
viewBox="0 0 100 60"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 20,
|
||||
opacity: 0.2,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M10 25 Q15 15 30 12 Q50 8 70 12 Q85 15 90 25 Q92 35 88 45 Q80 52 65 50 Q50 55 35 50 Q20 52 12 45 Q8 35 10 25 Z"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Stadium markers */}
|
||||
{stadiums.map((stadium, index) => {
|
||||
const markerDelay = index * 2;
|
||||
const markerProgress = spring({
|
||||
frame: frame - markerDelay,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const markerScale = interpolate(markerProgress, [0, 1], [0, 1]);
|
||||
const markerOpacity = interpolate(markerProgress, [0, 1], [0, 1]);
|
||||
|
||||
// Pulse effect for visited stadiums
|
||||
const pulseProgress = stadium.visited
|
||||
? interpolate(
|
||||
(frame + index * 10) % 60,
|
||||
[0, 30, 60],
|
||||
[1, 1.3, 1]
|
||||
)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${stadium.x * 100}%`,
|
||||
top: `${stadium.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) scale(${markerScale})`,
|
||||
opacity: markerOpacity,
|
||||
}}
|
||||
>
|
||||
{/* Pulse ring for visited */}
|
||||
{stadium.visited && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.success,
|
||||
opacity: 0.3,
|
||||
transform: `translate(-50%, -50%) scale(${pulseProgress})`,
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Marker dot */}
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: stadium.visited
|
||||
? theme.colors.success
|
||||
: theme.colors.textMuted,
|
||||
border: `2px solid ${stadium.visited ? "white" : theme.colors.textMuted}`,
|
||||
boxShadow: stadium.visited
|
||||
? `0 2px 8px rgba(76, 175, 80, 0.5)`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 40,
|
||||
marginTop: 24,
|
||||
opacity: counterProgress,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.success,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Visited
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.textMuted,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Remaining
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
266
marketing-videos/src/videos/TheBucketList/StadiumStamp.tsx
Normal file
266
marketing-videos/src/videos/TheBucketList/StadiumStamp.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type Stadium = {
|
||||
name: string;
|
||||
city: string;
|
||||
team: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type StadiumStampProps = {
|
||||
stadium: Stadium;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export const StadiumStamp: React.FC<StadiumStampProps> = ({
|
||||
stadium,
|
||||
delay = 0,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const delayFrames = delay * fps;
|
||||
const localFrame = frame - delayFrames;
|
||||
|
||||
if (localFrame < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Stamp slam animation (fast drop with bounce)
|
||||
const slamProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 8, stiffness: 300 },
|
||||
});
|
||||
|
||||
// Stamp comes from above
|
||||
const translateY = interpolate(slamProgress, [0, 1], [-200, 0]);
|
||||
const scale = interpolate(slamProgress, [0, 0.8, 1], [1.5, 1.1, 1]);
|
||||
const rotation = interpolate(slamProgress, [0, 1], [-5, 0]);
|
||||
|
||||
// Ink bleed effect
|
||||
const inkProgress = interpolate(
|
||||
localFrame,
|
||||
[5, 25],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const inkScale = interpolate(inkProgress, [0, 1], [0.8, 1.05]);
|
||||
const inkOpacity = interpolate(inkProgress, [0, 0.5, 1], [0, 0.15, 0.1]);
|
||||
|
||||
// Opacity for initial appearance
|
||||
const opacity = interpolate(localFrame, [0, 3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{/* Ink bleed effect */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 320,
|
||||
height: 320,
|
||||
borderRadius: "50%",
|
||||
background: stadium.color,
|
||||
opacity: inkOpacity,
|
||||
transform: `scale(${inkScale})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stamp */}
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${translateY}px) scale(${scale}) rotate(${rotation}deg)`,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
border: `8px solid ${stadium.color}`,
|
||||
background: "rgba(255,255,255,0.95)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
boxShadow: localFrame > 5 ? `0 4px 20px rgba(0,0,0,0.2)` : "none",
|
||||
}}
|
||||
>
|
||||
{/* Stadium name */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 22,
|
||||
fontWeight: 800,
|
||||
color: stadium.color,
|
||||
textAlign: "center",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 2,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{stadium.name}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 3,
|
||||
background: stadium.color,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{stadium.city}
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
textAlign: "center",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{stadium.team}
|
||||
</div>
|
||||
|
||||
{/* Visited badge */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "6px 16px",
|
||||
background: stadium.color,
|
||||
borderRadius: 20,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
Visited
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Page flip transition for passport effect
|
||||
type PassportPageProps = {
|
||||
children: React.ReactNode;
|
||||
pageIndex: number;
|
||||
};
|
||||
|
||||
export const PassportPage: React.FC<PassportPageProps> = ({
|
||||
children,
|
||||
pageIndex,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
// Each page flips after its stamp animation completes
|
||||
const flipDelay = pageIndex * fps * 1.2;
|
||||
const localFrame = frame - flipDelay;
|
||||
|
||||
// Page flip animation (0 = flat, 1 = fully flipped)
|
||||
const flipProgress = interpolate(
|
||||
localFrame,
|
||||
[fps * 0.8, fps * 1.1],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
// Only show page before it's fully flipped
|
||||
if (flipProgress >= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3D flip effect
|
||||
const rotateY = interpolate(flipProgress, [0, 1], [0, -180]);
|
||||
const zIndex = 100 - pageIndex;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
perspective: 1500,
|
||||
perspectiveOrigin: "0% 50%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transformStyle: "preserve-3d",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotateY(${rotateY}deg)`,
|
||||
zIndex,
|
||||
}}
|
||||
>
|
||||
{/* Page front */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backfaceVisibility: "hidden",
|
||||
background: "#F5F0E6", // Passport paper color
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Page back (blank) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(180deg)",
|
||||
background: "#F5F0E6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
160
marketing-videos/src/videos/TheBucketList/index.tsx
Normal file
160
marketing-videos/src/videos/TheBucketList/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Sequence,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
|
||||
import { StadiumStamp, PassportPage } from "./StadiumStamp";
|
||||
import { ProgressMap } from "./ProgressMap";
|
||||
import { AchievementBadge, CollectThemAll } from "./AchievementBadge";
|
||||
import {
|
||||
GradientBackground,
|
||||
LogoEndcard,
|
||||
TextReveal,
|
||||
theme,
|
||||
} from "../../components/shared";
|
||||
|
||||
// Stadium data
|
||||
const stamps = [
|
||||
{ name: "Wrigley Field", city: "Chicago, IL", team: "Cubs", color: "#0E3386" },
|
||||
{ name: "Fenway Park", city: "Boston, MA", team: "Red Sox", color: "#BD3039" },
|
||||
{ name: "Dodger Stadium", city: "Los Angeles, CA", team: "Dodgers", color: "#005A9C" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Video 3: "The Bucket List"
|
||||
*
|
||||
* Goal: Emotionally connect with stadium-chasers, highlight progress tracking
|
||||
* Length: 12 seconds (360 frames at 30fps)
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:02 (0-60): First stamp slams (Wrigley Field)
|
||||
* - 0:02-0:04 (60-120): Page flip, second stamp (Fenway)
|
||||
* - 0:04-0:06 (120-180): Third stamp (Dodger Stadium)
|
||||
* - 0:06-0:08 (180-240): Progress map reveal with counter
|
||||
* - 0:08-0:10 (240-300): Achievement badge unlock
|
||||
* - 0:10-0:12 (300-360): Logo endcard
|
||||
*/
|
||||
export const TheBucketList: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION_DURATION = 12;
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
stamp1: 1.5 * fps, // 45 frames
|
||||
stamp2: 1.2 * fps, // 36 frames
|
||||
stamp3: 1.2 * fps, // 36 frames
|
||||
progressMap: 2.5 * fps, // 75 frames
|
||||
achievement: 2.5 * fps, // 75 frames
|
||||
logo: 3.1 * fps, // 93 frames
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: First stamp - Wrigley Field */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp1}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: "#F5F0E6", // Passport paper color
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<StadiumStamp stadium={stamps[0]} delay={0} />
|
||||
</AbsoluteFill>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 8 })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: Second stamp - Fenway Park */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp2}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: "#F5F0E6",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<StadiumStamp stadium={stamps[1]} delay={0} />
|
||||
</AbsoluteFill>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 8 })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: Third stamp - Dodger Stadium */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stamp3}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: "#F5F0E6",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<StadiumStamp stadium={stamps[2]} delay={0} />
|
||||
</AbsoluteFill>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: Progress map with counter */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.progressMap}>
|
||||
<ProgressMap />
|
||||
{/* Counter text overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 180,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
text="12 down. 8 to go."
|
||||
fontSize={36}
|
||||
color={theme.colors.text}
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: Achievement badge unlock */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.achievement}>
|
||||
<AchievementBadge
|
||||
name="West Coast Complete"
|
||||
description="Achievement Unlocked"
|
||||
delay={0}
|
||||
/>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 6: Logo endcard */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
||||
<LogoEndcard tagline="Your stadium passport." />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
241
marketing-videos/src/videos/TheChecklist/ChecklistIntro.tsx
Normal file
241
marketing-videos/src/videos/TheChecklist/ChecklistIntro.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
const checklistItems = [
|
||||
{ label: "Sports", icon: "🏀" },
|
||||
{ label: "Dates", icon: "📅" },
|
||||
{ label: "Cities", icon: "🏙️" },
|
||||
{ label: "Teams", icon: "⚾" },
|
||||
];
|
||||
|
||||
export const ChecklistIntro: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{checklistItems.map((item, index) => {
|
||||
// Stagger each item
|
||||
const itemDelay = index * 8; // 8 frames between each
|
||||
const localFrame = frame - itemDelay;
|
||||
|
||||
// Item entrance
|
||||
const entranceProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
// Checkmark appears shortly after entrance
|
||||
const checkDelay = 12;
|
||||
const checkProgress = spring({
|
||||
frame: localFrame - checkDelay,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 200 },
|
||||
});
|
||||
|
||||
const opacity = interpolate(
|
||||
localFrame,
|
||||
[0, 8],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const translateX = interpolate(entranceProgress, [0, 1], [-100, 0]);
|
||||
const checkScale = interpolate(checkProgress, [0, 1], [0, 1]);
|
||||
const checkOpacity = interpolate(checkProgress, [0, 0.5, 1], [0, 1, 1]);
|
||||
|
||||
// Strike-through effect
|
||||
const strikeWidth = interpolate(
|
||||
checkProgress,
|
||||
[0.5, 1],
|
||||
[0, 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
opacity,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
border: `3px solid ${checkProgress > 0 ? theme.colors.success : theme.colors.textMuted}`,
|
||||
background: checkProgress > 0 ? theme.colors.success : "transparent",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
transition: "background 0.1s, border-color 0.1s",
|
||||
}}
|
||||
>
|
||||
{/* Checkmark */}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{
|
||||
transform: `scale(${checkScale})`,
|
||||
opacity: checkOpacity,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M5 12l5 5L19 7"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div style={{ position: "relative" }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: checkProgress > 0.5 ? theme.colors.textMuted : theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{/* Strike-through */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: 0,
|
||||
height: 4,
|
||||
width: `${strikeWidth}%`,
|
||||
background: theme.colors.success,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Exit animation for checklist
|
||||
export const ChecklistExit: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{checklistItems.map((item, index) => {
|
||||
// Stagger each item exit
|
||||
const itemDelay = index * 4;
|
||||
const localFrame = frame - itemDelay;
|
||||
|
||||
const exitProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 150 },
|
||||
});
|
||||
|
||||
const translateX = interpolate(exitProgress, [0, 1], [0, 200]);
|
||||
const opacity = interpolate(exitProgress, [0, 0.8, 1], [1, 0.5, 0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
opacity,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Checkbox (checked) */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
border: `3px solid ${theme.colors.success}`,
|
||||
background: theme.colors.success,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M5 12l5 5L19 7"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
287
marketing-videos/src/videos/TheChecklist/ItineraryReveal.tsx
Normal file
287
marketing-videos/src/videos/TheChecklist/ItineraryReveal.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
// Confetti particle component
|
||||
const Confetti: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
const particles = React.useMemo(() => {
|
||||
const colors = [theme.colors.accent, theme.colors.secondary, theme.colors.gold, "#FF69B4"];
|
||||
return Array.from({ length: 30 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * width,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
size: 8 + Math.random() * 12,
|
||||
rotation: Math.random() * 360,
|
||||
speed: 0.5 + Math.random() * 0.5,
|
||||
delay: Math.random() * 10,
|
||||
}));
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{particles.map((particle) => {
|
||||
const localFrame = frame - particle.delay;
|
||||
if (localFrame < 0) return null;
|
||||
|
||||
const y = interpolate(
|
||||
localFrame,
|
||||
[0, fps * 2],
|
||||
[-50, height + 100],
|
||||
{ extrapolateRight: "clamp" }
|
||||
) * particle.speed;
|
||||
|
||||
const rotation = particle.rotation + localFrame * 3;
|
||||
const opacity = interpolate(
|
||||
localFrame,
|
||||
[0, fps * 0.5, fps * 1.5, fps * 2],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={particle.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: particle.x,
|
||||
top: y,
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
background: particle.color,
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItineraryReveal: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Loading spinner phase
|
||||
const loadingDuration = fps * 0.8;
|
||||
const isLoading = frame < loadingDuration;
|
||||
|
||||
// Reveal phase
|
||||
const revealProgress = spring({
|
||||
frame: frame - loadingDuration,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const cardScale = interpolate(revealProgress, [0, 1], [0.8, 1]);
|
||||
const cardOpacity = interpolate(revealProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
{/* Confetti (appears after reveal) */}
|
||||
{!isLoading && <Confetti />}
|
||||
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
// Loading spinner
|
||||
<LoadingSpinner frame={frame} />
|
||||
) : (
|
||||
// Itinerary card
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${cardScale})`,
|
||||
opacity: cardOpacity,
|
||||
width: "85%",
|
||||
maxWidth: 700,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 28,
|
||||
padding: 36,
|
||||
boxShadow: "0 30px 80px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Success header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.success,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M5 12l5 5L19 7"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Trip Generated!
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
West Coast Basketball Tour
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini itinerary preview */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ day: "Day 1", city: "Los Angeles", game: "Lakers vs Celtics" },
|
||||
{ day: "Day 2", city: "Phoenix", game: "Suns vs Warriors" },
|
||||
{ day: "Day 3", city: "Denver", game: "Nuggets vs Heat" },
|
||||
].map((item, index) => {
|
||||
const itemProgress = spring({
|
||||
frame: frame - loadingDuration - index * 8,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const itemOpacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
||||
const itemTranslate = interpolate(itemProgress, [0, 1], [20, 0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
borderRadius: 12,
|
||||
opacity: itemOpacity,
|
||||
transform: `translateX(${itemTranslate}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.accent,
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{item.day} • {item.city}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{item.game}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingSpinner: React.FC<{ frame: number }> = ({ frame }) => {
|
||||
const rotation = (frame * 8) % 360;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: "50%",
|
||||
border: `4px solid ${theme.colors.textMuted}`,
|
||||
borderTopColor: theme.colors.accent,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Generating your trip...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
621
marketing-videos/src/videos/TheChecklist/WizardSteps.tsx
Normal file
621
marketing-videos/src/videos/TheChecklist/WizardSteps.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
||||
|
||||
type WizardStepProps = {
|
||||
step: "sports" | "dates" | "regions" | "review";
|
||||
};
|
||||
|
||||
export const WizardStep: React.FC<WizardStepProps> = ({ step }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
const contentMap = {
|
||||
sports: <SportsStep />,
|
||||
dates: <DatesStep />,
|
||||
regions: <RegionsStep />,
|
||||
review: <ReviewStep />,
|
||||
};
|
||||
|
||||
const tapPositions = {
|
||||
sports: { x: width * 0.3, y: height * 0.42 },
|
||||
dates: { x: width * 0.65, y: height * 0.45 },
|
||||
regions: { x: width * 0.35, y: height * 0.48 },
|
||||
review: { x: width * 0.5, y: height * 0.7 },
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{contentMap[step]}
|
||||
<TapIndicator
|
||||
x={tapPositions[step].x}
|
||||
y={tapPositions[step].y}
|
||||
delay={0.5}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const SportsStep: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const sports = [
|
||||
{ name: "MLB", color: "#002D72", selected: false },
|
||||
{ name: "NBA", color: "#C9082A", selected: true },
|
||||
{ name: "NFL", color: "#013369", selected: false },
|
||||
{ name: "NHL", color: "#000000", selected: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 60,
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 48 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Choose Sports
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Select the leagues you want to see
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sport toggles */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{sports.map((sport, index) => {
|
||||
const itemProgress = spring({
|
||||
frame: frame - index * 5,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const scale = interpolate(itemProgress, [0, 1], [0.9, 1]);
|
||||
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
||||
|
||||
// NBA gets selected animation
|
||||
const isNBA = sport.name === "NBA";
|
||||
const selectProgress = spring({
|
||||
frame: frame - fps * 0.7,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const isSelected = isNBA ? selectProgress > 0.5 : sport.selected;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sport.name}
|
||||
style={{
|
||||
background: isSelected ? sport.color : "#1C1C1E",
|
||||
borderRadius: 20,
|
||||
padding: 32,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
border: isSelected ? `3px solid ${theme.colors.text}` : "3px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{sport.name}
|
||||
</span>
|
||||
|
||||
{/* Toggle indicator */}
|
||||
<div
|
||||
style={{
|
||||
width: 60,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
background: isSelected ? theme.colors.success : "rgba(255,255,255,0.2)",
|
||||
padding: 3,
|
||||
display: "flex",
|
||||
alignItems: isSelected ? "center" : "center",
|
||||
justifyContent: isSelected ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const DatesStep: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Calendar highlight animation
|
||||
const highlightProgress = spring({
|
||||
frame: frame - fps * 0.5,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const highlightWidth = interpolate(highlightProgress, [0, 1], [0, 100]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 60,
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 48 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Pick Your Window
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
When do you want to travel?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini calendar */}
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 24,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
June 2026
|
||||
</div>
|
||||
|
||||
{/* Week days header */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{["S", "M", "T", "W", "T", "F", "S"].map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textMuted,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar dates */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7, 1fr)",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* Empty cells for start of month */}
|
||||
{[...Array(1)].map((_, i) => (
|
||||
<div key={`empty-${i}`} />
|
||||
))}
|
||||
|
||||
{/* Dates */}
|
||||
{[...Array(30)].map((_, i) => {
|
||||
const date = i + 1;
|
||||
const isInRange = date >= 14 && date <= 17;
|
||||
const rangeProgress = isInRange ? highlightWidth / 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
background: isInRange && rangeProgress > 0
|
||||
? theme.colors.accent
|
||||
: "transparent",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
fontWeight: isInRange ? 600 : 400,
|
||||
color: isInRange && rangeProgress > 0
|
||||
? theme.colors.text
|
||||
: theme.colors.textSecondary,
|
||||
opacity: isInRange ? 1 : rangeProgress > 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{date}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected range indicator */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 32,
|
||||
padding: 24,
|
||||
background: "rgba(255,107,53,0.1)",
|
||||
borderRadius: 16,
|
||||
borderLeft: `4px solid ${theme.colors.accent}`,
|
||||
opacity: highlightProgress,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
June 14 – June 17, 2026
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
marginLeft: 16,
|
||||
}}
|
||||
>
|
||||
4 days
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const RegionsStep: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width } = useVideoConfig();
|
||||
|
||||
const regions = [
|
||||
{ name: "Northeast", selected: false },
|
||||
{ name: "Southeast", selected: false },
|
||||
{ name: "Midwest", selected: false },
|
||||
{ name: "West Coast", selected: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 60,
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 48 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Choose Your Territory
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Select regions to explore
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Region buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{regions.map((region, index) => {
|
||||
const itemProgress = spring({
|
||||
frame: frame - index * 5,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
// West Coast gets selected animation
|
||||
const isWestCoast = region.name === "West Coast";
|
||||
const selectProgress = spring({
|
||||
frame: frame - fps * 0.7,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const isSelected = isWestCoast ? selectProgress > 0.5 : region.selected;
|
||||
|
||||
const scale = interpolate(itemProgress, [0, 1], [0.95, 1]);
|
||||
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.name}
|
||||
style={{
|
||||
background: isSelected ? theme.colors.accent : "#1C1C1E",
|
||||
borderRadius: 16,
|
||||
padding: 28,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{region.name}
|
||||
</span>
|
||||
|
||||
{isSelected && (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M5 12l5 5L19 7"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const ReviewStep: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const stats = [
|
||||
{ label: "Games", value: "4" },
|
||||
{ label: "Cities", value: "3" },
|
||||
{ label: "Days", value: "6" },
|
||||
];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 60,
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 48 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Review. Confirm. Done.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Your trip summary
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 20,
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{stats.map((stat, index) => {
|
||||
const itemProgress = spring({
|
||||
frame: frame - index * 8,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const scale = interpolate(itemProgress, [0, 1], [0.8, 1]);
|
||||
const opacity = interpolate(itemProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 20,
|
||||
padding: 28,
|
||||
textAlign: "center",
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.accent,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Generate button */}
|
||||
<GenerateButton />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const GenerateButton: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Button appears
|
||||
const buttonProgress = spring({
|
||||
frame: frame - fps * 0.5,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
// Pulse effect
|
||||
const pulseProgress = spring({
|
||||
frame: frame - fps * 1,
|
||||
fps,
|
||||
config: { damping: 8, stiffness: 80 },
|
||||
});
|
||||
|
||||
const pulseScale = interpolate(
|
||||
pulseProgress,
|
||||
[0, 0.5, 1],
|
||||
[1, 1.05, 1]
|
||||
);
|
||||
|
||||
const opacity = interpolate(buttonProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
opacity,
|
||||
transform: `scale(${pulseScale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${theme.colors.accent} 0%, ${theme.colors.accentDark} 100%)`,
|
||||
borderRadius: 20,
|
||||
padding: "28px 48px",
|
||||
textAlign: "center",
|
||||
boxShadow: `0 10px 40px rgba(255, 107, 53, 0.4)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Generate Trip
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
131
marketing-videos/src/videos/TheChecklist/index.tsx
Normal file
131
marketing-videos/src/videos/TheChecklist/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
import { ChecklistIntro, ChecklistExit } from "./ChecklistIntro";
|
||||
import { WizardStep } from "./WizardSteps";
|
||||
import { ItineraryReveal } from "./ItineraryReveal";
|
||||
import {
|
||||
GradientBackground,
|
||||
LogoEndcard,
|
||||
} from "../../components/shared";
|
||||
|
||||
/**
|
||||
* Video 2: "The Checklist"
|
||||
*
|
||||
* Goal: Showcase step-by-step wizard, reassure power planners
|
||||
* Length: 20 seconds (600 frames at 30fps)
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:02 (0-60): Checklist items check off rapidly
|
||||
* - 0:02-0:05 (60-150): Sports selection step
|
||||
* - 0:05-0:08 (150-240): Dates step with calendar
|
||||
* - 0:08-0:11 (240-330): Regions step
|
||||
* - 0:11-0:14 (330-420): Review step with generate button
|
||||
* - 0:14-0:17 (420-510): Itinerary reveal with confetti
|
||||
* - 0:17-0:20 (510-600): Logo endcard
|
||||
*/
|
||||
export const TheChecklist: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION_DURATION = 15;
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
checklistIntro: 2 * fps, // 60 frames
|
||||
checklistExit: 0.5 * fps, // 15 frames
|
||||
sportsStep: 2.5 * fps, // 75 frames
|
||||
datesStep: 2.5 * fps, // 75 frames
|
||||
regionsStep: 2.5 * fps, // 75 frames
|
||||
reviewStep: 3 * fps, // 90 frames
|
||||
itineraryReveal: 3 * fps, // 90 frames
|
||||
logo: 3.5 * fps, // 105 frames
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<GradientBackground />
|
||||
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: Checklist intro animation */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.checklistIntro}>
|
||||
<ChecklistIntro />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: Checklist exit */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.checklistExit}>
|
||||
<ChecklistExit />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: Sports selection */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.sportsStep}>
|
||||
<WizardStep step="sports" />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-bottom" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: Dates selection */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.datesStep}>
|
||||
<WizardStep step="dates" />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-bottom" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: Regions selection */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.regionsStep}>
|
||||
<WizardStep step="regions" />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-bottom" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 6: Review step */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.reviewStep}>
|
||||
<WizardStep step="review" />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 7: Itinerary reveal with confetti */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.itineraryReveal}>
|
||||
<ItineraryReveal />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 8: Logo endcard */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
||||
<LogoEndcard />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
332
marketing-videos/src/videos/TheHandoff/ExportAnimation.tsx
Normal file
332
marketing-videos/src/videos/TheHandoff/ExportAnimation.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
import { TapIndicator } from "../../components/shared/TapIndicator";
|
||||
|
||||
export const ExportTrigger: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
// Menu appearance
|
||||
const menuProgress = spring({
|
||||
frame: frame - fps * 0.5,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const menuOpacity = interpolate(menuProgress, [0, 1], [0, 1]);
|
||||
const menuTranslateY = interpolate(menuProgress, [0, 1], [100, 0]);
|
||||
|
||||
// PDF option highlight
|
||||
const highlightProgress = spring({
|
||||
frame: frame - fps * 1,
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 150 },
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 40,
|
||||
paddingTop: 80,
|
||||
}}
|
||||
>
|
||||
{/* Mock trip detail view */}
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
West Coast Tour
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
June 14-17, 2026 • 4 cities • 3 games
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share button area */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 24px",
|
||||
background: theme.colors.accent,
|
||||
borderRadius: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share menu (slides up) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#2C2C2E",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
padding: 24,
|
||||
transform: `translateY(${menuTranslateY}px)`,
|
||||
opacity: menuOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 5,
|
||||
background: "rgba(255,255,255,0.3)",
|
||||
borderRadius: 3,
|
||||
margin: "0 auto 24px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
Share Trip
|
||||
</div>
|
||||
|
||||
{/* Share options */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{[
|
||||
{ icon: "📄", label: "Export PDF", highlight: true },
|
||||
{ icon: "📱", label: "Share Link", highlight: false },
|
||||
{ icon: "📋", label: "Copy Details", highlight: false },
|
||||
].map((option, index) => {
|
||||
const isHighlighted = option.highlight && highlightProgress > 0.5;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
background: isHighlighted
|
||||
? theme.colors.accent
|
||||
: "rgba(255,255,255,0.05)",
|
||||
borderRadius: 14,
|
||||
border: isHighlighted
|
||||
? `2px solid ${theme.colors.text}`
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 24 }}>{option.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.text,
|
||||
fontWeight: isHighlighted ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tap indicators */}
|
||||
<TapIndicator x={width - 100} y={185} delay={0} />
|
||||
<TapIndicator x={width / 2} y={height - 200} delay={1} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const PDFIconFly: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, height } = useVideoConfig();
|
||||
|
||||
// Icon materializes
|
||||
const materializeProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
// Pulse
|
||||
const pulseProgress = spring({
|
||||
frame: frame - fps * 0.3,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 200 },
|
||||
});
|
||||
|
||||
const pulseScale = interpolate(pulseProgress, [0, 0.5, 1], [1, 1.15, 1]);
|
||||
|
||||
// Fly away
|
||||
const flyProgress = interpolate(
|
||||
frame,
|
||||
[fps * 0.6, fps * 1.2],
|
||||
[0, 1],
|
||||
{
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.in(Easing.quad),
|
||||
}
|
||||
);
|
||||
|
||||
const translateY = interpolate(flyProgress, [0, 1], [0, -height]);
|
||||
const scale = interpolate(materializeProgress, [0, 1], [0.5, 1]) * pulseScale;
|
||||
const opacity = interpolate(flyProgress, [0, 0.8, 1], [1, 1, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${translateY}px) scale(${scale})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{/* PDF Icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 140,
|
||||
height: 180,
|
||||
background: "white",
|
||||
borderRadius: 12,
|
||||
position: "relative",
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{/* Folded corner */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
background: "#E0E0E0",
|
||||
clipPath: "polygon(100% 0, 100% 100%, 0 100%)",
|
||||
borderBottomLeftRadius: 8,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* PDF badge */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
padding: "12px 0",
|
||||
background: theme.colors.accent,
|
||||
borderRadius: 8,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content lines */}
|
||||
<div
|
||||
style={{
|
||||
padding: "40px 20px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
background: "#DDD",
|
||||
borderRadius: 4,
|
||||
width: "80%",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
background: "#DDD",
|
||||
borderRadius: 4,
|
||||
width: "60%",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
background: "#DDD",
|
||||
borderRadius: 4,
|
||||
width: "70%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
365
marketing-videos/src/videos/TheHandoff/PDFDocumentFan.tsx
Normal file
365
marketing-videos/src/videos/TheHandoff/PDFDocumentFan.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type PDFPage = {
|
||||
title: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
const pages: PDFPage[] = [
|
||||
{ title: "Day-by-Day", icon: "📅" },
|
||||
{ title: "Route Map", icon: "🗺️" },
|
||||
{ title: "Cover", icon: "🏟️" },
|
||||
];
|
||||
|
||||
export const PDFDocumentFan: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
{/* Desk surface */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "70%",
|
||||
background: "linear-gradient(180deg, #2C2420 0%, #1A1714 100%)",
|
||||
borderTop: "3px solid rgba(255,255,255,0.05)",
|
||||
}}
|
||||
>
|
||||
{/* Wood grain texture (subtle) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
background: `repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 40px,
|
||||
rgba(255,255,255,0.03) 40px,
|
||||
rgba(255,255,255,0.03) 42px
|
||||
)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PDF Pages fanning out */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
perspective: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 500,
|
||||
height: 650,
|
||||
marginTop: 100,
|
||||
}}
|
||||
>
|
||||
{pages.map((page, index) => {
|
||||
// Stagger each page
|
||||
const pageDelay = (pages.length - 1 - index) * 8;
|
||||
const localFrame = frame - pageDelay;
|
||||
|
||||
const pageProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
// Fan out rotation and position
|
||||
const baseRotation = (index - 1) * -8; // -8, 0, 8 degrees
|
||||
const rotation = interpolate(pageProgress, [0, 1], [0, baseRotation]);
|
||||
const translateX = interpolate(pageProgress, [0, 1], [0, (index - 1) * 60]);
|
||||
const translateZ = interpolate(pageProgress, [0, 1], [0, -index * 5]);
|
||||
|
||||
// Land on desk
|
||||
const landProgress = spring({
|
||||
frame: localFrame - 10,
|
||||
fps,
|
||||
config: { damping: 25, stiffness: 150 },
|
||||
});
|
||||
|
||||
const dropY = interpolate(landProgress, [0, 1], [-200, 0]);
|
||||
const scale = interpolate(pageProgress, [0, 1], [0.9, 1]);
|
||||
const opacity = interpolate(pageProgress, [0, 0.3, 1], [0, 1, 1]);
|
||||
|
||||
// Shadow grows as page lands
|
||||
const shadowOpacity = interpolate(landProgress, [0, 1], [0, 0.3]);
|
||||
const shadowBlur = interpolate(landProgress, [0, 1], [10, 30]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transformStyle: "preserve-3d",
|
||||
transform: `
|
||||
translateX(${translateX}px)
|
||||
translateY(${dropY}px)
|
||||
translateZ(${translateZ}px)
|
||||
rotateZ(${rotation}deg)
|
||||
scale(${scale})
|
||||
`,
|
||||
opacity,
|
||||
zIndex: pages.length - index,
|
||||
}}
|
||||
>
|
||||
{/* Shadow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 20,
|
||||
background: "black",
|
||||
borderRadius: 16,
|
||||
filter: `blur(${shadowBlur}px)`,
|
||||
opacity: shadowOpacity,
|
||||
transform: "translateY(20px)",
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Page */}
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "white",
|
||||
borderRadius: 16,
|
||||
padding: 32,
|
||||
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Page header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
paddingBottom: 16,
|
||||
borderBottom: "2px solid #EEE",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 32 }}>{page.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
{page.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Page content placeholder */}
|
||||
<div style={{ flex: 1 }}>
|
||||
{index === 2 ? (
|
||||
// Cover page
|
||||
<CoverPageContent />
|
||||
) : index === 1 ? (
|
||||
// Map page
|
||||
<MapPageContent />
|
||||
) : (
|
||||
// Day-by-day page
|
||||
<DayByDayContent />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SportsTime branding */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingTop: 16,
|
||||
borderTop: "1px solid #EEE",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
SportsTime
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
Page {pages.length - index}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
const CoverPageContent: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 20,
|
||||
background: theme.colors.accent,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 48 }}>🏟️</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
West Coast Tour
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
June 14-17, 2026
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MapPageContent: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
background: "#F0F0F0",
|
||||
borderRadius: 12,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Simplified map */}
|
||||
<svg
|
||||
viewBox="0 0 100 80"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Route line */}
|
||||
<path
|
||||
d="M20 30 L40 35 L60 25 L80 40"
|
||||
fill="none"
|
||||
stroke={theme.colors.accent}
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
{/* City markers */}
|
||||
{[
|
||||
{ x: 20, y: 30 },
|
||||
{ x: 40, y: 35 },
|
||||
{ x: 60, y: 25 },
|
||||
{ x: 80, y: 40 },
|
||||
].map((pos, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="4"
|
||||
fill={theme.colors.secondary}
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DayByDayContent: React.FC = () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{["Day 1 - Los Angeles", "Day 2 - Phoenix", "Day 3 - Denver"].map(
|
||||
(day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "#F8F8F8",
|
||||
borderRadius: 8,
|
||||
borderLeft: `3px solid ${theme.colors.accent}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Game @ 7:00 PM • Stadium details
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
100
marketing-videos/src/videos/TheHandoff/index.tsx
Normal file
100
marketing-videos/src/videos/TheHandoff/index.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
|
||||
import { ExportTrigger, PDFIconFly } from "./ExportAnimation";
|
||||
import { PDFDocumentFan } from "./PDFDocumentFan";
|
||||
import {
|
||||
GradientBackground,
|
||||
LogoEndcard,
|
||||
TextReveal,
|
||||
theme,
|
||||
} from "../../components/shared";
|
||||
|
||||
/**
|
||||
* Video 5: "The Handoff"
|
||||
*
|
||||
* Goal: Showcase PDF export, deliver tangible output promise
|
||||
* Length: 10 seconds (300 frames at 30fps)
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:02 (0-60): Export trigger (share button tap)
|
||||
* - 0:02-0:04 (60-120): PDF icon materializes and flies
|
||||
* - 0:04-0:07 (120-210): PDF pages fan out on desk
|
||||
* - 0:07-0:10 (210-300): Logo endcard
|
||||
*/
|
||||
export const TheHandoff: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION_DURATION = 10;
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
exportTrigger: 2 * fps, // 60 frames
|
||||
pdfFly: 1.5 * fps, // 45 frames
|
||||
documentFan: 3.5 * fps, // 105 frames
|
||||
logo: 3 * fps, // 90 frames
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<GradientBackground />
|
||||
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: Export trigger */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.exportTrigger}>
|
||||
<ExportTrigger />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: PDF icon materializes and flies */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pdfFly}>
|
||||
<PDFIconFly />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: PDF pages fan out */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.documentFan}>
|
||||
<PDFDocumentFan />
|
||||
{/* Text overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
text="Print it. Share it. Live it."
|
||||
fontSize={36}
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: Logo endcard */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
||||
<LogoEndcard tagline="Your trip, anywhere." />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
194
marketing-videos/src/videos/TheRoute/GameCardReveal.tsx
Normal file
194
marketing-videos/src/videos/TheRoute/GameCardReveal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
export const GameCardReveal: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const cardScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const opacity = interpolate(frame, [0, fps * 0.3], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${cardScale})`,
|
||||
opacity,
|
||||
width: 700,
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Game card header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
MLB • Yankee Stadium
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.accent,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
7:05 PM
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Teams */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 40,
|
||||
}}
|
||||
>
|
||||
{/* Away team */}
|
||||
<div style={{ flex: 1, textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: "50%",
|
||||
background: "#BD3039",
|
||||
margin: "0 auto 16px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
BOS
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Red Sox
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VS */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
VS
|
||||
</div>
|
||||
|
||||
{/* Home team */}
|
||||
<div style={{ flex: 1, textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: "50%",
|
||||
background: "#003087",
|
||||
margin: "0 auto 16px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
NYY
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Yankees
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
paddingTop: 24,
|
||||
borderTop: "1px solid rgba(255,255,255,0.1)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Saturday, June 14, 2026
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
335
marketing-videos/src/videos/TheRoute/MapScene.tsx
Normal file
335
marketing-videos/src/videos/TheRoute/MapScene.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
Easing,
|
||||
continueRender,
|
||||
delayRender,
|
||||
} from "remotion";
|
||||
import mapboxgl, { Map } from "mapbox-gl";
|
||||
import * as turf from "@turf/turf";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
// Stadium coordinates: NYC (Yankee Stadium) → Chicago (Wrigley) → Denver (Coors) → LA (Dodger Stadium)
|
||||
const stadiumCoordinates: [number, number][] = [
|
||||
[-73.9262, 40.8296], // Yankee Stadium, NYC
|
||||
[-87.6555, 41.9484], // Wrigley Field, Chicago
|
||||
[-104.9942, 39.7559], // Coors Field, Denver
|
||||
[-118.24, 34.0739], // Dodger Stadium, LA
|
||||
];
|
||||
|
||||
const stadiumNames = ["NYC", "Chicago", "Denver", "Los Angeles"];
|
||||
|
||||
// Only render map if token is available
|
||||
const MAPBOX_TOKEN = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
type MapSceneProps = {
|
||||
animationDuration?: number; // in seconds
|
||||
};
|
||||
|
||||
export const MapScene: React.FC<MapSceneProps> = ({
|
||||
animationDuration = 2,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [handle] = useState(() => delayRender("Loading map..."));
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
|
||||
// Calculate route progress
|
||||
const progress = interpolate(
|
||||
frame,
|
||||
[0, animationDuration * fps],
|
||||
[0, 1],
|
||||
{
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}
|
||||
);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!ref.current || !MAPBOX_TOKEN) {
|
||||
// If no token, continue render without map
|
||||
continueRender(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
mapboxgl.accessToken = MAPBOX_TOKEN;
|
||||
|
||||
const _map = new Map({
|
||||
container: ref.current,
|
||||
zoom: 3.5,
|
||||
center: [-98, 39], // Center of US
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
style: "mapbox://styles/mapbox/dark-v11",
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
});
|
||||
|
||||
_map.on("style.load", () => {
|
||||
// Add route line source
|
||||
_map.addSource("route", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add route line layer
|
||||
_map.addLayer({
|
||||
id: "route-line",
|
||||
type: "line",
|
||||
source: "route",
|
||||
paint: {
|
||||
"line-color": theme.colors.mapLine,
|
||||
"line-width": 6,
|
||||
"line-opacity": 0.9,
|
||||
},
|
||||
layout: {
|
||||
"line-cap": "round",
|
||||
"line-join": "round",
|
||||
},
|
||||
});
|
||||
|
||||
// Add stadium markers source
|
||||
_map.addSource("stadiums", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: stadiumCoordinates.map((coord, i) => ({
|
||||
type: "Feature" as const,
|
||||
properties: { name: stadiumNames[i], index: i },
|
||||
geometry: { type: "Point" as const, coordinates: coord },
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
// Add stadium markers layer (initially hidden, revealed as route progresses)
|
||||
_map.addLayer({
|
||||
id: "stadium-markers",
|
||||
type: "circle",
|
||||
source: "stadiums",
|
||||
paint: {
|
||||
"circle-radius": 12,
|
||||
"circle-color": theme.colors.mapMarker,
|
||||
"circle-stroke-width": 3,
|
||||
"circle-stroke-color": "#FFFFFF",
|
||||
"circle-opacity": 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Add stadium labels
|
||||
_map.addLayer({
|
||||
id: "stadium-labels",
|
||||
type: "symbol",
|
||||
source: "stadiums",
|
||||
layout: {
|
||||
"text-field": ["get", "name"],
|
||||
"text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
|
||||
"text-size": 16,
|
||||
"text-offset": [0, 1.5],
|
||||
"text-anchor": "top",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#FFFFFF",
|
||||
"text-halo-color": "#000000",
|
||||
"text-halo-width": 2,
|
||||
"text-opacity": 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
_map.on("load", () => {
|
||||
continueRender(handle);
|
||||
setMap(_map);
|
||||
setMapLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Don't remove map - causes issues with Remotion
|
||||
};
|
||||
}, [handle]);
|
||||
|
||||
// Animate route and markers
|
||||
useEffect(() => {
|
||||
if (!map || !mapLoaded) return;
|
||||
|
||||
const animHandle = delayRender("Animating route...");
|
||||
|
||||
// Create the route line using turf for geodesic path
|
||||
const routeLine = turf.lineString(stadiumCoordinates);
|
||||
const routeDistance = turf.length(routeLine);
|
||||
|
||||
// Calculate current distance along route
|
||||
const currentDistance = Math.max(0.001, routeDistance * progress);
|
||||
|
||||
// Slice the line to current progress
|
||||
const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
|
||||
|
||||
// Update route line
|
||||
const routeSource = map.getSource("route") as mapboxgl.GeoJSONSource;
|
||||
if (routeSource) {
|
||||
routeSource.setData(slicedLine);
|
||||
}
|
||||
|
||||
// Calculate which stadiums should be visible
|
||||
const totalSegments = stadiumCoordinates.length - 1;
|
||||
const currentSegment = progress * totalSegments;
|
||||
|
||||
// Update marker opacities based on route progress
|
||||
stadiumCoordinates.forEach((_, index) => {
|
||||
const segmentProgress = index === 0 ? 0 : index / totalSegments;
|
||||
const shouldShow = progress >= segmentProgress;
|
||||
const opacity = shouldShow ? 1 : 0;
|
||||
|
||||
// Use filter to show/hide markers based on index
|
||||
map.setPaintProperty("stadium-markers", "circle-opacity", [
|
||||
"case",
|
||||
["<=", ["get", "index"], Math.floor(currentSegment) + (progress > 0 ? 1 : 0) - 1],
|
||||
1,
|
||||
["==", ["get", "index"], 0],
|
||||
progress > 0.01 ? 1 : 0,
|
||||
0,
|
||||
]);
|
||||
|
||||
map.setPaintProperty("stadium-labels", "text-opacity", [
|
||||
"case",
|
||||
["<=", ["get", "index"], Math.floor(currentSegment) + (progress > 0 ? 1 : 0) - 1],
|
||||
1,
|
||||
["==", ["get", "index"], 0],
|
||||
progress > 0.01 ? 1 : 0,
|
||||
0,
|
||||
]);
|
||||
});
|
||||
|
||||
map.once("idle", () => continueRender(animHandle));
|
||||
}, [map, mapLoaded, progress]);
|
||||
|
||||
const style: React.CSSProperties = useMemo(
|
||||
() => ({
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
}),
|
||||
[width, height]
|
||||
);
|
||||
|
||||
// Fallback if no Mapbox token
|
||||
if (!MAPBOX_TOKEN) {
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: "#1a1a2e",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FallbackMapAnimation progress={progress} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
return <div ref={ref} style={style} />;
|
||||
};
|
||||
|
||||
// Fallback SVG map animation when Mapbox token is not available
|
||||
const FallbackMapAnimation: React.FC<{ progress: number }> = ({ progress }) => {
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
// Simplified US map points
|
||||
const points = [
|
||||
{ x: width * 0.8, y: height * 0.35, name: "NYC" },
|
||||
{ x: width * 0.55, y: height * 0.35, name: "Chicago" },
|
||||
{ x: width * 0.4, y: height * 0.4, name: "Denver" },
|
||||
{ x: width * 0.2, y: height * 0.45, name: "Los Angeles" },
|
||||
];
|
||||
|
||||
// Calculate which segments to show
|
||||
const totalSegments = points.length - 1;
|
||||
const currentProgress = progress * totalSegments;
|
||||
|
||||
// Build path
|
||||
let pathD = `M ${points[0].x} ${points[0].y}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (currentProgress >= i - 1) {
|
||||
const segmentProgress = Math.min(1, currentProgress - (i - 1));
|
||||
const prevPoint = points[i - 1];
|
||||
const currPoint = points[i];
|
||||
const x = prevPoint.x + (currPoint.x - prevPoint.x) * segmentProgress;
|
||||
const y = prevPoint.y + (currPoint.y - prevPoint.y) * segmentProgress;
|
||||
pathD += ` L ${x} ${y}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
style={{ position: "absolute" }}
|
||||
>
|
||||
{/* Background US shape (simplified) */}
|
||||
<ellipse
|
||||
cx={width * 0.5}
|
||||
cy={height * 0.45}
|
||||
rx={width * 0.4}
|
||||
ry={height * 0.25}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Route line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={theme.colors.mapLine}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Stadium markers */}
|
||||
{points.map((point, index) => {
|
||||
const segmentThreshold = index === 0 ? 0.01 : index / totalSegments;
|
||||
const isVisible = progress >= segmentThreshold;
|
||||
const opacity = isVisible ? 1 : 0;
|
||||
|
||||
return (
|
||||
<g key={index} opacity={opacity}>
|
||||
<circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="16"
|
||||
fill={theme.colors.mapMarker}
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<text
|
||||
x={point.x}
|
||||
y={point.y + 35}
|
||||
fill="#FFFFFF"
|
||||
fontSize="20"
|
||||
fontFamily="system-ui"
|
||||
fontWeight="bold"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{point.name}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
287
marketing-videos/src/videos/TheRoute/TimelineSlide.tsx
Normal file
287
marketing-videos/src/videos/TheRoute/TimelineSlide.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type TimelineDay = {
|
||||
day: string;
|
||||
date: string;
|
||||
city: string;
|
||||
game?: {
|
||||
teams: string;
|
||||
time: string;
|
||||
sport: string;
|
||||
};
|
||||
isTravel?: boolean;
|
||||
};
|
||||
|
||||
const itinerary: TimelineDay[] = [
|
||||
{
|
||||
day: "Day 1",
|
||||
date: "Jun 14",
|
||||
city: "New York",
|
||||
game: { teams: "Red Sox @ Yankees", time: "7:05 PM", sport: "MLB" },
|
||||
},
|
||||
{
|
||||
day: "Day 2",
|
||||
date: "Jun 15",
|
||||
city: "Chicago",
|
||||
isTravel: true,
|
||||
},
|
||||
{
|
||||
day: "Day 3",
|
||||
date: "Jun 16",
|
||||
city: "Chicago",
|
||||
game: { teams: "Cardinals @ Cubs", time: "2:20 PM", sport: "MLB" },
|
||||
},
|
||||
{
|
||||
day: "Day 4",
|
||||
date: "Jun 17",
|
||||
city: "Denver",
|
||||
game: { teams: "Dodgers @ Rockies", time: "6:40 PM", sport: "MLB" },
|
||||
},
|
||||
];
|
||||
|
||||
export const TimelineSlide: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 60,
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Your Trip
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
4 days • 4 cities • 3 games
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
{itinerary.map((day, index) => {
|
||||
const itemDelay = index * 0.1;
|
||||
const itemProgress = spring({
|
||||
frame: frame - itemDelay * fps,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const opacity = interpolate(
|
||||
frame - itemDelay * fps,
|
||||
[0, fps * 0.2],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const translateX = interpolate(itemProgress, [0, 1], [50, 0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
gap: 20,
|
||||
opacity,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Timeline indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: day.game
|
||||
? theme.colors.accent
|
||||
: theme.colors.textMuted,
|
||||
}}
|
||||
/>
|
||||
{index < itinerary.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
width: 2,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
marginTop: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day card */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
borderLeft: day.game
|
||||
? `4px solid ${theme.colors.accent}`
|
||||
: "4px solid transparent",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: day.game ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{day.day}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{day.date}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.secondary,
|
||||
}}
|
||||
>
|
||||
{day.city}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{day.game && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{day.game.teams}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.accent,
|
||||
background: "rgba(255,107,53,0.2)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{day.game.sport}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{day.game.time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{day.isTravel && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2L4 12h3v8h10v-8h3L12 2z"
|
||||
fill={theme.colors.textMuted}
|
||||
transform="rotate(90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
Travel day
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
118
marketing-videos/src/videos/TheRoute/index.tsx
Normal file
118
marketing-videos/src/videos/TheRoute/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Sequence,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
import { MapScene } from "./MapScene";
|
||||
import { GameCardReveal } from "./GameCardReveal";
|
||||
import { TimelineSlide } from "./TimelineSlide";
|
||||
import {
|
||||
GradientBackground,
|
||||
TextReveal,
|
||||
LogoEndcard,
|
||||
theme,
|
||||
} from "../../components/shared";
|
||||
|
||||
/**
|
||||
* Video 1: "The Route"
|
||||
*
|
||||
* Goal: Establish brand identity and core value proposition
|
||||
* Length: 15 seconds (450 frames at 30fps)
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:02 (0-60): Map animation with route drawing
|
||||
* - 0:02-0:05 (60-150): Zoom into game card
|
||||
* - 0:05-0:08 (150-240): Timeline view slide in
|
||||
* - 0:08-0:11 (240-330): Text: "Your entire trip. One tap."
|
||||
* - 0:11-0:15 (330-450): Logo endcard
|
||||
*/
|
||||
export const TheRoute: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
map: 2.5 * fps, // 75 frames
|
||||
gameCard: 2.5 * fps, // 75 frames
|
||||
timeline: 3 * fps, // 90 frames
|
||||
tagline: 3 * fps, // 90 frames
|
||||
logo: 4 * fps, // 120 frames
|
||||
};
|
||||
|
||||
const TRANSITION_DURATION = 12;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<GradientBackground />
|
||||
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: Map with route animation */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.map}>
|
||||
<MapScene animationDuration={2} />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: Game card reveal */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.gameCard}>
|
||||
<GameCardReveal />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-right" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: Timeline view */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.timeline}>
|
||||
<TimelineSlide />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: Tagline */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.tagline}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
text="Your entire trip. One tap."
|
||||
fontSize={56}
|
||||
/>
|
||||
<div style={{ position: "absolute", bottom: 200 }}>
|
||||
<TextReveal
|
||||
text="Plan it. See it. Live it."
|
||||
fontSize={32}
|
||||
color={theme.colors.textSecondary}
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: Logo endcard */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
||||
<LogoEndcard showAppStoreBadge />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
219
marketing-videos/src/videos/TheSquad/ChatBubbles.tsx
Normal file
219
marketing-videos/src/videos/TheSquad/ChatBubbles.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type Message = {
|
||||
text: string;
|
||||
sender: string;
|
||||
color: string;
|
||||
side: "left" | "right";
|
||||
};
|
||||
|
||||
const messages: Message[] = [
|
||||
{ text: "Lakers game?", sender: "Mike", color: "#552583", side: "left" },
|
||||
{ text: "Dodgers?", sender: "Sarah", color: "#005A9C", side: "right" },
|
||||
{ text: "Both??", sender: "Jake", color: "#007AFF", side: "left" },
|
||||
{ text: "When though", sender: "Sarah", color: "#005A9C", side: "right" },
|
||||
{ text: "idk June maybe", sender: "Mike", color: "#552583", side: "left" },
|
||||
{ text: "too many options 😩", sender: "Jake", color: "#007AFF", side: "right" },
|
||||
];
|
||||
|
||||
export const ChatBubbles: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 40,
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
{/* Header - looks like a group chat */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 40,
|
||||
paddingBottom: 20,
|
||||
borderBottom: "1px solid rgba(255,255,255,0.1)",
|
||||
}}
|
||||
>
|
||||
{/* Avatar stack */}
|
||||
<div style={{ display: "flex", marginLeft: 20 }}>
|
||||
{["#FF6B6B", "#4ECDC4", "#45B7D1"].map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
border: `3px solid ${theme.colors.background}`,
|
||||
marginLeft: i > 0 ? -15 : 0,
|
||||
zIndex: 3 - i,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
LA Trip Planning
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
3 people
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
// Stagger each message
|
||||
const messageDelay = index * 6;
|
||||
const localFrame = frame - messageDelay;
|
||||
|
||||
const entranceProgress = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const opacity = interpolate(
|
||||
localFrame,
|
||||
[0, 5],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
const translateX = message.side === "left"
|
||||
? interpolate(entranceProgress, [0, 1], [-50, 0])
|
||||
: interpolate(entranceProgress, [0, 1], [50, 0]);
|
||||
|
||||
const scale = interpolate(entranceProgress, [0, 1], [0.8, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: message.side === "left" ? "flex-start" : "flex-end",
|
||||
opacity,
|
||||
transform: `translateX(${translateX}px) scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "75%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: message.side === "left" ? "flex-start" : "flex-end",
|
||||
}}
|
||||
>
|
||||
{/* Sender name */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
marginBottom: 4,
|
||||
marginLeft: message.side === "left" ? 12 : 0,
|
||||
marginRight: message.side === "right" ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{message.sender}
|
||||
</span>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
style={{
|
||||
background: message.side === "left" ? "#2C2C2E" : message.color,
|
||||
padding: "14px 20px",
|
||||
borderRadius: 22,
|
||||
borderBottomLeftRadius: message.side === "left" ? 6 : 22,
|
||||
borderBottomRightRadius: message.side === "right" ? 6 : 22,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// "Stop the chaos" text overlay
|
||||
export const StopTheChaos: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const progress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
||||
const translateY = interpolate(progress, [0, 1], [30, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Stop the chaos.
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
438
marketing-videos/src/videos/TheSquad/PollAnimation.tsx
Normal file
438
marketing-videos/src/videos/TheSquad/PollAnimation.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
type PollOption = {
|
||||
game: string;
|
||||
league: string;
|
||||
votes: { name: string; avatar: string }[];
|
||||
percentage: number;
|
||||
isWinner?: boolean;
|
||||
};
|
||||
|
||||
const pollOptions: PollOption[] = [
|
||||
{
|
||||
game: "Lakers vs Celtics",
|
||||
league: "NBA",
|
||||
votes: [
|
||||
{ name: "Mike", avatar: "#FF6B6B" },
|
||||
{ name: "Sarah", avatar: "#4ECDC4" },
|
||||
{ name: "Jake", avatar: "#45B7D1" },
|
||||
],
|
||||
percentage: 100,
|
||||
isWinner: true,
|
||||
},
|
||||
{
|
||||
game: "Dodgers vs Giants",
|
||||
league: "MLB",
|
||||
votes: [
|
||||
{ name: "Sarah", avatar: "#4ECDC4" },
|
||||
],
|
||||
percentage: 33,
|
||||
},
|
||||
];
|
||||
|
||||
export const PollCreation: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const entranceProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const scale = interpolate(entranceProgress, [0, 1], [0.95, 1]);
|
||||
const opacity = interpolate(entranceProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 40,
|
||||
paddingTop: 80,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{/* Poll card */}
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
LA Trip - Which games?
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
Vote for your favorites
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{pollOptions.map((option, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
border: `2px solid ${option.isWinner ? theme.colors.accent : "transparent"}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 12,
|
||||
color: theme.colors.accent,
|
||||
background: "rgba(255,107,53,0.2)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{option.league}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 20,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{option.game}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Vote checkbox */}
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
border: `2px solid ${theme.colors.textMuted}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Share button */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: theme.colors.accent,
|
||||
borderRadius: 14,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
Share Poll
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const PollVoting: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
padding: 40,
|
||||
paddingTop: 80,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
LA Trip - Which games?
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 16,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
3 votes cast
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options with votes */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{pollOptions.map((option, index) => {
|
||||
// Animate votes appearing
|
||||
const voteDelay = index * fps * 0.3;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
border: option.isWinner
|
||||
? `2px solid ${theme.colors.accent}`
|
||||
: "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
{/* Game info */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 12,
|
||||
color: theme.colors.accent,
|
||||
background: "rgba(255,107,53,0.2)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 6,
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{option.league}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
>
|
||||
{option.game}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{option.isWinner && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.success,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
✓ Winner
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<PollProgressBar
|
||||
percentage={option.percentage}
|
||||
delay={voteDelay}
|
||||
isWinner={option.isWinner}
|
||||
/>
|
||||
|
||||
{/* Voter avatars */}
|
||||
<VoterAvatars
|
||||
votes={option.votes}
|
||||
startDelay={voteDelay + fps * 0.2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
type PollProgressBarProps = {
|
||||
percentage: number;
|
||||
delay: number;
|
||||
isWinner?: boolean;
|
||||
};
|
||||
|
||||
const PollProgressBar: React.FC<PollProgressBarProps> = ({
|
||||
percentage,
|
||||
delay,
|
||||
isWinner,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const progress = spring({
|
||||
frame: frame - delay,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const width = interpolate(progress, [0, 1], [0, percentage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 8,
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${width}%`,
|
||||
background: isWinner
|
||||
? theme.colors.accent
|
||||
: theme.colors.secondary,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VoterAvatarsProps = {
|
||||
votes: { name: string; avatar: string }[];
|
||||
startDelay: number;
|
||||
};
|
||||
|
||||
const VoterAvatars: React.FC<VoterAvatarsProps> = ({ votes, startDelay }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ display: "flex", marginLeft: 8 }}>
|
||||
{votes.map((vote, i) => {
|
||||
const avatarProgress = spring({
|
||||
frame: frame - startDelay - i * 5,
|
||||
fps,
|
||||
config: theme.animation.snappy,
|
||||
});
|
||||
|
||||
const scale = interpolate(avatarProgress, [0, 1], [0, 1]);
|
||||
const opacity = interpolate(avatarProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: vote.avatar,
|
||||
border: `2px solid ${theme.colors.background}`,
|
||||
marginLeft: i > 0 ? -10 : 0,
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{vote.name[0]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
}}
|
||||
>
|
||||
{votes.length} vote{votes.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
258
marketing-videos/src/videos/TheSquad/ResultsReveal.tsx
Normal file
258
marketing-videos/src/videos/TheSquad/ResultsReveal.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from "remotion";
|
||||
import { theme } from "../../components/shared/theme";
|
||||
|
||||
// Confetti particles
|
||||
const Confetti: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
const particles = React.useMemo(() => {
|
||||
const colors = [theme.colors.accent, theme.colors.secondary, theme.colors.gold, "#9B59B6"];
|
||||
return Array.from({ length: 25 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * width,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
size: 6 + Math.random() * 10,
|
||||
rotation: Math.random() * 360,
|
||||
speed: 0.4 + Math.random() * 0.4,
|
||||
delay: Math.random() * 8,
|
||||
}));
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{particles.map((particle) => {
|
||||
const localFrame = frame - particle.delay;
|
||||
if (localFrame < 0) return null;
|
||||
|
||||
const y = interpolate(
|
||||
localFrame,
|
||||
[0, fps * 2],
|
||||
[-50, height + 100],
|
||||
{ extrapolateRight: "clamp" }
|
||||
) * particle.speed;
|
||||
|
||||
const rotation = particle.rotation + localFrame * 4;
|
||||
const opacity = interpolate(
|
||||
localFrame,
|
||||
[0, fps * 0.3, fps * 1.5, fps * 2],
|
||||
[0, 1, 1, 0],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={particle.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: particle.x,
|
||||
top: y,
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
background: particle.color,
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResultsReveal: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Winner card entrance
|
||||
const cardProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 120 },
|
||||
});
|
||||
|
||||
const cardScale = interpolate(cardProgress, [0, 1], [0.8, 1]);
|
||||
const cardOpacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
||||
|
||||
// "Democracy wins" text entrance
|
||||
const textProgress = spring({
|
||||
frame: frame - fps * 0.6,
|
||||
fps,
|
||||
config: theme.animation.smooth,
|
||||
});
|
||||
|
||||
const textOpacity = interpolate(textProgress, [0, 1], [0, 1]);
|
||||
const textY = interpolate(textProgress, [0, 1], [20, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
>
|
||||
{/* Confetti */}
|
||||
<Confetti />
|
||||
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
{/* Winner card */}
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${cardScale})`,
|
||||
opacity: cardOpacity,
|
||||
width: "90%",
|
||||
maxWidth: 600,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#1C1C1E",
|
||||
borderRadius: 28,
|
||||
padding: 40,
|
||||
border: `3px solid ${theme.colors.accent}`,
|
||||
boxShadow: `0 20px 60px rgba(255, 107, 53, 0.3)`,
|
||||
}}
|
||||
>
|
||||
{/* Winner badge */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
background: theme.colors.accent,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.accent,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 2,
|
||||
}}
|
||||
>
|
||||
Winner
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Winning game */}
|
||||
<div style={{ textAlign: "center", marginBottom: 24 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 14,
|
||||
color: theme.colors.textMuted,
|
||||
background: "rgba(255,107,53,0.2)",
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
NBA
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 36,
|
||||
fontWeight: 700,
|
||||
color: theme.colors.text,
|
||||
textAlign: "center",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Lakers vs Celtics
|
||||
</div>
|
||||
|
||||
{/* Vote count */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Voter avatars */}
|
||||
<div style={{ display: "flex" }}>
|
||||
{["#FF6B6B", "#4ECDC4", "#45B7D1"].map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
border: `2px solid #1C1C1E`,
|
||||
marginLeft: i > 0 ? -10 : 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.text,
|
||||
fontSize: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
3 votes • 100%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "Democracy wins" text */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 150,
|
||||
opacity: textOpacity,
|
||||
transform: `translateY(${textY}px)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: theme.fonts.display,
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
color: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
Democracy wins.
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
142
marketing-videos/src/videos/TheSquad/index.tsx
Normal file
142
marketing-videos/src/videos/TheSquad/index.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
import { ChatBubbles, StopTheChaos } from "./ChatBubbles";
|
||||
import { PollCreation, PollVoting } from "./PollAnimation";
|
||||
import { ResultsReveal } from "./ResultsReveal";
|
||||
import {
|
||||
GradientBackground,
|
||||
LogoEndcard,
|
||||
TextReveal,
|
||||
theme,
|
||||
} from "../../components/shared";
|
||||
|
||||
/**
|
||||
* Video 4: "The Squad"
|
||||
*
|
||||
* Goal: Promote group polling feature, position app for friend group planning
|
||||
* Length: 18 seconds (540 frames at 30fps)
|
||||
*
|
||||
* Scene breakdown:
|
||||
* - 0:00-0:02 (0-60): Chat bubbles chaos
|
||||
* - 0:02-0:05 (60-150): "Stop the chaos" + Poll creation
|
||||
* - 0:05-0:09 (150-270): Votes animating in
|
||||
* - 0:09-0:13 (270-390): Results reveal with confetti
|
||||
* - 0:13-0:16 (390-480): Trip planned text
|
||||
* - 0:16-0:18 (480-540): Logo endcard
|
||||
*/
|
||||
export const TheSquad: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const TRANSITION_DURATION = 12;
|
||||
|
||||
const SCENE_DURATIONS = {
|
||||
chatBubbles: 2.5 * fps, // 75 frames
|
||||
stopChaos: 1 * fps, // 30 frames
|
||||
pollCreation: 2 * fps, // 60 frames
|
||||
pollVoting: 3 * fps, // 90 frames
|
||||
results: 3.5 * fps, // 105 frames
|
||||
tripPlanned: 2.5 * fps, // 75 frames
|
||||
logo: 3.5 * fps, // 105 frames
|
||||
};
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<GradientBackground />
|
||||
|
||||
<TransitionSeries>
|
||||
{/* Scene 1: Chat bubbles chaos */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.chatBubbles}>
|
||||
<ChatBubbles />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 2: "Stop the chaos" */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.stopChaos}>
|
||||
<StopTheChaos />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 3: Poll creation UI */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pollCreation}>
|
||||
<PollCreation />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-right" })}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 4: Votes animating in */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.pollVoting}>
|
||||
<PollVoting />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 5: Results reveal with confetti */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.results}>
|
||||
<ResultsReveal />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 6: Trip planned text */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.tripPlanned}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<TextReveal
|
||||
text="Trip planned."
|
||||
fontSize={48}
|
||||
/>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<TextReveal
|
||||
text="Friends aligned."
|
||||
fontSize={48}
|
||||
color={theme.colors.accent}
|
||||
delay={0.3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: TRANSITION_DURATION })}
|
||||
/>
|
||||
|
||||
{/* Scene 7: Logo endcard */}
|
||||
<TransitionSeries.Sequence durationInFrames={SCENE_DURATIONS.logo}>
|
||||
<LogoEndcard tagline="Plan together." />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user