Merge branch 'feat/remotion-promo-videos'

# Conflicts:
#	pnpm-lock.yaml
This commit is contained in:
SnapOtter
2026-05-08 21:29:19 +08:00
105 changed files with 10901 additions and 227 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface LandingVideoProps {
slug: string;
className?: string;
priority?: boolean;
}
export function LandingVideo({ slug, className, priority = false }: LandingVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isVisible, setIsVisible] = useState(priority);
useEffect(() => {
if (priority || !videoRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: "200px" },
);
observer.observe(videoRef.current);
return () => observer.disconnect();
}, [priority]);
return (
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
tabIndex={-1}
poster={`/videos/${slug}-poster.webp`}
className={`motion-safe:block motion-reduce:hidden ${className ?? ""}`}
aria-hidden="true"
>
{isVisible && (
<>
<source src={`/videos/${slug}.webm`} type="video/webm" />
<source src={`/videos/${slug}.mp4`} type="video/mp4" />
</>
)}
</video>
);
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@snapotter/videos",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npx remotion studio",
"render": "node scripts/render-all.mjs",
"render:single": "npx remotion render"
},
"dependencies": {
"@remotion/bundler": "^4.0.0",
"@remotion/cli": "^4.0.0",
"@remotion/google-fonts": "^4.0.0",
"@remotion/media": "^4.0.459",
"@remotion/motion-blur": "^4.0.0",
"@remotion/noise": "^4.0.0",
"@remotion/paths": "^4.0.0",
"@remotion/renderer": "^4.0.0",
"@remotion/shapes": "^4.0.0",
"@remotion/tailwind": "^4.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remotion": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+19
View File
@@ -0,0 +1,19 @@
import path from "node:path";
import { Config } from "@remotion/cli/config";
import { enableTailwind } from "@remotion/tailwind";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.overrideWebpackConfig((config) => {
const withTailwind = enableTailwind(config);
return {
...withTailwind,
resolve: {
...withTailwind.resolve,
alias: {
...withTailwind.resolve?.alias,
"@": path.resolve("./src"),
},
},
};
});
+105
View File
@@ -0,0 +1,105 @@
import { bundle } from "@remotion/bundler";
import { renderMedia, renderStill, selectComposition } from "@remotion/renderer";
import { enableTailwind } from "@remotion/tailwind";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMPOSITIONS = [
{ id: "PrivacyPromise", slug: "privacy-promise", posterFrame: 140 },
{ id: "ToolGalaxy", slug: "tool-galaxy", posterFrame: 450 },
{ id: "OneCommand", slug: "one-command", posterFrame: 300 },
{ id: "AiMagicReel", slug: "ai-magic-reel", posterFrame: 190 },
{ id: "FormatUniverse", slug: "format-universe", posterFrame: 90 },
{ id: "PipelineFlow", slug: "pipeline-flow", posterFrame: 260 },
{ id: "FloatingTools", slug: "floating-tools", posterFrame: 60 },
{ id: "BrandGradient", slug: "brand-gradient", posterFrame: 0 },
{ id: "The48", slug: "the-48", posterFrame: 420 },
{ id: "CloudVsLocal", slug: "cloud-vs-local", posterFrame: 380 },
];
const OUTPUT_DIR = path.resolve(__dirname, "../../landing/public/videos");
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log("Bundling Remotion project...");
const bundleLocation = await bundle({
entryPoint: path.resolve(__dirname, "../src/index.ts"),
webpackOverride: (config) => {
const tailwindConfig = enableTailwind(config);
return {
...tailwindConfig,
resolve: {
...tailwindConfig.resolve,
alias: {
...tailwindConfig.resolve?.alias,
"@": path.resolve(__dirname, "../src"),
},
},
};
},
});
console.log("Bundle complete.\n");
const total = COMPOSITIONS.length;
for (let i = 0; i < total; i++) {
const comp = COMPOSITIONS[i];
console.log(`[${i + 1}/${total}] ${comp.id}`);
const composition = await selectComposition({
serveUrl: bundleLocation,
id: comp.id,
});
// MP4 (H.264, CRF 22)
console.log(" Rendering MP4 (H.264)...");
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
crf: 22,
pixelFormat: "yuv420p",
imageFormat: "jpeg",
concurrency: 4,
outputLocation: path.join(OUTPUT_DIR, `${comp.slug}.mp4`),
onProgress: ({ progress }) => {
const pct = Math.round(progress * 100);
if (pct % 10 === 0) process.stdout.write(`\r MP4: ${pct}%`);
},
});
console.log(`\r MP4: done`);
// WebM (VP9, CRF 31)
console.log(" Rendering WebM (VP9)...");
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "vp9",
crf: 31,
imageFormat: "jpeg",
concurrency: 4,
outputLocation: path.join(OUTPUT_DIR, `${comp.slug}.webm`),
onProgress: ({ progress }) => {
const pct = Math.round(progress * 100);
if (pct % 10 === 0) process.stdout.write(`\r WebM: ${pct}%`);
},
});
console.log(`\r WebM: done`);
// Poster frame (WebP still)
console.log(" Rendering poster frame...");
await renderStill({
composition,
serveUrl: bundleLocation,
frame: comp.posterFrame,
imageFormat: "webp",
output: path.join(OUTPUT_DIR, `${comp.slug}-poster.webp`),
});
console.log(" Poster: done\n");
}
console.log(
`All ${total} compositions rendered (MP4 + WebM + poster) to ${OUTPUT_DIR}`,
);
+99 -61
View File
@@ -1,66 +1,24 @@
import "./style.css";
import type React from "react";
import { AbsoluteFill, Composition } from "remotion";
import { ClipReveal } from "./components/ClipReveal";
import { Counter } from "./components/Counter";
import { GradientBlob } from "./components/GradientBlob";
import { GrainOverlay } from "./components/GrainOverlay";
import { PhotoPlaceholder } from "./components/PhotoPlaceholder";
import { ToolPill } from "./components/ToolPill";
import { Composition } from "remotion";
import { BrandGradient } from "./compositions/ambient/BrandGradient";
import { FloatingTools } from "./compositions/ambient/FloatingTools";
import { AiMagicReel } from "./compositions/features/AiMagicReel";
import { FormatUniverse } from "./compositions/features/FormatUniverse";
import { PipelineFlow } from "./compositions/features/PipelineFlow";
import { TEXT } from "./lib/fonts";
const ComponentTest: React.FC = () => (
<AbsoluteFill style={{ backgroundColor: "#0c0a09" }}>
<div style={{ position: "absolute", top: 40, left: 40 }}>
<ClipReveal startFrame={0}>
<span style={{ ...TEXT.heroHeadline }}>Hello SnapOtter</span>
</ClipReveal>
</div>
<div style={{ position: "absolute", top: 150, left: 40 }}>
<ToolPill name="Resize" category="essentials" />
</div>
<div style={{ position: "absolute", top: 200, left: 40 }}>
<Counter from={0} to={48} startFrame={0} duration={60} style={TEXT.counter} />
</div>
<PhotoPlaceholder
width={200}
height={150}
style={{ position: "absolute", top: 300, left: 40 }}
/>
<GradientBlob
config={{
color: "#f59e0b",
radius: 100,
cx: 600,
cy: 300,
a: 2,
b: 3,
phaseX: 0,
phaseY: 0,
amplitudeX: 50,
amplitudeY: 40,
}}
duration={90}
/>
<GrainOverlay />
</AbsoluteFill>
);
import { OneCommand } from "./compositions/hero/OneCommand";
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
import { ToolGalaxy } from "./compositions/hero/ToolGalaxy";
import { ProductDemo } from "./compositions/product-demo/ProductDemo";
import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
import { CloudVsLocal } from "./compositions/showcase/CloudVsLocal";
import { The48 } from "./compositions/showcase/The48";
import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo";
export const RemotionRoot: React.FC = () => (
<>
<Composition
id="ComponentTest"
component={ComponentTest}
durationInFrames={90}
fps={30}
width={1920}
height={1080}
/>
{/* Ambient */}
<Composition
id="FloatingTools"
component={FloatingTools}
@@ -77,6 +35,8 @@ export const RemotionRoot: React.FC = () => (
width={800}
height={600}
/>
{/* Features */}
<Composition
id="FormatUniverse"
component={FormatUniverse}
@@ -85,14 +45,6 @@ export const RemotionRoot: React.FC = () => (
width={800}
height={600}
/>
<Composition
id="AiMagicReel"
component={AiMagicReel}
durationInFrames={480}
fps={30}
width={800}
height={600}
/>
<Composition
id="PipelineFlow"
component={PipelineFlow}
@@ -101,5 +53,91 @@ export const RemotionRoot: React.FC = () => (
width={800}
height={600}
/>
<Composition
id="AiMagicReel"
component={AiMagicReel}
durationInFrames={480}
fps={30}
width={800}
height={600}
/>
{/* Hero */}
<Composition
id="PrivacyPromise"
component={PrivacyPromise}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="OneCommand"
component={OneCommand}
durationInFrames={390}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="ToolGalaxy"
component={ToolGalaxy}
durationInFrames={660}
fps={30}
width={1920}
height={1080}
/>
{/* Showcase */}
<Composition
id="The48"
component={The48}
durationInFrames={600}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="CloudVsLocal"
component={CloudVsLocal}
durationInFrames={540}
fps={30}
width={1920}
height={1080}
/>
{/* Promo / Marketing */}
<Composition
id="PromoTeaser"
component={PromoTeaser}
durationInFrames={600}
fps={30}
width={1080}
height={1080}
/>
<Composition
id="PromoTeaserVertical"
component={PromoTeaserVertical}
durationInFrames={600}
fps={30}
width={1080}
height={1920}
/>
<Composition
id="XLaunchVideo"
component={XLaunchVideo}
durationInFrames={1050}
fps={30}
width={1080}
height={1080}
/>
<Composition
id="ProductDemo"
component={ProductDemo}
durationInFrames={2250}
fps={30}
width={1920}
height={1080}
/>
</>
);
+201
View File
@@ -0,0 +1,201 @@
import type React from "react";
import { Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
const CATEGORY_COLORS = [
COLOR.category.essentials,
COLOR.category.optimization,
COLOR.category.adjustments,
COLOR.category.ai,
COLOR.category.watermark,
COLOR.category.utilities,
COLOR.category.layout,
COLOR.category.format,
];
const TOOLBAR_ICONS = [
{ id: "crop", width: 24, bg: "#d1d5db" },
{ id: "resize", width: 24, bg: "#d1d5db" },
{ id: "adjust", width: 24, bg: "#d1d5db" },
{ id: "filter", width: 32, bg: "#d1d5db" },
{ id: "export", width: 24, bg: "#d1d5db" },
];
export const AppMockup: React.FC<{
startFrame: number;
}> = ({ startFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
/* Sidebar slides in from left */
const sidebarSpring = spring({
frame: frame - startFrame,
fps,
config: SPRING.snappy,
});
const sidebarX = interpolate(sidebarSpring, [0, 1], [-240, 0]);
/* Header slides down from top */
const headerSpring = spring({
frame: frame - (startFrame + 4),
fps,
config: SPRING.snappy,
});
const headerY = interpolate(headerSpring, [0, 1], [-56, 0]);
/* Center canvas fade in */
const canvasOpacity = interpolate(frame, [startFrame + 8, startFrame + 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Toolbar fade in */
const toolbarOpacity = interpolate(frame, [startFrame + 12, startFrame + 24], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
backgroundColor: "#ffffff",
}}
>
{/* Header bar */}
<div
style={{
height: 56,
backgroundColor: "#ffffff",
borderBottom: "1px solid #e5e7eb",
display: "flex",
alignItems: "center",
paddingLeft: 16,
paddingRight: 16,
gap: 12,
flexShrink: 0,
transform: `translateY(${headerY}px)`,
}}
>
{/* Otter logo + name */}
<Img src={staticFile("logo.png")} style={{ width: 28, height: 28, borderRadius: 4 }} />
<span
style={{
fontFamily: FONT.heading,
fontWeight: 700,
fontSize: 16,
color: "#1a1a2e",
}}
>
SnapOtter
</span>
{/* Search bar placeholder */}
<div
style={{
flex: 1,
maxWidth: 400,
height: 32,
backgroundColor: "#f3f4f6",
borderRadius: 8,
marginLeft: 24,
border: "1px solid #e5e7eb",
}}
/>
</div>
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{/* Left sidebar */}
<div
style={{
width: 240,
backgroundColor: "#f8fafc",
borderRight: "1px solid #e5e7eb",
padding: "20px 16px",
display: "flex",
flexDirection: "column",
gap: 14,
flexShrink: 0,
transform: `translateX(${sidebarX}px)`,
}}
>
{CATEGORY_COLORS.map((color, i) => (
<div
key={color}
style={{
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: color,
opacity: 0.85,
flexShrink: 0,
}}
/>
<div
style={{
height: 10,
width: 60 + (i % 3) * 20,
backgroundColor: "#d1d5db",
borderRadius: 4,
}}
/>
</div>
))}
</div>
{/* Center canvas area */}
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 24,
}}
>
{/* Photo placeholder */}
<div style={{ opacity: canvasOpacity }}>
<PhotoPlaceholder width={320} height={220} hue={200} />
</div>
{/* Toolbar row */}
<div
style={{
display: "flex",
gap: 12,
opacity: toolbarOpacity,
}}
>
{TOOLBAR_ICONS.map((icon) => (
<div
key={icon.id}
style={{
width: icon.width,
height: 24,
backgroundColor: icon.bg,
borderRadius: 4,
}}
/>
))}
</div>
</div>
</div>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
import type React from "react";
const TRAFFIC_LIGHTS = [
{ color: "#ff5f57", border: "#e0443e" },
{ color: "#febc2e", border: "#dea123" },
{ color: "#28c840", border: "#1aab29" },
];
export const AppWindow: React.FC<{
children: React.ReactNode;
title?: string;
width: number;
height: number;
topBarColor?: string;
bodyColor?: string;
style?: React.CSSProperties;
}> = ({
children,
title,
width,
height,
topBarColor = "#1e1e2e",
bodyColor = "#0d1117",
style,
}) => {
return (
<div
style={{
width,
height,
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 25px 60px rgba(0,0,0,0.5)",
display: "flex",
flexDirection: "column",
...style,
}}
>
<div
style={{
height: 36,
backgroundColor: topBarColor,
display: "flex",
alignItems: "center",
paddingLeft: 12,
flexShrink: 0,
}}
>
{TRAFFIC_LIGHTS.map((dot) => (
<div
key={dot.color}
style={{
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: dot.color,
border: `1px solid ${dot.border}`,
marginRight: 8,
}}
/>
))}
{title && (
<div
style={{
flex: 1,
textAlign: "center",
fontSize: 13,
fontWeight: 500,
color: "rgba(255,255,255,0.5)",
marginRight: 56,
}}
>
{title}
</div>
)}
</div>
<div
style={{
flex: 1,
backgroundColor: bodyColor,
overflow: "hidden",
position: "relative",
}}
>
{children}
</div>
</div>
);
};
@@ -0,0 +1,65 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { COLOR } from "@/lib/colors";
import { EASE } from "@/lib/motion";
export const BeforeAfter: React.FC<{
before: React.ReactNode;
after: React.ReactNode;
scanStartFrame: number;
scanDuration: number;
width: number;
height: number;
style?: React.CSSProperties;
}> = ({ before, after, scanStartFrame, scanDuration, width, height, style }) => {
const frame = useCurrentFrame();
const scanProgress = interpolate(
frame,
[scanStartFrame, scanStartFrame + scanDuration],
[0, 100],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: EASE.smooth },
);
const scanX = (scanProgress / 100) * width;
return (
<div
style={{
position: "relative",
width,
height,
overflow: "hidden",
borderRadius: 8,
...style,
}}
>
<div style={{ position: "absolute", inset: 0 }}>{before}</div>
<div
style={{
position: "absolute",
inset: 0,
clipPath: `inset(0 ${100 - scanProgress}% 0 0)`,
}}
>
{after}
</div>
{scanProgress > 0 && scanProgress < 100 && (
<div
style={{
position: "absolute",
left: scanX,
top: 0,
width: 2,
height: "100%",
backgroundColor: COLOR.accent,
boxShadow: `0 0 20px ${COLOR.accent}80`,
zIndex: 10,
}}
/>
)}
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const ClipReveal: React.FC<{
children: React.ReactNode;
startFrame: number;
duration?: number;
direction?: "up" | "down";
}> = ({ children, startFrame, duration = 15, direction = "up" }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const translateY =
direction === "up"
? interpolate(progress, [0, 1], [40, 0])
: interpolate(progress, [0, 1], [-40, 0]);
return (
<div style={{ overflow: "hidden", display: "inline-block" }}>
<div
style={{
transform: `translateY(${translateY}px)`,
opacity: interpolate(progress, [0, 0.3], [0, 1], {
extrapolateRight: "clamp",
}),
}}
>
{children}
</div>
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const Counter: React.FC<{
from: number;
to: number;
startFrame: number;
duration: number;
style?: React.CSSProperties;
format?: (n: number) => string;
}> = ({ from, to, startFrame, duration, style, format }) => {
const frame = useCurrentFrame();
const raw = interpolate(frame, [startFrame, startFrame + duration], [from, to], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const value = Math.floor(raw);
return <span style={style}>{format ? format(value) : String(value)}</span>;
};
@@ -0,0 +1,55 @@
import type React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
export const FeaturePill: React.FC<{
label: string;
enterFrame: number;
targetX: number;
targetY: number;
fromDirection?: "left" | "right" | "top" | "bottom";
}> = ({ label, enterFrame, targetX, targetY, fromDirection = "left" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame: frame - enterFrame,
fps,
config: SPRING.popIn,
});
const offscreen = {
left: { x: -300, y: targetY },
right: { x: 1400, y: targetY },
top: { x: targetX, y: -100 },
bottom: { x: targetX, y: 1200 },
}[fromDirection];
const x = interpolate(progress, [0, 1], [offscreen.x, targetX]);
const y = interpolate(progress, [0, 1], [offscreen.y, targetY]);
const scale = interpolate(progress, [0, 1], [0.8, 1]);
return (
<div
style={{
position: "absolute",
left: x,
top: y,
transform: `scale(${scale})`,
padding: "8px 18px",
borderRadius: 8,
backgroundColor: `${COLOR.accent}26`,
border: `1px solid ${COLOR.accent}40`,
color: "white",
fontFamily: FONT.body,
fontWeight: 600,
fontSize: 16,
whiteSpace: "nowrap",
}}
>
{label}
</div>
);
};
+103
View File
@@ -0,0 +1,103 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";
export const GitHubCTA: React.FC<{
labelFrame?: number;
logoFrame?: number;
taglineFrame?: number;
ctaFrame?: number;
urlFrame?: number;
}> = ({ labelFrame = 0, logoFrame = 15, taglineFrame = 30, ctaFrame = 50, urlFrame = 65 }) => {
const frame = useCurrentFrame();
const logoScale = interpolate(frame, [logoFrame, logoFrame + 15], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const glowRadius = interpolate(frame, [logoFrame, logoFrame + 15], [0, 200], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const borderOpacity = interpolate(Math.sin(frame * 0.06), [-1, 1], [0.3, 0.6]);
return (
<AbsoluteFill
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: `2px solid rgba(245, 158, 11, ${borderOpacity})`,
}}
>
<ClipReveal startFrame={labelFrame}>
<span
style={{
...TEXT.label,
color: COLOR.accent,
fontSize: 18,
}}
>
100% OPEN SOURCE
</span>
</ClipReveal>
<div style={{ height: 24 }} />
<div style={{ position: "relative" }}>
<div
style={{
position: "absolute",
width: glowRadius * 2,
height: glowRadius * 2,
borderRadius: "50%",
background: `radial-gradient(circle, ${COLOR.accent} 0%, transparent 70%)`,
opacity: 0.15,
left: 40 - glowRadius,
top: 40 - glowRadius,
}}
/>
<Img
src={staticFile("logo.png")}
style={{
width: 80,
height: 80,
transform: `scale(${logoScale})`,
}}
/>
</div>
<div style={{ height: 20 }} />
<ClipReveal startFrame={taglineFrame}>
<span style={{ ...TEXT.sectionTitle, fontSize: 48 }}>Free forever.</span>
</ClipReveal>
<div style={{ height: 16 }} />
<ClipReveal startFrame={ctaFrame}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<span style={{ fontFamily: FONT.body, fontWeight: 400, fontSize: 20, color: "white" }}>
Star us on GitHub
</span>
</div>
</ClipReveal>
<div style={{ height: 8 }} />
<ClipReveal startFrame={urlFrame}>
<span style={{ fontFamily: FONT.mono, fontSize: 16, color: COLOR.accent }}>
github.com/snapotter-hq/SnapOtter
</span>
</ClipReveal>
</AbsoluteFill>
);
};
@@ -0,0 +1,41 @@
import type React from "react";
import { useCurrentFrame } from "remotion";
interface BlobConfig {
color: string;
radius: number;
cx: number;
cy: number;
a: number;
b: number;
phaseX: number;
phaseY: number;
amplitudeX: number;
amplitudeY: number;
}
export const GradientBlob: React.FC<{
config: BlobConfig;
duration: number;
}> = ({ config, duration }) => {
const frame = useCurrentFrame();
const t = (frame / duration) * Math.PI * 2;
const x = config.cx + config.amplitudeX * Math.sin(config.a * t + config.phaseX);
const y = config.cy + config.amplitudeY * Math.sin(config.b * t + config.phaseY);
return (
<div
style={{
position: "absolute",
width: config.radius * 2,
height: config.radius * 2,
left: x - config.radius,
top: y - config.radius,
background: `radial-gradient(circle, ${config.color} 0%, transparent 70%)`,
filter: "blur(60px)",
opacity: 0.5,
mixBlendMode: "screen",
}}
/>
);
};
@@ -0,0 +1,44 @@
import type React from "react";
import type { CSSProperties } from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
interface BlobDef {
color: string;
radius: number;
cx: number;
cy: number;
a: number;
b: number;
phaseX: number;
phaseY: number;
amplitudeX: number;
amplitudeY: number;
}
export const GradientMesh: React.FC<{
blobs: BlobDef[];
duration: number;
baseOpacity?: number;
opacityRange?: [number, number];
opacityFrameRange?: [number, number];
style?: CSSProperties;
}> = ({ blobs, duration, baseOpacity, opacityRange, opacityFrameRange, style }) => {
const frame = useCurrentFrame();
let containerOpacity = baseOpacity ?? 1;
if (opacityRange && opacityFrameRange) {
containerOpacity = interpolate(frame, opacityFrameRange, opacityRange, {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
}
return (
<AbsoluteFill style={{ opacity: containerOpacity, ...style }}>
{blobs.map((blob, i) => (
<GradientBlob key={`mesh-blob-${i}`} config={blob} duration={duration} />
))}
</AbsoluteFill>
);
};
@@ -0,0 +1,23 @@
import type React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
export const GrainOverlay: React.FC<{ opacity?: number }> = ({ opacity = 0.03 }) => {
const frame = useCurrentFrame();
return (
<AbsoluteFill style={{ opacity, mixBlendMode: "overlay", pointerEvents: "none" }}>
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<filter id={`grain-${frame}`}>
<feTurbulence
type="fractalNoise"
baseFrequency={0.65}
numOctaves={3}
seed={frame}
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter={`url(#grain-${frame})`} />
</svg>
</AbsoluteFill>
);
};
+140
View File
@@ -0,0 +1,140 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
random,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
const PARTICLE_COUNT = 25;
export const LogoReveal: React.FC<{
convergeFrame?: number;
burstFrame?: number;
logoFrame?: number;
textFrame?: number;
taglineFrame?: number;
logoSize?: number;
}> = ({
convergeFrame = 0,
burstFrame = 30,
logoFrame = 30,
textFrame = 50,
taglineFrame = 70,
logoSize = 80,
}) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const logoScale = spring({
frame: frame - logoFrame,
fps,
config: SPRING.popIn,
});
const glowRadius = interpolate(frame, [burstFrame, burstFrame + 15], [0, 200], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const glowOpacity = interpolate(frame, [burstFrame, burstFrame + 20], [0.2, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
const baseAngle = random(`particle-angle-${i}`) * Math.PI * 2;
const maxRadius = 300 + random(`particle-radius-${i}`) * 200;
const size = 2 + random(`particle-size-${i}`) * 3;
const rotSpeed = 0.03 + random(`particle-speed-${i}`) * 0.02;
const particleOpacity = 0.3 + random(`particle-opacity-${i}`) * 0.7;
const convergeProgress = interpolate(frame, [convergeFrame, burstFrame], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const angle = baseAngle + frame * rotSpeed;
const radius = maxRadius * convergeProgress;
const px = width / 2 + Math.cos(angle) * radius;
const py = height / 2 + Math.sin(angle) * radius;
const pOpacity = convergeProgress > 0.05 ? particleOpacity : 0;
return (
<div
key={`particle-${i}`}
style={{
position: "absolute",
left: px - size / 2,
top: py - size / 2,
width: size,
height: size,
borderRadius: "50%",
backgroundColor: COLOR.accent,
opacity: pOpacity,
}}
/>
);
})}
<div
style={{
position: "absolute",
width: glowRadius * 2,
height: glowRadius * 2,
borderRadius: "50%",
background: `radial-gradient(circle, ${COLOR.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
left: width / 2 - glowRadius,
top: height / 2 - glowRadius,
}}
/>
<Img
src={staticFile("logo.png")}
style={{
width: logoSize,
height: logoSize,
transform: `scale(${logoScale})`,
}}
/>
<div style={{ height: 16 }} />
<ClipReveal startFrame={textFrame}>
<span style={{ ...TEXT.sectionTitle, fontSize: 48, color: "white" }}>SnapOtter</span>
</ClipReveal>
<div style={{ height: 8 }} />
<ClipReveal startFrame={taglineFrame}>
<span
style={{
fontFamily: TEXT.heroSub.fontFamily,
fontWeight: 500,
fontSize: 24,
color: COLOR.accent,
}}
>
Your images. Stay yours.
</span>
</ClipReveal>
</AbsoluteFill>
);
};
@@ -0,0 +1,72 @@
import type React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
export const NumberPunch: React.FC<{
number: string;
descriptor: string;
enterFrame: number;
numberSize?: number;
shakeIntensity?: number;
}> = ({ number, descriptor, enterFrame, numberSize = 120, shakeIntensity = 2 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const elapsed = frame - enterFrame;
const scaleSpring = spring({ frame: elapsed, fps, config: SPRING.settle });
const scale = interpolate(scaleSpring, [0, 1], [1.5, 1]);
const shakeDecay = interpolate(elapsed, [0, 12], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const shakeX = Math.sin(elapsed * 3) * shakeIntensity * shakeDecay;
const shakeY = Math.cos(elapsed * 4) * shakeIntensity * shakeDecay;
const descOpacity = interpolate(elapsed, [8, 18], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const numberOpacity = interpolate(elapsed, [0, 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "center",
gap: 20,
transform: `translate(${shakeX}px, ${shakeY}px)`,
}}
>
<span
style={{
...TEXT.counter,
fontSize: numberSize,
transform: `scale(${scale})`,
opacity: numberOpacity,
display: "inline-block",
}}
>
{number}
</span>
<span
style={{
fontFamily: TEXT.heroSub.fontFamily,
fontWeight: 500,
fontSize: 36,
color: COLOR.accent,
opacity: descOpacity,
}}
>
{descriptor}
</span>
</div>
);
};
@@ -0,0 +1,20 @@
import type React from "react";
export const PhotoPlaceholder: React.FC<{
width: number;
height: number;
hue?: number;
style?: React.CSSProperties;
}> = ({ width, height, hue = 30, style }) => (
<div
style={{
width,
height,
borderRadius: 8,
background: `linear-gradient(135deg, hsl(${hue}, 60%, 65%) 0%, hsl(${hue + 30}, 50%, 55%) 100%)`,
border: "1.5px solid rgba(255,255,255,0.15)",
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
...style,
}}
/>
);
@@ -0,0 +1,50 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const ProgressBar: React.FC<{
startFrame: number;
duration: number;
width?: number;
height?: number;
color?: string;
bgColor?: string;
style?: React.CSSProperties;
}> = ({
startFrame,
duration,
width = 200,
height = 6,
color = "#3b82f6",
bgColor = "rgba(255,255,255,0.1)",
style,
}) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 100], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
return (
<div
style={{
width,
height,
borderRadius: height / 2,
backgroundColor: bgColor,
overflow: "hidden",
...style,
}}
>
<div
style={{
width: `${progress}%`,
height: "100%",
borderRadius: height / 2,
backgroundColor: color,
}}
/>
</div>
);
};
@@ -0,0 +1,80 @@
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { EASE } from "@/lib/motion";
export const RotatingTaglines: React.FC<{
lines: string[];
startFrame: number;
framesPerLine: number;
fontSize?: number;
color?: string;
}> = ({ lines, startFrame, framesPerLine, fontSize = 36, color = COLOR.accent }) => {
const frame = useCurrentFrame();
const elapsed = frame - startFrame;
if (elapsed < 0) return null;
const lineIndex = Math.floor(elapsed / framesPerLine);
const lineProgress = (elapsed % framesPerLine) / framesPerLine;
if (lineIndex >= lines.length) return null;
const enterDuration = 0.2;
const holdEnd = 0.7;
const exitEnd = 1.0;
const opacity =
lineProgress < enterDuration
? interpolate(lineProgress, [0, enterDuration], [0, 1], { easing: EASE.enter })
: lineProgress < holdEnd
? 1
: interpolate(lineProgress, [holdEnd, exitEnd], [1, 0], { easing: EASE.exit });
const translateY =
lineProgress < enterDuration
? interpolate(lineProgress, [0, enterDuration], [30, 0], { easing: EASE.enter })
: lineProgress > holdEnd
? interpolate(lineProgress, [holdEnd, exitEnd], [0, -20], { easing: EASE.exit })
: 0;
const glowOpacity = interpolate(Math.sin(frame * 0.08), [-1, 1], [0.03, 0.08]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
position: "relative",
}}
>
<div
style={{
position: "absolute",
width: 400,
height: 100,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${COLOR.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
filter: "blur(40px)",
}}
/>
<div
style={{
fontFamily: FONT.body,
fontWeight: 500,
fontSize,
color,
opacity,
transform: `translateY(${translateY}px)`,
textAlign: "center",
}}
>
{lines[lineIndex]}
</div>
</div>
);
};
+63
View File
@@ -0,0 +1,63 @@
import { evolvePath } from "@remotion/paths";
import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
const SHIELD_PATH = "M50 5 L90 25 L90 55 C90 80 70 95 50 100 C30 95 10 80 10 55 L10 25 Z";
export const ShieldIcon: React.FC<{
size: number;
startFrame: number;
duration?: number;
fillOpacity?: number;
style?: React.CSSProperties;
}> = ({ size, startFrame, duration = 35, fillOpacity = 0, style }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const { strokeDasharray, strokeDashoffset } = evolvePath(progress, SHIELD_PATH);
return (
<svg
viewBox="0 0 100 105"
width={size}
height={size * 1.05}
style={style}
role="img"
aria-label="Shield"
>
<path
d={SHIELD_PATH}
stroke="white"
strokeWidth={4}
fill="none"
opacity={0.3}
filter="blur(4px)"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
<defs>
<linearGradient id="shield-fill-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgba(255,255,255,0.12)" />
<stop offset="100%" stopColor="rgba(255,255,255,0.02)" />
</linearGradient>
</defs>
<path
d={SHIELD_PATH}
stroke="white"
strokeWidth={3.5}
fill={fillOpacity > 0 ? "url(#shield-fill-grad)" : "none"}
fillOpacity={fillOpacity > 0 ? fillOpacity * 12 : 0}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
+94
View File
@@ -0,0 +1,94 @@
import type React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { TypeWriter } from "@/components/TypeWriter";
import { SPRING } from "@/lib/motion";
interface OutputLine {
text: string;
color: string;
delay: number;
}
export const Terminal: React.FC<{
command: { text: string; color: string }[];
commandStartFrame: number;
commandSpeed?: number;
outputLines?: OutputLine[];
outputStartFrame: number;
outputStagger?: number;
width?: number;
height?: number;
style?: React.CSSProperties;
enterFrame?: number;
}> = ({
command,
commandStartFrame,
commandSpeed = 2,
outputLines = [],
outputStartFrame,
outputStagger = 3,
width = 800,
height = 450,
style,
enterFrame = 0,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enterProgress = spring({
frame: frame - enterFrame,
fps,
config: SPRING.snappy,
});
const translateY = interpolate(enterProgress, [0, 1], [height + 50, 0]);
const opacity = interpolate(enterProgress, [0, 1], [0, 1]);
return (
<div
style={{
transform: `translateY(${translateY}px)`,
opacity,
...style,
}}
>
<AppWindow title="Terminal" width={width} height={height}>
<div style={{ padding: "16px 20px" }}>
<div style={{ display: "flex", alignItems: "center" }}>
<span style={{ color: "#8b949e", fontSize: 16, fontFamily: "monospace" }}>$ </span>
<TypeWriter
segments={command}
startFrame={commandStartFrame}
speed={commandSpeed}
style={{ fontSize: 16 }}
/>
</div>
{outputLines.map((line, i) => {
const lineFrame = outputStartFrame + i * outputStagger;
const lineOpacity = interpolate(frame, [lineFrame, lineFrame + 6], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
key={`${line.text}-${i}`}
style={{
marginTop: i === 0 ? 12 : 4,
fontSize: 16,
fontFamily: "monospace",
color: line.color,
opacity: lineOpacity,
}}
>
{line.text}
</div>
);
})}
</div>
</AppWindow>
</div>
);
};
@@ -0,0 +1,116 @@
import type React from "react";
import { FONT } from "@/lib/fonts";
const TRAFFIC_LIGHTS = [
{ color: "#ff5f57", key: "red" },
{ color: "#febc2e", key: "yellow" },
{ color: "#28c840", key: "green" },
];
export const TerminalWindow: React.FC<{
width: number;
height: number;
title?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
/** Opacity for traffic light dots (0-1), used for morph transitions */
dotsOpacity?: number;
/** Override top bar background color */
topBarColor?: string;
/** Override body background color */
bodyColor?: string;
/** Optional element to render in the title area instead of plain text */
titleElement?: React.ReactNode;
}> = ({
width,
height,
title = "Terminal",
style,
children,
dotsOpacity = 1,
topBarColor = "#1e1e2e",
bodyColor = "#0d1117",
titleElement,
}) => {
return (
<div
style={{
width,
height,
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 25px 60px rgba(0,0,0,0.5)",
display: "flex",
flexDirection: "column",
...style,
}}
>
{/* Top bar */}
<div
style={{
height: 36,
backgroundColor: topBarColor,
display: "flex",
alignItems: "center",
paddingLeft: 12,
flexShrink: 0,
position: "relative",
}}
>
{/* Traffic light dots */}
<div style={{ display: "flex", gap: 8, opacity: dotsOpacity }}>
{TRAFFIC_LIGHTS.map((dot) => (
<div
key={dot.key}
style={{
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: dot.color,
}}
/>
))}
</div>
{/* Title area (centered) */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
pointerEvents: "none",
}}
>
{titleElement || (
<span
style={{
fontFamily: FONT.body,
fontSize: 13,
fontWeight: 500,
color: "rgba(255,255,255,0.5)",
}}
>
{title}
</span>
)}
</div>
</div>
{/* Body */}
<div
style={{
flex: 1,
backgroundColor: bodyColor,
padding: "16px 20px",
overflow: "hidden",
position: "relative",
}}
>
{children}
</div>
</div>
);
};
+62
View File
@@ -0,0 +1,62 @@
import type React from "react";
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
import { ToolPill } from "@/components/ToolPill";
import { CATEGORY_ORDER } from "@/lib/colors";
import { SPRING, TIMING } from "@/lib/motion";
import { getToolsByCategory, TOOLS } from "@/lib/tools";
export const ToolGrid: React.FC<{
startFrame: number;
cellWidth?: number;
cellHeight?: number;
gap?: number;
style?: React.CSSProperties;
}> = ({ startFrame, cellWidth = 140, cellHeight = 32, gap = 6, style }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
let globalIndex = 0;
return (
<div
style={{
display: "flex",
gap: gap * 2,
...style,
}}
>
{CATEGORY_ORDER.map((cat) => {
const tools = getToolsByCategory(cat);
return (
<div key={cat} style={{ display: "flex", flexDirection: "column", gap }}>
{tools.map((tool) => {
const i = globalIndex++;
const enterDelay = startFrame + i * TIMING.staggerFrames;
const s = spring({
frame: frame - enterDelay,
fps,
config: SPRING.settle,
});
return (
<div
key={tool.name}
style={{
opacity: s,
transform: `translateX(${(1 - s) * 100}px) scale(${0.8 + s * 0.2})`,
}}
>
<ToolPill
name={tool.name}
category={tool.category}
style={{ width: cellWidth, fontSize: 11, padding: "3px 8px" }}
/>
</div>
);
})}
</div>
);
})}
</div>
);
};
+31
View File
@@ -0,0 +1,31 @@
import type React from "react";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
export const ToolPill: React.FC<{
name: string;
category: string;
style?: React.CSSProperties;
}> = ({ name, category, style }) => {
const color = COLOR.category[category] ?? COLOR.accent;
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
borderRadius: 6,
backgroundColor: color,
color: "white",
...TEXT.toolPill,
whiteSpace: "nowrap",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
border: "1px solid rgba(255,255,255,0.1)",
backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,0.15) 0%, transparent 100%)",
...style,
}}
>
{name}
</div>
);
};
+55
View File
@@ -0,0 +1,55 @@
import type React from "react";
import { useCurrentFrame } from "remotion";
import { FONT } from "@/lib/fonts";
interface Segment {
text: string;
color: string;
}
export const TypeWriter: React.FC<{
segments: Segment[];
startFrame: number;
speed?: number;
style?: React.CSSProperties;
}> = ({ segments, startFrame, speed = 2, style }) => {
const frame = useCurrentFrame();
const elapsed = Math.max(0, frame - startFrame);
const charIndex = Math.floor(elapsed / speed);
const fullText = segments.map((s) => s.text).join("");
const visibleChars = Math.min(charIndex, fullText.length);
let rendered = 0;
const nodes: React.ReactNode[] = [];
for (const seg of segments) {
const segStart = rendered;
const visible = Math.max(0, Math.min(visibleChars - segStart, seg.text.length));
if (visible > 0) {
nodes.push(
<span key={segStart} style={{ color: seg.color }}>
{seg.text.slice(0, visible)}
</span>,
);
}
rendered += seg.text.length;
if (rendered >= visibleChars) break;
}
const showCursor = visibleChars < fullText.length && Math.floor(frame / 8) % 2 === 0;
return (
<span style={{ fontFamily: FONT.mono, ...style }}>
{nodes}
{showCursor && (
<span
style={{
backgroundColor: "#7ee787",
width: 10,
height: 20,
display: "inline-block",
}}
/>
)}
</span>
);
};
@@ -0,0 +1,21 @@
import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";
export const WipeTransition: React.FC<{
children: React.ReactNode;
startFrame: number;
duration?: number;
direction?: "left" | "right";
}> = ({ children, startFrame, duration = 18, direction = "left" }) => {
const frame = useCurrentFrame();
const progress = interpolate(frame, [startFrame, startFrame + duration], [0, 100], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const clipPath =
direction === "left" ? `inset(0 ${100 - progress}% 0 0)` : `inset(0 0 0 ${100 - progress}%)`;
return <AbsoluteFill style={{ clipPath }}>{children}</AbsoluteFill>;
};
@@ -0,0 +1,140 @@
import type React from "react";
import { AbsoluteFill, Img, staticFile, useCurrentFrame } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
import { GrainOverlay } from "@/components/GrainOverlay";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { TOOLS } from "@/lib/tools";
const DURATION = 150;
const BLOBS = [
{
color: "#f59e0b",
radius: 180,
cx: 200,
cy: 150,
a: 2,
b: 3,
phaseX: 0,
phaseY: 0,
amplitudeX: 80,
amplitudeY: 60,
},
{
color: "#f97316",
radius: 150,
cx: 500,
cy: 350,
a: 3,
b: 2,
phaseX: 1.2,
phaseY: 0.8,
amplitudeX: 70,
amplitudeY: 90,
},
{
color: "#3b82f6",
radius: 200,
cx: 600,
cy: 150,
a: 2,
b: 1,
phaseX: 2.4,
phaseY: 1.6,
amplitudeX: 90,
amplitudeY: 50,
},
{
color: "#14b8a6",
radius: 140,
cx: 350,
cy: 400,
a: 1,
b: 2,
phaseX: 3.6,
phaseY: 2.4,
amplitudeX: 60,
amplitudeY: 80,
},
{
color: "#8b5cf6",
radius: 160,
cx: 150,
cy: 350,
a: 3,
b: 1,
phaseX: 4.8,
phaseY: 3.2,
amplitudeX: 50,
amplitudeY: 70,
},
];
/** Pre-computed scattered tool name positions (frozen, for texture) */
const SCATTERED_TOOLS = TOOLS.slice(0, 15).map((tool, i) => ({
name: tool.name,
x: ((i * 137 + 47) % 750) + 25,
y: ((i * 89 + 23) % 550) + 25,
fontSize: 11 + (i % 3) * 2,
}));
export const BrandGradient: React.FC = () => {
const frame = useCurrentFrame();
const logoScale = 1 + Math.sin(frame * 0.08) * 0.03;
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark, overflow: "hidden" }}>
{/* Scattered tool names for texture (frozen, 5% opacity) */}
{SCATTERED_TOOLS.map((tool) => (
<span
key={tool.name}
style={{
position: "absolute",
left: tool.x,
top: tool.y,
fontFamily: FONT.body,
fontSize: tool.fontSize,
fontWeight: 500,
color: "rgba(255,255,255,0.05)",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{tool.name}
</span>
))}
{BLOBS.map((blob) => (
<GradientBlob key={blob.color} config={blob} duration={DURATION} />
))}
{/* Center logo with subtle scale pulse */}
<Img
src={staticFile("logo.png")}
style={{
position: "absolute",
width: 80,
height: 80,
left: 400 - 40,
top: 300 - 40,
transform: `scale(${logoScale})`,
zIndex: 10,
}}
/>
{/* Radial vignette */}
<div
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse 60% 55% at 50% 50%, transparent 0%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
<GrainOverlay opacity={0.04} />
</AbsoluteFill>
);
};
@@ -0,0 +1,79 @@
import type React from "react";
import { useState } from "react";
import { AbsoluteFill, random, useCurrentFrame, useVideoConfig } from "remotion";
import { GrainOverlay } from "@/components/GrainOverlay";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { TOOLS } from "@/lib/tools";
const LAYERS = [
{ speed: 0.8, fontSize: 14, opacity: 0.12, count: 10, blur: 1 },
{ speed: 1.5, fontSize: 18, opacity: 0.22, count: 9, blur: 0.5 },
{ speed: 2.5, fontSize: 24, opacity: 0.35, count: 6, blur: 0 },
];
const X_FREQ = (2 * Math.PI) / 120;
export const FloatingTools: React.FC = () => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const wrapH = height + 100;
const [items] = useState(() => {
let toolIdx = 0;
return LAYERS.flatMap((layer, li) =>
Array.from({ length: layer.count }, (_, i) => {
const tool = TOOLS[toolIdx % TOOLS.length];
toolIdx++;
return {
tool,
layer,
x: random(`float-x-${li}-${i}`) * width,
startY: random(`float-y-${li}-${i}`) * wrapH,
phase: random(`float-phase-${li}-${i}`) * Math.PI * 2,
};
}),
);
});
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark, overflow: "hidden" }}>
{items.map((item, idx) => {
const rawY = item.startY - frame * item.layer.speed;
const y = (((rawY % wrapH) + wrapH) % wrapH) - 50;
const xOffset = Math.sin(frame * X_FREQ + item.phase) * 8;
return (
<span
key={idx}
style={{
position: "absolute",
left: item.x + xOffset,
top: y,
fontFamily: FONT.body,
fontSize: item.layer.fontSize,
fontWeight: 500,
color: COLOR.category[item.tool.category] ?? COLOR.accent,
opacity: item.layer.opacity,
whiteSpace: "nowrap",
filter: item.layer.blur > 0 ? `blur(${item.layer.blur}px)` : undefined,
}}
>
{item.tool.name}
</span>
);
})}
{/* Radial vignette */}
<div
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse 60% 55% at 50% 50%, transparent 0%, rgba(0,0,0,0.6) 100%)",
pointerEvents: "none",
}}
/>
<GrainOverlay opacity={0.02} />
</AbsoluteFill>
);
};
@@ -1,11 +1,13 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
interpolateColors,
random,
Sequence,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
@@ -133,19 +135,8 @@ const SegmentBackgroundRemoval: React.FC = () => {
easing: EASE.smooth,
});
// Geometric pattern background
const patternBg = `
linear-gradient(45deg, rgba(245,158,11,0.15) 25%, transparent 25%),
linear-gradient(-45deg, rgba(245,158,11,0.15) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(245,158,11,0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(245,158,11,0.1) 75%)
`;
// Silhouette: a head-and-shoulders shape via gradient
const silhouetteGradient = `
radial-gradient(ellipse 90px 100px at 50% 30%, hsl(25, 65%, 65%) 0%, hsl(25, 65%, 65%) 70%, transparent 71%),
radial-gradient(ellipse 130px 200px at 50% 70%, hsl(30, 55%, 55%) 0%, hsl(30, 55%, 55%) 70%, transparent 71%)
`;
// Checkerboard background for transparency effect
const checkerBg = CHECKERBOARD_BG;
// Scan line particle trail
const particles = Array.from({ length: 5 }, (_, i) => {
@@ -187,28 +178,19 @@ const SegmentBackgroundRemoval: React.FC = () => {
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
}}
>
{/* "Before" layer: photo with geometric background */}
<div
{/* "Before" layer: real portrait photo */}
<Img
src={staticFile("screenshots/sample-photo-portrait.jpg")}
style={{
position: "absolute",
inset: 0,
backgroundImage: patternBg,
backgroundSize: "24px 24px",
backgroundColor: "hsl(30, 20%, 35%)",
width: "100%",
height: "100%",
objectFit: "cover",
}}
>
{/* Silhouette subject */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: silhouetteGradient,
backgroundRepeat: "no-repeat",
}}
/>
</div>
/>
{/* "After" layer: checkerboard + subject only */}
{/* "After" layer: checkerboard + portrait (revealed via clip) */}
<div
style={{
position: "absolute",
@@ -222,16 +204,18 @@ const SegmentBackgroundRemoval: React.FC = () => {
style={{
position: "absolute",
inset: 0,
background: CHECKERBOARD_BG,
background: checkerBg,
}}
/>
{/* Subject only */}
<div
{/* Subject portrait on top of checkerboard */}
<Img
src={staticFile("screenshots/sample-photo-portrait.jpg")}
style={{
position: "absolute",
inset: 0,
backgroundImage: silhouetteGradient,
backgroundRepeat: "no-repeat",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
@@ -246,7 +230,7 @@ const SegmentBackgroundRemoval: React.FC = () => {
width: 2,
height: "100%",
background: COLOR.accent,
boxShadow: `0 0 20px 10px rgba(245,158,11,0.3)`,
boxShadow: "0 0 20px 10px rgba(245,158,11,0.3)",
zIndex: 10,
}}
/>
@@ -260,7 +244,7 @@ const SegmentBackgroundRemoval: React.FC = () => {
key={`scan-p-${i}`}
style={{
position: "absolute",
left: photoX + p.x - photoX,
left: p.x,
top: p.y,
width: 3,
height: 3,
@@ -358,26 +342,38 @@ const SegmentColorization: React.FC = () => {
transform: `scale(${holdScale})`,
}}
>
{/* Grayscale layer (on top) */}
<div
{/* B&W layer (on top, real B&W photo) */}
<Img
src={staticFile("screenshots/sample-photo-bw.jpg")}
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(135deg, hsl(30, 60%, 65%) 0%, hsl(200, 50%, 55%) 50%, hsl(340, 55%, 60%) 100%)`,
filter: "grayscale(1)",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Color layer (revealed underneath via clip) */}
{/* Color layer (revealed via clip, using portrait as color version) */}
<div
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(135deg, hsl(30, 72%, 65%) 0%, hsl(200, 60%, 55%) 50%, hsl(340, 66%, 60%) 100%)`,
filter: "saturate(1.2)",
clipPath: `inset(0 ${100 - waveProgress}% 0 0)`,
}}
/>
>
<Img
src={staticFile("screenshots/sample-photo-portrait.jpg")}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
filter: "saturate(1.2)",
}}
/>
</div>
{/* Color particles */}
{colorParticles.map(
@@ -411,11 +407,11 @@ const SegmentColorization: React.FC = () => {
const PIXEL_GRID_SIZE = 8;
const PIXEL_GAP = 0.5;
/** Generate deterministic colors for the pixel grid */
/** Generate deterministic colors for the pixel grid (blues and greens from landscape) */
const pixelColors: string[] = Array.from({ length: PIXEL_GRID_SIZE * PIXEL_GRID_SIZE }, (_, i) => {
const hue = random(`pixel-hue-${i}`) * 60 + 15; // warm hues
const sat = 40 + random(`pixel-sat-${i}`) * 30;
const lit = 40 + random(`pixel-lit-${i}`) * 30;
const hue = random(`pixel-hue-${i}`) * 80 + 180; // blues and greens (180-260)
const sat = 35 + random(`pixel-sat-${i}`) * 35;
const lit = 35 + random(`pixel-lit-${i}`) * 30;
return `hsl(${hue}, ${sat}%, ${lit}%)`;
});
@@ -518,14 +514,17 @@ const SegmentSuperResolution: React.FC = () => {
))}
</div>
{/* Smooth "resolved" version */}
{/* Smooth "resolved" version - actual landscape photo */}
{showSmooth && (
<div
<Img
src={staticFile("screenshots/sample-photo-landscape.jpg")}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 8,
background: `linear-gradient(135deg, hsl(25, 55%, 50%) 0%, hsl(40, 50%, 55%) 50%, hsl(30, 60%, 45%) 100%)`,
opacity: smoothOpacity,
border: "1.5px solid rgba(255,255,255,0.15)",
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
@@ -630,8 +629,8 @@ const SegmentRestoration: React.FC = () => {
});
// Color shift from sepia to warm
const bgFrom = interpolateColors(frame, [80, 95], ["hsl(35, 40%, 45%)", "hsl(30, 55%, 50%)"]);
const bgTo = interpolateColors(frame, [80, 95], ["hsl(40, 35%, 55%)", "hsl(45, 50%, 58%)"]);
const _bgFrom = interpolateColors(frame, [80, 95], ["hsl(35, 40%, 45%)", "hsl(30, 55%, 50%)"]);
const _bgTo = interpolateColors(frame, [80, 95], ["hsl(40, 35%, 55%)", "hsl(45, 50%, 58%)"]);
return (
<AbsoluteFill style={{ background: COLOR.dark }}>
@@ -653,15 +652,18 @@ const SegmentRestoration: React.FC = () => {
borderRadius: 8,
overflow: "hidden",
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
filter: `sepia(${sepiaAmount}) contrast(${contrastAmount})`,
filter: `sepia(${sepiaAmount * 0.8}) contrast(${contrastAmount})`,
}}
>
{/* Base photo gradient */}
<div
{/* Base photo - real landscape */}
<Img
src={staticFile("screenshots/sample-photo-landscape.jpg")}
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(135deg, ${bgFrom} 0%, ${bgTo} 100%)`,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
@@ -0,0 +1,215 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { GrainOverlay } from "@/components/GrainOverlay";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
const RINGS = [
{
formats: ["CR2", "NEF", "ARW", "RAF", "DNG"],
radius: 120,
color: "#f59e0b",
direction: 1,
},
{
formats: ["AVIF", "JXL", "HEIC", "WebP", "QOI"],
radius: 195,
color: "#14b8a6",
direction: -1,
},
{
formats: ["JPEG", "PNG", "TIFF", "BMP", "GIF", "PSD"],
radius: 270,
color: "#3b82f6",
direction: 1,
},
];
const DURATION = 180;
const SPEED = (2 * Math.PI) / DURATION;
const Badge: React.FC<{
label: string;
color: string;
angle: number;
radius: number;
}> = ({ label, color, angle, radius }) => {
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius * 0.55;
const z = Math.sin(angle);
const scale = interpolate(z, [-1, 1], [0.75, 1.0]);
const opacity = interpolate(z, [-1, 1], [0.4, 1.0]);
const zIndex = Math.round(interpolate(z, [-1, 1], [0, 10]));
return (
<div
style={{
position: "absolute",
left: 400 + x,
top: 280 + y,
transform: `translate(-50%, -50%) scale(${scale})`,
opacity,
zIndex,
padding: "6px 14px",
borderRadius: 8,
backgroundColor: `${color}26`,
border: `1px solid ${color}66`,
boxShadow: `0 0 12px ${color}33`,
fontFamily: FONT.body,
fontWeight: 600,
fontSize: 14,
color,
whiteSpace: "nowrap" as const,
}}
>
{label}
</div>
);
};
/** Trail badge - renders a badge at a past frame's position with reduced opacity */
const TrailBadge: React.FC<{
label: string;
color: string;
angle: number;
radius: number;
trailOpacity: number;
}> = ({ label, color, angle, radius, trailOpacity }) => {
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius * 0.55;
const z = Math.sin(angle);
const scale = interpolate(z, [-1, 1], [0.75, 1.0]);
const zIndex = Math.round(interpolate(z, [-1, 1], [0, 10]));
return (
<div
style={{
position: "absolute",
left: 400 + x,
top: 280 + y,
transform: `translate(-50%, -50%) scale(${scale})`,
opacity: trailOpacity,
zIndex,
padding: "6px 14px",
borderRadius: 8,
backgroundColor: `${color}26`,
border: `1px solid ${color}44`,
fontFamily: FONT.body,
fontWeight: 600,
fontSize: 14,
color,
whiteSpace: "nowrap" as const,
pointerEvents: "none" as const,
}}
>
{label}
</div>
);
};
export const FormatUniverse: React.FC = () => {
const frame = useCurrentFrame();
const logoScale = Math.sin(frame * 0.06) * 0.05 + 1;
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark, overflow: "hidden" }}>
{/* Orbit ring lines (faint elliptical paths behind each ring) */}
<svg
style={{ position: "absolute", inset: 0 }}
width={800}
height={600}
role="img"
aria-label="Orbit rings"
>
{RINGS.map((ring) => (
<ellipse
key={`orbit-${ring.radius}`}
cx={400}
cy={280}
rx={ring.radius}
ry={ring.radius * 0.55}
fill="none"
stroke={ring.color}
strokeWidth={0.5}
opacity={0.05}
/>
))}
</svg>
{/* Opacity trail: render each badge at frame-1 and frame-2 positions */}
{RINGS.map((ring) =>
ring.formats.map((fmt, fi) => {
const angleOffset = (fi / ring.formats.length) * Math.PI * 2;
const trail1Angle = angleOffset + (frame - 1) * SPEED * ring.direction;
const trail2Angle = angleOffset + (frame - 2) * SPEED * ring.direction;
return [
<TrailBadge
key={`trail2-${ring.radius}-${fmt}`}
label={fmt}
color={ring.color}
angle={trail2Angle}
radius={ring.radius}
trailOpacity={0.1}
/>,
<TrailBadge
key={`trail1-${ring.radius}-${fmt}`}
label={fmt}
color={ring.color}
angle={trail1Angle}
radius={ring.radius}
trailOpacity={0.3}
/>,
];
}),
)}
{/* Main badges at current frame */}
{RINGS.map((ring) =>
ring.formats.map((fmt, fi) => {
const angleOffset = (fi / ring.formats.length) * Math.PI * 2;
const angle = angleOffset + frame * SPEED * ring.direction;
return (
<Badge
key={`badge-${ring.radius}-${fmt}`}
label={fmt}
color={ring.color}
angle={angle}
radius={ring.radius}
/>
);
}),
)}
<Img
src={staticFile("logo.png")}
style={{
position: "absolute",
width: 80,
height: 80,
left: 400 - 40,
top: 280 - 40,
transform: `scale(${logoScale})`,
zIndex: 11,
}}
/>
<div
style={{
position: "absolute",
bottom: 40,
width: "100%",
textAlign: "center",
fontFamily: FONT.body,
fontWeight: 500,
fontSize: 18,
color: "rgba(255, 255, 255, 0.7)",
zIndex: 12,
}}
>
55+ formats. Every camera. Every device.
</div>
<GrainOverlay opacity={0.03} />
</AbsoluteFill>
);
};
@@ -3,17 +3,18 @@ import { evolvePath } from "@remotion/paths";
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
random,
Sequence,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { Counter } from "@/components/Counter";
import { GrainOverlay } from "@/components/GrainOverlay";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
@@ -113,10 +114,10 @@ const Station: React.FC<{
border: `2px solid ${station.color}${glowing || pulseGlow ? "cc" : "66"}`,
boxShadow:
glowOpacity > 0
? `0 0 20px ${station.color}${Math.round(glowOpacity * 255)
? `0 4px 20px rgba(0,0,0,0.4), 0 0 20px ${station.color}${Math.round(glowOpacity * 255)
.toString(16)
.padStart(2, "0")}`
: "none",
: "0 4px 20px rgba(0,0,0,0.4)",
transform: `scale(${scale})`,
display: "flex",
flexDirection: "column",
@@ -386,7 +387,17 @@ const SingleFlow: React.FC = () => {
zIndex: frame >= 108 ? 0 : 5,
}}
>
<PhotoPlaceholder width={thumbSize} height={thumbSize} hue={30} />
<Img
src={staticFile("screenshots/sample-photo-landscape.jpg")}
style={{
width: thumbSize,
height: thumbSize,
objectFit: "cover",
borderRadius: 6,
border: "1.5px solid rgba(255,255,255,0.15)",
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
}}
/>
{/* Watermark stamp */}
{stampOpacity > 0 && (
<span
@@ -395,7 +406,7 @@ const SingleFlow: React.FC = () => {
bottom: 2,
right: 3,
fontFamily: FONT.body,
fontSize: 5,
fontSize: 8,
fontWeight: 700,
color: `rgba(255,255,255,${stampOpacity})`,
whiteSpace: "nowrap",
@@ -489,9 +500,10 @@ const BatchThumb: React.FC<{
if (localFrame < 0) return null;
const hue = random(`thumb-hue-${index}`) * 360;
const size = 24 + random(`thumb-size-${index}`) * 8;
const yOffset = (random(`thumb-y-${index}`) - 0.5) * 30;
const objPosX = Math.round(random(`thumb-opx-${index}`) * 100);
const objPosY = Math.round(random(`thumb-opy-${index}`) * 100);
// Fast traversal: cover pipeline in ~28 frames per thumb
const thumbX = interpolate(localFrame, [0, 28], [THUMB_START_X, FOLDER_X + 10], {
@@ -517,7 +529,17 @@ const BatchThumb: React.FC<{
zIndex: 4,
}}
>
<PhotoPlaceholder width={size} height={size} hue={hue} />
<Img
src={staticFile("screenshots/sample-photo-landscape.jpg")}
style={{
width: size,
height: size,
objectFit: "cover",
objectPosition: `${objPosX}% ${objPosY}%`,
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.1)",
}}
/>
</div>
);
};
@@ -601,13 +623,10 @@ export const PipelineFlow: React.FC = () => {
</Sequence>
{/* Act 1 stations (visible throughout until Act 2 takes over) */}
{frame < 40 && (
<>
{STATIONS.map((s, i) => (
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
))}
</>
)}
{frame < 40 &&
STATIONS.map((s, i) => (
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
))}
{/* Act 2: Single Image Flow (frame 40-170) */}
<Sequence from={40} durationInFrames={130}>
@@ -640,13 +659,11 @@ export const PipelineFlow: React.FC = () => {
</Sequence>
{/* Act 3 stations when not covered by SingleFlow or BatchStream */}
{frame >= 170 && frame < 180 && (
<>
{STATIONS.map((s, i) => (
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
))}
</>
)}
{frame >= 170 &&
frame < 180 &&
STATIONS.map((s, i) => (
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
))}
{/* Act 4: Result (frame 340-390) */}
{frame >= 340 && (
@@ -0,0 +1,355 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
interpolateColors,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { GrainOverlay } from "@/components/GrainOverlay";
import { TerminalWindow } from "@/components/TerminalWindow";
import { TypeWriter } from "@/components/TypeWriter";
import { FONT, TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
/* ------------------------------------------------------------------ */
/* Docker command segments with syntax highlighting */
/* ------------------------------------------------------------------ */
const DOCKER_SEGMENTS = [
{ text: "docker", color: "#ff7b72" },
{ text: " run ", color: "#7ee787" },
{ text: "-d ", color: "#79c0ff" },
{ text: "--name ", color: "#79c0ff" },
{ text: "snapotter ", color: "#a5d6ff" },
{ text: "-p ", color: "#79c0ff" },
{ text: "1349:1349 ", color: "#a5d6ff" },
{ text: "snapotter/snapotter", color: "#7ee787" },
];
/* ------------------------------------------------------------------ */
/* Spinner characters */
/* ------------------------------------------------------------------ */
const SPINNER_CHARS = ["|", "/", "-", "\\"];
/* ------------------------------------------------------------------ */
/* Address bar component for the morph */
/* ------------------------------------------------------------------ */
const AddressBar: React.FC<{ opacity: number }> = ({ opacity }) => (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
opacity,
backgroundColor: "rgba(255,255,255,0.1)",
borderRadius: 6,
padding: "3px 12px",
height: 24,
}}
>
{/* Green lock icon */}
<svg width={12} height={12} viewBox="0 0 12 12" role="img" aria-label="Secure">
<rect x={2} y={5.5} width={8} height={5.5} rx={1} fill="#28c840" />
<path
d="M3.5 5.5V4a2.5 2.5 0 0 1 5 0v1.5"
stroke="#28c840"
strokeWidth={1.2}
fill="none"
strokeLinecap="round"
/>
</svg>
<span
style={{
fontFamily: FONT.body,
fontSize: 12,
fontWeight: 500,
color: "rgba(255,255,255,0.7)",
}}
>
localhost:1349
</span>
</div>
);
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const OneCommand: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const cx = width / 2;
const cy = height / 2;
/* ================================================================ */
/* Act 1: Terminal slides up (frame 0-30) */
/* ================================================================ */
const terminalEntrySpring = spring({
frame,
fps,
config: SPRING.snappy,
});
const terminalSlideY = interpolate(terminalEntrySpring, [0, 1], [600, 0]);
/* ================================================================ */
/* Act 2: Typing (frame 30-180) */
/* ================================================================ */
/* Prompt "$ " visible once terminal is in */
const promptOpacity = interpolate(frame, [28, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Cursor blink at the prompt before typing starts (frame 30-35) */
const preTypingCursorVisible = frame >= 30 && frame < 35 && Math.floor(frame / 4) % 2 === 0;
/* ================================================================ */
/* Act 3: Processing lines (frame 180-215) */
/* ================================================================ */
/* "Pulling..." text appears */
const pullingOpacity = interpolate(frame, [180, 185], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Spinner cycles every 3 frames */
const spinnerChar =
frame >= 185 && frame < 210
? SPINNER_CHARS[Math.floor((frame - 185) / 3) % SPINNER_CHARS.length]
: null;
/* Checkmark line */
const checkmarkSpring = spring({
frame: frame - 210,
fps,
config: SPRING.popIn,
});
const showCheckmark = frame >= 210;
/* ================================================================ */
/* Act 4: Terminal-to-App Morph (frame 215-280) */
/* ================================================================ */
/* Body background color morph */
const bodyBg =
frame >= 215 ? interpolateColors(frame, [215, 250], ["#0d1117", "#ffffff"]) : "#0d1117";
/* Terminal text fade out */
const textFadeOut = interpolate(frame, [215, 235], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Window size morph */
const windowWidth = interpolate(frame, [215, 260], [900, 1100], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
const windowHeight = interpolate(frame, [215, 260], [500, 650], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
/* Dots fade out */
const dotsOpacity = interpolate(frame, [220, 240], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Address bar fade in */
const addressBarOpacity = interpolate(frame, [240, 255], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Top bar color morph */
const topBarColor =
frame >= 215 ? interpolateColors(frame, [215, 250], ["#1e1e2e", "#2d2d3f"]) : "#1e1e2e";
/* App mockup appears at frame 260 */
const showAppMockup = frame >= 258;
const appMockupOpacity = interpolate(frame, [258, 270], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ================================================================ */
/* Act 5: Tagline + float (frame 280-390) */
/* ================================================================ */
/* Amber glow behind browser */
const glowOpacity = interpolate(frame, [320, 340], [0, 0.12], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* Float animation (subtle oscillation) */
const floatY = frame >= 350 ? Math.sin((frame - 350) * 0.15) * 3 : 0;
return (
<AbsoluteFill style={{ backgroundColor: "#0c0a09", overflow: "hidden" }}>
{/* Amber glow behind browser (Act 5) */}
{frame >= 320 && (
<div
style={{
position: "absolute",
left: cx,
top: cy - 30,
width: 1200,
height: 800,
transform: "translate(-50%, -50%)",
background:
"radial-gradient(ellipse 50% 50% at 50% 50%, rgba(245,158,11,0.4) 0%, transparent 70%)",
opacity: glowOpacity,
pointerEvents: "none",
}}
/>
)}
{/* Terminal / Browser window */}
<div
style={{
position: "absolute",
left: cx,
top: cy - 30,
transform: `translate(-50%, -50%) translateY(${terminalSlideY + floatY}px)`,
}}
>
<TerminalWindow
width={windowWidth}
height={windowHeight}
title="Terminal"
dotsOpacity={dotsOpacity}
topBarColor={topBarColor}
bodyColor={bodyBg}
titleElement={frame >= 240 ? <AddressBar opacity={addressBarOpacity} /> : undefined}
>
{/* Terminal text content (fades out during morph) */}
<div style={{ opacity: textFadeOut }}>
{/* Prompt line */}
<div
style={{
...TEXT.mono,
fontSize: 18,
lineHeight: 1.6,
display: "flex",
alignItems: "center",
opacity: promptOpacity,
}}
>
<span style={{ color: "#8b949e", marginRight: 8 }}>$</span>
{frame >= 35 ? (
<TypeWriter
segments={DOCKER_SEGMENTS}
startFrame={35}
speed={2}
style={{ fontSize: 18 }}
/>
) : (
preTypingCursorVisible && (
<span
style={{
backgroundColor: "#7ee787",
width: 10,
height: 20,
display: "inline-block",
}}
/>
)
)}
</div>
{/* Processing output (Act 3) */}
{frame >= 180 && (
<div
style={{
...TEXT.mono,
fontSize: 16,
marginTop: 12,
opacity: pullingOpacity,
}}
>
<div style={{ color: "#8b949e" }}>Pulling snapotter/snapotter:latest...</div>
{/* Spinner line */}
{spinnerChar && !showCheckmark && (
<div style={{ color: "#58a6ff", marginTop: 4 }}>{spinnerChar}</div>
)}
{/* Checkmark line */}
{showCheckmark && (
<div
style={{
color: "#7ee787",
marginTop: 4,
transform: `scale(${checkmarkSpring})`,
transformOrigin: "left center",
}}
>
&#10003; Container started on :1349
</div>
)}
</div>
)}
</div>
{/* Real app screenshot (appears during morph) */}
{showAppMockup && (
<div
style={{
position: "absolute",
inset: 0,
opacity: appMockupOpacity,
overflow: "hidden",
borderRadius: "0 0 12px 12px",
}}
>
<Img
src={staticFile("screenshots/app-full.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "top center",
}}
/>
</div>
)}
</TerminalWindow>
</div>
{/* Tagline (Act 5) */}
{frame >= 310 && (
<div
style={{
position: "absolute",
left: cx,
top: cy + windowHeight / 2 + 20 + floatY,
transform: "translateX(-50%)",
textAlign: "center",
}}
>
<ClipReveal startFrame={320}>
<span style={{ ...TEXT.sectionTitle, color: "white" }}>
One command. That&apos;s it.
</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};
@@ -0,0 +1,298 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { GrainOverlay } from "@/components/GrainOverlay";
import { ShieldIcon } from "@/components/ShieldIcon";
import { TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
/* ------------------------------------------------------------------ */
/* Photo config */
/* ------------------------------------------------------------------ */
const PHOTOS = [
{
src: "screenshots/sample-photo-portrait.jpg",
w: 180,
h: 135,
fromX: -300,
fromY: -250,
delay: 0,
},
{
src: "screenshots/sample-photo-landscape.jpg",
w: 160,
h: 120,
fromX: 320,
fromY: 280,
delay: 4,
},
{ src: "screenshots/sample-photo-bw.jpg", w: 150, h: 115, fromX: 280, fromY: -260, delay: 8 },
] as const;
/* ------------------------------------------------------------------ */
/* Padlock SVG (simple circle + rect) */
/* ------------------------------------------------------------------ */
const PadlockIcon: React.FC<{ opacity: number }> = ({ opacity }) => (
<svg
width={36}
height={36}
viewBox="0 0 24 24"
style={{ opacity }}
role="img"
aria-label="Padlock"
>
<rect x={5} y={11} width={14} height={11} rx={2} fill="rgba(255,255,255,0.6)" />
<path
d="M8 11V7a4 4 0 0 1 8 0v4"
stroke="rgba(255,255,255,0.6)"
strokeWidth={2}
strokeLinecap="round"
fill="none"
/>
</svg>
);
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const PrivacyPromise: React.FC = () => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const cx = width / 2;
const cy = height / 2;
/* ---- Act 3: shield + photos scale & move up ---- */
const act3Scale = interpolate(frame, [160, 180], [1, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.snap,
});
const act3OffsetY = interpolate(frame, [160, 180], [0, -120], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.snap,
});
/* ---- Act 4: fade out ---- */
const act4ShieldOpacity = interpolate(frame, [240, 270], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
/* ---- Shield fill (Act 2) ---- */
const shieldFillOpacity = interpolate(frame, [110, 130], [0, 0.08], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ---- Shield pulse glow (Act 2, frame 130-160) ---- */
const pulseScale = frame >= 130 && frame <= 240 ? 1 + Math.sin((frame - 130) * 0.314) * 0.02 : 1;
/* ---- Padlock fade in (Act 2) ---- */
const padlockOpacity = interpolate(frame, [130, 145], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ---- Act 4 scale-down for shield ---- */
const act4ShieldScale = interpolate(frame, [240, 270], [1, 0.8], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
/* ---- Text fade out (Act 4) ---- */
const act4TextOpacity = interpolate(frame, [240, 265], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
return (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 60% 50% at 50% 50%, rgba(245,158,11,0.15) 0%, #0c0a09 70%)`,
overflow: "hidden",
}}
>
{/* ---- Shield + Photos group (scales together in Act 3) ---- */}
<div
style={{
position: "absolute",
left: cx,
top: cy,
transform: `translate(-50%, -50%) translateY(${act3OffsetY}px) scale(${act3Scale * act4ShieldScale * pulseScale})`,
opacity: act4ShieldOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{/* Shield icon */}
<ShieldIcon size={200} startFrame={75} duration={35} fillOpacity={shieldFillOpacity} />
{/* Padlock at shield base */}
<div style={{ marginTop: -30 }}>
<PadlockIcon opacity={padlockOpacity * act4ShieldOpacity} />
</div>
{/* Photos floating around the shield */}
{PHOTOS.map((photo, i) => {
const entrySpring = spring({
frame: frame - 15 - photo.delay,
fps,
config: SPRING.natural,
});
/* Act 1: drift from edges toward center */
const driftProgress = interpolate(frame, [25, 75], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
/* Act 2: settle inside shield */
const settleSpring = spring({
frame: frame - 110,
fps,
config: SPRING.settle,
});
/* Settled positions: clustered near center */
const settledX = (i - 1) * 40;
const settledY = (i - 1) * 25;
/* Current position blends drift -> settled */
const preSettleX = photo.fromX * (1 - driftProgress);
const preSettleY = photo.fromY * (1 - driftProgress);
const photoX =
frame < 110 ? preSettleX : preSettleX + (settledX - preSettleX) * settleSpring;
const photoY =
frame < 110 ? preSettleY : preSettleY + (settledY - preSettleY) * settleSpring;
/* Rotation: organic drift then settle */
const driftRotation = (i % 2 === 0 ? 3 : -3) * (1 - driftProgress);
const settledRotation = (i - 1) * 2;
const rotation =
frame < 110
? driftRotation
: driftRotation + (settledRotation - driftRotation) * settleSpring;
/* Act 4: photos drift back out (reverse of arrival) */
const exitProgress = interpolate(frame, [270, 295], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
const exitX = photoX + photo.fromX * exitProgress;
const exitY = photoY + photo.fromY * exitProgress;
const exitOpacity = interpolate(frame, [270, 295], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
/* Entry opacity (photos appear from frame 15) */
const entryOpacity = interpolate(frame, [15 + photo.delay, 20 + photo.delay], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<Img
key={photo.src}
src={staticFile(photo.src)}
style={{
position: "absolute",
width: photo.w,
height: photo.h,
objectFit: "cover",
borderRadius: 8,
border: "2px solid rgba(255,255,255,0.2)",
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
left: `calc(50% + ${exitX}px - ${photo.w / 2}px)`,
top: `calc(50% + ${exitY}px - ${photo.h / 2}px)`,
transform: `scale(${entrySpring}) rotate(${rotation}deg)`,
opacity: entryOpacity * exitOpacity,
zIndex: i,
}}
/>
);
})}
</div>
{/* ---- "Your images." text (Act 2) ---- */}
<div
style={{
position: "absolute",
left: cx,
top: cy + 130 * act3Scale + act3OffsetY,
transform: "translateX(-50%)",
opacity: act4TextOpacity,
textAlign: "center",
}}
>
<ClipReveal startFrame={95}>
<span style={{ ...TEXT.heroHeadline }}>Your images.</span>
</ClipReveal>
</div>
{/* ---- "Stay yours." text (Act 2) ---- */}
<div
style={{
position: "absolute",
left: cx,
top: cy + 200 * act3Scale + act3OffsetY,
transform: "translateX(-50%)",
opacity: act4TextOpacity,
textAlign: "center",
}}
>
<ClipReveal startFrame={130}>
<span style={{ ...TEXT.heroHeadline }}>Stay yours.</span>
</ClipReveal>
</div>
{/* ---- Subtext lines (Act 3) ---- */}
{frame >= 160 && (
<div
style={{
position: "absolute",
left: cx,
top: cy + 160,
transform: "translateX(-50%)",
opacity: act4TextOpacity,
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<ClipReveal startFrame={180}>
<span style={{ ...TEXT.heroSub, opacity: 0.8 }}>No uploads to the cloud. Ever.</span>
</ClipReveal>
<ClipReveal startFrame={184}>
<span style={{ ...TEXT.heroSub, opacity: 0.8 }}>100% local processing.</span>
</ClipReveal>
<ClipReveal startFrame={188}>
<span style={{ ...TEXT.heroSub, opacity: 0.8 }}>Free. Forever.</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};
@@ -0,0 +1,495 @@
import { Trail } from "@remotion/motion-blur";
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
random,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { Counter } from "@/components/Counter";
import { GrainOverlay } from "@/components/GrainOverlay";
import { ToolPill } from "@/components/ToolPill";
import { CATEGORY_LABELS, CATEGORY_ORDER, COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
import { TOOLS } from "@/lib/tools";
/* ------------------------------------------------------------------ */
/* Pre-computed pill data */
/* ------------------------------------------------------------------ */
interface PillData {
index: number;
name: string;
category: string;
categoryIndex: number;
rowInCategory: number;
angle: number;
burstRadius: number;
rotationOffset: number;
}
const RING_RADII = [160, 210, 260, 310, 360, 410, 460, 510];
/** Speed in rad/frame for each ring (inner faster, outer slower) */
const RING_SPEEDS = RING_RADII.map(
(_, i) => 0.008 - i * ((0.008 - 0.003) / (RING_RADII.length - 1)),
);
/** Alternating rotation direction: even rings CW, odd rings CCW */
const RING_DIRECTIONS = RING_RADII.map((_, i) => (i % 2 === 0 ? 1 : -1));
/** Category counts for computing per-category row indices */
const categoryCounts: Record<string, number> = {};
for (const cat of CATEGORY_ORDER) {
categoryCounts[cat] = 0;
}
const PILLS: PillData[] = TOOLS.map((tool, i) => {
const catIdx = CATEGORY_ORDER.indexOf(tool.category as (typeof CATEGORY_ORDER)[number]);
const categoryIndex = catIdx >= 0 ? catIdx : 0;
const rowInCategory = categoryCounts[tool.category] ?? 0;
categoryCounts[tool.category] = rowInCategory + 1;
return {
index: i,
name: tool.name,
category: tool.category,
categoryIndex,
rowInCategory,
angle: random(`angle-${i}`) * Math.PI * 2,
burstRadius: 200 + random(`radius-${i}`) * 400,
rotationOffset: (random(`rot-${i}`) - 0.5) * 30,
};
});
/* ------------------------------------------------------------------ */
/* Grid layout constants */
/* ------------------------------------------------------------------ */
const GRID_COLS = CATEGORY_ORDER.length; // 8
const CELL_W = 120;
const CELL_H = 32;
const CELL_GAP = 8;
/** Max rows across all categories (AI has 15) */
const MAX_ROWS = Math.max(
...CATEGORY_ORDER.map((cat) => TOOLS.filter((t) => t.category === cat).length),
);
const GRID_TOTAL_W = GRID_COLS * CELL_W + (GRID_COLS - 1) * CELL_GAP;
const GRID_TOTAL_H = MAX_ROWS * CELL_H + (MAX_ROWS - 1) * CELL_GAP;
const GRID_LEFT = (1920 - GRID_TOTAL_W) / 2;
const GRID_TOP = (1080 - GRID_TOTAL_H) / 2 + 30; // offset down slightly for label room
/* ------------------------------------------------------------------ */
/* Position helpers */
/* ------------------------------------------------------------------ */
const CX = 1920 / 2;
const CY = 1080 / 2;
function getBurstPosition(
frame: number,
fps: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
const entryFrame = 60 + pill.index * 2;
const s = spring({
frame: frame - entryFrame,
fps,
config: SPRING.popIn,
});
const x = CX + Math.cos(pill.angle) * pill.burstRadius * s;
const y = CY + Math.sin(pill.angle) * pill.burstRadius * s;
return {
x,
y,
scale: s,
opacity: Math.min(s * 2, 1),
rotation: pill.rotationOffset * (1 - s * 0.5),
};
}
function getOrbitPosition(
frame: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
const catIdx = pill.categoryIndex;
const ringRadius = RING_RADII[catIdx];
const ringSpeed = RING_SPEEDS[catIdx];
const direction = RING_DIRECTIONS[catIdx];
// Distribute pills evenly within their ring
const toolsInCat = TOOLS.filter((t) => t.category === pill.category).length;
const baseAngle = (pill.rowInCategory / toolsInCat) * Math.PI * 2;
// Accumulated angle: integrate speed from frame 210 to current frame
// For simplicity, use frame-based angle with deceleration applied
let orbitAngle: number;
if (frame < 360) {
orbitAngle = baseAngle + (frame - 210) * ringSpeed * direction;
} else {
// Angle accumulated up to frame 360 + decelerating portion
const preDecAngle = (360 - 210) * ringSpeed * direction;
const decProgress = interpolate(frame, [360, 420], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
// Deceleration: linearly decreasing speed from full to 0
// Integral of (1-t) from 0 to decProgress = decProgress - decProgress^2/2
const decAngle = (frame - 360) * ringSpeed * direction * (1 - decProgress * 0.5);
orbitAngle = baseAngle + preDecAngle + decAngle;
}
const rawX = Math.cos(orbitAngle) * ringRadius;
const rawY = Math.sin(orbitAngle) * ringRadius * 0.6; // elliptical perspective
// Depth: pills at "back" of orbit (positive sin component) are dimmer/smaller
const depthFactor = Math.sin(orbitAngle);
const depthOpacity = interpolate(depthFactor, [-1, 1], [1, 0.7]);
const depthScale = interpolate(depthFactor, [-1, 1], [1, 0.85]);
// Transition from burst to orbit (frame 180-210)
const transitionProgress = interpolate(frame, [180, 210], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
// If still transitioning, we need the burst end position
if (transitionProgress < 1) {
const burstX = CX + Math.cos(pill.angle) * pill.burstRadius;
const burstY = CY + Math.sin(pill.angle) * pill.burstRadius;
const orbitX = CX + rawX;
const orbitY = CY + rawY;
return {
x: burstX + (orbitX - burstX) * transitionProgress,
y: burstY + (orbitY - burstY) * transitionProgress,
scale: 1 + (depthScale - 1) * transitionProgress,
opacity: 1 + (depthOpacity - 1) * transitionProgress,
rotation: pill.rotationOffset * (1 - transitionProgress),
};
}
return {
x: CX + rawX,
y: CY + rawY,
scale: depthScale,
opacity: depthOpacity,
rotation: 0,
};
}
function getGridPosition(
frame: number,
fps: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
const col = pill.categoryIndex;
const row = pill.rowInCategory;
const targetX = GRID_LEFT + col * (CELL_W + CELL_GAP) + CELL_W / 2;
const targetY = GRID_TOP + row * (CELL_H + CELL_GAP) + CELL_H / 2;
// Spring animation from orbit position to grid position
const staggerDelay = pill.index * 1.5;
const settleFrame = 420 + staggerDelay;
const s = spring({
frame: frame - settleFrame,
fps,
config: SPRING.settle,
});
// Get last orbit position (frozen at deceleration end)
const orbitPos = getOrbitPosition(420, pill);
const x = orbitPos.x + (targetX - orbitPos.x) * s;
const y = orbitPos.y + (targetY - orbitPos.y) * s;
// Act 5: grid fades to 40% opacity
const gridOpacity = interpolate(frame, [540, 570], [1, 0.4], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
return {
x,
y,
scale: orbitPos.scale + (1 - orbitPos.scale) * s,
opacity: gridOpacity,
rotation: 0,
};
}
function getPillPosition(
frame: number,
fps: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
if (frame < 180) return getBurstPosition(frame, fps, pill);
if (frame < 420) return getOrbitPosition(frame, pill);
return getGridPosition(frame, fps, pill);
}
/* ------------------------------------------------------------------ */
/* AnimatedPill - renders a single pill at its computed position */
/* ------------------------------------------------------------------ */
const AnimatedPill: React.FC<{ pill: PillData }> = ({ pill }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pos = getPillPosition(frame, fps, pill);
// Don't render before the pill's entry frame
const entryFrame = 60 + pill.index * 2;
if (frame < entryFrame) return null;
return (
<div
style={{
position: "absolute",
left: pos.x,
top: pos.y,
transform: `translate(-50%, -50%) scale(${pos.scale}) rotate(${pos.rotation}deg)`,
opacity: pos.opacity,
}}
>
<ToolPill name={pill.name} category={pill.category} />
</div>
);
};
/* ------------------------------------------------------------------ */
/* BurstPhase - pills wrapped in Trail for motion blur */
/* ------------------------------------------------------------------ */
const BurstPills: React.FC = () => (
<AbsoluteFill>
{PILLS.map((pill) => (
<AnimatedPill key={pill.index} pill={pill} />
))}
</AbsoluteFill>
);
/* ------------------------------------------------------------------ */
/* Category labels for the grid */
/* ------------------------------------------------------------------ */
const CategoryLabels: React.FC = () => {
const frame = useCurrentFrame();
if (frame < 500) return null;
return (
<>
{CATEGORY_ORDER.map((cat, i) => {
const labelX = GRID_LEFT + i * (CELL_W + CELL_GAP) + CELL_W / 2;
const labelY = GRID_TOP - 20;
const catColor = COLOR.category[cat] ?? COLOR.accent;
// Act 5 grid opacity
const gridOpacity = interpolate(frame, [540, 570], [1, 0.4], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
return (
<div
key={cat}
style={{
position: "absolute",
left: labelX,
top: labelY,
transform: "translateX(-50%)",
opacity: gridOpacity,
}}
>
<ClipReveal startFrame={500 + i * 3}>
<span style={{ ...TEXT.label, fontSize: 11, color: catColor }}>
{CATEGORY_LABELS[cat]}
</span>
</ClipReveal>
</div>
);
})}
</>
);
};
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const ToolGalaxy: React.FC = () => {
const frame = useCurrentFrame();
/* ================================================================ */
/* Act 1: Logo Pulse (frame 0-60) */
/* ================================================================ */
const logoOpacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const logoPulseScale = frame >= 30 ? Math.sin(frame * 0.15) * 0.05 + 1 : 1;
// Logo fades out as burst starts
const logoFadeOut = interpolate(frame, [60, 90], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Radial glow breathes with the pulse
const glowOpacity = frame >= 30 ? 0.15 + Math.sin(frame * 0.15) * 0.05 : 0.05;
// Ambient radial glow (always present, fades during burst)
const ambientGlow = interpolate(frame, [0, 20], [0.05, 0.15], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ================================================================ */
/* Act 5: Counter + Tagline (frame 540-660) */
/* ================================================================ */
const counterOpacity = interpolate(frame, [540, 555], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Counter scale bump: subtle pulse as numbers tick
const counterProgress = interpolate(frame, [540, 590], [0, 48], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const counterBump = frame >= 540 && frame <= 595 ? 1 + Math.sin(counterProgress * 0.8) * 0.03 : 1;
/* ================================================================ */
/* Determine rendering phase */
/* ================================================================ */
// During burst (frame 60-180), wrap pills in Trail for motion blur
const inBurstPhase = frame >= 60 && frame < 180;
return (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 60% 50% at 50% 50%, rgba(245,158,11,${ambientGlow}) 0%, #0c0a09 70%)`,
overflow: "hidden",
}}
>
{/* ---- Radial amber glow behind logo (Act 1) ---- */}
{frame < 90 && (
<div
style={{
position: "absolute",
left: CX,
top: CY,
width: 400,
height: 400,
transform: "translate(-50%, -50%)",
background:
"radial-gradient(circle at 50% 50%, rgba(245,158,11,0.3) 0%, transparent 70%)",
opacity: glowOpacity * logoFadeOut,
filter: "blur(100px)",
pointerEvents: "none",
}}
/>
)}
{/* ---- Logo (Act 1) ---- */}
{frame < 120 && (
<div
style={{
position: "absolute",
left: CX,
top: CY,
transform: `translate(-50%, -50%) scale(${logoPulseScale})`,
opacity: logoOpacity * logoFadeOut,
}}
>
<Img
src={staticFile("logo.png")}
width={120}
height={120}
style={{ objectFit: "contain" }}
/>
</div>
)}
{/* ---- Pills ---- */}
{frame >= 60 &&
(inBurstPhase ? (
<Trail layers={6} lagInFrames={0.12} trailOpacity={0.4}>
<BurstPills />
</Trail>
) : (
<BurstPills />
))}
{/* ---- Category labels (Act 4) ---- */}
<CategoryLabels />
{/* ---- Counter + Tagline (Act 5) ---- */}
{frame >= 540 && (
<div
style={{
position: "absolute",
left: CX,
top: CY - 30,
transform: `translate(-50%, -50%) scale(${counterBump})`,
textAlign: "center",
opacity: counterOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
<Counter from={0} to={TOOLS.length} startFrame={540} duration={50} style={TEXT.counter} />
</div>
)}
{/* ---- Tagline text (Act 5) ---- */}
{frame >= 580 && (
<div
style={{
position: "absolute",
left: CX,
top: CY + 40,
transform: "translateX(-50%)",
textAlign: "center",
}}
>
<ClipReveal startFrame={580}>
<span style={{ ...TEXT.sectionTitle }}>
<Counter
from={0}
to={TOOLS.length}
startFrame={580}
duration={30}
style={{ ...TEXT.sectionTitle, color: COLOR.accent, display: "inline" }}
/>{" "}
tools. Zero cloud dependency.
</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};
@@ -0,0 +1,54 @@
import type React from "react";
import { AbsoluteFill, Series, staticFile } from "remotion";
import { GrainOverlay } from "@/components/GrainOverlay";
import { BackgroundMusic } from "@/lib/audio";
import { AiToolsScene } from "./scenes/AiToolsScene";
import { ApiDocsScene } from "./scenes/ApiDocsScene";
import { BatchProcessingScene } from "./scenes/BatchProcessingScene";
import { DashboardScene } from "./scenes/DashboardScene";
import { EndCardScene } from "./scenes/EndCardScene";
import { ImageEditorScene } from "./scenes/ImageEditorScene";
import { PipelineBuilderScene } from "./scenes/PipelineBuilderScene";
import { SingleToolScene } from "./scenes/SingleToolScene";
export const ProductDemo: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: "#f5f5f4" }}>
<Series>
<Series.Sequence durationInFrames={240}>
<DashboardScene />
</Series.Sequence>
<Series.Sequence durationInFrames={360}>
<SingleToolScene />
</Series.Sequence>
<Series.Sequence durationInFrames={360}>
<BatchProcessingScene />
</Series.Sequence>
<Series.Sequence durationInFrames={390}>
<PipelineBuilderScene />
</Series.Sequence>
<Series.Sequence durationInFrames={360}>
<AiToolsScene />
</Series.Sequence>
<Series.Sequence durationInFrames={300}>
<ImageEditorScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<ApiDocsScene />
</Series.Sequence>
<Series.Sequence durationInFrames={90}>
<EndCardScene />
</Series.Sequence>
</Series>
<GrainOverlay opacity={0.02} />
<BackgroundMusic
src={staticFile("audio/product-demo.mp3")}
volume={0.3}
fadeInFrames={30}
fadeOutFrames={60}
totalFrames={2250}
/>
</AbsoluteFill>
);
};
@@ -0,0 +1,13 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const AiToolsScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="remove-bg.png"
caption="15 AI models. Your hardware."
captionFrame={120}
panX={10}
/>
);
};
@@ -0,0 +1,14 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const ApiDocsScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="api-docs.png"
caption="Every tool via REST API."
captionFrame={120}
topBarColor="#ffffff"
bodyColor="#ffffff"
/>
);
};
@@ -0,0 +1,12 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const BatchProcessingScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="compress-tool.png"
caption="Unlimited batch. No caps."
captionFrame={160}
/>
);
};
@@ -0,0 +1,13 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const DashboardScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="dashboard.png"
caption="49 tools. One dashboard."
captionFrame={120}
panX={-20}
/>
);
};
@@ -0,0 +1,62 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
export const EndCardScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoSpring = spring({ frame, fps, config: SPRING.natural });
const fadeOut = interpolate(frame, [70, 90], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
backgroundColor: "white",
opacity: fadeOut,
}}
>
<Img
src={staticFile("logo.png")}
style={{
width: 80,
height: 80,
opacity: logoSpring,
transform: `scale(${0.8 + logoSpring * 0.2})`,
}}
/>
<div style={{ height: 16 }} />
<ClipReveal startFrame={10}>
<span style={{ ...TEXT.sectionTitle, fontSize: 36, color: "#0a0a0a" }}>
Self-hosted image processing.
</span>
</ClipReveal>
<div style={{ height: 12 }} />
<ClipReveal startFrame={25}>
<span style={{ fontFamily: FONT.body, fontSize: 18, color: COLOR.accent }}>
snapotter.com
</span>
</ClipReveal>
</AbsoluteFill>
);
};
@@ -0,0 +1,12 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const ImageEditorScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="editor.png"
caption="Full image editor built in."
captionFrame={100}
/>
);
};
@@ -0,0 +1,14 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const PipelineBuilderScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="automate.png"
caption="Chain tools into workflows."
captionFrame={120}
zoomFrom={1.05}
zoomTo={1.0}
/>
);
};
@@ -0,0 +1,97 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { ClipReveal } from "@/components/ClipReveal";
import { TEXT } from "@/lib/fonts";
export const ScreenshotScene: React.FC<{
screenshot: string;
caption?: string;
captionFrame?: number;
zoomFrom?: number;
zoomTo?: number;
panX?: number;
panY?: number;
topBarColor?: string;
bodyColor?: string;
}> = ({
screenshot,
caption,
captionFrame = 60,
zoomFrom = 1.0,
zoomTo = 1.06,
panX = 0,
panY = 0,
topBarColor = "#1a1a2e",
bodyColor = "#0f172a",
}) => {
const frame = useCurrentFrame();
const totalFrames = 300;
const scale = interpolate(frame, [0, totalFrames], [zoomFrom, zoomTo], {
extrapolateRight: "clamp",
});
const translateX = interpolate(frame, [0, totalFrames], [0, panX], {
extrapolateRight: "clamp",
});
const translateY = interpolate(frame, [0, totalFrames], [0, panY], {
extrapolateRight: "clamp",
});
const windowOpacity = interpolate(frame, [0, 15], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f0f0f0" }}
>
<div style={{ opacity: windowOpacity }}>
<AppWindow
title="SnapOtter"
width={1700}
height={950}
topBarColor={topBarColor}
bodyColor={bodyColor}
>
<div style={{ overflow: "hidden", width: "100%", height: "100%" }}>
<Img
src={staticFile(`screenshots/${screenshot}`)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
}}
/>
</div>
</AppWindow>
</div>
{caption && (
<div
style={{
position: "absolute",
bottom: 30,
width: "100%",
textAlign: "center",
}}
>
<ClipReveal startFrame={captionFrame}>
<span
style={{
...TEXT.sectionTitle,
fontSize: 32,
color: "#1a1a2e",
backgroundColor: "rgba(255,255,255,0.85)",
padding: "8px 24px",
borderRadius: 8,
}}
>
{caption}
</span>
</ClipReveal>
</div>
)}
</AbsoluteFill>
);
};
@@ -0,0 +1,14 @@
import type React from "react";
import { ScreenshotScene } from "./ScreenshotScene";
export const SingleToolScene: React.FC = () => {
return (
<ScreenshotScene
screenshot="resize-tool.png"
caption="Upload. Configure. Process."
captionFrame={120}
zoomTo={1.08}
panY={-15}
/>
);
};
@@ -0,0 +1,87 @@
import type React from "react";
import { AbsoluteFill, Series, staticFile } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
import { GrainOverlay } from "@/components/GrainOverlay";
import { BackgroundMusic } from "@/lib/audio";
import { COLOR } from "@/lib/colors";
import { AmbientOpenScene } from "./scenes/AmbientOpenScene";
import { CTAScene } from "./scenes/CTAScene";
import { LogoRevealScene } from "./scenes/LogoRevealScene";
import { NumberPunchScene } from "./scenes/NumberPunchScene";
import { TaglineCascadeScene } from "./scenes/TaglineCascadeScene";
const BG_BLOBS = [
{
color: "#f59e0b",
radius: 200,
cx: 300,
cy: 400,
a: 1,
b: 2,
phaseX: 0,
phaseY: 0,
amplitudeX: 60,
amplitudeY: 40,
},
{
color: "#f97316",
radius: 150,
cx: 750,
cy: 250,
a: 2,
b: 1,
phaseX: 1.2,
phaseY: 0.8,
amplitudeX: 50,
amplitudeY: 60,
},
{
color: "#d97706",
radius: 180,
cx: 540,
cy: 700,
a: 1,
b: 1,
phaseX: 2.4,
phaseY: 1.6,
amplitudeX: 70,
amplitudeY: 50,
},
];
export const PromoTeaser: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark }}>
{BG_BLOBS.map((blob, i) => (
<GradientBlob key={`bg-${i}`} config={blob} duration={600} />
))}
<Series>
<Series.Sequence durationInFrames={90}>
<AmbientOpenScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<NumberPunchScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<TaglineCascadeScene />
</Series.Sequence>
<Series.Sequence durationInFrames={120}>
<LogoRevealScene />
</Series.Sequence>
<Series.Sequence durationInFrames={90}>
<CTAScene />
</Series.Sequence>
</Series>
<GrainOverlay opacity={0.03} />
<BackgroundMusic
src={staticFile("audio/promo-teaser.mp3")}
volume={0.5}
fadeInFrames={15}
fadeOutFrames={45}
totalFrames={600}
/>
</AbsoluteFill>
);
};
@@ -0,0 +1,79 @@
import type React from "react";
import { AbsoluteFill, Series } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
import { GrainOverlay } from "@/components/GrainOverlay";
import { COLOR } from "@/lib/colors";
import { AmbientOpenScene } from "./scenes/AmbientOpenScene";
import { CTAScene } from "./scenes/CTAScene";
import { LogoRevealScene } from "./scenes/LogoRevealScene";
import { NumberPunchScene } from "./scenes/NumberPunchScene";
import { TaglineCascadeScene } from "./scenes/TaglineCascadeScene";
const BG_BLOBS = [
{
color: "#f59e0b",
radius: 250,
cx: 540,
cy: 600,
a: 1,
b: 2,
phaseX: 0,
phaseY: 0,
amplitudeX: 80,
amplitudeY: 100,
},
{
color: "#f97316",
radius: 200,
cx: 300,
cy: 1200,
a: 2,
b: 1,
phaseX: 1.2,
phaseY: 0.8,
amplitudeX: 60,
amplitudeY: 120,
},
{
color: "#d97706",
radius: 220,
cx: 700,
cy: 1600,
a: 1,
b: 1,
phaseX: 2.4,
phaseY: 1.6,
amplitudeX: 90,
amplitudeY: 80,
},
];
export const PromoTeaserVertical: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark }}>
{BG_BLOBS.map((blob, i) => (
<GradientBlob key={`bg-v-${i}`} config={blob} duration={600} />
))}
<Series>
<Series.Sequence durationInFrames={90}>
<AmbientOpenScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<NumberPunchScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<TaglineCascadeScene />
</Series.Sequence>
<Series.Sequence durationInFrames={120}>
<LogoRevealScene />
</Series.Sequence>
<Series.Sequence durationInFrames={90}>
<CTAScene />
</Series.Sequence>
</Series>
<GrainOverlay opacity={0.03} />
</AbsoluteFill>
);
};
@@ -0,0 +1,115 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
const BLOBS = [
{
color: "#f59e0b",
radius: 200,
cx: 300,
cy: 400,
a: 1,
b: 2,
phaseX: 0,
phaseY: 0,
amplitudeX: 60,
amplitudeY: 40,
},
{
color: "#f97316",
radius: 150,
cx: 750,
cy: 250,
a: 2,
b: 1,
phaseX: 1.2,
phaseY: 0.8,
amplitudeX: 50,
amplitudeY: 60,
},
{
color: "#d97706",
radius: 180,
cx: 540,
cy: 700,
a: 1,
b: 1,
phaseX: 2.4,
phaseY: 1.6,
amplitudeX: 70,
amplitudeY: 50,
},
];
const SCREENSHOTS = ["dashboard.png", "resize-tool.png", "editor.png"];
const CYCLE_FRAMES = 30;
export const AmbientOpenScene: React.FC = () => {
const frame = useCurrentFrame();
const fadeIn = interpolate(frame, [0, 45], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const currentIdx = Math.floor(frame / CYCLE_FRAMES) % SCREENSHOTS.length;
const nextIdx = (currentIdx + 1) % SCREENSHOTS.length;
const cycleProgress = (frame % CYCLE_FRAMES) / CYCLE_FRAMES;
const currentOpacity = interpolate(cycleProgress, [0, 0.8, 1], [0.6, 0.6, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const nextOpacity = interpolate(cycleProgress, [0.7, 1], [0, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const scale = interpolate(frame % CYCLE_FRAMES, [0, CYCLE_FRAMES], [1.05, 1.0], {
extrapolateRight: "clamp",
});
const rotation = (currentIdx % 2 === 0 ? 1 : -1) * 3.5;
return (
<AbsoluteFill style={{ opacity: fadeIn }}>
{BLOBS.map((blob, i) => (
<GradientBlob key={`ambient-${i}`} config={blob} duration={600} />
))}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
width: 700,
height: 450,
position: "relative",
transform: `rotate(${rotation}deg) scale(${scale})`,
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
}}
>
<Img
src={staticFile(`screenshots/${SCREENSHOTS[currentIdx]}`)}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
opacity: currentOpacity,
}}
/>
<Img
src={staticFile(`screenshots/${SCREENSHOTS[nextIdx]}`)}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
opacity: nextOpacity,
}}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,60 @@
import type React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
export const CTAScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pillScale = spring({ frame, fps, config: SPRING.popIn });
const urlOpacity = interpolate(frame, [15, 25], [0, 0.7], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOut = interpolate(frame, [60, 90], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const glowOpacity = interpolate(Math.sin(frame * 0.1), [-1, 1], [0.3, 0.6]);
return (
<AbsoluteFill
style={{
justifyContent: "center",
alignItems: "center",
opacity: fadeOut,
}}
>
<div
style={{
transform: `scale(${pillScale})`,
padding: "14px 36px",
borderRadius: 50,
background: `linear-gradient(135deg, ${COLOR.accent}, ${COLOR.accentHover})`,
boxShadow: `0 0 30px rgba(245, 158, 11, ${glowOpacity})`,
color: "white",
fontFamily: FONT.heading,
fontWeight: 700,
fontSize: 20,
}}
>
Get it free
</div>
<div style={{ height: 16 }} />
<span
style={{
fontFamily: FONT.mono,
fontSize: 16,
color: "white",
opacity: urlOpacity,
}}
>
snapotter.com
</span>
</AbsoluteFill>
);
};
@@ -0,0 +1,18 @@
import type React from "react";
import { AbsoluteFill } from "remotion";
import { LogoReveal } from "@/components/LogoReveal";
export const LogoRevealScene: React.FC = () => {
return (
<AbsoluteFill>
<LogoReveal
convergeFrame={0}
burstFrame={30}
logoFrame={30}
textFrame={50}
taglineFrame={70}
logoSize={80}
/>
</AbsoluteFill>
);
};
@@ -0,0 +1,50 @@
import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { NumberPunch } from "@/components/NumberPunch";
const PUNCHES = [
{ number: "49", descriptor: "tools", frame: 0 },
{ number: "15", descriptor: "AI models", frame: 37 },
{ number: "55+", descriptor: "formats", frame: 74 },
{ number: "1", descriptor: "container", frame: 111 },
];
export const NumberPunchScene: React.FC = () => {
const frame = useCurrentFrame();
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
{PUNCHES.map((p, i) => {
const segmentEnd = i < PUNCHES.length - 1 ? PUNCHES[i + 1].frame : p.frame + 37;
const fadeInOpacity = interpolate(frame, [p.frame, p.frame + 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOutOpacity = interpolate(frame, [segmentEnd - 6, segmentEnd], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const isLastPunch = i === PUNCHES.length - 1;
const visible = frame >= p.frame && frame < segmentEnd;
const opacity = isLastPunch ? fadeInOpacity : Math.min(fadeInOpacity, fadeOutOpacity);
if (!visible) return null;
return (
<AbsoluteFill
key={p.number}
style={{ justifyContent: "center", alignItems: "center", opacity }}
>
<NumberPunch
number={p.number}
descriptor={p.descriptor}
enterFrame={p.frame}
numberSize={200}
shakeIntensity={2}
/>
</AbsoluteFill>
);
})}
</AbsoluteFill>
);
};
@@ -0,0 +1,46 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { RotatingTaglines } from "@/components/RotatingTaglines";
const TAGLINES = [
"No signups.",
"No uploads.",
"No limits.",
"Free forever.",
"Open source.",
"Fully offline.",
];
export const TaglineCascadeScene: React.FC = () => {
const frame = useCurrentFrame();
const totalFrames = 150;
const bgScale = interpolate(frame, [0, totalFrames], [1.0, 1.1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<Img
src={staticFile("screenshots/dashboard.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.15,
transform: `scale(${bgScale})`,
}}
/>
</AbsoluteFill>
<RotatingTaglines
lines={TAGLINES}
startFrame={0}
framesPerLine={25}
fontSize={56}
color="white"
/>
</AbsoluteFill>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,667 @@
import { Trail } from "@remotion/motion-blur";
import type React from "react";
import {
AbsoluteFill,
interpolate,
Sequence,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { GrainOverlay } from "@/components/GrainOverlay";
import { ToolPill } from "@/components/ToolPill";
import { CATEGORY_LABELS, CATEGORY_ORDER, COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
import { getToolsByCategory } from "@/lib/tools";
/* ------------------------------------------------------------------ */
/* Grid layout constants (from spec) */
/* ------------------------------------------------------------------ */
const COLS = 8;
const ROWS = 6;
const CELL_W = 190;
const CELL_H = 44;
const GAP = 8;
const GRID_W = COLS * CELL_W + (COLS - 1) * GAP; // 1576
const GRID_H = ROWS * CELL_H + (ROWS - 1) * GAP; // 304
const GRID_X = (1920 - GRID_W) / 2;
const GRID_Y = (1080 - GRID_H) / 2 + 40;
const CX = 1920 / 2;
const CY = 1080 / 2;
const FRAMES_PER_TOOL = 10;
/* ------------------------------------------------------------------ */
/* Pre-compute tool order and grid positions */
/* ------------------------------------------------------------------ */
interface ToolEntry {
index: number;
name: string;
category: string;
col: number;
row: number;
gridX: number;
gridY: number;
}
const TOOL_ENTRIES: ToolEntry[] = [];
const colCounts: number[] = new Array(COLS).fill(0);
for (const cat of CATEGORY_ORDER) {
const colIdx = CATEGORY_ORDER.indexOf(cat);
const tools = getToolsByCategory(cat);
for (const tool of tools) {
const row = colCounts[colIdx];
colCounts[colIdx] = row + 1;
TOOL_ENTRIES.push({
index: TOOL_ENTRIES.length,
name: tool.name,
category: tool.category,
col: colIdx,
row,
gridX: GRID_X + colIdx * (CELL_W + GAP) + CELL_W / 2,
gridY: GRID_Y + row * (CELL_H + GAP) + CELL_H / 2,
});
}
}
/* ------------------------------------------------------------------ */
/* Micro-visualization categories */
/* ------------------------------------------------------------------ */
type MicroViz = "scale" | "clip" | "rotate" | "sparkle" | "squeeze" | "stamp" | "badge" | "pulse";
function getMicroViz(name: string, category: string): MicroViz {
const lower = name.toLowerCase();
if (lower.includes("resize") || lower.includes("upscal")) return "scale";
if (lower.includes("crop") || lower.includes("split")) return "clip";
if (lower.includes("rotate") || lower.includes("flip")) return "rotate";
if (
lower.includes("convert") ||
lower.includes("svg") ||
lower.includes("pdf") ||
lower.includes("gif")
)
return "badge";
if (lower.includes("compress") || lower.includes("optimi")) return "squeeze";
if (lower.includes("watermark") || lower.includes("stamp") || lower.includes("overlay"))
return "stamp";
if (category === "ai") return "sparkle";
return "pulse";
}
/* ------------------------------------------------------------------ */
/* Entry direction helper */
/* ------------------------------------------------------------------ */
type Direction =
| "left"
| "right"
| "top"
| "bottom"
| "diag-tl"
| "diag-tr"
| "diag-bl"
| "diag-br";
function getEntryDirection(index: number): Direction {
const dirs: Direction[] = [
"left",
"right",
"top",
"bottom",
"diag-tl",
"diag-tr",
"diag-bl",
"diag-br",
];
return dirs[index % dirs.length];
}
function getEntryPosition(dir: Direction): { x: number; y: number } {
switch (dir) {
case "left":
return { x: -200, y: CY };
case "right":
return { x: 1920 + 200, y: CY };
case "top":
return { x: CX, y: -80 };
case "bottom":
return { x: CX, y: 1080 + 80 };
case "diag-tl":
return { x: -150, y: -80 };
case "diag-tr":
return { x: 1920 + 150, y: -80 };
case "diag-bl":
return { x: -150, y: 1080 + 80 };
case "diag-br":
return { x: 1920 + 150, y: 1080 + 80 };
}
}
/* ------------------------------------------------------------------ */
/* Sparkle dots component */
/* ------------------------------------------------------------------ */
const SparkleDots: React.FC<{ progress: number }> = ({ progress }) => {
const offsets = [
{ dx: -16, dy: -16 },
{ dx: 16, dy: -12 },
{ dx: -12, dy: 16 },
{ dx: 18, dy: 14 },
];
const spread = interpolate(progress, [0, 1], [0, 1]);
const opacity = interpolate(progress, [0, 0.3, 1], [0, 1, 0], {
extrapolateRight: "clamp",
});
return (
<>
{offsets.map((o) => (
<div
key={`${o.dx}-${o.dy}`}
style={{
position: "absolute",
left: `calc(50% + ${o.dx * spread * 2}px)`,
top: `calc(50% + ${o.dy * spread * 2}px)`,
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: COLOR.accent,
opacity,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
/>
))}
</>
);
};
/* ------------------------------------------------------------------ */
/* AnimatedToolPill -- self-contained 15-frame animation */
/* ------------------------------------------------------------------ */
const AnimatedToolPill: React.FC<{ tool: ToolEntry }> = ({ tool }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const dir = getEntryDirection(tool.index);
const entry = getEntryPosition(dir);
const viz = getMicroViz(tool.name, tool.category);
// Phase 1: entrance (frame 0-2) -- edge to center
// Phase 2: center flash (frame 2-6) -- micro-visualization (4 frames for visibility)
// Phase 3: grid snap (frame 6-10) -- center to grid position
let x: number;
let y: number;
let extraTransform = "";
let clipPath: string | undefined;
let sparkleProgress = 0;
let showSparkle = false;
if (frame < 2) {
// Entrance: edge to center
const t = interpolate(frame, [0, 2], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.snap,
});
x = entry.x + (CX - entry.x) * t;
y = entry.y + (CY - entry.y) * t;
} else if (frame < 6) {
// Center flash with micro-visualization (4 frames)
x = CX;
y = CY;
const vizProgress = interpolate(frame, [2, 6], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
switch (viz) {
case "scale": {
const s = interpolate(vizProgress, [0, 0.5, 1], [1.3, 0.8, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
case "clip": {
const inset = interpolate(vizProgress, [0, 0.5, 1], [0, 15, 0]);
clipPath = `inset(${inset}% ${inset}% ${inset}% ${inset}%)`;
break;
}
case "rotate": {
const deg = interpolate(vizProgress, [0, 1], [0, 90]);
extraTransform = ` rotate(${deg}deg)`;
break;
}
case "badge": {
const s = interpolate(vizProgress, [0, 0.3, 0.7, 1], [0.9, 1.15, 1.15, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
case "squeeze": {
const sx = interpolate(vizProgress, [0, 0.5, 1], [1, 0.7, 1]);
const sy = interpolate(vizProgress, [0, 0.5, 1], [1, 1.2, 1]);
extraTransform = ` scaleX(${sx}) scaleY(${sy})`;
break;
}
case "stamp": {
const ty = interpolate(vizProgress, [0, 0.4, 0.6, 1], [-20, 4, -2, 0]);
const s = interpolate(vizProgress, [0, 0.4, 0.6, 1], [1.2, 0.95, 1.02, 1]);
extraTransform = ` translateY(${ty}px) scale(${s})`;
break;
}
case "sparkle": {
showSparkle = true;
sparkleProgress = vizProgress;
const s = interpolate(vizProgress, [0, 0.5, 1], [1.0, 1.1, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
default: {
const s = interpolate(vizProgress, [0, 0.5, 1], [1.0, 1.1, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
}
} else {
// Grid snap: center to grid position via spring
const s = spring({
frame: frame - 6,
fps,
config: SPRING.settle,
});
x = CX + (tool.gridX - CX) * s;
y = CY + (tool.gridY - CY) * s;
}
return (
<div
style={{
position: "absolute",
left: x,
top: y,
transform: `translate(-50%, -50%)${extraTransform}`,
clipPath,
}}
>
<ToolPill name={tool.name} category={tool.category} />
{showSparkle && <SparkleDots progress={sparkleProgress} />}
</div>
);
};
/* ------------------------------------------------------------------ */
/* Entrance phase wrapper -- uses Trail for motion blur frames 0-3 */
/* ------------------------------------------------------------------ */
const ToolEntrance: React.FC<{ tool: ToolEntry }> = ({ tool }) => {
const frame = useCurrentFrame();
const inMotion = frame < 6;
if (inMotion) {
return (
<Trail layers={4} lagInFrames={0.15} trailOpacity={0.3}>
<AbsoluteFill>
<AnimatedToolPill tool={tool} />
</AbsoluteFill>
</Trail>
);
}
return <AnimatedToolPill tool={tool} />;
};
/* ------------------------------------------------------------------ */
/* Running counter (top-right, amber) */
/* ------------------------------------------------------------------ */
const RunningCounter: React.FC = () => {
const frame = useCurrentFrame();
// Counter shows how many tools have landed (reached their local sequence end)
let landed = 0;
for (let i = 0; i < TOOL_ENTRIES.length; i++) {
const landFrame = 30 + i * FRAMES_PER_TOOL + FRAMES_PER_TOOL;
if (frame >= landFrame) landed = i + 1;
}
// Don't show counter before any tool has started
if (frame < 40) return null;
// During Act 3 (frame 520-560), counter transitions to "48 tools" and moves to center-top
const isAct3Text = frame >= 520;
// Fade in
const opacity = interpolate(frame, [40, 45], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
if (isAct3Text) {
const moveProgress = interpolate(frame, [520, 550], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const xPos = interpolate(moveProgress, [0, 1], [1920 - 100, CX]);
const yPos = interpolate(moveProgress, [0, 1], [40, 30]);
return (
<div
style={{
position: "absolute",
left: xPos,
top: yPos,
transform: "translateX(-50%)",
opacity,
}}
>
<span
style={{
...TEXT.counter,
fontSize: 48,
color: COLOR.accent,
}}
>
48 tools
</span>
</div>
);
}
return (
<div
style={{
position: "absolute",
right: 60,
top: 40,
opacity,
}}
>
<span
style={{
...TEXT.counter,
fontSize: 48,
color: COLOR.accent,
}}
>
{landed}
</span>
</div>
);
};
/* ------------------------------------------------------------------ */
/* Grid glow wave (Act 3, frame 750-770) */
/* ------------------------------------------------------------------ */
const GridGlowWave: React.FC = () => {
const frame = useCurrentFrame();
if (frame < 500 || frame > 530) return null;
return (
<>
{CATEGORY_ORDER.map((cat, colIdx) => {
const glowStart = 500 + colIdx * 3;
const glowOpacity = interpolate(
frame,
[glowStart, glowStart + 6, glowStart + 12],
[0, 0.2, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
const colX = GRID_X + colIdx * (CELL_W + GAP);
return (
<div
key={cat}
style={{
position: "absolute",
left: colX,
top: GRID_Y - 10,
width: CELL_W,
height: GRID_H + 20,
backgroundColor: "white",
opacity: glowOpacity,
borderRadius: 8,
pointerEvents: "none",
filter: "blur(8px)",
}}
/>
);
})}
</>
);
};
/* ------------------------------------------------------------------ */
/* Category labels (Act 3, frame 770-810) */
/* ------------------------------------------------------------------ */
const CategoryLabelsRow: React.FC = () => {
const frame = useCurrentFrame();
if (frame < 520) return null;
// Act 4 fade
const act4Opacity = interpolate(frame, [560, 570], [1, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<>
{CATEGORY_ORDER.map((cat, i) => {
const labelX = GRID_X + i * (CELL_W + GAP) + CELL_W / 2;
const labelY = GRID_Y - 24;
const catColor = COLOR.category[cat] ?? COLOR.accent;
return (
<div
key={cat}
style={{
position: "absolute",
left: labelX,
top: labelY,
transform: "translateX(-50%)",
opacity: act4Opacity,
}}
>
<ClipReveal startFrame={520 + i * 3}>
<span style={{ ...TEXT.label, fontSize: 12, color: catColor }}>
{CATEGORY_LABELS[cat]}
</span>
</ClipReveal>
</div>
);
})}
</>
);
};
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const The48: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
/* ================================================================ */
/* Act 1: Title (frame 0-30) */
/* ================================================================ */
const titleScale =
frame < 30
? interpolate(spring({ frame, fps, config: SPRING.settle }), [0, 1], [1.1, 1.0])
: 1.0;
// Title moves to top and shrinks during frames 30-60
const titleY = interpolate(frame, [0, 30, 60], [CY - 48, CY - 48, 60], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const titleFontSize = interpolate(frame, [30, 60], [96, 48], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
// Title as persistent header after frame 30 (fades down to subtle)
const headerOpacity =
frame >= 30
? interpolate(frame, [30, 60], [1, 0.7], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 0;
// During Act 4, header fades out
const headerAct4Opacity =
frame >= 560
? interpolate(frame, [560, 570], [0.7, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: headerOpacity;
/* ================================================================ */
/* Act 3: Grid pulse (frame 500-520) */
/* ================================================================ */
const gridPulseScale =
frame >= 500 && frame < 520
? 1 +
0.015 *
Math.sin(
interpolate(frame, [500, 520], [0, Math.PI], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
)
: 1.0;
/* ================================================================ */
/* Act 4: Grid fades, tagline (frame 560-600) */
/* ================================================================ */
const gridOpacity =
frame >= 560
? interpolate(frame, [560, 575], [1, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 1;
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark, overflow: "hidden" }}>
{/* ---- Act 1: Title ---- */}
{frame < 30 && (
<div
style={{
position: "absolute",
left: CX,
top: titleY,
transform: `translate(-50%, -50%) scale(${titleScale})`,
textAlign: "center",
}}
>
<ClipReveal startFrame={0} duration={12}>
<span style={{ ...TEXT.counter, fontSize: 96 }}>
48 tools<span style={{ color: COLOR.accent }}>.</span>
</span>
</ClipReveal>
</div>
)}
{/* ---- Title as header (frame 30+) ---- */}
{frame >= 30 && frame < 570 && (
<div
style={{
position: "absolute",
left: CX,
top: titleY,
transform: "translate(-50%, -50%)",
textAlign: "center",
opacity: headerAct4Opacity,
}}
>
<span
style={{
...TEXT.counter,
fontSize: titleFontSize,
}}
>
48 tools<span style={{ color: COLOR.accent }}>.</span>
</span>
</div>
)}
{/* ---- Act 2: Rapid montage ---- */}
<div
style={{
position: "absolute",
inset: 0,
transform: `scale(${gridPulseScale})`,
transformOrigin: "center center",
opacity: gridOpacity,
}}
>
{TOOL_ENTRIES.map((tool) => (
<Sequence
key={tool.index}
from={30 + tool.index * FRAMES_PER_TOOL}
durationInFrames={560 - (30 + tool.index * FRAMES_PER_TOOL)}
layout="none"
>
<ToolEntrance tool={tool} />
</Sequence>
))}
</div>
{/* ---- Grid glow wave (Act 3) ---- */}
<GridGlowWave />
{/* ---- Category labels (Act 3) ---- */}
<div style={{ opacity: gridOpacity }}>
<CategoryLabelsRow />
</div>
{/* ---- Running counter ---- */}
<RunningCounter />
{/* ---- Act 4: Tagline ---- */}
{frame >= 560 && (
<div
style={{
position: "absolute",
left: CX,
top: GRID_Y + GRID_H + 60,
transform: "translateX(-50%)",
textAlign: "center",
}}
>
<ClipReveal startFrame={560} duration={18}>
<span style={{ ...TEXT.sectionTitle }}>
One container. <span style={{ color: COLOR.accent }}>Zero cloud.</span>
</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};
@@ -0,0 +1,51 @@
import type React from "react";
import { AbsoluteFill, Series, staticFile } from "remotion";
import { GrainOverlay } from "@/components/GrainOverlay";
import { BackgroundMusic } from "@/lib/audio";
import { COLOR } from "@/lib/colors";
import { AiShowcaseScene } from "./scenes/AiShowcaseScene";
import { FeatureBurstScene } from "./scenes/FeatureBurstScene";
import { GitHubCTAScene } from "./scenes/GitHubCTAScene";
import { HookScene } from "./scenes/HookScene";
import { PrivacyBeatScene } from "./scenes/PrivacyBeatScene";
import { TerminalInstallScene } from "./scenes/TerminalInstallScene";
import { ToolGridRevealScene } from "./scenes/ToolGridRevealScene";
export const XLaunchVideo: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark }}>
<Series>
<Series.Sequence durationInFrames={120}>
<HookScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<TerminalInstallScene />
</Series.Sequence>
<Series.Sequence durationInFrames={180}>
<ToolGridRevealScene />
</Series.Sequence>
<Series.Sequence durationInFrames={180}>
<AiShowcaseScene />
</Series.Sequence>
<Series.Sequence durationInFrames={120}>
<PrivacyBeatScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<FeatureBurstScene />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<GitHubCTAScene />
</Series.Sequence>
</Series>
<GrainOverlay opacity={0.03} />
<BackgroundMusic
src={staticFile("audio/x-launch.mp3")}
volume={0.4}
fadeInFrames={30}
fadeOutFrames={60}
totalFrames={1050}
/>
</AbsoluteFill>
);
};
@@ -0,0 +1,73 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { FONT } from "@/lib/fonts";
export const AiShowcaseScene: React.FC = () => {
const frame = useCurrentFrame();
const totalFrames = 180;
const crossfade = interpolate(frame, [80, 100], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const scale1 = interpolate(frame, [0, 90], [1.0, 1.05], {
extrapolateRight: "clamp",
});
const scale2 = interpolate(frame, [90, totalFrames], [1.0, 1.05], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const textOpacity = interpolate(frame, [30, 45], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<AbsoluteFill>
<Img
src={staticFile("screenshots/remove-bg.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale1})`,
opacity: 1 - crossfade,
}}
/>
</AbsoluteFill>
<AbsoluteFill>
<Img
src={staticFile("screenshots/resize-tool.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale2})`,
opacity: crossfade,
}}
/>
</AbsoluteFill>
<div
style={{
position: "absolute",
bottom: 40,
width: "100%",
textAlign: "center",
fontFamily: FONT.body,
fontWeight: 600,
fontSize: 24,
color: "rgba(255,255,255,0.9)",
opacity: textOpacity,
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
}}
>
15 AI models. Your hardware. No cloud.
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,44 @@
import type React from "react";
import { AbsoluteFill } from "remotion";
import { FeaturePill } from "@/components/FeaturePill";
const FEATURES = [
{ label: "Batch Processing", col: 0, row: 0, from: "left" as const },
{ label: "Pipeline Automation", col: 1, row: 0, from: "right" as const },
{ label: "REST API", col: 0, row: 1, from: "left" as const },
{ label: "55+ Input Formats", col: 1, row: 1, from: "right" as const },
{ label: "Image Editor", col: 0, row: 2, from: "left" as const },
{ label: "One Container", col: 1, row: 2, from: "right" as const },
{ label: "Multi-arch", col: 0, row: 3, from: "left" as const },
{ label: "15 AI Models", col: 1, row: 3, from: "right" as const },
];
const PILL_WIDTH = 200;
const GAP_X = 20;
const GAP_Y = 16;
const PILL_HEIGHT = 50;
const GRID_WIDTH = PILL_WIDTH * 2 + GAP_X;
const GRID_HEIGHT = PILL_HEIGHT * 4 + GAP_Y * 3;
const START_X = (1080 - GRID_WIDTH) / 2;
const START_Y = (1080 - GRID_HEIGHT) / 2;
export const FeatureBurstScene: React.FC = () => {
return (
<AbsoluteFill>
{FEATURES.map((f, i) => {
const x = START_X + f.col * (PILL_WIDTH + GAP_X);
const y = START_Y + f.row * (PILL_HEIGHT + GAP_Y);
return (
<FeaturePill
key={f.label}
label={f.label}
enterFrame={i * 8}
targetX={x}
targetY={y}
fromDirection={f.from}
/>
);
})}
</AbsoluteFill>
);
};
@@ -0,0 +1,11 @@
import type React from "react";
import { AbsoluteFill } from "remotion";
import { GitHubCTA } from "@/components/GitHubCTA";
export const GitHubCTAScene: React.FC = () => {
return (
<AbsoluteFill>
<GitHubCTA labelFrame={5} logoFrame={20} taglineFrame={40} ctaFrame={60} urlFrame={75} />
</AbsoluteFill>
);
};
@@ -0,0 +1,36 @@
import type React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
export const HookScene: React.FC = () => {
const frame = useCurrentFrame();
const glowOpacity = Math.sin(frame * 0.08) * 0.03 + 0.08;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
position: "absolute",
width: 600,
height: 200,
borderRadius: "50%",
background: `radial-gradient(ellipse, ${COLOR.accent} 0%, transparent 70%)`,
opacity: glowOpacity,
filter: "blur(60px)",
}}
/>
<div style={{ textAlign: "center" }}>
<ClipReveal startFrame={10} duration={20}>
<span style={{ ...TEXT.heroHeadline }}>Your images.</span>
</ClipReveal>
<div style={{ height: 12 }} />
<ClipReveal startFrame={40} duration={20}>
<span style={{ ...TEXT.heroHeadline }}>Stay yours.</span>
</ClipReveal>
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,47 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { RotatingTaglines } from "@/components/RotatingTaglines";
import { COLOR } from "@/lib/colors";
const TAGLINES = [
"No uploads to the cloud. Ever.",
"100% local processing.",
"Works fully offline.",
"Air-gapped ready.",
];
export const PrivacyBeatScene: React.FC = () => {
const frame = useCurrentFrame();
const totalFrames = 120;
const bgScale = interpolate(frame, [0, totalFrames], [1.0, 1.05], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<AbsoluteFill>
<Img
src={staticFile("screenshots/dashboard.png")}
style={{
width: "180%",
height: "100%",
objectFit: "cover",
objectPosition: "left top",
opacity: 0.12,
transform: `scale(${bgScale})`,
transformOrigin: "left center",
}}
/>
</AbsoluteFill>
<RotatingTaglines
lines={TAGLINES}
startFrame={0}
framesPerLine={30}
fontSize={48}
color={COLOR.accent}
/>
</AbsoluteFill>
);
};
@@ -0,0 +1,37 @@
import type React from "react";
import { AbsoluteFill } from "remotion";
import { Terminal } from "@/components/Terminal";
const COMMAND = [
{ text: "docker", color: "#ff7b72" },
{ text: " run ", color: "#e6edf3" },
{ text: "-p ", color: "#79c0ff" },
{ text: "3000:3000", color: "#a5d6ff" },
{ text: " ", color: "#e6edf3" },
{ text: "snapotter/snapotter", color: "#7ee787" },
];
const OUTPUT = [
{ text: "v2.4.0", color: "#f59e0b", delay: 0 },
{ text: "49 tools loaded", color: "#7ee787", delay: 3 },
{ text: "15 AI models ready", color: "#7ee787", delay: 6 },
{ text: "✓ Server running on :3000", color: "#3fb950", delay: 9 },
];
export const TerminalInstallScene: React.FC = () => {
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<Terminal
command={COMMAND}
commandStartFrame={15}
commandSpeed={2}
outputLines={OUTPUT}
outputStartFrame={95}
outputStagger={8}
width={800}
height={350}
enterFrame={0}
/>
</AbsoluteFill>
);
};
@@ -0,0 +1,61 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { Counter } from "@/components/Counter";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
export const ToolGridRevealScene: React.FC = () => {
const frame = useCurrentFrame();
const totalFrames = 180;
const scale = interpolate(frame, [0, totalFrames], [1.0, 1.08], {
extrapolateRight: "clamp",
});
const windowOpacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ opacity: windowOpacity }}>
<AppWindow
title="SnapOtter"
width={900}
height={900}
topBarColor="#1a1a2e"
bodyColor="#0f172a"
>
<div style={{ overflow: "hidden", width: "100%", height: "100%" }}>
<Img
src={staticFile("screenshots/dashboard.png")}
style={{
width: "180%",
height: "100%",
objectFit: "cover",
objectPosition: "left top",
transform: `scale(${scale})`,
transformOrigin: "left center",
}}
/>
</div>
</AppWindow>
</div>
<div style={{ position: "absolute", bottom: 60, right: 80 }}>
<Counter
from={0}
to={49}
startFrame={10}
duration={90}
style={{
...TEXT.counter,
fontSize: 120,
color: COLOR.accent,
}}
/>
</div>
</AbsoluteFill>
);
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+23
View File
@@ -0,0 +1,23 @@
import { Audio } from "@remotion/media";
import type React from "react";
import { interpolate, staticFile, useCurrentFrame } from "remotion";
export const BackgroundMusic: React.FC<{
src?: string;
volume?: number;
fadeInFrames?: number;
fadeOutFrames?: number;
totalFrames: number;
}> = ({ src, volume = 0.4, fadeInFrames = 30, fadeOutFrames = 60, totalFrames }) => {
const frame = useCurrentFrame();
if (!src) return null;
const vol = interpolate(
frame,
[0, fadeInFrames, totalFrames - fadeOutFrames, totalFrames],
[0, volume, volume, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
return <Audio src={src} volume={vol} />;
};
+48
View File
@@ -0,0 +1,48 @@
export const COLOR = {
accent: "#f59e0b",
accentHover: "#d97706",
dark: "#0c0a09",
darkAlt: "#1a1a2e",
light: "#ffffff",
lightAlt: "#fafaf9",
warmGradientFrom: "#f59e0b",
warmGradientVia: "#f97316",
warmGradientTo: "#ef4444",
category: {
essentials: "#3B82F6",
optimization: "#10B981",
adjustments: "#8B5CF6",
watermark: "#EF4444",
utilities: "#6366F1",
layout: "#EC4899",
format: "#14B8A6",
ai: "#F59E0B",
} as Record<string, string>,
safe: "#22c55e",
danger: "#ef4444",
muted: "#737373",
cloudRed: "#fef2f2",
localGreen: "#f0fdf4",
};
export const CATEGORY_ORDER = [
"essentials",
"optimization",
"adjustments",
"ai",
"watermark",
"utilities",
"layout",
"format",
] as const;
export const CATEGORY_LABELS: Record<string, string> = {
essentials: "Essentials",
optimization: "Optimization",
adjustments: "Adjustments",
ai: "AI Tools",
watermark: "Watermark",
utilities: "Utilities",
layout: "Layout",
format: "Format",
};
+67
View File
@@ -0,0 +1,67 @@
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadJetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
import { loadFont as loadNunito } from "@remotion/google-fonts/Nunito";
const { fontFamily: nunito } = loadNunito();
const { fontFamily: inter } = loadInter();
const { fontFamily: mono } = loadJetBrainsMono();
export const FONT = { heading: nunito, body: inter, mono };
export const TEXT = {
heroHeadline: {
fontFamily: nunito,
fontWeight: 800,
fontSize: 72,
letterSpacing: "-0.02em",
lineHeight: 1.1,
color: "white",
},
heroSub: {
fontFamily: inter,
fontWeight: 500,
fontSize: 28,
letterSpacing: "0em",
lineHeight: 1.4,
color: "white",
},
sectionTitle: {
fontFamily: nunito,
fontWeight: 700,
fontSize: 48,
letterSpacing: "-0.01em",
color: "white",
},
label: {
fontFamily: inter,
fontWeight: 600,
fontSize: 18,
letterSpacing: "0.04em",
textTransform: "uppercase" as const,
},
body: {
fontFamily: inter,
fontWeight: 400,
fontSize: 20,
lineHeight: 1.5,
},
mono: {
fontFamily: mono,
fontWeight: 400,
fontSize: 16,
lineHeight: 1.6,
},
toolPill: {
fontFamily: inter,
fontWeight: 600,
fontSize: 14,
letterSpacing: "0.01em",
},
counter: {
fontFamily: nunito,
fontWeight: 800,
fontSize: 96,
letterSpacing: "-0.03em",
color: "white",
},
} as const;
+30
View File
@@ -0,0 +1,30 @@
import { Easing } from "remotion";
export const EASE = {
enter: Easing.bezier(0.16, 1, 0.3, 1),
exit: Easing.bezier(0.55, 0, 1, 0.45),
emphasis: Easing.bezier(0.34, 1.56, 0.64, 1),
smooth: Easing.bezier(0.37, 0, 0.63, 1),
snap: Easing.bezier(0.22, 1, 0.36, 1),
};
export const SPRING = {
snappy: { damping: 200, stiffness: 100, mass: 0.5 },
natural: { damping: 15, stiffness: 80, mass: 1 },
popIn: { damping: 12, stiffness: 200, mass: 0.6 },
heavy: { damping: 20, stiffness: 60, mass: 2 },
settle: { damping: 18, stiffness: 150, mass: 0.8 },
};
export const TIMING = {
fps: 30,
staggerFrames: 2,
holdShort: 30,
holdMedium: 60,
holdLong: 90,
fadeIn: 12,
fadeOut: 8,
wipe: 18,
sectionGap: 12,
anticipation: 3,
};
+68
View File
@@ -0,0 +1,68 @@
export interface ToolDef {
name: string;
category: string;
}
export const TOOLS: ToolDef[] = [
// Essentials
{ name: "Resize", category: "essentials" },
{ name: "Crop", category: "essentials" },
{ name: "Rotate & Flip", category: "essentials" },
{ name: "Convert", category: "essentials" },
{ name: "Compress", category: "essentials" },
// Optimization
{ name: "Optimize for Web", category: "optimization" },
{ name: "Remove Metadata", category: "optimization" },
{ name: "Edit Metadata", category: "optimization" },
{ name: "Bulk Rename", category: "optimization" },
{ name: "Image to PDF", category: "optimization" },
{ name: "Favicon Generator", category: "optimization" },
// Adjustments
{ name: "Adjust Colors", category: "adjustments" },
{ name: "Sharpening", category: "adjustments" },
{ name: "Replace & Invert Color", category: "adjustments" },
{ name: "Color Blindness Simulation", category: "adjustments" },
// AI Tools
{ name: "Remove Background", category: "ai" },
{ name: "Image Upscaling", category: "ai" },
{ name: "Object Eraser", category: "ai" },
{ name: "OCR / Text Extraction", category: "ai" },
{ name: "Face / PII Blur", category: "ai" },
{ name: "Smart Crop", category: "ai" },
{ name: "Image Enhancement", category: "ai" },
{ name: "Face Enhancement", category: "ai" },
{ name: "AI Colorization", category: "ai" },
{ name: "Noise Removal", category: "ai" },
{ name: "Red Eye Removal", category: "ai" },
{ name: "Photo Restoration", category: "ai" },
{ name: "Passport Photo", category: "ai" },
{ name: "Content-Aware Resize", category: "ai" },
{ name: "PNG Transparency Fixer", category: "ai" },
// Watermark & Overlay
{ name: "Text Watermark", category: "watermark" },
{ name: "Image Watermark", category: "watermark" },
{ name: "Text Overlay", category: "watermark" },
{ name: "Image Composition", category: "watermark" },
// Utilities
{ name: "Image Info", category: "utilities" },
{ name: "Image Compare", category: "utilities" },
{ name: "Find Duplicates", category: "utilities" },
{ name: "Color Palette", category: "utilities" },
{ name: "QR Code Generator", category: "utilities" },
{ name: "Barcode Reader", category: "utilities" },
{ name: "Image to Base64", category: "utilities" },
// Layout & Composition
{ name: "Collage / Grid", category: "layout" },
{ name: "Stitch / Combine", category: "layout" },
{ name: "Image Splitting", category: "layout" },
{ name: "Border & Frame", category: "layout" },
// Format & Conversion
{ name: "SVG to Raster", category: "format" },
{ name: "Image to SVG", category: "format" },
{ name: "GIF Tools", category: "format" },
{ name: "PDF to Image", category: "format" },
];
export function getToolsByCategory(category: string): ToolDef[] {
return TOOLS.filter((t) => t.category === category);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

Some files were not shown because too many files have changed in this diff Show More