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..0c4bc42c --- /dev/null +++ b/apps/videos/remotion.config.ts @@ -0,0 +1,6 @@ +import { Config } from "@remotion/cli/config"; +import { enableTailwind } from "@remotion/tailwind"; + +Config.setVideoImageFormat("jpeg"); +Config.setOverwriteOutput(true); +Config.overrideWebpackConfig((config) => enableTailwind(config)); 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/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/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/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/ToolPill.tsx b/apps/videos/src/components/ToolPill.tsx new file mode 100644 index 00000000..b8815e88 --- /dev/null +++ b/apps/videos/src/components/ToolPill.tsx @@ -0,0 +1,28 @@ +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/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/colors.ts b/apps/videos/src/lib/colors.ts new file mode 100644 index 00000000..59aadbdd --- /dev/null +++ b/apps/videos/src/lib/colors.ts @@ -0,0 +1,48 @@ +export const COLOR = { + accent: "#f59e0b", + accentHover: "#d97706", + dark: "#0c0a09", + darkAlt: "#1a1a2e", + light: "#ffffff", + lightAlt: "#fafaf9", + warmGradientFrom: "#f59e0b", + warmGradientVia: "#f97316", + warmGradientTo: "#ef4444", + category: { + essentials: "#3B82F6", + optimization: "#10B981", + adjustments: "#8B5CF6", + watermark: "#EF4444", + utilities: "#6366F1", + layout: "#EC4899", + format: "#14B8A6", + ai: "#F59E0B", + } as Record, + safe: "#22c55e", + danger: "#ef4444", + muted: "#737373", + cloudRed: "#fef2f2", + localGreen: "#f0fdf4", +}; + +export const CATEGORY_ORDER = [ + "essentials", + "optimization", + "adjustments", + "ai", + "watermark", + "utilities", + "layout", + "format", +] as const; + +export const CATEGORY_LABELS: Record = { + essentials: "Essentials", + optimization: "Optimization", + adjustments: "Adjustments", + ai: "AI Tools", + watermark: "Watermark", + utilities: "Utilities", + layout: "Layout", + format: "Format", +}; diff --git a/apps/videos/src/lib/fonts.ts b/apps/videos/src/lib/fonts.ts new file mode 100644 index 00000000..ba1f3097 --- /dev/null +++ b/apps/videos/src/lib/fonts.ts @@ -0,0 +1,67 @@ +import { loadFont as loadInter } from "@remotion/google-fonts/Inter"; +import { loadFont as loadJetBrainsMono } from "@remotion/google-fonts/JetBrainsMono"; +import { loadFont as loadNunito } from "@remotion/google-fonts/Nunito"; + +const { fontFamily: nunito } = loadNunito(); +const { fontFamily: inter } = loadInter(); +const { fontFamily: mono } = loadJetBrainsMono(); + +export const FONT = { heading: nunito, body: inter, mono }; + +export const TEXT = { + heroHeadline: { + fontFamily: nunito, + fontWeight: 800, + fontSize: 72, + letterSpacing: "-0.02em", + lineHeight: 1.1, + color: "white", + }, + heroSub: { + fontFamily: inter, + fontWeight: 500, + fontSize: 28, + letterSpacing: "0em", + lineHeight: 1.4, + color: "white", + }, + sectionTitle: { + fontFamily: nunito, + fontWeight: 700, + fontSize: 48, + letterSpacing: "-0.01em", + color: "white", + }, + label: { + fontFamily: inter, + fontWeight: 600, + fontSize: 18, + letterSpacing: "0.04em", + textTransform: "uppercase" as const, + }, + body: { + fontFamily: inter, + fontWeight: 400, + fontSize: 20, + lineHeight: 1.5, + }, + mono: { + fontFamily: mono, + fontWeight: 400, + fontSize: 16, + lineHeight: 1.6, + }, + toolPill: { + fontFamily: inter, + fontWeight: 600, + fontSize: 14, + letterSpacing: "0.01em", + }, + counter: { + fontFamily: nunito, + fontWeight: 800, + fontSize: 96, + letterSpacing: "-0.03em", + color: "white", + }, +} as const; diff --git a/apps/videos/src/lib/motion.ts b/apps/videos/src/lib/motion.ts new file mode 100644 index 00000000..eb8a0206 --- /dev/null +++ b/apps/videos/src/lib/motion.ts @@ -0,0 +1,30 @@ +import { Easing } from "remotion"; + +export const EASE = { + enter: Easing.bezier(0.16, 1, 0.3, 1), + exit: Easing.bezier(0.55, 0, 1, 0.45), + emphasis: Easing.bezier(0.34, 1.56, 0.64, 1), + smooth: Easing.bezier(0.37, 0, 0.63, 1), + snap: Easing.bezier(0.22, 1, 0.36, 1), +}; + +export const SPRING = { + snappy: { damping: 200, stiffness: 100, mass: 0.5 }, + natural: { damping: 15, stiffness: 80, mass: 1 }, + popIn: { damping: 12, stiffness: 200, mass: 0.6 }, + heavy: { damping: 20, stiffness: 60, mass: 2 }, + settle: { damping: 18, stiffness: 150, mass: 0.8 }, +}; + +export const TIMING = { + fps: 30, + staggerFrames: 2, + holdShort: 30, + holdMedium: 60, + holdLong: 90, + fadeIn: 12, + fadeOut: 8, + wipe: 18, + sectionGap: 12, + anticipation: 3, +}; diff --git a/apps/videos/src/lib/tools.ts b/apps/videos/src/lib/tools.ts new file mode 100644 index 00000000..bd9f3117 --- /dev/null +++ b/apps/videos/src/lib/tools.ts @@ -0,0 +1,68 @@ +export interface ToolDef { + name: string; + category: string; +} + +export const TOOLS: ToolDef[] = [ + // Essentials + { name: "Resize", category: "essentials" }, + { name: "Crop", category: "essentials" }, + { name: "Rotate & Flip", category: "essentials" }, + { name: "Convert", category: "essentials" }, + { name: "Compress", category: "essentials" }, + // Optimization + { name: "Optimize for Web", category: "optimization" }, + { name: "Remove Metadata", category: "optimization" }, + { name: "Edit Metadata", category: "optimization" }, + { name: "Bulk Rename", category: "optimization" }, + { name: "Image to PDF", category: "optimization" }, + { name: "Favicon Generator", category: "optimization" }, + // Adjustments + { name: "Adjust Colors", category: "adjustments" }, + { name: "Sharpening", category: "adjustments" }, + { name: "Replace & Invert Color", category: "adjustments" }, + { name: "Color Blindness Simulation", category: "adjustments" }, + // AI Tools + { name: "Remove Background", category: "ai" }, + { name: "Image Upscaling", category: "ai" }, + { name: "Object Eraser", category: "ai" }, + { name: "OCR / Text Extraction", category: "ai" }, + { name: "Face / PII Blur", category: "ai" }, + { name: "Smart Crop", category: "ai" }, + { name: "Image Enhancement", category: "ai" }, + { name: "Face Enhancement", category: "ai" }, + { name: "AI Colorization", category: "ai" }, + { name: "Noise Removal", category: "ai" }, + { name: "Red Eye Removal", category: "ai" }, + { name: "Photo Restoration", category: "ai" }, + { name: "Passport Photo", category: "ai" }, + { name: "Content-Aware Resize", category: "ai" }, + { name: "PNG Transparency Fixer", category: "ai" }, + // Watermark & Overlay + { name: "Text Watermark", category: "watermark" }, + { name: "Image Watermark", category: "watermark" }, + { name: "Text Overlay", category: "watermark" }, + { name: "Image Composition", category: "watermark" }, + // Utilities + { name: "Image Info", category: "utilities" }, + { name: "Image Compare", category: "utilities" }, + { name: "Find Duplicates", category: "utilities" }, + { name: "Color Palette", category: "utilities" }, + { name: "QR Code Generator", category: "utilities" }, + { name: "Barcode Reader", category: "utilities" }, + { name: "Image to Base64", category: "utilities" }, + // Layout & Composition + { name: "Collage / Grid", category: "layout" }, + { name: "Stitch / Combine", category: "layout" }, + { name: "Image Splitting", category: "layout" }, + { name: "Border & Frame", category: "layout" }, + // Format & Conversion + { name: "SVG to Raster", category: "format" }, + { name: "Image to SVG", category: "format" }, + { name: "GIF Tools", category: "format" }, + { name: "PDF to Image", category: "format" }, +]; + +export function getToolsByCategory(category: string): ToolDef[] { + return TOOLS.filter((t) => t.category === category); +} diff --git a/apps/videos/src/style.css b/apps/videos/src/style.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/apps/videos/src/style.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/videos/tailwind.config.js b/apps/videos/tailwind.config.js new file mode 100644 index 00000000..7557b071 --- /dev/null +++ b/apps/videos/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{ts,tsx}"], + theme: { + extend: { + fontFamily: { + heading: ["Nunito", "sans-serif"], + body: ["Inter", "sans-serif"], + mono: ["JetBrains Mono", "monospace"], + }, + }, + }, +}; diff --git a/apps/videos/tsconfig.json b/apps/videos/tsconfig.json new file mode 100644 index 00000000..8e06e09d --- /dev/null +++ b/apps/videos/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "noEmit": true, + "lib": ["ES2022", "DOM"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "remotion.config.ts"] +} 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