Merge branch 'feat/remotion-promo-videos'
# Conflicts: # pnpm-lock.yaml
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 29 KiB |
@@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
✓ 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'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||