diff --git a/apps/landing/public/videos/ai-magic-reel-poster.webp b/apps/landing/public/videos/ai-magic-reel-poster.webp new file mode 100644 index 00000000..4f661dea Binary files /dev/null and b/apps/landing/public/videos/ai-magic-reel-poster.webp differ diff --git a/apps/landing/public/videos/ai-magic-reel.mp4 b/apps/landing/public/videos/ai-magic-reel.mp4 new file mode 100644 index 00000000..840c1e66 Binary files /dev/null and b/apps/landing/public/videos/ai-magic-reel.mp4 differ diff --git a/apps/landing/public/videos/ai-magic-reel.webm b/apps/landing/public/videos/ai-magic-reel.webm new file mode 100644 index 00000000..157212ba Binary files /dev/null and b/apps/landing/public/videos/ai-magic-reel.webm differ diff --git a/apps/landing/public/videos/brand-gradient-poster.webp b/apps/landing/public/videos/brand-gradient-poster.webp new file mode 100644 index 00000000..181974b1 Binary files /dev/null and b/apps/landing/public/videos/brand-gradient-poster.webp differ diff --git a/apps/landing/public/videos/brand-gradient.mp4 b/apps/landing/public/videos/brand-gradient.mp4 new file mode 100644 index 00000000..d5c13749 Binary files /dev/null and b/apps/landing/public/videos/brand-gradient.mp4 differ diff --git a/apps/landing/public/videos/brand-gradient.webm b/apps/landing/public/videos/brand-gradient.webm new file mode 100644 index 00000000..18b617aa Binary files /dev/null and b/apps/landing/public/videos/brand-gradient.webm differ diff --git a/apps/landing/public/videos/cloud-vs-local-poster.webp b/apps/landing/public/videos/cloud-vs-local-poster.webp new file mode 100644 index 00000000..0299b1c6 Binary files /dev/null and b/apps/landing/public/videos/cloud-vs-local-poster.webp differ diff --git a/apps/landing/public/videos/cloud-vs-local.mp4 b/apps/landing/public/videos/cloud-vs-local.mp4 new file mode 100644 index 00000000..d29e39fe Binary files /dev/null and b/apps/landing/public/videos/cloud-vs-local.mp4 differ diff --git a/apps/landing/public/videos/cloud-vs-local.webm b/apps/landing/public/videos/cloud-vs-local.webm new file mode 100644 index 00000000..a9db4664 Binary files /dev/null and b/apps/landing/public/videos/cloud-vs-local.webm differ diff --git a/apps/landing/public/videos/floating-tools-poster.webp b/apps/landing/public/videos/floating-tools-poster.webp new file mode 100644 index 00000000..f224ca8f Binary files /dev/null and b/apps/landing/public/videos/floating-tools-poster.webp differ diff --git a/apps/landing/public/videos/floating-tools.mp4 b/apps/landing/public/videos/floating-tools.mp4 new file mode 100644 index 00000000..cb497d3e Binary files /dev/null and b/apps/landing/public/videos/floating-tools.mp4 differ diff --git a/apps/landing/public/videos/floating-tools.webm b/apps/landing/public/videos/floating-tools.webm new file mode 100644 index 00000000..70da3ea5 Binary files /dev/null and b/apps/landing/public/videos/floating-tools.webm differ diff --git a/apps/landing/public/videos/format-universe-poster.webp b/apps/landing/public/videos/format-universe-poster.webp new file mode 100644 index 00000000..1cc22f5a Binary files /dev/null and b/apps/landing/public/videos/format-universe-poster.webp differ diff --git a/apps/landing/public/videos/format-universe.mp4 b/apps/landing/public/videos/format-universe.mp4 new file mode 100644 index 00000000..82a83c67 Binary files /dev/null and b/apps/landing/public/videos/format-universe.mp4 differ diff --git a/apps/landing/public/videos/format-universe.webm b/apps/landing/public/videos/format-universe.webm new file mode 100644 index 00000000..0a0a65e8 Binary files /dev/null and b/apps/landing/public/videos/format-universe.webm differ diff --git a/apps/landing/public/videos/one-command-poster.webp b/apps/landing/public/videos/one-command-poster.webp new file mode 100644 index 00000000..9971683f Binary files /dev/null and b/apps/landing/public/videos/one-command-poster.webp differ diff --git a/apps/landing/public/videos/one-command.mp4 b/apps/landing/public/videos/one-command.mp4 new file mode 100644 index 00000000..14aa363f Binary files /dev/null and b/apps/landing/public/videos/one-command.mp4 differ diff --git a/apps/landing/public/videos/one-command.webm b/apps/landing/public/videos/one-command.webm new file mode 100644 index 00000000..0cbbc9f6 Binary files /dev/null and b/apps/landing/public/videos/one-command.webm differ diff --git a/apps/landing/public/videos/pipeline-flow-poster.webp b/apps/landing/public/videos/pipeline-flow-poster.webp new file mode 100644 index 00000000..f686df07 Binary files /dev/null and b/apps/landing/public/videos/pipeline-flow-poster.webp differ diff --git a/apps/landing/public/videos/pipeline-flow.mp4 b/apps/landing/public/videos/pipeline-flow.mp4 new file mode 100644 index 00000000..7c712662 Binary files /dev/null and b/apps/landing/public/videos/pipeline-flow.mp4 differ diff --git a/apps/landing/public/videos/pipeline-flow.webm b/apps/landing/public/videos/pipeline-flow.webm new file mode 100644 index 00000000..e271fb61 Binary files /dev/null and b/apps/landing/public/videos/pipeline-flow.webm differ diff --git a/apps/landing/public/videos/privacy-promise-poster.webp b/apps/landing/public/videos/privacy-promise-poster.webp new file mode 100644 index 00000000..b93f930b Binary files /dev/null and b/apps/landing/public/videos/privacy-promise-poster.webp differ diff --git a/apps/landing/public/videos/privacy-promise.mp4 b/apps/landing/public/videos/privacy-promise.mp4 new file mode 100644 index 00000000..58cb4d3c Binary files /dev/null and b/apps/landing/public/videos/privacy-promise.mp4 differ diff --git a/apps/landing/public/videos/privacy-promise.webm b/apps/landing/public/videos/privacy-promise.webm new file mode 100644 index 00000000..678a14f1 Binary files /dev/null and b/apps/landing/public/videos/privacy-promise.webm differ diff --git a/apps/landing/public/videos/the-48-poster.webp b/apps/landing/public/videos/the-48-poster.webp new file mode 100644 index 00000000..10c124b1 Binary files /dev/null and b/apps/landing/public/videos/the-48-poster.webp differ diff --git a/apps/landing/public/videos/the-48.mp4 b/apps/landing/public/videos/the-48.mp4 new file mode 100644 index 00000000..a8cab4b0 Binary files /dev/null and b/apps/landing/public/videos/the-48.mp4 differ diff --git a/apps/landing/public/videos/the-48.webm b/apps/landing/public/videos/the-48.webm new file mode 100644 index 00000000..a9756684 Binary files /dev/null and b/apps/landing/public/videos/the-48.webm differ diff --git a/apps/landing/public/videos/tool-galaxy-poster.webp b/apps/landing/public/videos/tool-galaxy-poster.webp new file mode 100644 index 00000000..21712204 Binary files /dev/null and b/apps/landing/public/videos/tool-galaxy-poster.webp differ diff --git a/apps/landing/public/videos/tool-galaxy.mp4 b/apps/landing/public/videos/tool-galaxy.mp4 new file mode 100644 index 00000000..840de5c9 Binary files /dev/null and b/apps/landing/public/videos/tool-galaxy.mp4 differ diff --git a/apps/landing/public/videos/tool-galaxy.webm b/apps/landing/public/videos/tool-galaxy.webm new file mode 100644 index 00000000..cbb66221 Binary files /dev/null and b/apps/landing/public/videos/tool-galaxy.webm differ diff --git a/apps/landing/src/components/landing-video.tsx b/apps/landing/src/components/landing-video.tsx new file mode 100644 index 00000000..6f883843 --- /dev/null +++ b/apps/landing/src/components/landing-video.tsx @@ -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(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 ( + + ); +} diff --git a/apps/videos/package.json b/apps/videos/package.json new file mode 100644 index 00000000..a575af36 --- /dev/null +++ b/apps/videos/package.json @@ -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" + } +} diff --git a/apps/videos/public/logo.png b/apps/videos/public/logo.png new file mode 100644 index 00000000..02115384 Binary files /dev/null and b/apps/videos/public/logo.png differ diff --git a/apps/videos/remotion.config.ts b/apps/videos/remotion.config.ts new file mode 100644 index 00000000..f14d9396 --- /dev/null +++ b/apps/videos/remotion.config.ts @@ -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"), + }, + }, + }; +}); diff --git a/apps/videos/scripts/render-all.mjs b/apps/videos/scripts/render-all.mjs new file mode 100644 index 00000000..3f69025f --- /dev/null +++ b/apps/videos/scripts/render-all.mjs @@ -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}`, +); diff --git a/apps/videos/src/Root.tsx b/apps/videos/src/Root.tsx index ed9cb33d..01ac7afa 100644 --- a/apps/videos/src/Root.tsx +++ b/apps/videos/src/Root.tsx @@ -1,66 +1,24 @@ import "./style.css"; import type React from "react"; -import { AbsoluteFill, Composition } from "remotion"; -import { ClipReveal } from "./components/ClipReveal"; -import { Counter } from "./components/Counter"; -import { GradientBlob } from "./components/GradientBlob"; -import { GrainOverlay } from "./components/GrainOverlay"; -import { PhotoPlaceholder } from "./components/PhotoPlaceholder"; -import { ToolPill } from "./components/ToolPill"; +import { Composition } from "remotion"; import { BrandGradient } from "./compositions/ambient/BrandGradient"; import { FloatingTools } from "./compositions/ambient/FloatingTools"; import { AiMagicReel } from "./compositions/features/AiMagicReel"; import { FormatUniverse } from "./compositions/features/FormatUniverse"; import { PipelineFlow } from "./compositions/features/PipelineFlow"; -import { TEXT } from "./lib/fonts"; - -const ComponentTest: React.FC = () => ( - -
- - Hello SnapOtter - -
-
- -
-
- -
- - - -
-); +import { OneCommand } from "./compositions/hero/OneCommand"; +import { PrivacyPromise } from "./compositions/hero/PrivacyPromise"; +import { ToolGalaxy } from "./compositions/hero/ToolGalaxy"; +import { ProductDemo } from "./compositions/product-demo/ProductDemo"; +import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser"; +import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical"; +import { CloudVsLocal } from "./compositions/showcase/CloudVsLocal"; +import { The48 } from "./compositions/showcase/The48"; +import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo"; export const RemotionRoot: React.FC = () => ( <> - + {/* Ambient */} ( width={800} height={600} /> + + {/* Features */} ( width={800} height={600} /> - ( width={800} height={600} /> + + + {/* Hero */} + + + + + {/* Showcase */} + + + + {/* Promo / Marketing */} + + + + ); diff --git a/apps/videos/src/components/AppMockup.tsx b/apps/videos/src/components/AppMockup.tsx new file mode 100644 index 00000000..8c250b4e --- /dev/null +++ b/apps/videos/src/components/AppMockup.tsx @@ -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 ( +
+ {/* Header bar */} +
+ {/* Otter logo + name */} + + + SnapOtter + + + {/* Search bar placeholder */} +
+
+ +
+ {/* Left sidebar */} +
+ {CATEGORY_COLORS.map((color, i) => ( +
+
+
+
+ ))} +
+ + {/* Center canvas area */} +
+ {/* Photo placeholder */} +
+ +
+ + {/* Toolbar row */} +
+ {TOOLBAR_ICONS.map((icon) => ( +
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/videos/src/components/AppWindow.tsx b/apps/videos/src/components/AppWindow.tsx new file mode 100644 index 00000000..5a9bb02b --- /dev/null +++ b/apps/videos/src/components/AppWindow.tsx @@ -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 ( +
+
+ {TRAFFIC_LIGHTS.map((dot) => ( +
+ ))} + {title && ( +
+ {title} +
+ )} +
+
+ {children} +
+
+ ); +}; diff --git a/apps/videos/src/components/BeforeAfter.tsx b/apps/videos/src/components/BeforeAfter.tsx new file mode 100644 index 00000000..88b0de62 --- /dev/null +++ b/apps/videos/src/components/BeforeAfter.tsx @@ -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 ( +
+
{before}
+ +
+ {after} +
+ + {scanProgress > 0 && scanProgress < 100 && ( +
+ )} +
+ ); +}; diff --git a/apps/videos/src/components/ClipReveal.tsx b/apps/videos/src/components/ClipReveal.tsx new file mode 100644 index 00000000..0e9400e1 --- /dev/null +++ b/apps/videos/src/components/ClipReveal.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +}; diff --git a/apps/videos/src/components/Counter.tsx b/apps/videos/src/components/Counter.tsx new file mode 100644 index 00000000..432817a4 --- /dev/null +++ b/apps/videos/src/components/Counter.tsx @@ -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 {format ? format(value) : String(value)}; +}; diff --git a/apps/videos/src/components/FeaturePill.tsx b/apps/videos/src/components/FeaturePill.tsx new file mode 100644 index 00000000..d2b751f9 --- /dev/null +++ b/apps/videos/src/components/FeaturePill.tsx @@ -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 ( +
+ {label} +
+ ); +}; diff --git a/apps/videos/src/components/GitHubCTA.tsx b/apps/videos/src/components/GitHubCTA.tsx new file mode 100644 index 00000000..9bd83ea6 --- /dev/null +++ b/apps/videos/src/components/GitHubCTA.tsx @@ -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 ( + + + + 100% OPEN SOURCE + + + +
+ +
+
+ +
+ +
+ + + Free forever. + + +
+ + +
+ + + + + Star us on GitHub + +
+
+ +
+ + + + github.com/snapotter-hq/SnapOtter + + + + ); +}; diff --git a/apps/videos/src/components/GradientBlob.tsx b/apps/videos/src/components/GradientBlob.tsx new file mode 100644 index 00000000..d7ae3da5 --- /dev/null +++ b/apps/videos/src/components/GradientBlob.tsx @@ -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 ( +
+ ); +}; diff --git a/apps/videos/src/components/GradientMesh.tsx b/apps/videos/src/components/GradientMesh.tsx new file mode 100644 index 00000000..33c80021 --- /dev/null +++ b/apps/videos/src/components/GradientMesh.tsx @@ -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 ( + + {blobs.map((blob, i) => ( + + ))} + + ); +}; diff --git a/apps/videos/src/components/GrainOverlay.tsx b/apps/videos/src/components/GrainOverlay.tsx new file mode 100644 index 00000000..0b4f5289 --- /dev/null +++ b/apps/videos/src/components/GrainOverlay.tsx @@ -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 ( + + + + + + + + + + ); +}; diff --git a/apps/videos/src/components/LogoReveal.tsx b/apps/videos/src/components/LogoReveal.tsx new file mode 100644 index 00000000..c8188c00 --- /dev/null +++ b/apps/videos/src/components/LogoReveal.tsx @@ -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 ( + + {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 ( +
+ ); + })} + +
+ + + +
+ + + SnapOtter + + +
+ + + + Your images. Stay yours. + + + + ); +}; diff --git a/apps/videos/src/components/NumberPunch.tsx b/apps/videos/src/components/NumberPunch.tsx new file mode 100644 index 00000000..57039487 --- /dev/null +++ b/apps/videos/src/components/NumberPunch.tsx @@ -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 ( +
+ + {number} + + + {descriptor} + +
+ ); +}; diff --git a/apps/videos/src/components/PhotoPlaceholder.tsx b/apps/videos/src/components/PhotoPlaceholder.tsx new file mode 100644 index 00000000..8d5f2592 --- /dev/null +++ b/apps/videos/src/components/PhotoPlaceholder.tsx @@ -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 }) => ( +
+); diff --git a/apps/videos/src/components/ProgressBar.tsx b/apps/videos/src/components/ProgressBar.tsx new file mode 100644 index 00000000..adcccfa8 --- /dev/null +++ b/apps/videos/src/components/ProgressBar.tsx @@ -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 ( +
+
+
+ ); +}; diff --git a/apps/videos/src/components/RotatingTaglines.tsx b/apps/videos/src/components/RotatingTaglines.tsx new file mode 100644 index 00000000..f7801894 --- /dev/null +++ b/apps/videos/src/components/RotatingTaglines.tsx @@ -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 ( +
+
+
+ {lines[lineIndex]} +
+
+ ); +}; diff --git a/apps/videos/src/components/ShieldIcon.tsx b/apps/videos/src/components/ShieldIcon.tsx new file mode 100644 index 00000000..f4a80be0 --- /dev/null +++ b/apps/videos/src/components/ShieldIcon.tsx @@ -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 ( + + + + + + + + + + 0 ? "url(#shield-fill-grad)" : "none"} + fillOpacity={fillOpacity > 0 ? fillOpacity * 12 : 0} + strokeDasharray={strokeDasharray} + strokeDashoffset={strokeDashoffset} + strokeLinecap="round" + strokeLinejoin="round" + /> + + ); +}; diff --git a/apps/videos/src/components/Terminal.tsx b/apps/videos/src/components/Terminal.tsx new file mode 100644 index 00000000..95348002 --- /dev/null +++ b/apps/videos/src/components/Terminal.tsx @@ -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 ( +
+ +
+
+ $ + +
+ + {outputLines.map((line, i) => { + const lineFrame = outputStartFrame + i * outputStagger; + const lineOpacity = interpolate(frame, [lineFrame, lineFrame + 6], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + return ( +
+ {line.text} +
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/videos/src/components/TerminalWindow.tsx b/apps/videos/src/components/TerminalWindow.tsx new file mode 100644 index 00000000..1323dc46 --- /dev/null +++ b/apps/videos/src/components/TerminalWindow.tsx @@ -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 ( +
+ {/* Top bar */} +
+ {/* Traffic light dots */} +
+ {TRAFFIC_LIGHTS.map((dot) => ( +
+ ))} +
+ + {/* Title area (centered) */} +
+ {titleElement || ( + + {title} + + )} +
+
+ + {/* Body */} +
+ {children} +
+
+ ); +}; diff --git a/apps/videos/src/components/ToolGrid.tsx b/apps/videos/src/components/ToolGrid.tsx new file mode 100644 index 00000000..e26a08f6 --- /dev/null +++ b/apps/videos/src/components/ToolGrid.tsx @@ -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 ( +
+ {CATEGORY_ORDER.map((cat) => { + const tools = getToolsByCategory(cat); + return ( +
+ {tools.map((tool) => { + const i = globalIndex++; + const enterDelay = startFrame + i * TIMING.staggerFrames; + const s = spring({ + frame: frame - enterDelay, + fps, + config: SPRING.settle, + }); + + return ( +
+ +
+ ); + })} +
+ ); + })} +
+ ); +}; diff --git a/apps/videos/src/components/ToolPill.tsx b/apps/videos/src/components/ToolPill.tsx new file mode 100644 index 00000000..7b9ccbf1 --- /dev/null +++ b/apps/videos/src/components/ToolPill.tsx @@ -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 ( +
+ {name} +
+ ); +}; diff --git a/apps/videos/src/components/TypeWriter.tsx b/apps/videos/src/components/TypeWriter.tsx new file mode 100644 index 00000000..6f48582c --- /dev/null +++ b/apps/videos/src/components/TypeWriter.tsx @@ -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( + + {seg.text.slice(0, visible)} + , + ); + } + rendered += seg.text.length; + if (rendered >= visibleChars) break; + } + + const showCursor = visibleChars < fullText.length && Math.floor(frame / 8) % 2 === 0; + + return ( + + {nodes} + {showCursor && ( + + )} + + ); +}; diff --git a/apps/videos/src/components/WipeTransition.tsx b/apps/videos/src/components/WipeTransition.tsx new file mode 100644 index 00000000..0fd0ff7b --- /dev/null +++ b/apps/videos/src/components/WipeTransition.tsx @@ -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 {children}; +}; diff --git a/apps/videos/src/compositions/ambient/BrandGradient.tsx b/apps/videos/src/compositions/ambient/BrandGradient.tsx new file mode 100644 index 00000000..2502b34e --- /dev/null +++ b/apps/videos/src/compositions/ambient/BrandGradient.tsx @@ -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 ( + + {/* Scattered tool names for texture (frozen, 5% opacity) */} + {SCATTERED_TOOLS.map((tool) => ( + + {tool.name} + + ))} + + {BLOBS.map((blob) => ( + + ))} + + {/* Center logo with subtle scale pulse */} + + + {/* Radial vignette */} +
+ + + + ); +}; diff --git a/apps/videos/src/compositions/ambient/FloatingTools.tsx b/apps/videos/src/compositions/ambient/FloatingTools.tsx new file mode 100644 index 00000000..f0b3f335 --- /dev/null +++ b/apps/videos/src/compositions/ambient/FloatingTools.tsx @@ -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 ( + + {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 ( + 0 ? `blur(${item.layer.blur}px)` : undefined, + }} + > + {item.tool.name} + + ); + })} + {/* Radial vignette */} +
+ + + ); +}; diff --git a/apps/videos/src/compositions/features/AiMagicReel.tsx b/apps/videos/src/compositions/features/AiMagicReel.tsx index cf38b68c..5fe440da 100644 --- a/apps/videos/src/compositions/features/AiMagicReel.tsx +++ b/apps/videos/src/compositions/features/AiMagicReel.tsx @@ -1,11 +1,13 @@ import type React from "react"; import { AbsoluteFill, + Img, interpolate, interpolateColors, random, Sequence, spring, + staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; @@ -133,19 +135,8 @@ const SegmentBackgroundRemoval: React.FC = () => { easing: EASE.smooth, }); - // Geometric pattern background - const patternBg = ` - linear-gradient(45deg, rgba(245,158,11,0.15) 25%, transparent 25%), - linear-gradient(-45deg, rgba(245,158,11,0.15) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, rgba(245,158,11,0.1) 75%), - linear-gradient(-45deg, transparent 75%, rgba(245,158,11,0.1) 75%) - `; - - // Silhouette: a head-and-shoulders shape via gradient - const silhouetteGradient = ` - radial-gradient(ellipse 90px 100px at 50% 30%, hsl(25, 65%, 65%) 0%, hsl(25, 65%, 65%) 70%, transparent 71%), - radial-gradient(ellipse 130px 200px at 50% 70%, hsl(30, 55%, 55%) 0%, hsl(30, 55%, 55%) 70%, transparent 71%) - `; + // Checkerboard background for transparency effect + const checkerBg = CHECKERBOARD_BG; // Scan line particle trail const particles = Array.from({ length: 5 }, (_, i) => { @@ -187,28 +178,19 @@ const SegmentBackgroundRemoval: React.FC = () => { boxShadow: "0 8px 24px rgba(0,0,0,0.3)", }} > - {/* "Before" layer: photo with geometric background */} -
- {/* Silhouette subject */} -
-
+ /> - {/* "After" layer: checkerboard + subject only */} + {/* "After" layer: checkerboard + portrait (revealed via clip) */}
{ style={{ position: "absolute", inset: 0, - background: CHECKERBOARD_BG, + background: checkerBg, }} /> - {/* Subject only */} -
@@ -246,7 +230,7 @@ const SegmentBackgroundRemoval: React.FC = () => { width: 2, height: "100%", background: COLOR.accent, - boxShadow: `0 0 20px 10px rgba(245,158,11,0.3)`, + boxShadow: "0 0 20px 10px rgba(245,158,11,0.3)", zIndex: 10, }} /> @@ -260,7 +244,7 @@ const SegmentBackgroundRemoval: React.FC = () => { key={`scan-p-${i}`} style={{ position: "absolute", - left: photoX + p.x - photoX, + left: p.x, top: p.y, width: 3, height: 3, @@ -358,26 +342,38 @@ const SegmentColorization: React.FC = () => { transform: `scale(${holdScale})`, }} > - {/* Grayscale layer (on top) */} -
- {/* Color layer (revealed underneath via clip) */} + {/* Color layer (revealed via clip, using portrait as color version) */}
+ > + +
{/* Color particles */} {colorParticles.map( @@ -411,11 +407,11 @@ const SegmentColorization: React.FC = () => { const PIXEL_GRID_SIZE = 8; const PIXEL_GAP = 0.5; -/** Generate deterministic colors for the pixel grid */ +/** Generate deterministic colors for the pixel grid (blues and greens from landscape) */ const pixelColors: string[] = Array.from({ length: PIXEL_GRID_SIZE * PIXEL_GRID_SIZE }, (_, i) => { - const hue = random(`pixel-hue-${i}`) * 60 + 15; // warm hues - const sat = 40 + random(`pixel-sat-${i}`) * 30; - const lit = 40 + random(`pixel-lit-${i}`) * 30; + const hue = random(`pixel-hue-${i}`) * 80 + 180; // blues and greens (180-260) + const sat = 35 + random(`pixel-sat-${i}`) * 35; + const lit = 35 + random(`pixel-lit-${i}`) * 30; return `hsl(${hue}, ${sat}%, ${lit}%)`; }); @@ -518,14 +514,17 @@ const SegmentSuperResolution: React.FC = () => { ))}
- {/* Smooth "resolved" version */} + {/* Smooth "resolved" version - actual landscape photo */} {showSmooth && ( -
{ }); // Color shift from sepia to warm - const bgFrom = interpolateColors(frame, [80, 95], ["hsl(35, 40%, 45%)", "hsl(30, 55%, 50%)"]); - const bgTo = interpolateColors(frame, [80, 95], ["hsl(40, 35%, 55%)", "hsl(45, 50%, 58%)"]); + const _bgFrom = interpolateColors(frame, [80, 95], ["hsl(35, 40%, 45%)", "hsl(30, 55%, 50%)"]); + const _bgTo = interpolateColors(frame, [80, 95], ["hsl(40, 35%, 55%)", "hsl(45, 50%, 58%)"]); return ( @@ -653,15 +652,18 @@ const SegmentRestoration: React.FC = () => { borderRadius: 8, overflow: "hidden", boxShadow: "0 8px 24px rgba(0,0,0,0.3)", - filter: `sepia(${sepiaAmount}) contrast(${contrastAmount})`, + filter: `sepia(${sepiaAmount * 0.8}) contrast(${contrastAmount})`, }} > - {/* Base photo gradient */} -
diff --git a/apps/videos/src/compositions/features/FormatUniverse.tsx b/apps/videos/src/compositions/features/FormatUniverse.tsx new file mode 100644 index 00000000..be9398f3 --- /dev/null +++ b/apps/videos/src/compositions/features/FormatUniverse.tsx @@ -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 ( +
+ {label} +
+ ); +}; + +/** 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 ( +
+ {label} +
+ ); +}; + +export const FormatUniverse: React.FC = () => { + const frame = useCurrentFrame(); + const logoScale = Math.sin(frame * 0.06) * 0.05 + 1; + + return ( + + {/* Orbit ring lines (faint elliptical paths behind each ring) */} + + {RINGS.map((ring) => ( + + ))} + + + {/* 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 [ + , + , + ]; + }), + )} + + {/* 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 ( + + ); + }), + )} + + + +
+ 55+ formats. Every camera. Every device. +
+ + +
+ ); +}; diff --git a/apps/videos/src/compositions/features/PipelineFlow.tsx b/apps/videos/src/compositions/features/PipelineFlow.tsx index 2e509ce7..1c3ca8bd 100644 --- a/apps/videos/src/compositions/features/PipelineFlow.tsx +++ b/apps/videos/src/compositions/features/PipelineFlow.tsx @@ -3,17 +3,18 @@ import { evolvePath } from "@remotion/paths"; import type React from "react"; import { AbsoluteFill, + Img, interpolate, random, Sequence, spring, + staticFile, useCurrentFrame, useVideoConfig, } from "remotion"; import { ClipReveal } from "@/components/ClipReveal"; import { Counter } from "@/components/Counter"; import { GrainOverlay } from "@/components/GrainOverlay"; -import { PhotoPlaceholder } from "@/components/PhotoPlaceholder"; import { COLOR } from "@/lib/colors"; import { FONT, TEXT } from "@/lib/fonts"; import { EASE, SPRING } from "@/lib/motion"; @@ -113,10 +114,10 @@ const Station: React.FC<{ border: `2px solid ${station.color}${glowing || pulseGlow ? "cc" : "66"}`, boxShadow: glowOpacity > 0 - ? `0 0 20px ${station.color}${Math.round(glowOpacity * 255) + ? `0 4px 20px rgba(0,0,0,0.4), 0 0 20px ${station.color}${Math.round(glowOpacity * 255) .toString(16) .padStart(2, "0")}` - : "none", + : "0 4px 20px rgba(0,0,0,0.4)", transform: `scale(${scale})`, display: "flex", flexDirection: "column", @@ -386,7 +387,17 @@ const SingleFlow: React.FC = () => { zIndex: frame >= 108 ? 0 : 5, }} > - + {/* Watermark stamp */} {stampOpacity > 0 && ( { bottom: 2, right: 3, fontFamily: FONT.body, - fontSize: 5, + fontSize: 8, fontWeight: 700, color: `rgba(255,255,255,${stampOpacity})`, whiteSpace: "nowrap", @@ -489,9 +500,10 @@ const BatchThumb: React.FC<{ if (localFrame < 0) return null; - const hue = random(`thumb-hue-${index}`) * 360; const size = 24 + random(`thumb-size-${index}`) * 8; const yOffset = (random(`thumb-y-${index}`) - 0.5) * 30; + const objPosX = Math.round(random(`thumb-opx-${index}`) * 100); + const objPosY = Math.round(random(`thumb-opy-${index}`) * 100); // Fast traversal: cover pipeline in ~28 frames per thumb const thumbX = interpolate(localFrame, [0, 28], [THUMB_START_X, FOLDER_X + 10], { @@ -517,7 +529,17 @@ const BatchThumb: React.FC<{ zIndex: 4, }} > - +
); }; @@ -601,13 +623,10 @@ export const PipelineFlow: React.FC = () => { {/* Act 1 stations (visible throughout until Act 2 takes over) */} - {frame < 40 && ( - <> - {STATIONS.map((s, i) => ( - - ))} - - )} + {frame < 40 && + STATIONS.map((s, i) => ( + + ))} {/* Act 2: Single Image Flow (frame 40-170) */} @@ -640,13 +659,11 @@ export const PipelineFlow: React.FC = () => { {/* Act 3 stations when not covered by SingleFlow or BatchStream */} - {frame >= 170 && frame < 180 && ( - <> - {STATIONS.map((s, i) => ( - - ))} - - )} + {frame >= 170 && + frame < 180 && + STATIONS.map((s, i) => ( + + ))} {/* Act 4: Result (frame 340-390) */} {frame >= 340 && ( diff --git a/apps/videos/src/compositions/hero/OneCommand.tsx b/apps/videos/src/compositions/hero/OneCommand.tsx new file mode 100644 index 00000000..6d0018d1 --- /dev/null +++ b/apps/videos/src/compositions/hero/OneCommand.tsx @@ -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 }) => ( +
+ {/* Green lock icon */} + + + + + + localhost:1349 + +
+); + +/* ------------------------------------------------------------------ */ +/* 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 ( + + {/* Amber glow behind browser (Act 5) */} + {frame >= 320 && ( +
+ )} + + {/* Terminal / Browser window */} +
+ = 240 ? : undefined} + > + {/* Terminal text content (fades out during morph) */} +
+ {/* Prompt line */} +
+ $ + {frame >= 35 ? ( + + ) : ( + preTypingCursorVisible && ( + + ) + )} +
+ + {/* Processing output (Act 3) */} + {frame >= 180 && ( +
+
Pulling snapotter/snapotter:latest...
+ + {/* Spinner line */} + {spinnerChar && !showCheckmark && ( +
{spinnerChar}
+ )} + + {/* Checkmark line */} + {showCheckmark && ( +
+ ✓ Container started on :1349 +
+ )} +
+ )} +
+ + {/* Real app screenshot (appears during morph) */} + {showAppMockup && ( +
+ +
+ )} +
+
+ + {/* Tagline (Act 5) */} + {frame >= 310 && ( +
+ + + One command. That's it. + + +
+ )} + + + + ); +}; diff --git a/apps/videos/src/compositions/hero/PrivacyPromise.tsx b/apps/videos/src/compositions/hero/PrivacyPromise.tsx new file mode 100644 index 00000000..1d253e6b --- /dev/null +++ b/apps/videos/src/compositions/hero/PrivacyPromise.tsx @@ -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 }) => ( + + + + +); + +/* ------------------------------------------------------------------ */ +/* 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 ( + + {/* ---- Shield + Photos group (scales together in Act 3) ---- */} +
+ {/* Shield icon */} + + + {/* Padlock at shield base */} +
+ +
+ + {/* 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 ( + + ); + })} +
+ + {/* ---- "Your images." text (Act 2) ---- */} +
+ + Your images. + +
+ + {/* ---- "Stay yours." text (Act 2) ---- */} +
+ + Stay yours. + +
+ + {/* ---- Subtext lines (Act 3) ---- */} + {frame >= 160 && ( +
+ + No uploads to the cloud. Ever. + + + 100% local processing. + + + Free. Forever. + +
+ )} + + +
+ ); +}; diff --git a/apps/videos/src/compositions/hero/ToolGalaxy.tsx b/apps/videos/src/compositions/hero/ToolGalaxy.tsx new file mode 100644 index 00000000..4724fc45 --- /dev/null +++ b/apps/videos/src/compositions/hero/ToolGalaxy.tsx @@ -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 = {}; +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 ( +
+ +
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* BurstPhase - pills wrapped in Trail for motion blur */ +/* ------------------------------------------------------------------ */ + +const BurstPills: React.FC = () => ( + + {PILLS.map((pill) => ( + + ))} + +); + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ + + {CATEGORY_LABELS[cat]} + + +
+ ); + })} + + ); +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( + + {/* ---- Radial amber glow behind logo (Act 1) ---- */} + {frame < 90 && ( +
+ )} + + {/* ---- Logo (Act 1) ---- */} + {frame < 120 && ( +
+ +
+ )} + + {/* ---- Pills ---- */} + {frame >= 60 && + (inBurstPhase ? ( + + + + ) : ( + + ))} + + {/* ---- Category labels (Act 4) ---- */} + + + {/* ---- Counter + Tagline (Act 5) ---- */} + {frame >= 540 && ( +
+ +
+ )} + + {/* ---- Tagline text (Act 5) ---- */} + {frame >= 580 && ( +
+ + + {" "} + tools. Zero cloud dependency. + + +
+ )} + + + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/ProductDemo.tsx b/apps/videos/src/compositions/product-demo/ProductDemo.tsx new file mode 100644 index 00000000..f29b887b --- /dev/null +++ b/apps/videos/src/compositions/product-demo/ProductDemo.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/AiToolsScene.tsx b/apps/videos/src/compositions/product-demo/scenes/AiToolsScene.tsx new file mode 100644 index 00000000..93d746b2 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/AiToolsScene.tsx @@ -0,0 +1,13 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const AiToolsScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/ApiDocsScene.tsx b/apps/videos/src/compositions/product-demo/scenes/ApiDocsScene.tsx new file mode 100644 index 00000000..bc952aa2 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/ApiDocsScene.tsx @@ -0,0 +1,14 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const ApiDocsScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/BatchProcessingScene.tsx b/apps/videos/src/compositions/product-demo/scenes/BatchProcessingScene.tsx new file mode 100644 index 00000000..0c188962 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/BatchProcessingScene.tsx @@ -0,0 +1,12 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const BatchProcessingScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/DashboardScene.tsx b/apps/videos/src/compositions/product-demo/scenes/DashboardScene.tsx new file mode 100644 index 00000000..f8389562 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/DashboardScene.tsx @@ -0,0 +1,13 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const DashboardScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/EndCardScene.tsx b/apps/videos/src/compositions/product-demo/scenes/EndCardScene.tsx new file mode 100644 index 00000000..f6ab9e53 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/EndCardScene.tsx @@ -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 ( + + + +
+ + + + Self-hosted image processing. + + + +
+ + + + snapotter.com + + + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/ImageEditorScene.tsx b/apps/videos/src/compositions/product-demo/scenes/ImageEditorScene.tsx new file mode 100644 index 00000000..f8274d35 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/ImageEditorScene.tsx @@ -0,0 +1,12 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const ImageEditorScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/PipelineBuilderScene.tsx b/apps/videos/src/compositions/product-demo/scenes/PipelineBuilderScene.tsx new file mode 100644 index 00000000..cf578879 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/PipelineBuilderScene.tsx @@ -0,0 +1,14 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const PipelineBuilderScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/ScreenshotScene.tsx b/apps/videos/src/compositions/product-demo/scenes/ScreenshotScene.tsx new file mode 100644 index 00000000..84cf796f --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/ScreenshotScene.tsx @@ -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 ( + +
+ +
+ +
+
+
+ + {caption && ( +
+ + + {caption} + + +
+ )} +
+ ); +}; diff --git a/apps/videos/src/compositions/product-demo/scenes/SingleToolScene.tsx b/apps/videos/src/compositions/product-demo/scenes/SingleToolScene.tsx new file mode 100644 index 00000000..e64ad026 --- /dev/null +++ b/apps/videos/src/compositions/product-demo/scenes/SingleToolScene.tsx @@ -0,0 +1,14 @@ +import type React from "react"; +import { ScreenshotScene } from "./ScreenshotScene"; + +export const SingleToolScene: React.FC = () => { + return ( + + ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/PromoTeaser.tsx b/apps/videos/src/compositions/promo-teaser/PromoTeaser.tsx new file mode 100644 index 00000000..9e9aec4a --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/PromoTeaser.tsx @@ -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 ( + + {BG_BLOBS.map((blob, i) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/PromoTeaserVertical.tsx b/apps/videos/src/compositions/promo-teaser/PromoTeaserVertical.tsx new file mode 100644 index 00000000..53d4f5f0 --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/PromoTeaserVertical.tsx @@ -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 ( + + {BG_BLOBS.map((blob, i) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/scenes/AmbientOpenScene.tsx b/apps/videos/src/compositions/promo-teaser/scenes/AmbientOpenScene.tsx new file mode 100644 index 00000000..763d1eb2 --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/scenes/AmbientOpenScene.tsx @@ -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 ( + + {BLOBS.map((blob, i) => ( + + ))} + + +
+ + +
+
+
+ ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/scenes/CTAScene.tsx b/apps/videos/src/compositions/promo-teaser/scenes/CTAScene.tsx new file mode 100644 index 00000000..63f7124a --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/scenes/CTAScene.tsx @@ -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 ( + +
+ Get it free +
+ +
+ + + snapotter.com + + + ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/scenes/LogoRevealScene.tsx b/apps/videos/src/compositions/promo-teaser/scenes/LogoRevealScene.tsx new file mode 100644 index 00000000..f7d7fc93 --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/scenes/LogoRevealScene.tsx @@ -0,0 +1,18 @@ +import type React from "react"; +import { AbsoluteFill } from "remotion"; +import { LogoReveal } from "@/components/LogoReveal"; + +export const LogoRevealScene: React.FC = () => { + return ( + + + + ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/scenes/NumberPunchScene.tsx b/apps/videos/src/compositions/promo-teaser/scenes/NumberPunchScene.tsx new file mode 100644 index 00000000..1ddaebb9 --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/scenes/NumberPunchScene.tsx @@ -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 ( + + {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 ( + + + + ); + })} + + ); +}; diff --git a/apps/videos/src/compositions/promo-teaser/scenes/TaglineCascadeScene.tsx b/apps/videos/src/compositions/promo-teaser/scenes/TaglineCascadeScene.tsx new file mode 100644 index 00000000..a38b0f88 --- /dev/null +++ b/apps/videos/src/compositions/promo-teaser/scenes/TaglineCascadeScene.tsx @@ -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 ( + + + + + + + + ); +}; diff --git a/apps/videos/src/compositions/showcase/CloudVsLocal.tsx b/apps/videos/src/compositions/showcase/CloudVsLocal.tsx new file mode 100644 index 00000000..8489838d --- /dev/null +++ b/apps/videos/src/compositions/showcase/CloudVsLocal.tsx @@ -0,0 +1,1417 @@ +import { evolvePath } from "@remotion/paths"; +import type React from "react"; +import { + AbsoluteFill, + Img, + interpolate, + random, + spring, + staticFile, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import { ClipReveal } from "@/components/ClipReveal"; +import { GrainOverlay } from "@/components/GrainOverlay"; +import { ShieldIcon } from "@/components/ShieldIcon"; +import { COLOR } from "@/lib/colors"; +import { FONT, TEXT } from "@/lib/fonts"; +import { EASE, SPRING } from "@/lib/motion"; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const CX = 1920 / 2; +const CY = 1080 / 2; +const DIVIDER_X = 960; + +/* ------------------------------------------------------------------ */ +/* SVG path helpers */ +/* ------------------------------------------------------------------ */ + +const CLOUD_SVG_PATH = "M6 19a5 5 0 0 1-1-9.9A7 7 0 0 1 19.07 11 4.5 4.5 0 0 1 18 20H6Z"; + +const LOCK_SVG_PATH = + "M5 11V7a5 5 0 0 1 10 0v4M3 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z"; + +const _SERVER_SVG_PATH = "M2 2h16v6H2zM2 10h16v6H2zM6 5h.01M6 13h.01"; + +const _EYE_SVG_PATH = + "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"; + +/* ------------------------------------------------------------------ */ +/* Cloud icon */ +/* ------------------------------------------------------------------ */ + +const CloudIcon: React.FC<{ + size: number; + color?: string; + drawProgress?: number; + style?: React.CSSProperties; +}> = ({ size, color = COLOR.danger, drawProgress = 1, style }) => { + const { strokeDasharray, strokeDashoffset } = evolvePath(drawProgress, CLOUD_SVG_PATH); + return ( + + + + ); +}; + +/* ------------------------------------------------------------------ */ +/* Server icon */ +/* ------------------------------------------------------------------ */ + +const ServerIcon: React.FC<{ + size: number; + color?: string; + label?: string; + style?: React.CSSProperties; +}> = ({ size, color = "#6b7280", label, style }) => ( +
+ + + + + + + {label && ( + + {label} + + )} +
+); + +/* ------------------------------------------------------------------ */ +/* Question mark badge */ +/* ------------------------------------------------------------------ */ + +const QuestionBadge: React.FC<{ + x: number; + y: number; + scale: number; +}> = ({ x, y, scale }) => ( +
+ ? +
+); + +/* ------------------------------------------------------------------ */ +/* Warning badge (triangle with !) */ +/* ------------------------------------------------------------------ */ + +const WarningBadge: React.FC<{ + x: number; + y: number; + scale: number; + color?: string; +}> = ({ x, y, scale, color = COLOR.danger }) => ( +
+ + + + ! + + +
+); + +/* ------------------------------------------------------------------ */ +/* Lock icon */ +/* ------------------------------------------------------------------ */ + +const LockIcon: React.FC<{ + size: number; + color?: string; + drawProgress?: number; + style?: React.CSSProperties; +}> = ({ size, color = COLOR.safe, drawProgress = 1, style }) => { + const { strokeDasharray, strokeDashoffset } = evolvePath(drawProgress, LOCK_SVG_PATH); + return ( + + + + ); +}; + +/* ------------------------------------------------------------------ */ +/* Checkmark icon */ +/* ------------------------------------------------------------------ */ + +const Checkmark: React.FC<{ + size: number; + color: string; + scale: number; + style?: React.CSSProperties; +}> = ({ size, color, scale, style }) => ( + + + + +); + +/* ------------------------------------------------------------------ */ +/* Spinner */ +/* ------------------------------------------------------------------ */ + +const Spinner: React.FC<{ + color: string; + size: number; + style?: React.CSSProperties; +}> = ({ color, size, style }) => { + const frame = useCurrentFrame(); + const rotation = frame * 18; // 6 deg/frame => 180 deg/s + const circumference = Math.PI * (size - 4); + return ( + + + + ); +}; + +/* ------------------------------------------------------------------ */ +/* Eye icon (watching) */ +/* ------------------------------------------------------------------ */ + +const EyeIcon: React.FC<{ + x: number; + y: number; + opacity: number; +}> = ({ x, y, opacity }) => { + const frame = useCurrentFrame(); + // Blink: close eye briefly every ~40 frames + const blinkPhase = frame % 40; + const eyeScale = blinkPhase >= 0 && blinkPhase < 3 ? 0.3 : 1; + + return ( + + + + + ); +}; + +/* ------------------------------------------------------------------ */ +/* Cloud server positions (pre-computed for scatter) */ +/* ------------------------------------------------------------------ */ + +const CLOUD_SERVERS = [ + { x: 180, y: 340, label: "" }, + { x: 340, y: 420, label: "" }, + { x: 580, y: 300, label: "" }, +]; + +const EXTRA_SERVERS = [ + { x: 120, y: 500, label: "" }, + { x: 450, y: 540, label: "" }, + { x: 680, y: 460, label: "" }, + { x: 280, y: 580, label: "" }, +]; + +/* Labeled nodes for Act 3 */ +const THIRD_PARTY_NODES = [ + { x: 620, y: 540, label: "3rd Party", color: "#6b7280" }, + { x: 150, y: 600, label: "Analytics", color: "#6b7280" }, + { x: 420, y: 640, label: "Training Data", color: "#d97706" }, +]; + +/* ------------------------------------------------------------------ */ +/* Main composition */ +/* ------------------------------------------------------------------ */ + +export const CloudVsLocal: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + /* ================================================================ */ + /* Act 1: Split Establishment (frame 0-40) */ + /* ================================================================ */ + + /* Divider line draws top to bottom */ + const dividerPath = `M${DIVIDER_X} 0 L${DIVIDER_X} 1080`; + const dividerProgress = interpolate(frame, [0, 15], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + const dividerEvolve = evolvePath(dividerProgress, dividerPath); + + /* Tint overlays */ + const leftTintOpacity = interpolate(frame, [15, 25], [0, 0.03], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const rightTintOpacity = interpolate(frame, [15, 25], [0, 0.03], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Cloud icon draw */ + const cloudDrawProgress = interpolate(frame, [25, 35], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + + /* ================================================================ */ + /* Act 2: Single Photo Comparison (frame 40-160) */ + /* ================================================================ */ + + /* -- LEFT SIDE -- */ + + /* Photo appears */ + const leftPhotoOpacity = interpolate(frame, [40, 48], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Photo moves to cloud: follows curved path upward */ + const leftUploadProgress = interpolate(frame, [55, 80], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.smooth, + }); + const leftPhotoX = interpolate(leftUploadProgress, [0, 0.5, 1], [200, 350, 400]); + const leftPhotoY = interpolate(leftUploadProgress, [0, 0.5, 1], [700, 400, 160]); + + /* Cloud flash when photo arrives */ + const cloudFlashOpacity = + frame >= 80 && frame < 88 + ? interpolate(frame, [80, 84, 88], [0, 0.4, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : 0; + + /* Scatter copies to servers */ + const scatterProgress = interpolate(frame, [85, 110], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + + /* Question marks pop in */ + const questionMarks = CLOUD_SERVERS.map((server, i) => { + const qStart = 110 + i * 6; + const qSpring = spring({ + frame: frame - qStart, + fps, + config: SPRING.popIn, + }); + return { ...server, scale: qSpring }; + }); + + /* Eye icon */ + const eyeOpacity = interpolate(frame, [118, 125], [0, 0.7], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* "who has your data?" */ + const leftLabelOpacity = interpolate(frame, [130, 145], [0, 0.4], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* -- RIGHT SIDE -- */ + + /* Photo appears */ + const rightPhotoOpacity = interpolate(frame, [40, 48], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Photo moves to local server: curves downward */ + const rightDownloadProgress = interpolate(frame, [55, 80], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.smooth, + }); + const rightPhotoX = interpolate(rightDownloadProgress, [0, 0.5, 1], [1400, 1350, 1400]); + const rightPhotoY = interpolate(rightDownloadProgress, [0, 0.5, 1], [300, 500, 620]); + + /* Processing spinner -> checkmark */ + const showSpinner = frame >= 80 && frame < 100; + const checkmarkSpring = spring({ + frame: frame - 100, + fps, + config: SPRING.popIn, + }); + const showCheckmark = frame >= 100; + + /* Lock icon draws in */ + const lockProgress = interpolate(frame, [100, 125], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + + /* "stays on your machine" */ + const rightLabelOpacity = interpolate(frame, [130, 145], [0, 0.7], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Shield outline */ + const _shieldDrawProgress = interpolate(frame, [135, 160], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + + /* ================================================================ */ + /* Act 3: Escalation (frame 160-320) */ + /* ================================================================ */ + + /* Left: extra photos upload (staggered) */ + const extraPhotoCount = 4; + const extraPhotos = Array.from({ length: extraPhotoCount }, (_, i) => { + const start = 160 + i * 6; + const progress = interpolate(frame, [start, start + 20], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.smooth, + }); + const hue = 20 + random(`extra-hue-${i}`) * 60; + return { progress, hue, index: i }; + }); + + /* Extra server appearances */ + const extraServerAppearances = EXTRA_SERVERS.map((server, i) => { + const start = 175 + i * 8; + const s = spring({ + frame: frame - start, + fps, + config: SPRING.snappy, + }); + return { ...server, scale: s }; + }); + + /* 3rd party node connections */ + const thirdPartyNodes = THIRD_PARTY_NODES.map((node, i) => { + const start = 200 + i * 15; + const drawProg = interpolate(frame, [start, start + 20], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + const labelOpacity = interpolate(frame, [start + 15, start + 25], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + return { ...node, drawProgress: drawProg, labelOpacity }; + }); + + /* ToS scroll */ + const tosHeight = interpolate(frame, [220, 260], [0, 100], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.smooth, + }); + + /* Warning badges */ + const warningBadges = [ + { x: 160, y: 360, start: 265 }, + { x: 360, y: 440, start: 272 }, + { x: 560, y: 320, start: 278 }, + { x: 110, y: 520, start: 284 }, + { x: 440, y: 560, start: 290 }, + ].map((badge) => { + const s = spring({ + frame: frame - badge.start, + fps, + config: SPRING.popIn, + }); + return { ...badge, scale: s }; + }); + + /* Left side red tint escalation (starts at 5%, escalates to 20% by Act 4) */ + const escalatedLeftTint = interpolate(frame, [160, 320], [0.05, 0.2], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Shake animation (frame 300-320) */ + const shakeX = frame >= 300 && frame <= 320 ? Math.sin(frame * 2.5) * 5 : 0; + + /* Right: extra photos arrive calmly */ + const rightExtraPhotos = Array.from({ length: 4 }, (_, i) => { + const start = 165 + i * 20; + const progress = interpolate(frame, [start, start + 25], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.smooth, + }); + const checkSpring = spring({ + frame: frame - (start + 28), + fps, + config: SPRING.popIn, + }); + const hue = 140 + random(`right-hue-${i}`) * 60; + return { progress, checkScale: checkSpring, hue, index: i }; + }); + + /* Shield breathing */ + const shieldBreathing = frame >= 290 ? 1 + Math.sin((frame - 290) * 0.08) * 0.01 : 1; + + /* ================================================================ */ + /* Act 4: Contrast Peak (frame 320-420) */ + /* ================================================================ */ + + /* Left: deeper red tint */ + const peakLeftTint = interpolate(frame, [320, 360], [0.2, 0.25], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Left vibrate */ + const vibrateX = frame >= 320 && frame < 420 ? Math.sin(frame * 3) * 3 : 0; + + /* Right glow */ + const rightGlowOpacity = interpolate(frame, [360, 400], [0, 0.12], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* ================================================================ */ + /* Act 5: Resolution (frame 420-540) */ + /* ================================================================ */ + + /* Left half slides off */ + const leftSlideX = interpolate(frame, [420, 470], [0, -960], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.exit, + }); + const leftFadeOut = interpolate(frame, [420, 460], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Divider slides with left */ + const dividerSlideX = interpolate(frame, [420, 470], [0, -960], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.exit, + }); + + /* Right side expands to center */ + const rightExpandX = interpolate(frame, [450, 500], [960, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + easing: EASE.enter, + }); + + /* Full-width shield */ + const fullShieldOpacity = interpolate(frame, [470, 500], [0, 0.1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Logo fade in */ + const logoOpacity = interpolate(frame, [520, 535], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + /* Compute effective left tint for current frame */ + const effectiveLeftTint = + frame < 160 ? leftTintOpacity : frame < 320 ? escalatedLeftTint : peakLeftTint; + + return ( + + {/* ================================================================ */} + {/* LEFT HALF: Cloud Services */} + {/* ================================================================ */} +
+ {/* Red tint */} +
+ + {/* Title */} + {frame >= 25 && ( +
+ + + + Cloud Services + + +
+ )} + + {/* Cloud icon at top center */} +
+ + {/* Cloud flash */} +
+
+ + {/* Eye icon above cloud */} + {frame >= 115 && } + + {/* Photo thumbnail (Act 2) */} + {frame >= 40 && frame < 85 && ( + + )} + + {/* Upload arrow path */} + {frame >= 55 && frame < 85 && ( + + + + )} + + {/* Scattered servers */} + {frame >= 85 && + CLOUD_SERVERS.map((server, i) => ( +
+ +
+ ))} + + {/* Scattered photo copies to servers */} + {frame >= 85 && + CLOUD_SERVERS.map((server, i) => { + const copyX = interpolate(scatterProgress, [0, 1], [400, server.x]); + const copyY = interpolate(scatterProgress, [0, 1], [160, server.y - 30]); + return ( + + ); + })} + + {/* Dotted trail lines from cloud to servers */} + {frame >= 90 && ( + + {CLOUD_SERVERS.map((server, i) => ( + + ))} + + )} + + {/* Question marks */} + {frame >= 110 && + questionMarks.map((q, i) => ( + + ))} + + {/* "who has your data?" label */} + {frame >= 130 && ( +
+ + who has your data? + +
+ )} + + {/* Act 3: Extra photos uploading */} + {frame >= 160 && + extraPhotos.map((photo) => { + const px = interpolate(photo.progress, [0, 1], [100 + photo.index * 60, 400]); + const py = interpolate(photo.progress, [0, 1], [750, 160]); + if (photo.progress <= 0) return null; + return ( + + ); + })} + + {/* Extra servers */} + {frame >= 175 && + extraServerAppearances.map((server, i) => ( +
+ +
+ ))} + + {/* 3rd party nodes with connections */} + {frame >= 200 && ( + + {thirdPartyNodes.map((node, i) => { + const sourceServer = i < EXTRA_SERVERS.length ? EXTRA_SERVERS[i] : CLOUD_SERVERS[0]; + return ( + + ); + })} + + )} + + {frame >= 200 && + thirdPartyNodes.map((node, i) => ( +
+
+ + {node.label} + +
+
+ ))} + + {/* ToS scroll */} + {frame >= 220 && tosHeight > 0 && ( +
+ {Array.from({ length: 12 }, (_, i) => ( +
+ ))} +
+ )} + + {/* Warning badges */} + {frame >= 260 && + warningBadges.map((badge, i) => ( + = 3 ? COLOR.danger : "#d97706"} + /> + ))} + + {/* Act 4: "where did your data go?" */} + {frame >= 320 && frame < 420 && ( +
+ + + where did your data go? + + +
+ )} +
+ + {/* ================================================================ */} + {/* RIGHT HALF: SnapOtter Local */} + {/* ================================================================ */} +
= 420 ? rightExpandX : DIVIDER_X, + top: 0, + width: frame >= 450 ? 1920 : DIVIDER_X, + height: 1080, + overflow: "hidden", + }} + > + {/* Green tint */} +
+ + {/* Title */} + {frame >= 30 && frame < 470 && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2, + top: 50, + transform: "translateX(-50%)", + display: "flex", + alignItems: "center", + gap: 10, + }} + > + = 35 ? 1 : 0 }} + /> + + + SnapOtter + + +
+ )} + + {/* Local server icon */} +
= 450 ? CX - rightExpandX : DIVIDER_X / 2, + top: 620, + transform: `translate(-50%, -50%) scale(${shieldBreathing})`, + }} + > +
+ + + Local Server + +
+ + {/* Right glow behind server (Act 4) */} + {frame >= 360 && ( +
+ )} +
+ + {/* First photo (Act 2) */} + {frame >= 40 && frame < 80 && ( + + )} + + {/* Download arrow path */} + {frame >= 55 && frame < 80 && ( + + + + )} + + {/* Processing spinner */} + {showSpinner && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2) - 12, + top: 560, + }} + > + +
+ )} + + {/* Checkmark */} + {showCheckmark && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2) - 12, + top: 560, + }} + > + +
+ )} + + {/* Lock icon */} + {frame >= 100 && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2) - 10, + top: 520, + }} + > + +
+ )} + + {/* "stays on your machine" label */} + {frame >= 130 && frame < 470 && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2, + top: 780, + transform: "translateX(-50%)", + opacity: rightLabelOpacity, + textAlign: "center", + }} + > + + stays on your machine + +
+ )} + + {/* Shield outline */} + {frame >= 135 && frame < 470 && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2) - 100, + top: 400, + opacity: 0.2, + transform: `scale(${shieldBreathing})`, + }} + > + +
+ )} + + {/* Right extra photos (Act 3) */} + {frame >= 165 && + rightExtraPhotos.map((photo) => { + if (photo.progress <= 0) return null; + const px = DIVIDER_X / 2 - 80 + photo.index * 40; + const py = interpolate(photo.progress, [0, 1], [400, 680]); + return ( +
+ + {photo.checkScale > 0.1 && ( +
+ +
+ )} +
+ ); + })} + + {/* "Air-gapped ready" label (Act 3) */} + {frame >= 260 && frame < 470 && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2, + top: 820, + transform: "translateX(-50%)", + textAlign: "center", + }} + > + + + Air-gapped ready + + +
+ )} + + {/* GDPR/HIPAA badge */} + {frame >= 270 && frame < 470 && ( +
= 450 ? CX - rightExpandX : DIVIDER_X / 2, + top: 850, + transform: "translateX(-50%)", + }} + > + +
+ + GDPR / HIPAA + +
+
+
+ )} +
+ + {/* ================================================================ */} + {/* DIVIDER LINE */} + {/* ================================================================ */} + {frame < 470 && ( + + + + )} + + {/* ================================================================ */} + {/* Act 5: Full-width shield overlay */} + {/* ================================================================ */} + {frame >= 470 && ( +
+ +
+ )} + + {/* ================================================================ */} + {/* Act 5: Hero tagline */} + {/* ================================================================ */} + {frame >= 500 && ( +
+ + Your images never leave your network. + +
+ )} + + {/* ================================================================ */} + {/* Act 5: Logo */} + {/* ================================================================ */} + {frame >= 520 && ( +
+ +
+ )} + + + + ); +}; diff --git a/apps/videos/src/compositions/showcase/The48.tsx b/apps/videos/src/compositions/showcase/The48.tsx new file mode 100644 index 00000000..bbcd7a80 --- /dev/null +++ b/apps/videos/src/compositions/showcase/The48.tsx @@ -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) => ( +
+ ))} + + ); +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ + {showSparkle && } +
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( + + + + + + ); + } + + return ; +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ + 48 tools + +
+ ); + } + + return ( +
+ + {landed} + +
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ ); + })} + + ); +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( +
+ + + {CATEGORY_LABELS[cat]} + + +
+ ); + })} + + ); +}; + +/* ------------------------------------------------------------------ */ +/* 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 ( + + {/* ---- Act 1: Title ---- */} + {frame < 30 && ( +
+ + + 48 tools. + + +
+ )} + + {/* ---- Title as header (frame 30+) ---- */} + {frame >= 30 && frame < 570 && ( +
+ + 48 tools. + +
+ )} + + {/* ---- Act 2: Rapid montage ---- */} +
+ {TOOL_ENTRIES.map((tool) => ( + + + + ))} +
+ + {/* ---- Grid glow wave (Act 3) ---- */} + + + {/* ---- Category labels (Act 3) ---- */} +
+ +
+ + {/* ---- Running counter ---- */} + + + {/* ---- Act 4: Tagline ---- */} + {frame >= 560 && ( +
+ + + One container. Zero cloud. + + +
+ )} + + +
+ ); +}; diff --git a/apps/videos/src/compositions/x-launch/XLaunchVideo.tsx b/apps/videos/src/compositions/x-launch/XLaunchVideo.tsx new file mode 100644 index 00000000..99be15d8 --- /dev/null +++ b/apps/videos/src/compositions/x-launch/XLaunchVideo.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/AiShowcaseScene.tsx b/apps/videos/src/compositions/x-launch/scenes/AiShowcaseScene.tsx new file mode 100644 index 00000000..d834ec6c --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/AiShowcaseScene.tsx @@ -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 ( + + + + + + + + + +
+ 15 AI models. Your hardware. No cloud. +
+
+ ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/FeatureBurstScene.tsx b/apps/videos/src/compositions/x-launch/scenes/FeatureBurstScene.tsx new file mode 100644 index 00000000..1d5d945b --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/FeatureBurstScene.tsx @@ -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 ( + + {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 ( + + ); + })} + + ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/GitHubCTAScene.tsx b/apps/videos/src/compositions/x-launch/scenes/GitHubCTAScene.tsx new file mode 100644 index 00000000..50bae7e6 --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/GitHubCTAScene.tsx @@ -0,0 +1,11 @@ +import type React from "react"; +import { AbsoluteFill } from "remotion"; +import { GitHubCTA } from "@/components/GitHubCTA"; + +export const GitHubCTAScene: React.FC = () => { + return ( + + + + ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/HookScene.tsx b/apps/videos/src/compositions/x-launch/scenes/HookScene.tsx new file mode 100644 index 00000000..4be724c5 --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/HookScene.tsx @@ -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 ( + +
+ +
+ + Your images. + +
+ + Stay yours. + +
+ + ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/PrivacyBeatScene.tsx b/apps/videos/src/compositions/x-launch/scenes/PrivacyBeatScene.tsx new file mode 100644 index 00000000..7e4f5fe6 --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/PrivacyBeatScene.tsx @@ -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 ( + + + + + + + + ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/TerminalInstallScene.tsx b/apps/videos/src/compositions/x-launch/scenes/TerminalInstallScene.tsx new file mode 100644 index 00000000..74eb6c76 --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/TerminalInstallScene.tsx @@ -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 ( + + + + ); +}; diff --git a/apps/videos/src/compositions/x-launch/scenes/ToolGridRevealScene.tsx b/apps/videos/src/compositions/x-launch/scenes/ToolGridRevealScene.tsx new file mode 100644 index 00000000..c9fd507a --- /dev/null +++ b/apps/videos/src/compositions/x-launch/scenes/ToolGridRevealScene.tsx @@ -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 ( + +
+ +
+ +
+
+
+ +
+ +
+
+ ); +}; diff --git a/apps/videos/src/index.ts b/apps/videos/src/index.ts new file mode 100644 index 00000000..f31c790e --- /dev/null +++ b/apps/videos/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); diff --git a/apps/videos/src/lib/audio.tsx b/apps/videos/src/lib/audio.tsx new file mode 100644 index 00000000..32844ee1 --- /dev/null +++ b/apps/videos/src/lib/audio.tsx @@ -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
{/* RIGHT PANE — Pipeline Canvas + Preview */} diff --git a/docs/superpowers/specs/2026-05-08-remotion-promo-videos-design.md b/docs/superpowers/specs/2026-05-08-remotion-promo-videos-design.md new file mode 100644 index 00000000..c0001bf4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-remotion-promo-videos-design.md @@ -0,0 +1,736 @@ +# Remotion Promotional Videos -- Design Spec + +## Overview + +Three standalone promotional videos for SnapOtter built with Remotion. Each video serves a different purpose and platform, with a shared design system for brand consistency. + +| # | Video | Purpose | Theme | Resolution | Duration | +|---|-------|---------|-------|------------|----------| +| 1 | X Launch Video | Twitter/X announcement | Dark | 1080x1080 | ~35s | +| 2 | Product Demo | Website/sales walkthrough | Light | 1920x1080 | ~75s | +| 3 | Promo Teaser | Social media awareness | Dark | 1080x1080 + 1080x1920 | ~20s | + +All videos use music + text captions (no voiceover). Music is placeholder-ready (Remotion audio infrastructure wired up, user swaps in a real track later). + +--- + +## Technical Architecture + +### Workspace Setup + +New monorepo workspace: `apps/videos/` + +``` +apps/videos/ + package.json (@snapotter/videos) + tsconfig.json + remotion.config.ts + tailwind.config.js (v3 format for Remotion Webpack compat) + src/ + index.ts (entry point) + Root.tsx (Composition registry -- all 3 + aspect ratio variants) + design-system/ + colors.ts (dark + light palettes from SnapOtter brand) + fonts.ts (Inter, Nunito, JetBrains Mono registration) + animations.ts (spring presets, easing curves, timing constants) + components/ + AppWindow.tsx (macOS-style window chrome with traffic lights) + Terminal.tsx (terminal with typing animation + syntax highlighting) + AnimatedText.tsx (word-by-word / character reveal with clip-mask) + ToolGrid.tsx (animated grid of tool cards by category) + BeforeAfter.tsx (split comparison with scan-line wipe) + LogoReveal.tsx (SnapOtter logo with particle convergence) + GitHubCTA.tsx (star on GitHub end card) + RotatingTaglines.tsx (cycling phrases from landing page) + FeaturePill.tsx (animated feature badge) + NumberPunch.tsx (large number with impact animation) + GrainOverlay.tsx (animated Perlin noise film grain) + ProgressBar.tsx (animated processing progress) + GradientMesh.tsx (ambient amber/orange blob background) + compositions/ + x-launch/ + XLaunchVideo.tsx + scenes/ + HookScene.tsx + TerminalInstallScene.tsx + ToolGridRevealScene.tsx + AiShowcaseScene.tsx + PrivacyBeatScene.tsx + FeatureBurstScene.tsx + GitHubCTAScene.tsx + product-demo/ + ProductDemo.tsx + scenes/ + DashboardScene.tsx + SingleToolScene.tsx + BatchProcessingScene.tsx + PipelineBuilderScene.tsx + AiToolsScene.tsx + ImageEditorScene.tsx + ApiDocsScene.tsx + EndCardScene.tsx + promo-teaser/ + PromoTeaser.tsx + PromoTeaserVertical.tsx (1080x1920 variant) + scenes/ + AmbientOpenScene.tsx + NumberPunchScene.tsx + TaglineCascadeScene.tsx + LogoRevealScene.tsx + CTAScene.tsx + lib/ + tools.ts (all 49 tool names + categories, mirrored from shared) + audio.ts (audio placeholder config with fade-in/fade-out) + public/ + otter-logo.svg + audio/ + placeholder.mp3 (silent placeholder, same duration as longest video) + scripts/ + render-all.mjs (batch render all compositions to MP4) +``` + +### Dependencies + +```json +{ + "dependencies": { + "remotion": "^4.0.0", + "@remotion/cli": "^4.0.0", + "@remotion/renderer": "^4.0.0", + "@remotion/bundler": "^4.0.0", + "@remotion/tailwind": "^4.0.0", + "@remotion/noise": "^4.0.0", + "@remotion/motion-blur": "^4.0.0", + "@remotion/paths": "^4.0.0", + "@remotion/google-fonts": "^4.0.0", + "@remotion/shapes": "^4.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0" + } +} +``` + +Note: Remotion v4 requires React 18, not React 19. This workspace pins React 18 independently (pnpm handles this with workspace overrides). `@remotion/tailwind` uses Webpack and requires Tailwind CSS v3 config format. + +### Config + +**`remotion.config.ts`:** +```ts +import { Config } from "@remotion/cli/config"; +import { enableTailwind } from "@remotion/tailwind"; + +Config.setVideoImageFormat("jpeg"); +Config.setOverwriteOutput(true); +Config.overrideWebpackConfig((config) => enableTailwind(config)); +``` + +**`tailwind.config.js`** (v3 format): +```js +module.exports = { + content: ["./src/**/*.{ts,tsx}"], + theme: { + extend: { + fontFamily: { + heading: ["Nunito", "sans-serif"], + body: ["Inter", "sans-serif"], + mono: ["JetBrains Mono", "monospace"], + }, + colors: { + accent: "#f59e0b", + safe: "#22c55e", + danger: "#ef4444", + }, + }, + }, +}; +``` + +### Root Composition Registry + +```tsx +// Root.tsx +import { Composition } from "remotion"; +import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo"; +import { ProductDemo } from "./compositions/product-demo/ProductDemo"; +import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser"; +import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical"; + +export const RemotionRoot = () => ( + <> + + + + + +); +``` + +### Audio Setup + +All videos use a placeholder audio track. The infrastructure is wired so the user drops in a real track later. + +```ts +// lib/audio.ts +import { Audio, interpolate, useCurrentFrame } from "remotion"; +import { staticFile } from "remotion"; + +export const BackgroundMusic: React.FC<{ + src?: string; + volume?: number; + fadeInFrames?: number; + fadeOutFrames?: number; + totalFrames: number; +}> = ({ + src = staticFile("audio/placeholder.mp3"), + volume = 0.4, + fadeInFrames = 30, + fadeOutFrames = 60, + totalFrames, +}) => { + const frame = useCurrentFrame(); + const vol = interpolate( + frame, + [0, fadeInFrames, totalFrames - fadeOutFrames, totalFrames], + [0, volume, volume, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + return