diff --git a/apps/videos/src/Root.tsx b/apps/videos/src/Root.tsx index d43ed70b..a136b6cc 100644 --- a/apps/videos/src/Root.tsx +++ b/apps/videos/src/Root.tsx @@ -15,8 +15,10 @@ import { PipelineFlow } from "./compositions/features/PipelineFlow"; 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 { The48 } from "./compositions/showcase/The48"; import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo"; import { TEXT } from "./lib/fonts"; @@ -155,5 +157,21 @@ export const RemotionRoot: React.FC = () => ( width={1080} height={1080} /> + + ); diff --git a/apps/videos/src/compositions/showcase/The48.tsx b/apps/videos/src/compositions/showcase/The48.tsx new file mode 100644 index 00000000..3e1c1090 --- /dev/null +++ b/apps/videos/src/compositions/showcase/The48.tsx @@ -0,0 +1,641 @@ +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 = 15; + +/* ------------------------------------------------------------------ */ +/* 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"; + +function getEntryDirection(index: number): Direction { + if (index % 8 === 0 && index > 0) return "top"; + return index % 2 === 0 ? "left" : "right"; +} + +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 }; + } +} + +/* ------------------------------------------------------------------ */ +/* 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-3) -- edge to center + // Phase 2: center flash (frame 3-5) -- micro-visualization + // Phase 3: grid snap (frame 5-15) -- center to grid position + + let x: number; + let y: number; + let extraTransform = ""; + let clipPath: string | undefined; + let sparkleProgress = 0; + let showSparkle = false; + + if (frame < 3) { + // Entrance: edge to center + const t = interpolate(frame, [0, 3], [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 < 5) { + // Center flash with micro-visualization + x = CX; + y = CY; + const vizProgress = interpolate(frame, [3, 5], [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 - 5, + 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 < 5; + + if (inMotion) { + return ( + + + + + + ); + } + + return ; +}; + +/* ------------------------------------------------------------------ */ +/* Running counter (top-right, amber) */ +/* ------------------------------------------------------------------ */ + +const RunningCounter: React.FC = () => { + const frame = useCurrentFrame(); + + // Counter shows how many tools have landed (reached frame 15 in their local sequence) + // Tool i starts at frame 30 + i*15, lands at frame 30 + i*15 + 15 = 45 + i*15 + 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 < 45) return null; + + // During Act 3 (frame 770-810), counter transitions to "48 tools" and moves to center-top + const isAct3Text = frame >= 770; + + // Fade in + const opacity = interpolate(frame, [45, 50], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + if (isAct3Text) { + const moveProgress = interpolate(frame, [770, 800], [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 < 750 || frame > 780) return null; + + return ( + <> + {CATEGORY_ORDER.map((cat, colIdx) => { + const glowStart = 750 + 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 < 770) return null; + + // Act 4 fade + const act4Opacity = interpolate(frame, [810, 820], [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 >= 810 + ? interpolate(frame, [810, 820], [0.7, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : headerOpacity; + + /* ================================================================ */ + /* Act 3: Grid pulse (frame 750-770) */ + /* ================================================================ */ + + const gridPulseScale = + frame >= 750 && frame < 770 + ? 1 + + 0.015 * + Math.sin( + interpolate(frame, [750, 770], [0, Math.PI], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }), + ) + : 1.0; + + /* ================================================================ */ + /* Act 4: Grid fades, tagline (frame 810-840) */ + /* ================================================================ */ + + const gridOpacity = + frame >= 810 + ? interpolate(frame, [810, 825], [1, 0.6], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : 1; + + return ( + + {/* ---- Act 1: Title ---- */} + {frame < 30 && ( +
+ + + 48 tools. + + +
+ )} + + {/* ---- Title as header (frame 30+) ---- */} + {frame >= 30 && frame < 820 && ( +
+ + 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 >= 810 && ( +
+ + + One container. Zero cloud. + + +
+ )} + + +
+ ); +};