Add Remotion promo video project with 7-scene App Store flow
- Create feels-promo Remotion project for promotional videos - Implement FeelsPromoV1 with scenes matching App Store screenshots: - Hero scene with mood tracking - Widget + Apple Watch scene - Journal notes with photos - AI-powered insights with badge - Privacy & security features - Theme customization - Notification styles - Add screens folder with source assets and flow reference - Include phone frames, widget, and watch frame assets Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
933
feels-promo/src/FeelsPromo.tsx
Normal file
933
feels-promo/src/FeelsPromo.tsx
Normal file
@@ -0,0 +1,933 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Img,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
spring,
|
||||
} from "remotion";
|
||||
import {
|
||||
TransitionSeries,
|
||||
linearTiming,
|
||||
} from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
|
||||
// Tiled App Icon Background Component
|
||||
const TiledIconBackground: React.FC<{ color?: string }> = ({
|
||||
color = "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
const iconSize = 80;
|
||||
const gap = 40;
|
||||
const cellSize = iconSize + gap;
|
||||
|
||||
const cols = Math.ceil(width / cellSize) + 4;
|
||||
const rows = Math.ceil(height / cellSize) + 4;
|
||||
|
||||
const offsetX = (frame * 0.3) % cellSize;
|
||||
const offsetY = (frame * 0.2) % cellSize;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: color,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -cellSize * 2,
|
||||
left: -cellSize * 2,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`,
|
||||
}}
|
||||
>
|
||||
{[...Array(rows)].map((_, row) =>
|
||||
[...Array(cols)].map((_, col) => {
|
||||
const staggerX = row % 2 === 0 ? 0 : cellSize / 2;
|
||||
return (
|
||||
<Img
|
||||
key={`${row}-${col}`}
|
||||
src={staticFile("app-icon.png")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
left: col * cellSize + staggerX,
|
||||
top: row * cellSize,
|
||||
opacity: 0.08,
|
||||
borderRadius: iconSize * 0.22,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Reusable Phone Component
|
||||
const PhoneFrame: React.FC<{
|
||||
mediaSrc: string;
|
||||
width?: number;
|
||||
rotation?: number;
|
||||
style?: React.CSSProperties;
|
||||
}> = ({ mediaSrc, width = 460, rotation = 0, style }) => {
|
||||
const aspectRatio = 2760 / 1350;
|
||||
const height = width * aspectRatio;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
transform: rotation ? `rotate(${rotation}deg)` : undefined,
|
||||
filter: "drop-shadow(0 40px 80px rgba(0,0,0,0.5))",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2.5%",
|
||||
left: "5.33%",
|
||||
right: "5.33%",
|
||||
bottom: "2.5%",
|
||||
borderRadius: width * 0.083,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile(mediaSrc)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Img
|
||||
src={staticFile("phone.png")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 1: Hero - Your mood. Your journey. Your way.
|
||||
const HeroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [80, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const phoneProgress = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||
const phoneX = interpolate(phoneProgress, [0, 1], [100, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #1a472a 0%, #2d5a3d 100%)" />
|
||||
|
||||
{/* Large title - top left */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 80,
|
||||
left: 50,
|
||||
right: 50,
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Your mood.
|
||||
<br />
|
||||
Your journey.
|
||||
<br />
|
||||
Your way.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone - large, positioned right-center */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -100,
|
||||
bottom: -200,
|
||||
transform: `scale(${phoneScale}) translateX(${phoneX}px)`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame mediaSrc="screen1-day.png" width={680} rotation={-5} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 2: Tap. Logged. Done.
|
||||
const WidgetWatchScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const widgetProgress = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const widgetScale = interpolate(widgetProgress, [0, 1], [0.7, 1]);
|
||||
|
||||
const watchProgress = spring({
|
||||
frame: frame - 12,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const watchScale = interpolate(watchProgress, [0, 1], [0.7, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #c4a000 0%, #d4b400 100%)" />
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 60,
|
||||
right: 60,
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Tap.
|
||||
<br />
|
||||
Logged.
|
||||
<br />
|
||||
Done.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
Never miss a day
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widget - large, center-left */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 60,
|
||||
bottom: 180,
|
||||
transform: `scale(${widgetScale})`,
|
||||
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.4))",
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("screen2-widget.png")}
|
||||
style={{
|
||||
width: 580,
|
||||
height: 580,
|
||||
borderRadius: 50,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 24,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
One-tap widgets
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watch - right side */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 80,
|
||||
bottom: 120,
|
||||
transform: `scale(${watchScale})`,
|
||||
filter: "drop-shadow(0 30px 60px rgba(0,0,0,0.4))",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "22%",
|
||||
left: "15%",
|
||||
width: "70%",
|
||||
height: "42%",
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("screen2-watch.png")}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Img
|
||||
src={staticFile("watch-frame.png")}
|
||||
style={{
|
||||
width: 360,
|
||||
height: 576,
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 24,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Wrist ready
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 3: Reflect & Record
|
||||
const JournalScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const phoneProgress = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #3d7a4a 0%, #4a8f5a 100%)" />
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 60,
|
||||
right: 60,
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Reflect & Record
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
Add notes & photos to remember why
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone - large, centered, tilted */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: -180,
|
||||
transform: `translateX(-50%) scale(${phoneScale})`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame mediaSrc="screen3-journal.png" width={660} rotation={8} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 4: Beautiful Insights
|
||||
const InsightsScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const phoneProgress = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||
|
||||
const badgeProgress = spring({
|
||||
frame: frame - 20,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
});
|
||||
const badgeScale = interpolate(badgeProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #2563eb 0%, #3b82f6 100%)" />
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
Beautiful
|
||||
<br />
|
||||
Insights
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone - centered, much larger */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
bottom: -200,
|
||||
transform: `translateX(-50%) scale(${phoneScale})`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame mediaSrc="screen4-insights.png" width={660} />
|
||||
</div>
|
||||
|
||||
{/* Apple AI Badge */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 80,
|
||||
left: "50%",
|
||||
transform: `translateX(-50%) scale(${badgeScale})`,
|
||||
background: "rgba(255,255,255,0.95)",
|
||||
borderRadius: 50,
|
||||
padding: "20px 40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
boxShadow: "0 10px 40px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 32 }}>✨</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 600,
|
||||
color: "#1a1a1a",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
Powered by Apple AI
|
||||
</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 5: Private & Secure
|
||||
const PrivacyScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const phoneProgress = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||
|
||||
const shieldProgress = spring({
|
||||
frame: frame - 15,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 100 },
|
||||
});
|
||||
const shieldScale = interpolate(shieldProgress, [0, 1], [0, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #059669 0%, #10b981 100%)" />
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
Private
|
||||
<br />
|
||||
& Secure
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
Syncs with Apple Health • Locked to you
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shield icon */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 500,
|
||||
left: 100,
|
||||
transform: `scale(${shieldScale})`,
|
||||
fontSize: 160,
|
||||
filter: "drop-shadow(0 10px 30px rgba(0,0,0,0.3))",
|
||||
}}
|
||||
>
|
||||
🛡️
|
||||
</div>
|
||||
|
||||
{/* Phone - right side, much larger */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -120,
|
||||
bottom: -200,
|
||||
transform: `scale(${phoneScale})`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame mediaSrc="screen5-privacy.png" width={680} rotation={-8} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 6: Complete Customization
|
||||
const ThemesScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const phoneProgress = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #7c3aed 0%, #8b5cf6 100%)" />
|
||||
|
||||
{/* Title - top right aligned */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
right: 60,
|
||||
textAlign: "right",
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Complete
|
||||
<br />
|
||||
Customization
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.95)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 30,
|
||||
}}
|
||||
>
|
||||
Your Style
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
12 Thoughtful Themes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone - left side, much larger */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -120,
|
||||
bottom: -200,
|
||||
transform: `scale(${phoneScale})`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame mediaSrc="screen6-themes.png" width={680} rotation={5} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Scene 7: Guidance that gets you
|
||||
const NotificationsScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const titleProgress = spring({ frame, fps, config: { damping: 200 } });
|
||||
const titleY = interpolate(titleProgress, [0, 1], [60, 0]);
|
||||
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
|
||||
|
||||
const phoneProgress = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80 },
|
||||
});
|
||||
const phoneScale = interpolate(phoneProgress, [0, 1], [0.8, 1]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||
<TiledIconBackground color="linear-gradient(180deg, #0891b2 0%, #06b6d4 100%)" />
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 60,
|
||||
right: 60,
|
||||
zIndex: 10,
|
||||
transform: `translateY(${-titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 88,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
Guidance that
|
||||
<br />
|
||||
gets you
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone - large, right-center */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -80,
|
||||
bottom: -200,
|
||||
transform: `scale(${phoneScale})`,
|
||||
}}
|
||||
>
|
||||
<PhoneFrame mediaSrc="screen7-notifications.png" width={680} rotation={-3} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// Outro Scene
|
||||
const OutroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const logoScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 80 },
|
||||
});
|
||||
|
||||
const textOpacity = spring({
|
||||
frame: frame - 15,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
});
|
||||
|
||||
const glowIntensity = interpolate(
|
||||
Math.sin(frame * 0.1),
|
||||
[-1, 1],
|
||||
[0.3, 0.6]
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TiledIconBackground />
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 500,
|
||||
height: 500,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, rgba(255,255,255,${glowIntensity}) 0%, transparent 70%)`,
|
||||
transform: `scale(${interpolate(logoScale, [0, 1], [0.3, 1])})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
transform: `scale(${interpolate(logoScale, [0, 1], [0.5, 1])})`,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile("app-icon.png")}
|
||||
style={{
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderRadius: 240 * 0.22,
|
||||
marginBottom: 40,
|
||||
filter: `drop-shadow(0 0 60px rgba(255,255,255,${glowIntensity}))`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 100,
|
||||
fontWeight: 800,
|
||||
color: "white",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
textShadow: "0 4px 30px rgba(0,0,0,0.3)",
|
||||
letterSpacing: "-3px",
|
||||
}}
|
||||
>
|
||||
Feels
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 500,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
marginTop: 24,
|
||||
opacity: interpolate(textOpacity, [0, 1], [0, 1]),
|
||||
}}
|
||||
>
|
||||
Track your mood. Understand yourself.
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const FeelsPromoV1: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const sceneDuration = 3.5 * fps;
|
||||
const transitionDuration = Math.round(0.6 * fps);
|
||||
|
||||
return (
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<HeroScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<WidgetWatchScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<JournalScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<InsightsScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<PrivacyScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<ThemesScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={sceneDuration}>
|
||||
<NotificationsScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: transitionDuration })}
|
||||
/>
|
||||
|
||||
<TransitionSeries.Sequence durationInFrames={Math.round(2.5 * fps)}>
|
||||
<OutroScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
);
|
||||
};
|
||||
76
feels-promo/src/HelloWorld.tsx
Normal file
76
feels-promo/src/HelloWorld.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { spring } from "remotion";
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
Sequence,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { Logo } from "./HelloWorld/Logo";
|
||||
import { Subtitle } from "./HelloWorld/Subtitle";
|
||||
import { Title } from "./HelloWorld/Title";
|
||||
import { z } from "zod";
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
|
||||
export const myCompSchema = z.object({
|
||||
titleText: z.string(),
|
||||
titleColor: zColor(),
|
||||
logoColor1: zColor(),
|
||||
logoColor2: zColor(),
|
||||
});
|
||||
|
||||
export const HelloWorld: React.FC<z.infer<typeof myCompSchema>> = ({
|
||||
titleText: propOne,
|
||||
titleColor: propTwo,
|
||||
logoColor1,
|
||||
logoColor2,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames, fps } = useVideoConfig();
|
||||
|
||||
// Animate from 0 to 1 after 25 frames
|
||||
const logoTranslationProgress = spring({
|
||||
frame: frame - 25,
|
||||
fps,
|
||||
config: {
|
||||
damping: 100,
|
||||
},
|
||||
});
|
||||
|
||||
// Move the logo up by 150 pixels once the transition starts
|
||||
const logoTranslation = interpolate(
|
||||
logoTranslationProgress,
|
||||
[0, 1],
|
||||
[0, -150],
|
||||
);
|
||||
|
||||
// Fade out the animation at the end
|
||||
const opacity = interpolate(
|
||||
frame,
|
||||
[durationInFrames - 25, durationInFrames - 15],
|
||||
[1, 0],
|
||||
{
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
},
|
||||
);
|
||||
|
||||
// A <AbsoluteFill> is just a absolutely positioned <div>!
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "white" }}>
|
||||
<AbsoluteFill style={{ opacity }}>
|
||||
<AbsoluteFill style={{ transform: `translateY(${logoTranslation}px)` }}>
|
||||
<Logo logoColor1={logoColor1} logoColor2={logoColor2} />
|
||||
</AbsoluteFill>
|
||||
{/* Sequences can shift the time for its children! */}
|
||||
<Sequence from={35}>
|
||||
<Title titleText={propOne} titleColor={propTwo} />
|
||||
</Sequence>
|
||||
{/* The subtitle will only enter on the 75th frame. */}
|
||||
<Sequence from={75}>
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
55
feels-promo/src/HelloWorld/Arc.tsx
Normal file
55
feels-promo/src/HelloWorld/Arc.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { random, useVideoConfig } from "remotion";
|
||||
|
||||
const getCircumferenceOfArc = (rx: number, ry: number) => {
|
||||
return Math.PI * 2 * Math.sqrt((rx * rx + ry * ry) / 2);
|
||||
};
|
||||
|
||||
const rx = 135;
|
||||
const ry = 300;
|
||||
const cx = 960;
|
||||
const cy = 540;
|
||||
const arcLength = getCircumferenceOfArc(rx, ry);
|
||||
const strokeWidth = 30;
|
||||
|
||||
export const Arc: React.FC<{
|
||||
progress: number;
|
||||
rotation: number;
|
||||
rotateProgress: number;
|
||||
color1: string;
|
||||
color2: string;
|
||||
}> = ({ progress, rotation, rotateProgress, color1, color2 }) => {
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
// Each svg Id must be unique to not conflict with each other
|
||||
const [gradientId] = useState(() => String(random(null)));
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `rotate(${rotation * rotateProgress}deg)`,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={color1} />
|
||||
<stop offset="100%" stopColor={color2} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill="none"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeDasharray={arcLength}
|
||||
strokeDashoffset={arcLength - arcLength * progress}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
36
feels-promo/src/HelloWorld/Atom.tsx
Normal file
36
feels-promo/src/HelloWorld/Atom.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { random, useVideoConfig } from "remotion";
|
||||
|
||||
export const Atom: React.FC<{
|
||||
scale: number;
|
||||
color1: string;
|
||||
color2: string;
|
||||
}> = ({ scale, color1, color2 }) => {
|
||||
const config = useVideoConfig();
|
||||
|
||||
// Each SVG ID must be unique to not conflict with each other
|
||||
const [gradientId] = useState(() => String(random(null)));
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${config.width} ${config.height}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={color1} />
|
||||
<stop offset="100%" stopColor={color2} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
r={70}
|
||||
cx={config.width / 2}
|
||||
cy={config.height / 2}
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
87
feels-promo/src/HelloWorld/Logo.tsx
Normal file
87
feels-promo/src/HelloWorld/Logo.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { Arc } from "./Arc";
|
||||
import { Atom } from "./Atom";
|
||||
import { z } from "zod";
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
|
||||
export const myCompSchema2 = z.object({
|
||||
logoColor1: zColor(),
|
||||
logoColor2: zColor(),
|
||||
});
|
||||
|
||||
export const Logo: React.FC<z.infer<typeof myCompSchema2>> = ({
|
||||
logoColor1: color1,
|
||||
logoColor2: color2,
|
||||
}) => {
|
||||
const videoConfig = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const development = spring({
|
||||
config: {
|
||||
damping: 100,
|
||||
mass: 0.5,
|
||||
},
|
||||
fps: videoConfig.fps,
|
||||
frame,
|
||||
});
|
||||
|
||||
const rotationDevelopment = spring({
|
||||
config: {
|
||||
damping: 100,
|
||||
mass: 0.5,
|
||||
},
|
||||
fps: videoConfig.fps,
|
||||
frame,
|
||||
});
|
||||
|
||||
const scale = spring({
|
||||
frame,
|
||||
config: {
|
||||
mass: 0.5,
|
||||
},
|
||||
fps: videoConfig.fps,
|
||||
});
|
||||
|
||||
const logoRotation = interpolate(
|
||||
frame,
|
||||
[0, videoConfig.durationInFrames],
|
||||
[0, 360],
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
transform: `scale(${scale}) rotate(${logoRotation}deg)`,
|
||||
}}
|
||||
>
|
||||
<Arc
|
||||
rotateProgress={rotationDevelopment}
|
||||
progress={development}
|
||||
rotation={30}
|
||||
color1={color1}
|
||||
color2={color2}
|
||||
/>
|
||||
<Arc
|
||||
rotateProgress={rotationDevelopment}
|
||||
rotation={90}
|
||||
progress={development}
|
||||
color1={color1}
|
||||
color2={color2}
|
||||
/>
|
||||
<Arc
|
||||
rotateProgress={rotationDevelopment}
|
||||
rotation={-30}
|
||||
progress={development}
|
||||
color1={color1}
|
||||
color2={color2}
|
||||
/>
|
||||
<Atom scale={rotationDevelopment} color1={color1} color2={color2} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
26
feels-promo/src/HelloWorld/Subtitle.tsx
Normal file
26
feels-promo/src/HelloWorld/Subtitle.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
import { COLOR_1, FONT_FAMILY } from "./constants";
|
||||
|
||||
const subtitle: React.CSSProperties = {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: 40,
|
||||
textAlign: "center",
|
||||
position: "absolute",
|
||||
bottom: 140,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const codeStyle: React.CSSProperties = {
|
||||
color: COLOR_1,
|
||||
};
|
||||
|
||||
export const Subtitle: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1]);
|
||||
return (
|
||||
<div style={{ ...subtitle, opacity }}>
|
||||
Edit <code style={codeStyle}>src/Root.tsx</code> and save to reload.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
feels-promo/src/HelloWorld/Title.tsx
Normal file
58
feels-promo/src/HelloWorld/Title.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { FONT_FAMILY } from "./constants";
|
||||
|
||||
const title: React.CSSProperties = {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontWeight: "bold",
|
||||
fontSize: 100,
|
||||
textAlign: "center",
|
||||
position: "absolute",
|
||||
bottom: 160,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const word: React.CSSProperties = {
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
display: "inline-block",
|
||||
};
|
||||
|
||||
export const Title: React.FC<{
|
||||
readonly titleText: string;
|
||||
readonly titleColor: string;
|
||||
}> = ({ titleText, titleColor }) => {
|
||||
const videoConfig = useVideoConfig();
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const words = titleText.split(" ");
|
||||
|
||||
return (
|
||||
<h1 style={title}>
|
||||
{words.map((t, i) => {
|
||||
const delay = i * 5;
|
||||
|
||||
const scale = spring({
|
||||
fps: videoConfig.fps,
|
||||
frame: frame - delay,
|
||||
config: {
|
||||
damping: 200,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<span
|
||||
key={t}
|
||||
style={{
|
||||
...word,
|
||||
color: titleColor,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
5
feels-promo/src/HelloWorld/constants.ts
Normal file
5
feels-promo/src/HelloWorld/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Change any of these to update your video live.
|
||||
|
||||
export const COLOR_1 = "#86A8E7";
|
||||
|
||||
export const FONT_FAMILY = "SF Pro Text, Helvetica, Arial, sans-serif";
|
||||
27
feels-promo/src/Root.tsx
Normal file
27
feels-promo/src/Root.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Composition } from "remotion";
|
||||
import { FeelsPromoV1 } from "./FeelsPromo";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
const fps = 30;
|
||||
const sceneDuration = 3.5 * fps; // 3.5 seconds per scene
|
||||
const transitionDuration = Math.round(0.6 * fps); // 0.6 second transitions
|
||||
const outroDuration = Math.round(2.5 * fps);
|
||||
|
||||
// Calculate total duration accounting for transition overlaps
|
||||
// 7 scenes + outro - 7 transitions
|
||||
const totalDuration =
|
||||
sceneDuration * 7 + outroDuration - transitionDuration * 7;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="FeelsPromoV1"
|
||||
component={FeelsPromoV1}
|
||||
durationInFrames={totalDuration}
|
||||
fps={fps}
|
||||
width={1080}
|
||||
height={1920}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
feels-promo/src/index.ts
Normal file
7
feels-promo/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// This is your entry file! Refer to it when you render:
|
||||
// npx remotion render <entry-file> HelloWorld out/video.mp4
|
||||
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
Reference in New Issue
Block a user