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 "./style.css";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { AbsoluteFill, Composition } from "remotion";
|
import { 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 { BrandGradient } from "./compositions/ambient/BrandGradient";
|
import { BrandGradient } from "./compositions/ambient/BrandGradient";
|
||||||
import { FloatingTools } from "./compositions/ambient/FloatingTools";
|
import { FloatingTools } from "./compositions/ambient/FloatingTools";
|
||||||
import { AiMagicReel } from "./compositions/features/AiMagicReel";
|
import { AiMagicReel } from "./compositions/features/AiMagicReel";
|
||||||
import { FormatUniverse } from "./compositions/features/FormatUniverse";
|
import { FormatUniverse } from "./compositions/features/FormatUniverse";
|
||||||
import { PipelineFlow } from "./compositions/features/PipelineFlow";
|
import { PipelineFlow } from "./compositions/features/PipelineFlow";
|
||||||
import { TEXT } from "./lib/fonts";
|
import { OneCommand } from "./compositions/hero/OneCommand";
|
||||||
|
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
|
||||||
const ComponentTest: React.FC = () => (
|
import { ToolGalaxy } from "./compositions/hero/ToolGalaxy";
|
||||||
<AbsoluteFill style={{ backgroundColor: "#0c0a09" }}>
|
import { ProductDemo } from "./compositions/product-demo/ProductDemo";
|
||||||
<div style={{ position: "absolute", top: 40, left: 40 }}>
|
import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
|
||||||
<ClipReveal startFrame={0}>
|
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
|
||||||
<span style={{ ...TEXT.heroHeadline }}>Hello SnapOtter</span>
|
import { CloudVsLocal } from "./compositions/showcase/CloudVsLocal";
|
||||||
</ClipReveal>
|
import { The48 } from "./compositions/showcase/The48";
|
||||||
</div>
|
import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo";
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const RemotionRoot: React.FC = () => (
|
export const RemotionRoot: React.FC = () => (
|
||||||
<>
|
<>
|
||||||
<Composition
|
{/* Ambient */}
|
||||||
id="ComponentTest"
|
|
||||||
component={ComponentTest}
|
|
||||||
durationInFrames={90}
|
|
||||||
fps={30}
|
|
||||||
width={1920}
|
|
||||||
height={1080}
|
|
||||||
/>
|
|
||||||
<Composition
|
<Composition
|
||||||
id="FloatingTools"
|
id="FloatingTools"
|
||||||
component={FloatingTools}
|
component={FloatingTools}
|
||||||
@@ -77,6 +35,8 @@ export const RemotionRoot: React.FC = () => (
|
|||||||
width={800}
|
width={800}
|
||||||
height={600}
|
height={600}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
<Composition
|
<Composition
|
||||||
id="FormatUniverse"
|
id="FormatUniverse"
|
||||||
component={FormatUniverse}
|
component={FormatUniverse}
|
||||||
@@ -85,14 +45,6 @@ export const RemotionRoot: React.FC = () => (
|
|||||||
width={800}
|
width={800}
|
||||||
height={600}
|
height={600}
|
||||||
/>
|
/>
|
||||||
<Composition
|
|
||||||
id="AiMagicReel"
|
|
||||||
component={AiMagicReel}
|
|
||||||
durationInFrames={480}
|
|
||||||
fps={30}
|
|
||||||
width={800}
|
|
||||||
height={600}
|
|
||||||
/>
|
|
||||||
<Composition
|
<Composition
|
||||||
id="PipelineFlow"
|
id="PipelineFlow"
|
||||||
component={PipelineFlow}
|
component={PipelineFlow}
|
||||||
@@ -101,5 +53,91 @@ export const RemotionRoot: React.FC = () => (
|
|||||||
width={800}
|
width={800}
|
||||||
height={600}
|
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 type React from "react";
|
||||||
import {
|
import {
|
||||||
AbsoluteFill,
|
AbsoluteFill,
|
||||||
|
Img,
|
||||||
interpolate,
|
interpolate,
|
||||||
interpolateColors,
|
interpolateColors,
|
||||||
random,
|
random,
|
||||||
Sequence,
|
Sequence,
|
||||||
spring,
|
spring,
|
||||||
|
staticFile,
|
||||||
useCurrentFrame,
|
useCurrentFrame,
|
||||||
useVideoConfig,
|
useVideoConfig,
|
||||||
} from "remotion";
|
} from "remotion";
|
||||||
@@ -133,19 +135,8 @@ const SegmentBackgroundRemoval: React.FC = () => {
|
|||||||
easing: EASE.smooth,
|
easing: EASE.smooth,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Geometric pattern background
|
// Checkerboard background for transparency effect
|
||||||
const patternBg = `
|
const checkerBg = CHECKERBOARD_BG;
|
||||||
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%)
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Scan line particle trail
|
// Scan line particle trail
|
||||||
const particles = Array.from({ length: 5 }, (_, i) => {
|
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)",
|
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* "Before" layer: photo with geometric background */}
|
{/* "Before" layer: real portrait photo */}
|
||||||
<div
|
<Img
|
||||||
|
src={staticFile("screenshots/sample-photo-portrait.jpg")}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
backgroundImage: patternBg,
|
width: "100%",
|
||||||
backgroundSize: "24px 24px",
|
height: "100%",
|
||||||
backgroundColor: "hsl(30, 20%, 35%)",
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -222,16 +204,18 @@ const SegmentBackgroundRemoval: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: CHECKERBOARD_BG,
|
background: checkerBg,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Subject only */}
|
{/* Subject portrait on top of checkerboard */}
|
||||||
<div
|
<Img
|
||||||
|
src={staticFile("screenshots/sample-photo-portrait.jpg")}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
backgroundImage: silhouetteGradient,
|
width: "100%",
|
||||||
backgroundRepeat: "no-repeat",
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +230,7 @@ const SegmentBackgroundRemoval: React.FC = () => {
|
|||||||
width: 2,
|
width: 2,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
background: COLOR.accent,
|
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,
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -260,7 +244,7 @@ const SegmentBackgroundRemoval: React.FC = () => {
|
|||||||
key={`scan-p-${i}`}
|
key={`scan-p-${i}`}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: photoX + p.x - photoX,
|
left: p.x,
|
||||||
top: p.y,
|
top: p.y,
|
||||||
width: 3,
|
width: 3,
|
||||||
height: 3,
|
height: 3,
|
||||||
@@ -358,26 +342,38 @@ const SegmentColorization: React.FC = () => {
|
|||||||
transform: `scale(${holdScale})`,
|
transform: `scale(${holdScale})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Grayscale layer (on top) */}
|
{/* B&W layer (on top, real B&W photo) */}
|
||||||
<div
|
<Img
|
||||||
|
src={staticFile("screenshots/sample-photo-bw.jpg")}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: `linear-gradient(135deg, hsl(30, 60%, 65%) 0%, hsl(200, 50%, 55%) 50%, hsl(340, 55%, 60%) 100%)`,
|
width: "100%",
|
||||||
filter: "grayscale(1)",
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Color layer (revealed underneath via clip) */}
|
{/* Color layer (revealed via clip, using portrait as color version) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
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)`,
|
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 */}
|
{/* Color particles */}
|
||||||
{colorParticles.map(
|
{colorParticles.map(
|
||||||
@@ -411,11 +407,11 @@ const SegmentColorization: React.FC = () => {
|
|||||||
const PIXEL_GRID_SIZE = 8;
|
const PIXEL_GRID_SIZE = 8;
|
||||||
const PIXEL_GAP = 0.5;
|
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 pixelColors: string[] = Array.from({ length: PIXEL_GRID_SIZE * PIXEL_GRID_SIZE }, (_, i) => {
|
||||||
const hue = random(`pixel-hue-${i}`) * 60 + 15; // warm hues
|
const hue = random(`pixel-hue-${i}`) * 80 + 180; // blues and greens (180-260)
|
||||||
const sat = 40 + random(`pixel-sat-${i}`) * 30;
|
const sat = 35 + random(`pixel-sat-${i}`) * 35;
|
||||||
const lit = 40 + random(`pixel-lit-${i}`) * 30;
|
const lit = 35 + random(`pixel-lit-${i}`) * 30;
|
||||||
return `hsl(${hue}, ${sat}%, ${lit}%)`;
|
return `hsl(${hue}, ${sat}%, ${lit}%)`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -518,14 +514,17 @@ const SegmentSuperResolution: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Smooth "resolved" version */}
|
{/* Smooth "resolved" version - actual landscape photo */}
|
||||||
{showSmooth && (
|
{showSmooth && (
|
||||||
<div
|
<Img
|
||||||
|
src={staticFile("screenshots/sample-photo-landscape.jpg")}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: `linear-gradient(135deg, hsl(25, 55%, 50%) 0%, hsl(40, 50%, 55%) 50%, hsl(30, 60%, 45%) 100%)`,
|
|
||||||
opacity: smoothOpacity,
|
opacity: smoothOpacity,
|
||||||
border: "1.5px solid rgba(255,255,255,0.15)",
|
border: "1.5px solid rgba(255,255,255,0.15)",
|
||||||
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
|
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
|
||||||
@@ -630,8 +629,8 @@ const SegmentRestoration: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Color shift from sepia to warm
|
// Color shift from sepia to warm
|
||||||
const bgFrom = interpolateColors(frame, [80, 95], ["hsl(35, 40%, 45%)", "hsl(30, 55%, 50%)"]);
|
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 _bgTo = interpolateColors(frame, [80, 95], ["hsl(40, 35%, 55%)", "hsl(45, 50%, 58%)"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ background: COLOR.dark }}>
|
<AbsoluteFill style={{ background: COLOR.dark }}>
|
||||||
@@ -653,15 +652,18 @@ const SegmentRestoration: React.FC = () => {
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
boxShadow: "0 8px 24px rgba(0,0,0,0.3)",
|
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 */}
|
{/* Base photo - real landscape */}
|
||||||
<div
|
<Img
|
||||||
|
src={staticFile("screenshots/sample-photo-landscape.jpg")}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
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 type React from "react";
|
||||||
import {
|
import {
|
||||||
AbsoluteFill,
|
AbsoluteFill,
|
||||||
|
Img,
|
||||||
interpolate,
|
interpolate,
|
||||||
random,
|
random,
|
||||||
Sequence,
|
Sequence,
|
||||||
spring,
|
spring,
|
||||||
|
staticFile,
|
||||||
useCurrentFrame,
|
useCurrentFrame,
|
||||||
useVideoConfig,
|
useVideoConfig,
|
||||||
} from "remotion";
|
} from "remotion";
|
||||||
import { ClipReveal } from "@/components/ClipReveal";
|
import { ClipReveal } from "@/components/ClipReveal";
|
||||||
import { Counter } from "@/components/Counter";
|
import { Counter } from "@/components/Counter";
|
||||||
import { GrainOverlay } from "@/components/GrainOverlay";
|
import { GrainOverlay } from "@/components/GrainOverlay";
|
||||||
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
|
|
||||||
import { COLOR } from "@/lib/colors";
|
import { COLOR } from "@/lib/colors";
|
||||||
import { FONT, TEXT } from "@/lib/fonts";
|
import { FONT, TEXT } from "@/lib/fonts";
|
||||||
import { EASE, SPRING } from "@/lib/motion";
|
import { EASE, SPRING } from "@/lib/motion";
|
||||||
@@ -113,10 +114,10 @@ const Station: React.FC<{
|
|||||||
border: `2px solid ${station.color}${glowing || pulseGlow ? "cc" : "66"}`,
|
border: `2px solid ${station.color}${glowing || pulseGlow ? "cc" : "66"}`,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
glowOpacity > 0
|
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)
|
.toString(16)
|
||||||
.padStart(2, "0")}`
|
.padStart(2, "0")}`
|
||||||
: "none",
|
: "0 4px 20px rgba(0,0,0,0.4)",
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -386,7 +387,17 @@ const SingleFlow: React.FC = () => {
|
|||||||
zIndex: frame >= 108 ? 0 : 5,
|
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 */}
|
{/* Watermark stamp */}
|
||||||
{stampOpacity > 0 && (
|
{stampOpacity > 0 && (
|
||||||
<span
|
<span
|
||||||
@@ -395,7 +406,7 @@ const SingleFlow: React.FC = () => {
|
|||||||
bottom: 2,
|
bottom: 2,
|
||||||
right: 3,
|
right: 3,
|
||||||
fontFamily: FONT.body,
|
fontFamily: FONT.body,
|
||||||
fontSize: 5,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: `rgba(255,255,255,${stampOpacity})`,
|
color: `rgba(255,255,255,${stampOpacity})`,
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
@@ -489,9 +500,10 @@ const BatchThumb: React.FC<{
|
|||||||
|
|
||||||
if (localFrame < 0) return null;
|
if (localFrame < 0) return null;
|
||||||
|
|
||||||
const hue = random(`thumb-hue-${index}`) * 360;
|
|
||||||
const size = 24 + random(`thumb-size-${index}`) * 8;
|
const size = 24 + random(`thumb-size-${index}`) * 8;
|
||||||
const yOffset = (random(`thumb-y-${index}`) - 0.5) * 30;
|
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
|
// Fast traversal: cover pipeline in ~28 frames per thumb
|
||||||
const thumbX = interpolate(localFrame, [0, 28], [THUMB_START_X, FOLDER_X + 10], {
|
const thumbX = interpolate(localFrame, [0, 28], [THUMB_START_X, FOLDER_X + 10], {
|
||||||
@@ -517,7 +529,17 @@ const BatchThumb: React.FC<{
|
|||||||
zIndex: 4,
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -601,13 +623,10 @@ export const PipelineFlow: React.FC = () => {
|
|||||||
</Sequence>
|
</Sequence>
|
||||||
|
|
||||||
{/* Act 1 stations (visible throughout until Act 2 takes over) */}
|
{/* Act 1 stations (visible throughout until Act 2 takes over) */}
|
||||||
{frame < 40 && (
|
{frame < 40 &&
|
||||||
<>
|
STATIONS.map((s, i) => (
|
||||||
{STATIONS.map((s, i) => (
|
|
||||||
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
|
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
|
||||||
))}
|
))}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Act 2: Single Image Flow (frame 40-170) */}
|
{/* Act 2: Single Image Flow (frame 40-170) */}
|
||||||
<Sequence from={40} durationInFrames={130}>
|
<Sequence from={40} durationInFrames={130}>
|
||||||
@@ -640,13 +659,11 @@ export const PipelineFlow: React.FC = () => {
|
|||||||
</Sequence>
|
</Sequence>
|
||||||
|
|
||||||
{/* Act 3 stations when not covered by SingleFlow or BatchStream */}
|
{/* Act 3 stations when not covered by SingleFlow or BatchStream */}
|
||||||
{frame >= 170 && frame < 180 && (
|
{frame >= 170 &&
|
||||||
<>
|
frame < 180 &&
|
||||||
{STATIONS.map((s, i) => (
|
STATIONS.map((s, i) => (
|
||||||
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
|
<Station key={s.id} station={s} index={i} glowing={false} pulseGlow={false} />
|
||||||
))}
|
))}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Act 4: Result (frame 340-390) */}
|
{/* Act 4: Result (frame 340-390) */}
|
||||||
{frame >= 340 && (
|
{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;
|
||||||