Files
SnapOtter/docs/superpowers/plans/2026-05-08-remotion-promo-videos.md
T
SnapOtter 664605ff7f docs: add Remotion promo videos implementation plan
16 tasks covering audio infrastructure, 10 new shared components,
3 compositions (X Launch 7 scenes, Product Demo 8 scenes, Promo Teaser
5 scenes + vertical variant), render script, and verification.
2026-05-08 16:28:51 +08:00

96 KiB

Remotion Promotional Videos Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build three promotional videos for SnapOtter (X launch, product demo, promo teaser) using Remotion in the existing apps/videos/ workspace.

Architecture: Extends the existing apps/videos/ workspace with new shared components and three multi-scene compositions. Each scene is a self-contained React component receiving relative frames (0 to durationInFrames) via Remotion's Series. Background layers (grain, gradient mesh) live at the composition level.

Tech Stack: Remotion v4, React 18, TypeScript, Tailwind CSS v3 (Webpack compat), @remotion/motion-blur, @remotion/paths

Spec: docs/superpowers/specs/2026-05-08-remotion-promo-videos-design.md

Existing codebase patterns (follow these exactly):

  • Path alias: @/* maps to ./src/*
  • All styling is inline style objects (not Tailwind classes)
  • Design system imports: @/lib/colors (COLOR), @/lib/fonts (FONT, TEXT), @/lib/motion (EASE, SPRING, TIMING)
  • Components use useCurrentFrame() + interpolate() from remotion
  • AbsoluteFill for full-frame positioning
  • staticFile() for public/ assets
  • Img from remotion for images
  • Logo asset: staticFile("logo.png") (PNG, not SVG)
  • Tool data: @/lib/tools (TOOLS array, getToolsByCategory)

Verification approach: Remotion is visual -- there are no unit tests. Every task ends with "Open Remotion Studio (pnpm --filter @snapotter/videos dev), select the composition, scrub the timeline, verify the animation looks correct." This IS the test.


Task 1: Audio Infrastructure

Files:

  • Modify: apps/videos/package.json
  • Create: apps/videos/src/lib/audio.tsx

The Audio component in Remotion v4 is imported from @remotion/media. We need to add this dependency and create a reusable BackgroundMusic component.

  • Step 1: Add @remotion/media dependency
cd apps/videos && pnpm add @remotion/media
  • Step 2: Create the BackgroundMusic component

Create apps/videos/src/lib/audio.tsx:

import type React from "react";
import { Audio } from "@remotion/media";
import { interpolate, staticFile, useCurrentFrame } from "remotion";

export const BackgroundMusic: React.FC<{
  src?: string;
  volume?: number;
  fadeInFrames?: number;
  fadeOutFrames?: number;
  totalFrames: number;
}> = ({
  src,
  volume = 0.4,
  fadeInFrames = 30,
  fadeOutFrames = 60,
  totalFrames,
}) => {
  const frame = useCurrentFrame();
  if (!src) return null;

  const vol = interpolate(
    frame,
    [0, fadeInFrames, totalFrames - fadeOutFrames, totalFrames],
    [0, volume, volume, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );

  return <Audio src={src} volume={vol} />;
};
  • Step 3: Verify the workspace still builds
cd apps/videos && npx tsc --noEmit

Expected: No errors.

  • Step 4: Commit
git add apps/videos/package.json apps/videos/pnpm-lock.yaml apps/videos/src/lib/audio.tsx
git commit -m "feat(videos): add BackgroundMusic component with fade-in/fade-out"

Task 2: AppWindow Component

Files:

  • Create: apps/videos/src/components/AppWindow.tsx

macOS-style window chrome with traffic light dots. Used by Terminal, and for the Product Demo UI mockups.

  • Step 1: Create AppWindow

Create apps/videos/src/components/AppWindow.tsx:

import type React from "react";

const TRAFFIC_LIGHTS = [
  { color: "#ff5f57", border: "#e0443e" },
  { color: "#febc2e", border: "#dea123" },
  { color: "#28c840", border: "#1aab29" },
];

export const AppWindow: React.FC<{
  children: React.ReactNode;
  title?: string;
  width: number;
  height: number;
  topBarColor?: string;
  bodyColor?: string;
  style?: React.CSSProperties;
}> = ({
  children,
  title,
  width,
  height,
  topBarColor = "#1e1e2e",
  bodyColor = "#0d1117",
  style,
}) => {
  return (
    <div
      style={{
        width,
        height,
        borderRadius: 12,
        overflow: "hidden",
        boxShadow: "0 25px 60px rgba(0,0,0,0.5)",
        display: "flex",
        flexDirection: "column",
        ...style,
      }}
    >
      <div
        style={{
          height: 36,
          backgroundColor: topBarColor,
          display: "flex",
          alignItems: "center",
          paddingLeft: 12,
          flexShrink: 0,
        }}
      >
        {TRAFFIC_LIGHTS.map((dot) => (
          <div
            key={dot.color}
            style={{
              width: 12,
              height: 12,
              borderRadius: 6,
              backgroundColor: dot.color,
              border: `1px solid ${dot.border}`,
              marginRight: 8,
            }}
          />
        ))}
        {title && (
          <div
            style={{
              flex: 1,
              textAlign: "center",
              fontSize: 13,
              fontWeight: 500,
              color: "rgba(255,255,255,0.5)",
              marginRight: 56,
            }}
          >
            {title}
          </div>
        )}
      </div>
      <div
        style={{
          flex: 1,
          backgroundColor: bodyColor,
          overflow: "hidden",
          position: "relative",
        }}
      >
        {children}
      </div>
    </div>
  );
};
  • Step 2: Verify in Remotion Studio

Add a quick test to Root.tsx ComponentTest or open Studio and import manually. Verify the window chrome renders with traffic lights.

  • Step 3: Commit
git add apps/videos/src/components/AppWindow.tsx
git commit -m "feat(videos): add AppWindow component with macOS traffic light chrome"

Task 3: Terminal Component

Files:

  • Create: apps/videos/src/components/Terminal.tsx

Wraps AppWindow with terminal-specific styling. Uses the existing TypeWriter component for typed output.

  • Step 1: Create Terminal

Create apps/videos/src/components/Terminal.tsx:

import type React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { TypeWriter } from "@/components/TypeWriter";
import { SPRING } from "@/lib/motion";

interface OutputLine {
  text: string;
  color: string;
  delay: number;
}

export const Terminal: React.FC<{
  command: { text: string; color: string }[];
  commandStartFrame: number;
  commandSpeed?: number;
  outputLines?: OutputLine[];
  outputStartFrame: number;
  outputStagger?: number;
  width?: number;
  height?: number;
  style?: React.CSSProperties;
  enterFrame?: number;
}> = ({
  command,
  commandStartFrame,
  commandSpeed = 2,
  outputLines = [],
  outputStartFrame,
  outputStagger = 3,
  width = 800,
  height = 450,
  style,
  enterFrame = 0,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const enterProgress = spring({
    frame: frame - enterFrame,
    fps,
    config: SPRING.snappy,
  });

  const translateY = interpolate(enterProgress, [0, 1], [height + 50, 0]);
  const opacity = interpolate(enterProgress, [0, 1], [0, 1]);

  return (
    <div
      style={{
        transform: `translateY(${translateY}px)`,
        opacity,
        ...style,
      }}
    >
      <AppWindow title="Terminal" width={width} height={height}>
        <div style={{ padding: "16px 20px" }}>
          <div style={{ display: "flex", alignItems: "center" }}>
            <span style={{ color: "#8b949e", fontSize: 16, fontFamily: "monospace" }}>
              ${" "}
            </span>
            <TypeWriter
              segments={command}
              startFrame={commandStartFrame}
              speed={commandSpeed}
              style={{ fontSize: 16 }}
            />
          </div>

          {outputLines.map((line, i) => {
            const lineFrame = outputStartFrame + i * outputStagger;
            const lineOpacity = interpolate(
              frame,
              [lineFrame, lineFrame + 6],
              [0, 1],
              { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
            );

            return (
              <div
                key={`${line.text}-${i}`}
                style={{
                  marginTop: i === 0 ? 12 : 4,
                  fontSize: 16,
                  fontFamily: "monospace",
                  color: line.color,
                  opacity: lineOpacity,
                }}
              >
                {line.text}
              </div>
            );
          })}
        </div>
      </AppWindow>
    </div>
  );
};
  • Step 2: Verify in Remotion Studio

Temporarily add a Composition that renders Terminal with sample command and output. Scrub timeline to verify typing and output animation.

  • Step 3: Commit
git add apps/videos/src/components/Terminal.tsx
git commit -m "feat(videos): add Terminal component with typing + output animation"

Task 4: Simple Components Batch -- FeaturePill, ProgressBar, NumberPunch

Files:

  • Create: apps/videos/src/components/FeaturePill.tsx

  • Create: apps/videos/src/components/ProgressBar.tsx

  • Create: apps/videos/src/components/NumberPunch.tsx

  • Step 1: Create FeaturePill

Create apps/videos/src/components/FeaturePill.tsx:

import type React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

export const FeaturePill: React.FC<{
  label: string;
  enterFrame: number;
  targetX: number;
  targetY: number;
  fromDirection?: "left" | "right" | "top" | "bottom";
}> = ({ label, enterFrame, targetX, targetY, fromDirection = "left" }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({
    frame: frame - enterFrame,
    fps,
    config: SPRING.popIn,
  });

  const offscreen = {
    left: { x: -300, y: targetY },
    right: { x: 1400, y: targetY },
    top: { x: targetX, y: -100 },
    bottom: { x: targetX, y: 1200 },
  }[fromDirection];

  const x = interpolate(progress, [0, 1], [offscreen.x, targetX]);
  const y = interpolate(progress, [0, 1], [offscreen.y, targetY]);
  const scale = interpolate(progress, [0, 1], [0.8, 1]);

  return (
    <div
      style={{
        position: "absolute",
        left: x,
        top: y,
        transform: `scale(${scale})`,
        padding: "8px 18px",
        borderRadius: 8,
        backgroundColor: `${COLOR.accent}26`,
        border: `1px solid ${COLOR.accent}40`,
        color: "white",
        fontFamily: FONT.body,
        fontWeight: 600,
        fontSize: 16,
        whiteSpace: "nowrap",
      }}
    >
      {label}
    </div>
  );
};
  • Step 2: Create ProgressBar

Create apps/videos/src/components/ProgressBar.tsx:

import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { EASE } from "@/lib/motion";

export const ProgressBar: React.FC<{
  startFrame: number;
  duration: number;
  width?: number;
  height?: number;
  color?: string;
  bgColor?: string;
  style?: React.CSSProperties;
}> = ({
  startFrame,
  duration,
  width = 200,
  height = 6,
  color = "#3b82f6",
  bgColor = "rgba(255,255,255,0.1)",
  style,
}) => {
  const frame = useCurrentFrame();
  const progress = interpolate(
    frame,
    [startFrame, startFrame + duration],
    [0, 100],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: EASE.smooth },
  );

  return (
    <div
      style={{
        width,
        height,
        borderRadius: height / 2,
        backgroundColor: bgColor,
        overflow: "hidden",
        ...style,
      }}
    >
      <div
        style={{
          width: `${progress}%`,
          height: "100%",
          borderRadius: height / 2,
          backgroundColor: color,
        }}
      />
    </div>
  );
};
  • Step 3: Create NumberPunch

Create apps/videos/src/components/NumberPunch.tsx:

import type React from "react";
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

export const NumberPunch: React.FC<{
  number: string;
  descriptor: string;
  enterFrame: number;
  numberSize?: number;
  shakeIntensity?: number;
}> = ({ number, descriptor, enterFrame, numberSize = 120, shakeIntensity = 2 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const elapsed = frame - enterFrame;
  const scaleSpring = spring({ frame: elapsed, fps, config: SPRING.settle });
  const scale = interpolate(scaleSpring, [0, 1], [1.5, 1]);

  const shakeDecay = interpolate(elapsed, [0, 12], [1, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const shakeX = Math.sin(elapsed * 3) * shakeIntensity * shakeDecay;
  const shakeY = Math.cos(elapsed * 4) * shakeIntensity * shakeDecay;

  const descOpacity = interpolate(elapsed, [8, 18], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  const numberOpacity = interpolate(elapsed, [0, 4], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <div
      style={{
        display: "flex",
        alignItems: "baseline",
        justifyContent: "center",
        gap: 20,
        transform: `translate(${shakeX}px, ${shakeY}px)`,
      }}
    >
      <span
        style={{
          ...TEXT.counter,
          fontSize: numberSize,
          transform: `scale(${scale})`,
          opacity: numberOpacity,
          display: "inline-block",
        }}
      >
        {number}
      </span>
      <span
        style={{
          fontFamily: TEXT.heroSub.fontFamily,
          fontWeight: 500,
          fontSize: 36,
          color: COLOR.accent,
          opacity: descOpacity,
        }}
      >
        {descriptor}
      </span>
    </div>
  );
};
  • Step 4: Verify all three in Remotion Studio

  • Step 5: Commit

git add apps/videos/src/components/FeaturePill.tsx apps/videos/src/components/ProgressBar.tsx apps/videos/src/components/NumberPunch.tsx
git commit -m "feat(videos): add FeaturePill, ProgressBar, NumberPunch components"

Task 5: Simple Components Batch -- RotatingTaglines, GradientMesh

Files:

  • Create: apps/videos/src/components/RotatingTaglines.tsx

  • Create: apps/videos/src/components/GradientMesh.tsx

  • Step 1: Create RotatingTaglines

Create apps/videos/src/components/RotatingTaglines.tsx:

import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { EASE } from "@/lib/motion";

export const RotatingTaglines: React.FC<{
  lines: string[];
  startFrame: number;
  framesPerLine: number;
  fontSize?: number;
  color?: string;
}> = ({
  lines,
  startFrame,
  framesPerLine,
  fontSize = 36,
  color = COLOR.accent,
}) => {
  const frame = useCurrentFrame();
  const elapsed = frame - startFrame;
  if (elapsed < 0) return null;

  const lineIndex = Math.floor(elapsed / framesPerLine);
  const lineProgress = (elapsed % framesPerLine) / framesPerLine;
  if (lineIndex >= lines.length) return null;

  const enterDuration = 0.2;
  const holdEnd = 0.7;
  const exitEnd = 1.0;

  const opacity =
    lineProgress < enterDuration
      ? interpolate(lineProgress, [0, enterDuration], [0, 1], { easing: EASE.enter })
      : lineProgress < holdEnd
        ? 1
        : interpolate(lineProgress, [holdEnd, exitEnd], [1, 0], { easing: EASE.exit });

  const translateY =
    lineProgress < enterDuration
      ? interpolate(lineProgress, [0, enterDuration], [30, 0], { easing: EASE.enter })
      : lineProgress > holdEnd
        ? interpolate(lineProgress, [holdEnd, exitEnd], [0, -20], { easing: EASE.exit })
        : 0;

  const glowOpacity = interpolate(
    Math.sin(frame * 0.08),
    [-1, 1],
    [0.03, 0.08],
  );

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        width: "100%",
        height: "100%",
        position: "relative",
      }}
    >
      <div
        style={{
          position: "absolute",
          width: 400,
          height: 100,
          borderRadius: "50%",
          background: `radial-gradient(ellipse, ${COLOR.accent} 0%, transparent 70%)`,
          opacity: glowOpacity,
          filter: "blur(40px)",
        }}
      />
      <div
        style={{
          fontFamily: FONT.body,
          fontWeight: 500,
          fontSize,
          color,
          opacity,
          transform: `translateY(${translateY}px)`,
          textAlign: "center",
        }}
      >
        {lines[lineIndex]}
      </div>
    </div>
  );
};
  • Step 2: Create GradientMesh

Create apps/videos/src/components/GradientMesh.tsx:

import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
import type { CSSProperties } from "react";

interface BlobDef {
  color: string;
  radius: number;
  cx: number;
  cy: number;
  a: number;
  b: number;
  phaseX: number;
  phaseY: number;
  amplitudeX: number;
  amplitudeY: number;
}

export const GradientMesh: React.FC<{
  blobs: BlobDef[];
  duration: number;
  baseOpacity?: number;
  opacityRange?: [number, number];
  opacityFrameRange?: [number, number];
  style?: CSSProperties;
}> = ({
  blobs,
  duration,
  baseOpacity,
  opacityRange,
  opacityFrameRange,
  style,
}) => {
  const frame = useCurrentFrame();
  let containerOpacity = baseOpacity ?? 1;

  if (opacityRange && opacityFrameRange) {
    containerOpacity = interpolate(
      frame,
      opacityFrameRange,
      opacityRange,
      { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
    );
  }

  return (
    <AbsoluteFill style={{ opacity: containerOpacity, ...style }}>
      {blobs.map((blob, i) => (
        <GradientBlob key={`mesh-blob-${i}`} config={blob} duration={duration} />
      ))}
    </AbsoluteFill>
  );
};
  • Step 3: Verify in Remotion Studio

  • Step 4: Commit

git add apps/videos/src/components/RotatingTaglines.tsx apps/videos/src/components/GradientMesh.tsx
git commit -m "feat(videos): add RotatingTaglines and GradientMesh components"

Task 6: LogoReveal + GitHubCTA Components

Files:

  • Create: apps/videos/src/components/LogoReveal.tsx

  • Create: apps/videos/src/components/GitHubCTA.tsx

  • Step 1: Create LogoReveal

Create apps/videos/src/components/LogoReveal.tsx:

import type React from "react";
import {
  AbsoluteFill,
  Img,
  interpolate,
  random,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

const PARTICLE_COUNT = 25;

export const LogoReveal: React.FC<{
  convergeFrame?: number;
  burstFrame?: number;
  logoFrame?: number;
  textFrame?: number;
  taglineFrame?: number;
  logoSize?: number;
}> = ({
  convergeFrame = 0,
  burstFrame = 30,
  logoFrame = 30,
  textFrame = 50,
  taglineFrame = 70,
  logoSize = 80,
}) => {
  const frame = useCurrentFrame();
  const { fps, width, height } = useVideoConfig();

  const logoScale = spring({
    frame: frame - logoFrame,
    fps,
    config: SPRING.popIn,
  });

  const glowRadius = interpolate(
    frame,
    [burstFrame, burstFrame + 15],
    [0, 200],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );
  const glowOpacity = interpolate(
    frame,
    [burstFrame, burstFrame + 20],
    [0.2, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );

  return (
    <AbsoluteFill
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      {Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
        const baseAngle = random(`particle-angle-${i}`) * Math.PI * 2;
        const maxRadius = 300 + random(`particle-radius-${i}`) * 200;
        const size = 2 + random(`particle-size-${i}`) * 3;
        const rotSpeed = 0.03 + random(`particle-speed-${i}`) * 0.02;
        const particleOpacity = 0.3 + random(`particle-opacity-${i}`) * 0.7;

        const convergeProgress = interpolate(
          frame,
          [convergeFrame, burstFrame],
          [1, 0],
          { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
        );

        const angle = baseAngle + frame * rotSpeed;
        const radius = maxRadius * convergeProgress;
        const px = width / 2 + Math.cos(angle) * radius;
        const py = height / 2 + Math.sin(angle) * radius;
        const pOpacity = convergeProgress > 0.05 ? particleOpacity : 0;

        return (
          <div
            key={`particle-${i}`}
            style={{
              position: "absolute",
              left: px - size / 2,
              top: py - size / 2,
              width: size,
              height: size,
              borderRadius: "50%",
              backgroundColor: COLOR.accent,
              opacity: pOpacity,
            }}
          />
        );
      })}

      <div
        style={{
          position: "absolute",
          width: glowRadius * 2,
          height: glowRadius * 2,
          borderRadius: "50%",
          background: `radial-gradient(circle, ${COLOR.accent} 0%, transparent 70%)`,
          opacity: glowOpacity,
          left: width / 2 - glowRadius,
          top: height / 2 - glowRadius,
        }}
      />

      <Img
        src={staticFile("logo.png")}
        style={{
          width: logoSize,
          height: logoSize,
          transform: `scale(${logoScale})`,
        }}
      />

      <div style={{ height: 16 }} />

      <ClipReveal startFrame={textFrame}>
        <span style={{ ...TEXT.sectionTitle, fontSize: 48, color: "white" }}>
          SnapOtter
        </span>
      </ClipReveal>

      <div style={{ height: 8 }} />

      <ClipReveal startFrame={taglineFrame}>
        <span
          style={{
            fontFamily: TEXT.heroSub.fontFamily,
            fontWeight: 500,
            fontSize: 24,
            color: COLOR.accent,
          }}
        >
          Your images. Stay yours.
        </span>
      </ClipReveal>
    </AbsoluteFill>
  );
};
  • Step 2: Create GitHubCTA

Create apps/videos/src/components/GitHubCTA.tsx:

import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";

export const GitHubCTA: React.FC<{
  labelFrame?: number;
  logoFrame?: number;
  taglineFrame?: number;
  ctaFrame?: number;
  urlFrame?: number;
}> = ({
  labelFrame = 0,
  logoFrame = 15,
  taglineFrame = 30,
  ctaFrame = 50,
  urlFrame = 65,
}) => {
  const frame = useCurrentFrame();

  const logoScale = interpolate(
    frame,
    [logoFrame, logoFrame + 15],
    [0, 1],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );

  const glowRadius = interpolate(
    frame,
    [logoFrame, logoFrame + 15],
    [0, 200],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
  );

  const borderOpacity = interpolate(
    Math.sin(frame * 0.06),
    [-1, 1],
    [0.3, 0.6],
  );

  return (
    <AbsoluteFill
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        border: `2px solid rgba(245, 158, 11, ${borderOpacity})`,
      }}
    >
      <ClipReveal startFrame={labelFrame}>
        <span
          style={{
            ...TEXT.label,
            color: COLOR.accent,
            fontSize: 18,
          }}
        >
          100% OPEN SOURCE
        </span>
      </ClipReveal>

      <div style={{ height: 24 }} />

      <div style={{ position: "relative" }}>
        <div
          style={{
            position: "absolute",
            width: glowRadius * 2,
            height: glowRadius * 2,
            borderRadius: "50%",
            background: `radial-gradient(circle, ${COLOR.accent} 0%, transparent 70%)`,
            opacity: 0.15,
            left: 40 - glowRadius,
            top: 40 - glowRadius,
          }}
        />
        <Img
          src={staticFile("logo.png")}
          style={{
            width: 80,
            height: 80,
            transform: `scale(${logoScale})`,
          }}
        />
      </div>

      <div style={{ height: 20 }} />

      <ClipReveal startFrame={taglineFrame}>
        <span style={{ ...TEXT.sectionTitle, fontSize: 48 }}>
          Free forever.
        </span>
      </ClipReveal>

      <div style={{ height: 16 }} />

      <ClipReveal startFrame={ctaFrame}>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <svg width="24" height="24" viewBox="0 0 24 24" fill="white">
            <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
          </svg>
          <span style={{ fontFamily: FONT.body, fontWeight: 400, fontSize: 20, color: "white" }}>
            Star us on GitHub
          </span>
        </div>
      </ClipReveal>

      <div style={{ height: 8 }} />

      <ClipReveal startFrame={urlFrame}>
        <span style={{ fontFamily: FONT.mono, fontSize: 16, color: COLOR.accent }}>
          github.com/snapotter-hq/SnapOtter
        </span>
      </ClipReveal>
    </AbsoluteFill>
  );
};
  • Step 3: Verify in Remotion Studio

  • Step 4: Commit

git add apps/videos/src/components/LogoReveal.tsx apps/videos/src/components/GitHubCTA.tsx
git commit -m "feat(videos): add LogoReveal and GitHubCTA components"

Task 7: ToolGrid + BeforeAfter Components

Files:

  • Create: apps/videos/src/components/ToolGrid.tsx

  • Create: apps/videos/src/components/BeforeAfter.tsx

  • Step 1: Create ToolGrid

Create apps/videos/src/components/ToolGrid.tsx:

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 { TOOLS, getToolsByCategory } from "@/lib/tools";

export const ToolGrid: React.FC<{
  startFrame: number;
  cellWidth?: number;
  cellHeight?: number;
  gap?: number;
  style?: React.CSSProperties;
}> = ({ startFrame, cellWidth = 140, cellHeight = 32, gap = 6, style }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  let globalIndex = 0;

  return (
    <div
      style={{
        display: "flex",
        gap: gap * 2,
        ...style,
      }}
    >
      {CATEGORY_ORDER.map((cat) => {
        const tools = getToolsByCategory(cat);
        return (
          <div key={cat} style={{ display: "flex", flexDirection: "column", gap }}>
            {tools.map((tool) => {
              const i = globalIndex++;
              const enterDelay = startFrame + i * TIMING.staggerFrames;
              const s = spring({
                frame: frame - enterDelay,
                fps,
                config: SPRING.settle,
              });

              return (
                <div
                  key={tool.name}
                  style={{
                    opacity: s,
                    transform: `translateX(${(1 - s) * 100}px) scale(${0.8 + s * 0.2})`,
                  }}
                >
                  <ToolPill
                    name={tool.name}
                    category={tool.category}
                    style={{ width: cellWidth, fontSize: 11, padding: "3px 8px" }}
                  />
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
};
  • Step 2: Create BeforeAfter

Create apps/videos/src/components/BeforeAfter.tsx:

import type React from "react";
import { interpolate, useCurrentFrame } from "remotion";
import { COLOR } from "@/lib/colors";
import { EASE } from "@/lib/motion";

export const BeforeAfter: React.FC<{
  before: React.ReactNode;
  after: React.ReactNode;
  scanStartFrame: number;
  scanDuration: number;
  width: number;
  height: number;
  style?: React.CSSProperties;
}> = ({ before, after, scanStartFrame, scanDuration, width, height, style }) => {
  const frame = useCurrentFrame();

  const scanProgress = interpolate(
    frame,
    [scanStartFrame, scanStartFrame + scanDuration],
    [0, 100],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: EASE.smooth },
  );

  const scanX = (scanProgress / 100) * width;

  return (
    <div
      style={{
        position: "relative",
        width,
        height,
        overflow: "hidden",
        borderRadius: 8,
        ...style,
      }}
    >
      <div style={{ position: "absolute", inset: 0 }}>{before}</div>

      <div
        style={{
          position: "absolute",
          inset: 0,
          clipPath: `inset(0 ${100 - scanProgress}% 0 0)`,
        }}
      >
        {after}
      </div>

      {scanProgress > 0 && scanProgress < 100 && (
        <div
          style={{
            position: "absolute",
            left: scanX,
            top: 0,
            width: 2,
            height: "100%",
            backgroundColor: COLOR.accent,
            boxShadow: `0 0 20px ${COLOR.accent}80`,
            zIndex: 10,
          }}
        />
      )}
    </div>
  );
};
  • Step 3: Verify in Remotion Studio

  • Step 4: Commit

git add apps/videos/src/components/ToolGrid.tsx apps/videos/src/components/BeforeAfter.tsx
git commit -m "feat(videos): add ToolGrid and BeforeAfter components"

Task 8: Promo Teaser Scenes

Files:

  • Create: apps/videos/src/compositions/promo-teaser/scenes/AmbientOpenScene.tsx
  • Create: apps/videos/src/compositions/promo-teaser/scenes/NumberPunchScene.tsx
  • Create: apps/videos/src/compositions/promo-teaser/scenes/TaglineCascadeScene.tsx
  • Create: apps/videos/src/compositions/promo-teaser/scenes/LogoRevealScene.tsx
  • Create: apps/videos/src/compositions/promo-teaser/scenes/CTAScene.tsx

Each scene receives relative frames (0 to its durationInFrames) from Series.Sequence.

  • Step 1: Create AmbientOpenScene (90 frames)

Create apps/videos/src/compositions/promo-teaser/scenes/AmbientOpenScene.tsx:

import type React from "react";
import { AbsoluteFill, interpolate, 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 },
];

export const AmbientOpenScene: React.FC = () => {
  const frame = useCurrentFrame();
  const fadeIn = interpolate(frame, [0, 45], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill style={{ opacity: fadeIn }}>
      {BLOBS.map((blob, i) => (
        <GradientBlob key={`ambient-${i}`} config={blob} duration={600} />
      ))}
    </AbsoluteFill>
  );
};
  • Step 2: Create NumberPunchScene (150 frames)

Create apps/videos/src/compositions/promo-teaser/scenes/NumberPunchScene.tsx:

import type React from "react";
import { AbsoluteFill, Sequence, interpolate, useCurrentFrame } from "remotion";
import { NumberPunch } from "@/components/NumberPunch";
import { COLOR } from "@/lib/colors";

const PUNCHES = [
  { number: "49", descriptor: "tools", frame: 0, size: 120, shake: 2 },
  { number: "15", descriptor: "AI models", frame: 37, size: 120, shake: 2 },
  { number: "55+", descriptor: "formats", frame: 74, size: 120, shake: 2 },
  { number: "1", descriptor: "container", frame: 111, size: 144, shake: 3 },
];

export const NumberPunchScene: React.FC = () => {
  const frame = useCurrentFrame();

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
      {PUNCHES.map((p, i) => {
        const visible = frame >= p.frame && frame < p.frame + 37;
        const flashStart = p.frame - 2;
        const flashOpacity =
          i > 0 && frame >= flashStart && frame < flashStart + 2
            ? interpolate(frame, [flashStart, flashStart + 2], [1, 0])
            : 0;

        return (
          <AbsoluteFill key={p.number} style={{ justifyContent: "center", alignItems: "center" }}>
            {i > 0 && (
              <AbsoluteFill
                style={{ backgroundColor: COLOR.dark, opacity: flashOpacity }}
              />
            )}
            {visible && (
              <NumberPunch
                number={p.number}
                descriptor={p.descriptor}
                enterFrame={p.frame}
                numberSize={p.size}
                shakeIntensity={p.shake}
              />
            )}
          </AbsoluteFill>
        );
      })}
    </AbsoluteFill>
  );
};
  • Step 3: Create TaglineCascadeScene (150 frames)

Create apps/videos/src/compositions/promo-teaser/scenes/TaglineCascadeScene.tsx:

import type React from "react";
import { AbsoluteFill } 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 = () => {
  return (
    <AbsoluteFill>
      <RotatingTaglines
        lines={TAGLINES}
        startFrame={0}
        framesPerLine={25}
        fontSize={40}
        color="white"
      />
    </AbsoluteFill>
  );
};
  • Step 4: Create LogoRevealScene (120 frames)

Create apps/videos/src/compositions/promo-teaser/scenes/LogoRevealScene.tsx:

import type React from "react";
import { AbsoluteFill } from "remotion";
import { LogoReveal } from "@/components/LogoReveal";

export const LogoRevealScene: React.FC = () => {
  return (
    <AbsoluteFill>
      <LogoReveal
        convergeFrame={0}
        burstFrame={30}
        logoFrame={30}
        textFrame={50}
        taglineFrame={70}
        logoSize={80}
      />
    </AbsoluteFill>
  );
};
  • Step 5: Create CTAScene (90 frames)

Create apps/videos/src/compositions/promo-teaser/scenes/CTAScene.tsx:

import type React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

export const CTAScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const pillScale = spring({ frame, fps, config: SPRING.popIn });
  const urlOpacity = interpolate(frame, [15, 25], [0, 0.7], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const fadeOut = interpolate(frame, [60, 90], [1, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const glowOpacity = interpolate(Math.sin(frame * 0.1), [-1, 1], [0.3, 0.6]);

  return (
    <AbsoluteFill
      style={{
        justifyContent: "center",
        alignItems: "center",
        opacity: fadeOut,
      }}
    >
      <div
        style={{
          transform: `scale(${pillScale})`,
          padding: "14px 36px",
          borderRadius: 50,
          background: `linear-gradient(135deg, ${COLOR.accent}, ${COLOR.accentHover})`,
          boxShadow: `0 0 30px rgba(245, 158, 11, ${glowOpacity})`,
          color: "white",
          fontFamily: FONT.heading,
          fontWeight: 700,
          fontSize: 20,
        }}
      >
        Get it free
      </div>

      <div style={{ height: 16 }} />

      <span
        style={{
          fontFamily: FONT.mono,
          fontSize: 16,
          color: "white",
          opacity: urlOpacity,
        }}
      >
        snapotter.com
      </span>
    </AbsoluteFill>
  );
};
  • Step 6: Commit
git add apps/videos/src/compositions/promo-teaser/
git commit -m "feat(videos): add all 5 Promo Teaser scene components"

Task 9: Promo Teaser Compositions + Root Registration

Files:

  • Create: apps/videos/src/compositions/promo-teaser/PromoTeaser.tsx

  • Create: apps/videos/src/compositions/promo-teaser/PromoTeaserVertical.tsx

  • Modify: apps/videos/src/Root.tsx

  • Step 1: Create PromoTeaser (square)

Create apps/videos/src/compositions/promo-teaser/PromoTeaser.tsx:

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: 200, cx: 300, cy: 400, a: 1, b: 2, phaseX: 0, phaseY: 0, amplitudeX: 60, amplitudeY: 40 },
  { color: "#f97316", radius: 150, cx: 750, cy: 250, a: 2, b: 1, phaseX: 1.2, phaseY: 0.8, amplitudeX: 50, amplitudeY: 60 },
  { color: "#d97706", radius: 180, cx: 540, cy: 700, a: 1, b: 1, phaseX: 2.4, phaseY: 1.6, amplitudeX: 70, amplitudeY: 50 },
];

export const PromoTeaser: React.FC = () => {
  return (
    <AbsoluteFill style={{ backgroundColor: COLOR.dark }}>
      {BG_BLOBS.map((blob, i) => (
        <GradientBlob key={`bg-${i}`} config={blob} duration={600} />
      ))}

      <Series>
        <Series.Sequence durationInFrames={90}>
          <AmbientOpenScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <NumberPunchScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <TaglineCascadeScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={120}>
          <LogoRevealScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={90}>
          <CTAScene />
        </Series.Sequence>
      </Series>

      <GrainOverlay opacity={0.03} />
    </AbsoluteFill>
  );
};
  • Step 2: Create PromoTeaserVertical

Create apps/videos/src/compositions/promo-teaser/PromoTeaserVertical.tsx:

import type React from "react";
import { AbsoluteFill, Series } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
import { GrainOverlay } from "@/components/GrainOverlay";
import { COLOR } from "@/lib/colors";
import { AmbientOpenScene } from "./scenes/AmbientOpenScene";
import { CTAScene } from "./scenes/CTAScene";
import { LogoRevealScene } from "./scenes/LogoRevealScene";
import { NumberPunchScene } from "./scenes/NumberPunchScene";
import { TaglineCascadeScene } from "./scenes/TaglineCascadeScene";

const BG_BLOBS = [
  { color: "#f59e0b", radius: 250, cx: 540, cy: 600, a: 1, b: 2, phaseX: 0, phaseY: 0, amplitudeX: 80, amplitudeY: 100 },
  { color: "#f97316", radius: 200, cx: 300, cy: 1200, a: 2, b: 1, phaseX: 1.2, phaseY: 0.8, amplitudeX: 60, amplitudeY: 120 },
  { color: "#d97706", radius: 220, cx: 700, cy: 1600, a: 1, b: 1, phaseX: 2.4, phaseY: 1.6, amplitudeX: 90, amplitudeY: 80 },
];

export const PromoTeaserVertical: React.FC = () => {
  return (
    <AbsoluteFill style={{ backgroundColor: COLOR.dark }}>
      {BG_BLOBS.map((blob, i) => (
        <GradientBlob key={`bg-v-${i}`} config={blob} duration={600} />
      ))}

      <Series>
        <Series.Sequence durationInFrames={90}>
          <AmbientOpenScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <NumberPunchScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <TaglineCascadeScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={120}>
          <LogoRevealScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={90}>
          <CTAScene />
        </Series.Sequence>
      </Series>

      <GrainOverlay opacity={0.03} />
    </AbsoluteFill>
  );
};
  • Step 3: Register in Root.tsx

In apps/videos/src/Root.tsx, add the imports and Composition entries after the existing ones:

import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";

Add inside the <> fragment:

    <Composition
      id="PromoTeaser"
      component={PromoTeaser}
      durationInFrames={600}
      fps={30}
      width={1080}
      height={1080}
    />
    <Composition
      id="PromoTeaserVertical"
      component={PromoTeaserVertical}
      durationInFrames={600}
      fps={30}
      width={1080}
      height={1920}
    />
  • Step 4: Verify in Remotion Studio
pnpm --filter @snapotter/videos dev

Select "PromoTeaser" from the composition picker. Scrub through all 600 frames. Verify:

  • Frames 0-90: ambient blobs fade in
  • Frames 90-240: numbers punch in (49, 15, 55+, 1) with black flashes between
  • Frames 240-390: taglines cascade
  • Frames 390-510: particles converge, logo reveals
  • Frames 510-600: CTA pill, fade to black

Then check PromoTeaserVertical in portrait mode.

  • Step 5: Commit
git add apps/videos/src/compositions/promo-teaser/ apps/videos/src/Root.tsx
git commit -m "feat(videos): add PromoTeaser square and vertical compositions"

Task 10: X Launch Video Scenes 1-4

Files:

  • Create: apps/videos/src/compositions/x-launch/scenes/HookScene.tsx

  • Create: apps/videos/src/compositions/x-launch/scenes/TerminalInstallScene.tsx

  • Create: apps/videos/src/compositions/x-launch/scenes/ToolGridRevealScene.tsx

  • Create: apps/videos/src/compositions/x-launch/scenes/AiShowcaseScene.tsx

  • Step 1: Create HookScene (120 frames)

Create apps/videos/src/compositions/x-launch/scenes/HookScene.tsx:

import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";

export const HookScene: React.FC = () => {
  const frame = useCurrentFrame();
  const glowOpacity = Math.sin(frame * 0.08) * 0.03 + 0.08;

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
      <div
        style={{
          position: "absolute",
          width: 600,
          height: 200,
          borderRadius: "50%",
          background: `radial-gradient(ellipse, ${COLOR.accent} 0%, transparent 70%)`,
          opacity: glowOpacity,
          filter: "blur(60px)",
        }}
      />

      <div style={{ textAlign: "center" }}>
        <ClipReveal startFrame={10} duration={20}>
          <span style={{ ...TEXT.heroHeadline }}>Your images.</span>
        </ClipReveal>
        <div style={{ height: 12 }} />
        <ClipReveal startFrame={40} duration={20}>
          <span style={{ ...TEXT.heroHeadline }}>Stay yours.</span>
        </ClipReveal>
      </div>
    </AbsoluteFill>
  );
};
  • Step 2: Create TerminalInstallScene (150 frames)

Create apps/videos/src/compositions/x-launch/scenes/TerminalInstallScene.tsx:

import type React from "react";
import { AbsoluteFill } from "remotion";
import { Terminal } from "@/components/Terminal";

const COMMAND = [
  { text: "docker", color: "#ff7b72" },
  { text: " run ", color: "#e6edf3" },
  { text: "-p ", color: "#79c0ff" },
  { text: "3000:3000", color: "#a5d6ff" },
  { text: " ", color: "#e6edf3" },
  { text: "snapotter/snapotter", color: "#7ee787" },
];

const OUTPUT = [
  { text: "v2.4.0", color: "#f59e0b", delay: 0 },
  { text: "49 tools loaded", color: "#7ee787", delay: 3 },
  { text: "15 AI models ready", color: "#7ee787", delay: 6 },
  { text: "✓ Server running on :3000", color: "#3fb950", delay: 9 },
];

export const TerminalInstallScene: React.FC = () => {
  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
      <Terminal
        command={COMMAND}
        commandStartFrame={15}
        commandSpeed={2}
        outputLines={OUTPUT}
        outputStartFrame={95}
        outputStagger={8}
        width={800}
        height={350}
        enterFrame={0}
      />
    </AbsoluteFill>
  );
};
  • Step 3: Create ToolGridRevealScene (180 frames)

Create apps/videos/src/compositions/x-launch/scenes/ToolGridRevealScene.tsx:

import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { Counter } from "@/components/Counter";
import { ToolGrid } from "@/components/ToolGrid";
import { COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";

export const ToolGridRevealScene: React.FC = () => {
  const frame = useCurrentFrame();

  const gridOpacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill>
      <div
        style={{
          position: "absolute",
          top: 60,
          left: 40,
          right: 40,
          opacity: gridOpacity,
          transform: "scale(0.85)",
          transformOrigin: "top center",
        }}
      >
        <ToolGrid startFrame={10} cellWidth={110} cellHeight={26} gap={4} />
      </div>

      <div style={{ position: "absolute", bottom: 60, right: 80 }}>
        <Counter
          from={0}
          to={49}
          startFrame={10}
          duration={90}
          style={{
            ...TEXT.counter,
            fontSize: 64,
            color: COLOR.accent,
          }}
        />
      </div>
    </AbsoluteFill>
  );
};
  • Step 4: Create AiShowcaseScene (180 frames)

Create apps/videos/src/compositions/x-launch/scenes/AiShowcaseScene.tsx:

import type React from "react";
import { AbsoluteFill, Sequence, interpolate, useCurrentFrame } from "remotion";
import { BeforeAfter } from "@/components/BeforeAfter";
import { ClipReveal } from "@/components/ClipReveal";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";

const Checkerboard: React.FC<{ width: number; height: number }> = ({ width, height }) => (
  <div
    style={{
      width,
      height,
      backgroundImage:
        "linear-gradient(45deg, #808080 25%, transparent 25%), linear-gradient(-45deg, #808080 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #808080 75%), linear-gradient(-45deg, transparent 75%, #808080 75%)",
      backgroundSize: "16px 16px",
      backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
      opacity: 0.3,
    }}
  />
);

const BgRemovalDemo: React.FC = () => (
  <BeforeAfter
    before={<PhotoPlaceholder width={400} height={300} hue={30} />}
    after={
      <div style={{ position: "relative", width: 400, height: 300 }}>
        <Checkerboard width={400} height={300} />
        <div
          style={{
            position: "absolute",
            top: 60,
            left: 120,
            width: 160,
            height: 200,
            borderRadius: "50% 50% 45% 45%",
            background: "linear-gradient(135deg, #e8a87c, #d4856b)",
          }}
        />
      </div>
    }
    scanStartFrame={5}
    scanDuration={40}
    width={400}
    height={300}
  />
);

const UpscaleDemo: React.FC = () => {
  const frame = useCurrentFrame();
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 30 }}>
      <div
        style={{
          width: 80,
          height: 80,
          display: "grid",
          gridTemplateColumns: "repeat(4, 1fr)",
          gap: 2,
          borderRadius: 4,
          overflow: "hidden",
        }}
      >
        {Array.from({ length: 16 }).map((_, i) => (
          <div
            key={`px-${i}`}
            style={{
              backgroundColor: `hsl(${(i * 23) % 360}, 60%, ${50 + (i % 3) * 10}%)`,
            }}
          />
        ))}
      </div>
      <span style={{ color: COLOR.accent, fontFamily: FONT.heading, fontWeight: 800, fontSize: 36 }}>
        4x
      </span>
      <PhotoPlaceholder width={160} height={160} hue={200} />
    </div>
  );
};

export const AiShowcaseScene: React.FC = () => {
  return (
    <AbsoluteFill>
      <Sequence from={0} durationInFrames={60}>
        <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
          <BgRemovalDemo />
        </AbsoluteFill>
      </Sequence>

      <Sequence from={60} durationInFrames={60}>
        <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
          <UpscaleDemo />
        </AbsoluteFill>
      </Sequence>

      <Sequence from={120} durationInFrames={60}>
        <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
          <BeforeAfter
            before={
              <div style={{ position: "relative", width: 400, height: 300 }}>
                <PhotoPlaceholder width={400} height={300} hue={180} />
                <div
                  style={{
                    position: "absolute",
                    top: 80,
                    left: 150,
                    width: 100,
                    height: 100,
                    border: `2px dashed ${COLOR.accent}`,
                    borderRadius: 8,
                  }}
                />
              </div>
            }
            after={<PhotoPlaceholder width={400} height={300} hue={180} />}
            scanStartFrame={5}
            scanDuration={40}
            width={400}
            height={300}
          />
        </AbsoluteFill>
      </Sequence>

      <div
        style={{
          position: "absolute",
          bottom: 40,
          width: "100%",
          textAlign: "center",
          fontFamily: FONT.body,
          fontWeight: 600,
          fontSize: 24,
          color: "rgba(255,255,255,0.8)",
        }}
      >
        15 AI models. Your hardware. No cloud.
      </div>
    </AbsoluteFill>
  );
};
  • Step 5: Commit
git add apps/videos/src/compositions/x-launch/scenes/
git commit -m "feat(videos): add X Launch scenes 1-4 (Hook, Terminal, ToolGrid, AI)"

Task 11: X Launch Video Scenes 5-7 + Composition

Files:

  • Create: apps/videos/src/compositions/x-launch/scenes/PrivacyBeatScene.tsx

  • Create: apps/videos/src/compositions/x-launch/scenes/FeatureBurstScene.tsx

  • Create: apps/videos/src/compositions/x-launch/scenes/GitHubCTAScene.tsx

  • Create: apps/videos/src/compositions/x-launch/XLaunchVideo.tsx

  • Modify: apps/videos/src/Root.tsx

  • Step 1: Create PrivacyBeatScene (120 frames)

Create apps/videos/src/compositions/x-launch/scenes/PrivacyBeatScene.tsx:

import type React from "react";
import { AbsoluteFill } 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 = () => {
  return (
    <AbsoluteFill>
      <RotatingTaglines
        lines={TAGLINES}
        startFrame={0}
        framesPerLine={30}
        fontSize={36}
        color={COLOR.accent}
      />
    </AbsoluteFill>
  );
};
  • Step 2: Create FeatureBurstScene (150 frames)

Create apps/videos/src/compositions/x-launch/scenes/FeatureBurstScene.tsx:

import type React from "react";
import { AbsoluteFill } from "remotion";
import { FeaturePill } from "@/components/FeaturePill";

const FEATURES = [
  { label: "Batch Processing", x: 340, y: 380, from: "left" as const },
  { label: "Pipeline Automation", x: 560, y: 380, from: "right" as const },
  { label: "REST API", x: 340, y: 440, from: "left" as const },
  { label: "55+ Input Formats", x: 560, y: 440, from: "right" as const },
  { label: "Image Editor", x: 340, y: 500, from: "left" as const },
  { label: "One Container", x: 560, y: 500, from: "right" as const },
  { label: "Multi-arch", x: 340, y: 560, from: "left" as const },
  { label: "15 AI Models", x: 560, y: 560, from: "right" as const },
];

export const FeatureBurstScene: React.FC = () => {
  return (
    <AbsoluteFill>
      {FEATURES.map((f, i) => (
        <FeaturePill
          key={f.label}
          label={f.label}
          enterFrame={i * 8}
          targetX={f.x}
          targetY={f.y}
          fromDirection={f.from}
        />
      ))}
    </AbsoluteFill>
  );
};
  • Step 3: Create GitHubCTAScene (150 frames)

Create apps/videos/src/compositions/x-launch/scenes/GitHubCTAScene.tsx:

import type React from "react";
import { AbsoluteFill } from "remotion";
import { GitHubCTA } from "@/components/GitHubCTA";

export const GitHubCTAScene: React.FC = () => {
  return (
    <AbsoluteFill>
      <GitHubCTA
        labelFrame={5}
        logoFrame={20}
        taglineFrame={40}
        ctaFrame={60}
        urlFrame={75}
      />
    </AbsoluteFill>
  );
};
  • Step 4: Create XLaunchVideo composition

Create apps/videos/src/compositions/x-launch/XLaunchVideo.tsx:

import type React from "react";
import { AbsoluteFill, Series } from "remotion";
import { GrainOverlay } from "@/components/GrainOverlay";
import { COLOR } from "@/lib/colors";
import { AiShowcaseScene } from "./scenes/AiShowcaseScene";
import { FeatureBurstScene } from "./scenes/FeatureBurstScene";
import { GitHubCTAScene } from "./scenes/GitHubCTAScene";
import { HookScene } from "./scenes/HookScene";
import { PrivacyBeatScene } from "./scenes/PrivacyBeatScene";
import { TerminalInstallScene } from "./scenes/TerminalInstallScene";
import { ToolGridRevealScene } from "./scenes/ToolGridRevealScene";

export const XLaunchVideo: React.FC = () => {
  return (
    <AbsoluteFill style={{ backgroundColor: COLOR.dark }}>
      <Series>
        <Series.Sequence durationInFrames={120}>
          <HookScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <TerminalInstallScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={180}>
          <ToolGridRevealScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={180}>
          <AiShowcaseScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={120}>
          <PrivacyBeatScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <FeatureBurstScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <GitHubCTAScene />
        </Series.Sequence>
      </Series>

      <GrainOverlay opacity={0.03} />
    </AbsoluteFill>
  );
};
  • Step 5: Register in Root.tsx

Add import and Composition entry to apps/videos/src/Root.tsx:

import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo";
    <Composition
      id="XLaunchVideo"
      component={XLaunchVideo}
      durationInFrames={1050}
      fps={30}
      width={1080}
      height={1080}
    />
  • Step 6: Verify in Remotion Studio

Select "XLaunchVideo". Scrub through all 1050 frames (35 seconds). Verify all 7 scenes play in sequence.

  • Step 7: Commit
git add apps/videos/src/compositions/x-launch/ apps/videos/src/Root.tsx
git commit -m "feat(videos): add XLaunchVideo composition with all 7 scenes"

Task 12: Product Demo Scenes 1-3

Files:

  • Create: apps/videos/src/compositions/product-demo/scenes/DashboardScene.tsx
  • Create: apps/videos/src/compositions/product-demo/scenes/SingleToolScene.tsx
  • Create: apps/videos/src/compositions/product-demo/scenes/BatchProcessingScene.tsx

These scenes replicate the SnapOtter UI in light theme. Each is a simplified mockup built from divs and inline styles.

  • Step 1: Create DashboardScene (240 frames)

Create apps/videos/src/compositions/product-demo/scenes/DashboardScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  Img,
  interpolate,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { COLOR, CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
import { TOOLS } from "@/lib/tools";

const VISIBLE_TOOLS = TOOLS.slice(0, 12);

export const DashboardScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const windowScale = spring({ frame, fps, config: SPRING.snappy });
  const searchText = "resize";
  const typingStart = 60;
  const charsVisible = Math.floor(Math.max(0, frame - typingStart) / 3);
  const typed = searchText.slice(0, charsVisible);

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      <div style={{ transform: `scale(${0.95 + windowScale * 0.05})`, opacity: windowScale }}>
        <AppWindow title="SnapOtter" width={1600} height={900} topBarColor="#ffffff" bodyColor="#ffffff">
          <div style={{ padding: 24 }}>
            {/* Top bar */}
            <div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 20 }}>
              <Img src={staticFile("logo.png")} style={{ width: 32, height: 32 }} />
              <span style={{ fontFamily: FONT.heading, fontWeight: 800, fontSize: 20, color: "#0a0a0a" }}>
                SnapOtter
              </span>
              <div
                style={{
                  flex: 1,
                  maxWidth: 400,
                  height: 40,
                  borderRadius: 8,
                  border: "1px solid #e5e5e5",
                  padding: "0 16px",
                  display: "flex",
                  alignItems: "center",
                  fontFamily: FONT.body,
                  fontSize: 14,
                  color: typed ? "#0a0a0a" : "#a3a3a3",
                }}
              >
                {typed || "Search tools..."}
                {frame >= typingStart && charsVisible < searchText.length && Math.floor(frame / 8) % 2 === 0 && (
                  <span style={{ width: 2, height: 16, backgroundColor: "#3b82f6", marginLeft: 1 }} />
                )}
              </div>
            </div>

            {/* Category pills */}
            <div style={{ display: "flex", gap: 8, marginBottom: 24, flexWrap: "wrap" }}>
              {CATEGORY_ORDER.map((cat) => (
                <div
                  key={cat}
                  style={{
                    padding: "4px 12px",
                    borderRadius: 16,
                    fontSize: 12,
                    fontFamily: FONT.body,
                    fontWeight: 500,
                    backgroundColor: `${COLOR.category[cat]}15`,
                    color: COLOR.category[cat],
                    border: `1px solid ${COLOR.category[cat]}30`,
                  }}
                >
                  {CATEGORY_LABELS[cat]}
                </div>
              ))}
            </div>

            {/* Tool grid */}
            <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12 }}>
              {VISIBLE_TOOLS.map((tool) => {
                const matches = !typed || tool.name.toLowerCase().includes(typed.toLowerCase());
                const cardOpacity = typed ? (matches ? 1 : 0.2) : 1;
                const cardScale = typed ? (matches ? 1 : 0.95) : 1;
                return (
                  <div
                    key={tool.name}
                    style={{
                      padding: 16,
                      borderRadius: 8,
                      border: "1px solid #e5e5e5",
                      backgroundColor: "white",
                      opacity: cardOpacity,
                      transform: `scale(${cardScale})`,
                      transition: "all 0.2s",
                    }}
                  >
                    <div
                      style={{
                        width: 8,
                        height: 8,
                        borderRadius: 4,
                        backgroundColor: COLOR.category[tool.category] ?? COLOR.accent,
                        marginBottom: 8,
                      }}
                    />
                    <div
                      style={{
                        fontFamily: FONT.body,
                        fontWeight: 500,
                        fontSize: 14,
                        color: "#0a0a0a",
                      }}
                    >
                      {tool.name}
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
        </AppWindow>
      </div>
    </AbsoluteFill>
  );
};
  • Step 2: Create SingleToolScene (360 frames)

Create apps/videos/src/compositions/product-demo/scenes/SingleToolScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { Counter } from "@/components/Counter";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { ProgressBar } from "@/components/ProgressBar";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

export const SingleToolScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const slideIn = spring({ frame, fps, config: SPRING.snappy });
  const dropFrame = 40;
  const dropProgress = spring({ frame: frame - dropFrame, fps, config: SPRING.natural });
  const processFrame = 180;
  const showResult = frame > processFrame + 60;

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      <div style={{ transform: `translateX(${(1 - slideIn) * 300}px)`, opacity: slideIn }}>
        <AppWindow title="Resize -- SnapOtter" width={1600} height={900} topBarColor="#ffffff" bodyColor="#ffffff">
          <div style={{ display: "flex", height: "100%" }}>
            {/* Dropzone */}
            <div style={{ flex: 0.6, padding: 24, display: "flex", alignItems: "center", justifyContent: "center" }}>
              {frame < dropFrame ? (
                <div
                  style={{
                    width: "100%",
                    height: 400,
                    border: "2px dashed #d4d4d4",
                    borderRadius: 12,
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    fontFamily: FONT.body,
                    fontSize: 16,
                    color: "#a3a3a3",
                  }}
                >
                  Drop an image here
                </div>
              ) : (
                <div style={{ transform: `translateY(${(1 - dropProgress) * -200}px)`, opacity: dropProgress }}>
                  <PhotoPlaceholder width={500} height={375} hue={30} />
                </div>
              )}
            </div>

            {/* Settings */}
            <div style={{ flex: 0.4, padding: 24, backgroundColor: "#fafafa", borderLeft: "1px solid #e5e5e5" }}>
              <div style={{ fontFamily: FONT.heading, fontWeight: 700, fontSize: 18, color: "#0a0a0a", marginBottom: 20 }}>
                Settings
              </div>

              {/* Width field */}
              <label style={{ fontFamily: FONT.body, fontSize: 13, color: "#737373", display: "block", marginBottom: 4 }}>
                Width
              </label>
              <div
                style={{
                  height: 36,
                  borderRadius: 6,
                  border: "1px solid #e5e5e5",
                  padding: "0 12px",
                  display: "flex",
                  alignItems: "center",
                  fontFamily: FONT.mono,
                  fontSize: 14,
                  color: "#0a0a0a",
                  marginBottom: 16,
                }}
              >
                1920
              </div>

              {/* Height field */}
              <label style={{ fontFamily: FONT.body, fontSize: 13, color: "#737373", display: "block", marginBottom: 4 }}>
                Height
              </label>
              <div
                style={{
                  height: 36,
                  borderRadius: 6,
                  border: "1px solid #e5e5e5",
                  padding: "0 12px",
                  display: "flex",
                  alignItems: "center",
                  fontFamily: FONT.mono,
                  fontSize: 14,
                  color: "#a3a3a3",
                  marginBottom: 16,
                }}
              >
                auto
              </div>

              {/* Process button / results */}
              {frame >= processFrame && !showResult && (
                <ProgressBar startFrame={0} duration={60} width={300} color="#3b82f6" style={{ marginTop: 20 }} />
              )}

              {showResult && (
                <div style={{ marginTop: 20, display: "flex", gap: 16 }}>
                  <div style={{ fontFamily: FONT.body, fontSize: 14, color: "#a3a3a3" }}>2.4 MB</div>
                  <div style={{ fontFamily: FONT.body, fontSize: 14, color: "#22c55e", fontWeight: 600 }}>340 KB</div>
                  <div style={{ fontFamily: FONT.heading, fontSize: 14, color: COLOR.accent, fontWeight: 700 }}>-86%</div>
                </div>
              )}
            </div>
          </div>
        </AppWindow>
      </div>
    </AbsoluteFill>
  );
};
  • Step 3: Create BatchProcessingScene (360 frames)

Create apps/videos/src/compositions/product-demo/scenes/BatchProcessingScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { ClipReveal } from "@/components/ClipReveal";
import { ProgressBar } from "@/components/ProgressBar";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

const FILES = [
  { name: "photo_001.jpg", from: "1.8 MB", to: "95 KB" },
  { name: "photo_002.jpg", from: "3.2 MB", to: "210 KB" },
  { name: "landscape.png", from: "4.1 MB", to: "280 KB" },
  { name: "portrait.jpg", from: "2.7 MB", to: "160 KB" },
  { name: "macro_01.jpg", from: "5.0 MB", to: "320 KB" },
  { name: "event_23.jpg", from: "1.5 MB", to: "85 KB" },
  { name: "scan_hires.png", from: "8.2 MB", to: "450 KB" },
  { name: "product_a.jpg", from: "2.0 MB", to: "120 KB" },
];

export const BatchProcessingScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const processStartFrame = 100;
  const filesPerSecond = 3;
  const framesPerFile = Math.floor(30 / filesPerSecond);

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      <AppWindow title="Compress -- SnapOtter" width={1600} height={900} topBarColor="#ffffff" bodyColor="#ffffff">
        <div style={{ padding: 24 }}>
          <div
            style={{
              display: "grid",
              gridTemplateColumns: "repeat(4, 1fr)",
              gap: 12,
            }}
          >
            {FILES.map((file, i) => {
              const enterDelay = 20 + i * 4;
              const enterSpring = spring({ frame: frame - enterDelay, fps, config: SPRING.popIn });
              const fileProcessFrame = processStartFrame + i * framesPerFile;
              const isDone = frame > fileProcessFrame + framesPerFile;
              const isProcessing = frame >= fileProcessFrame && !isDone;

              return (
                <div
                  key={file.name}
                  style={{
                    padding: 16,
                    borderRadius: 8,
                    border: `1px solid ${isDone ? "#22c55e40" : "#e5e5e5"}`,
                    backgroundColor: isDone ? "#f0fdf4" : "white",
                    opacity: enterSpring,
                    transform: `scale(${0.8 + enterSpring * 0.2})`,
                  }}
                >
                  <div style={{ fontFamily: FONT.body, fontSize: 12, color: "#0a0a0a", fontWeight: 500, marginBottom: 8 }}>
                    {file.name}
                  </div>

                  {isProcessing && (
                    <ProgressBar
                      startFrame={0}
                      duration={framesPerFile}
                      width={200}
                      height={4}
                      color="#3b82f6"
                    />
                  )}

                  {isDone && (
                    <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
                      <span style={{ color: "#22c55e", fontSize: 14 }}>{"✓"}</span>
                      <span style={{ fontFamily: FONT.mono, fontSize: 11, color: "#a3a3a3" }}>{file.from}</span>
                      <span style={{ fontFamily: FONT.mono, fontSize: 11, color: "#a3a3a3" }}>{"→"}</span>
                      <span style={{ fontFamily: FONT.mono, fontSize: 11, color: "#22c55e", fontWeight: 600 }}>{file.to}</span>
                    </div>
                  )}
                </div>
              );
            })}
          </div>

          {frame > 280 && (
            <div style={{ marginTop: 24, textAlign: "center" }}>
              <ClipReveal startFrame={280}>
                <span style={{ ...TEXT.sectionTitle, fontSize: 32, color: "#0a0a0a" }}>
                  Unlimited batch. No caps.
                </span>
              </ClipReveal>
            </div>
          )}
        </div>
      </AppWindow>
    </AbsoluteFill>
  );
};
  • Step 4: Commit
git add apps/videos/src/compositions/product-demo/scenes/
git commit -m "feat(videos): add Product Demo scenes 1-3 (Dashboard, SingleTool, Batch)"

Task 13: Product Demo Scenes 4-6

Files:

  • Create: apps/videos/src/compositions/product-demo/scenes/PipelineBuilderScene.tsx

  • Create: apps/videos/src/compositions/product-demo/scenes/AiToolsScene.tsx

  • Create: apps/videos/src/compositions/product-demo/scenes/ImageEditorScene.tsx

  • Step 1: Create PipelineBuilderScene (390 frames)

Create apps/videos/src/compositions/product-demo/scenes/PipelineBuilderScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING, EASE } from "@/lib/motion";

const BLOCKS = [
  { name: "Resize", color: COLOR.category.essentials, enterFrame: 40, x: 250 },
  { name: "Compress", color: COLOR.category.optimization, enterFrame: 80, x: 580 },
  { name: "Watermark", color: COLOR.category.watermark, enterFrame: 120, x: 910 },
];

export const PipelineBuilderScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      <AppWindow title="Pipeline Builder -- SnapOtter" width={1600} height={900} topBarColor="#ffffff" bodyColor="#ffffff">
        <div style={{ position: "relative", width: "100%", height: "100%" }}>
          {/* Blocks */}
          {BLOCKS.map((block, i) => {
            const s = spring({ frame: frame - block.enterFrame, fps, config: SPRING.popIn });
            return (
              <div
                key={block.name}
                style={{
                  position: "absolute",
                  left: block.x,
                  top: 280,
                  width: 160,
                  height: 80,
                  borderRadius: 12,
                  backgroundColor: `${block.color}20`,
                  border: `2px solid ${block.color}`,
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  fontFamily: FONT.body,
                  fontWeight: 600,
                  fontSize: 16,
                  color: block.color,
                  opacity: s,
                  transform: `scale(${0.5 + s * 0.5})`,
                }}
              >
                {block.name}
              </div>
            );
          })}

          {/* Connection lines */}
          {frame > 140 &&
            [0, 1].map((ci) => {
              const lineProgress = interpolate(
                frame,
                [140 + ci * 20, 170 + ci * 20],
                [0, 1],
                { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: EASE.enter },
              );
              const x1 = BLOCKS[ci].x + 160;
              const x2 = BLOCKS[ci + 1].x;
              return (
                <svg
                  key={`line-${ci}`}
                  style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%", pointerEvents: "none" }}
                >
                  <line
                    x1={x1}
                    y1={320}
                    x2={x1 + (x2 - x1) * lineProgress}
                    y2={320}
                    stroke="#a3a3a3"
                    strokeWidth={2}
                    strokeDasharray="6,4"
                  />
                </svg>
              );
            })}

          {/* Running indicator */}
          {frame > 220 && (
            <div
              style={{
                position: "absolute",
                bottom: 100,
                width: "100%",
                textAlign: "center",
                fontFamily: FONT.body,
                fontSize: 14,
                color: "#22c55e",
                fontWeight: 500,
                opacity: interpolate(frame, [220, 235], [0, 1], {
                  extrapolateLeft: "clamp",
                  extrapolateRight: "clamp",
                }),
              }}
            >
              {"✓"} Pipeline completed -- 5 images processed
            </div>
          )}
        </div>
      </AppWindow>
    </AbsoluteFill>
  );
};
  • Step 2: Create AiToolsScene (360 frames)

Create apps/videos/src/compositions/product-demo/scenes/AiToolsScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  Sequence,
  interpolate,
  useCurrentFrame,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { BeforeAfter } from "@/components/BeforeAfter";
import { ClipReveal } from "@/components/ClipReveal";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { ProgressBar } from "@/components/ProgressBar";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";

const Checkerboard: React.FC<{ w: number; h: number }> = ({ w, h }) => (
  <div
    style={{
      width: w,
      height: h,
      backgroundImage:
        "linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
      backgroundSize: "16px 16px",
      backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
      opacity: 0.4,
    }}
  />
);

export const AiToolsScene: React.FC = () => {
  const frame = useCurrentFrame();

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      {/* Background removal demo (0-180) */}
      <Sequence from={0} durationInFrames={180}>
        <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
          <AppWindow title="Remove Background -- SnapOtter" width={1200} height={700} topBarColor="#ffffff" bodyColor="#ffffff">
            <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", gap: 40, padding: 24 }}>
              <Sequence from={0} durationInFrames={60}>
                <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                    <div
                      style={{
                        width: 20,
                        height: 20,
                        border: "3px solid #3b82f6",
                        borderTop: "3px solid transparent",
                        borderRadius: "50%",
                        animation: "spin 1s linear infinite",
                      }}
                    />
                    <span style={{ fontFamily: FONT.body, fontSize: 14, color: "#3b82f6" }}>
                      Running rembg model locally...
                    </span>
                  </div>
                </AbsoluteFill>
              </Sequence>

              <Sequence from={60} durationInFrames={120}>
                <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
                  <BeforeAfter
                    before={<PhotoPlaceholder width={450} height={340} hue={30} />}
                    after={
                      <div style={{ position: "relative", width: 450, height: 340 }}>
                        <Checkerboard w={450} h={340} />
                        <div
                          style={{
                            position: "absolute",
                            top: 50,
                            left: 140,
                            width: 170,
                            height: 240,
                            borderRadius: "50% 50% 45% 45%",
                            background: "linear-gradient(135deg, #e8a87c, #d4856b)",
                          }}
                        />
                      </div>
                    }
                    scanStartFrame={10}
                    scanDuration={50}
                    width={450}
                    height={340}
                  />
                </AbsoluteFill>
              </Sequence>
            </div>
          </AppWindow>
        </AbsoluteFill>
      </Sequence>

      {/* Quick AI tool montage (180-360) */}
      <Sequence from={180} durationInFrames={90}>
        <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
          <AppWindow title="Upscale 4x -- SnapOtter" width={1000} height={500} topBarColor="#ffffff" bodyColor="#ffffff">
            <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", gap: 30 }}>
              <PhotoPlaceholder width={100} height={100} hue={200} />
              <span style={{ fontFamily: FONT.heading, fontWeight: 800, fontSize: 32, color: COLOR.accent }}>4x</span>
              <PhotoPlaceholder width={300} height={300} hue={200} />
            </div>
          </AppWindow>
        </AbsoluteFill>
      </Sequence>

      <Sequence from={270} durationInFrames={90}>
        <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
          <AppWindow title="OCR / Text Extraction -- SnapOtter" width={1000} height={500} topBarColor="#ffffff" bodyColor="#ffffff">
            <div style={{ display: "flex", height: "100%" }}>
              <div style={{ flex: 0.5, display: "flex", alignItems: "center", justifyContent: "center" }}>
                <PhotoPlaceholder width={300} height={350} hue={50} />
              </div>
              <div style={{ flex: 0.5, padding: 24, borderLeft: "1px solid #e5e5e5", display: "flex", flexDirection: "column", gap: 8 }}>
                <div style={{ fontFamily: FONT.body, fontWeight: 600, fontSize: 14, color: "#0a0a0a" }}>Extracted Text</div>
                {["Lorem ipsum dolor sit", "amet consectetur", "adipiscing elit sed do"].map((line, i) => (
                  <div
                    key={`ocr-${i}`}
                    style={{
                      fontFamily: FONT.mono,
                      fontSize: 13,
                      color: "#525252",
                      padding: "4px 8px",
                      backgroundColor: "#eff6ff",
                      borderRadius: 4,
                    }}
                  >
                    {line}
                  </div>
                ))}
              </div>
            </div>
          </AppWindow>
        </AbsoluteFill>
      </Sequence>

      {/* Bottom text */}
      <div style={{ position: "absolute", bottom: 40, width: "100%", textAlign: "center" }}>
        <ClipReveal startFrame={300}>
          <span style={{ ...TEXT.sectionTitle, fontSize: 32, color: "#0a0a0a" }}>
            All on your hardware.
          </span>
        </ClipReveal>
      </div>
    </AbsoluteFill>
  );
};
  • Step 3: Create ImageEditorScene (300 frames)

Create apps/videos/src/compositions/product-demo/scenes/ImageEditorScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { COLOR } from "@/lib/colors";
import { FONT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

const TOOLBAR_ICONS = [
  "Move", "Select", "Brush", "Eraser", "Clone", "Dodge", "Text", "Shape",
  "Line", "Gradient", "Fill", "Crop", "Eyedropper", "Zoom", "Hand",
];

export const ImageEditorScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const enterSpring = spring({ frame, fps, config: SPRING.snappy });
  const brushFrame = 60;
  const brushProgress = interpolate(frame, [brushFrame, brushFrame + 60], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  const activeToolIdx = frame < 60 ? 2 : frame < 120 ? 6 : frame < 200 ? 11 : 2;

  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      <div style={{ transform: `scale(${0.95 + enterSpring * 0.05})`, opacity: enterSpring }}>
        <AppWindow title="Editor -- SnapOtter" width={1600} height={900} topBarColor="#1e293b" bodyColor="#0f172a">
          <div style={{ display: "flex", height: "100%" }}>
            {/* Toolbar */}
            <div
              style={{
                width: 48,
                backgroundColor: "#1e293b",
                borderRight: "1px solid #334155",
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                paddingTop: 8,
                gap: 2,
              }}
            >
              {TOOLBAR_ICONS.map((icon, i) => (
                <div
                  key={icon}
                  style={{
                    width: 36,
                    height: 32,
                    borderRadius: 6,
                    backgroundColor: i === activeToolIdx ? "#3b82f6" : "transparent",
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    fontSize: 10,
                    color: i === activeToolIdx ? "white" : "#94a3b8",
                    fontFamily: FONT.body,
                    fontWeight: 500,
                  }}
                >
                  {icon.slice(0, 2)}
                </div>
              ))}
            </div>

            {/* Canvas */}
            <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }}>
              <PhotoPlaceholder width={900} height={600} hue={180} />

              {/* Brush stroke */}
              {brushProgress > 0 && (
                <svg
                  style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
                  width="100%"
                  height="100%"
                >
                  <path
                    d={`M 300 350 Q 450 280 600 320 T 900 300`}
                    fill="none"
                    stroke={COLOR.accent}
                    strokeWidth={4}
                    strokeLinecap="round"
                    strokeDasharray={800}
                    strokeDashoffset={800 * (1 - brushProgress)}
                    opacity={0.7}
                  />
                </svg>
              )}

              {/* Text overlay */}
              {frame > 130 && (
                <div
                  style={{
                    position: "absolute",
                    bottom: 80,
                    left: "50%",
                    transform: "translateX(-50%)",
                    fontFamily: FONT.heading,
                    fontWeight: 700,
                    fontSize: 28,
                    color: "white",
                    textShadow: "0 2px 8px rgba(0,0,0,0.5)",
                    opacity: interpolate(frame, [130, 145], [0, 1], {
                      extrapolateLeft: "clamp",
                      extrapolateRight: "clamp",
                    }),
                  }}
                >
                  SnapOtter
                </div>
              )}
            </div>

            {/* Layers panel */}
            <div
              style={{
                width: 200,
                backgroundColor: "#1e293b",
                borderLeft: "1px solid #334155",
                padding: 12,
              }}
            >
              <div style={{ fontFamily: FONT.body, fontSize: 11, color: "#94a3b8", fontWeight: 600, marginBottom: 8, textTransform: "uppercase", letterSpacing: "0.05em" }}>
                Layers
              </div>
              {["Text Layer", "Brush Stroke", "Background"].map((layer, i) => (
                <div
                  key={layer}
                  style={{
                    padding: "6px 8px",
                    borderRadius: 4,
                    backgroundColor: i === 0 ? "#334155" : "transparent",
                    fontFamily: FONT.body,
                    fontSize: 12,
                    color: "#e2e8f0",
                    marginBottom: 2,
                  }}
                >
                  {layer}
                </div>
              ))}
            </div>
          </div>
        </AppWindow>
      </div>
    </AbsoluteFill>
  );
};
  • Step 4: Commit
git add apps/videos/src/compositions/product-demo/scenes/
git commit -m "feat(videos): add Product Demo scenes 4-6 (Pipeline, AI, Editor)"

Task 14: Product Demo Scenes 7-8 + Composition

Files:

  • Create: apps/videos/src/compositions/product-demo/scenes/ApiDocsScene.tsx

  • Create: apps/videos/src/compositions/product-demo/scenes/EndCardScene.tsx

  • Create: apps/videos/src/compositions/product-demo/ProductDemo.tsx

  • Modify: apps/videos/src/Root.tsx

  • Step 1: Create ApiDocsScene (150 frames)

Create apps/videos/src/compositions/product-demo/scenes/ApiDocsScene.tsx:

import type React from "react";
import { AbsoluteFill } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { ClipReveal } from "@/components/ClipReveal";
import { TypeWriter } from "@/components/TypeWriter";
import { FONT, TEXT } from "@/lib/fonts";

const CURL_SEGMENTS = [
  { text: "curl ", color: "#e6edf3" },
  { text: "-X POST ", color: "#79c0ff" },
  { text: "localhost:1349/api/v1/tools/resize ", color: "#a5d6ff" },
  { text: '-F "file=@photo.jpg" ', color: "#7ee787" },
  { text: "-F ", color: "#79c0ff" },
  { text: '"settings={\\"width\\":800}"', color: "#ffa657" },
];

export const ApiDocsScene: React.FC = () => {
  return (
    <AbsoluteFill style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f5f5f4" }}>
      <AppWindow title="API Docs -- SnapOtter" width={1200} height={600} topBarColor="#1e293b" bodyColor="#0d1117">
        <div style={{ padding: 24 }}>
          <div style={{ fontFamily: FONT.body, fontSize: 12, color: "#8b949e", marginBottom: 8, textTransform: "uppercase", letterSpacing: "0.05em" }}>
            Example Request
          </div>
          <div style={{ backgroundColor: "#161b22", borderRadius: 8, padding: 16, marginBottom: 16 }}>
            <TypeWriter segments={CURL_SEGMENTS} startFrame={10} speed={1} style={{ fontSize: 14, lineHeight: 1.6 }} />
          </div>

          <ClipReveal startFrame={80}>
            <div style={{ backgroundColor: "#161b22", borderRadius: 8, padding: 16 }}>
              <pre style={{ fontFamily: FONT.mono, fontSize: 13, color: "#e6edf3", margin: 0, lineHeight: 1.6 }}>
                {`{
  "downloadUrl": "/api/v1/download/abc123",
  "originalSize": 2400000,
  "processedSize": 340000
}`}
              </pre>
            </div>
          </ClipReveal>
        </div>
      </AppWindow>

      <div style={{ position: "absolute", bottom: 40, width: "100%", textAlign: "center" }}>
        <ClipReveal startFrame={100}>
          <span style={{ ...TEXT.sectionTitle, fontSize: 28, color: "#0a0a0a" }}>
            Every tool via REST API.
          </span>
        </ClipReveal>
      </div>
    </AbsoluteFill>
  );
};
  • Step 2: Create EndCardScene (90 frames)

Create apps/videos/src/compositions/product-demo/scenes/EndCardScene.tsx:

import type React from "react";
import {
  AbsoluteFill,
  Img,
  interpolate,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";

export const EndCardScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const logoSpring = spring({ frame, fps, config: SPRING.natural });
  const fadeOut = interpolate(frame, [70, 90], [1, 0], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "white",
        opacity: fadeOut,
      }}
    >
      <Img
        src={staticFile("logo.png")}
        style={{
          width: 80,
          height: 80,
          opacity: logoSpring,
          transform: `scale(${0.8 + logoSpring * 0.2})`,
        }}
      />

      <div style={{ height: 16 }} />

      <ClipReveal startFrame={10}>
        <span style={{ ...TEXT.sectionTitle, fontSize: 36, color: "#0a0a0a" }}>
          Self-hosted image processing.
        </span>
      </ClipReveal>

      <div style={{ height: 12 }} />

      <ClipReveal startFrame={25}>
        <span style={{ fontFamily: FONT.body, fontSize: 18, color: COLOR.accent }}>
          snapotter.com
        </span>
      </ClipReveal>
    </AbsoluteFill>
  );
};
  • Step 3: Create ProductDemo composition

Create apps/videos/src/compositions/product-demo/ProductDemo.tsx:

import type React from "react";
import { AbsoluteFill, Series } from "remotion";
import { GrainOverlay } from "@/components/GrainOverlay";
import { AiToolsScene } from "./scenes/AiToolsScene";
import { ApiDocsScene } from "./scenes/ApiDocsScene";
import { BatchProcessingScene } from "./scenes/BatchProcessingScene";
import { DashboardScene } from "./scenes/DashboardScene";
import { EndCardScene } from "./scenes/EndCardScene";
import { ImageEditorScene } from "./scenes/ImageEditorScene";
import { PipelineBuilderScene } from "./scenes/PipelineBuilderScene";
import { SingleToolScene } from "./scenes/SingleToolScene";

export const ProductDemo: React.FC = () => {
  return (
    <AbsoluteFill style={{ backgroundColor: "#f5f5f4" }}>
      <Series>
        <Series.Sequence durationInFrames={240}>
          <DashboardScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={360}>
          <SingleToolScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={360}>
          <BatchProcessingScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={390}>
          <PipelineBuilderScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={360}>
          <AiToolsScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={300}>
          <ImageEditorScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={150}>
          <ApiDocsScene />
        </Series.Sequence>
        <Series.Sequence durationInFrames={90}>
          <EndCardScene />
        </Series.Sequence>
      </Series>

      <GrainOverlay opacity={0.02} />
    </AbsoluteFill>
  );
};
  • Step 4: Register in Root.tsx

Add to apps/videos/src/Root.tsx:

import { ProductDemo } from "./compositions/product-demo/ProductDemo";
    <Composition
      id="ProductDemo"
      component={ProductDemo}
      durationInFrames={2250}
      fps={30}
      width={1920}
      height={1080}
    />
  • Step 5: Verify in Remotion Studio

Select "ProductDemo". Scrub through all 2250 frames (75 seconds). Verify all 8 scenes play in sequence with light-theme UI mockups.

  • Step 6: Commit
git add apps/videos/src/compositions/product-demo/ apps/videos/src/Root.tsx
git commit -m "feat(videos): add ProductDemo composition with all 8 scenes"

Task 15: Render Script

Files:

  • Create: apps/videos/scripts/render-all.mjs

  • Step 1: Create render script

Create apps/videos/scripts/render-all.mjs:

import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { enableTailwind } from "@remotion/tailwind";
import fs from "node:fs";
import path from "node:path";

const COMPOSITIONS = [
  { id: "XLaunchVideo", slug: "x-launch" },
  { id: "ProductDemo", slug: "product-demo" },
  { id: "PromoTeaser", slug: "promo-teaser-square" },
  { id: "PromoTeaserVertical", slug: "promo-teaser-vertical" },
];

const OUTPUT_DIR = path.resolve("./out");
fs.mkdirSync(OUTPUT_DIR, { recursive: true });

console.log("Bundling...");
const bundleLocation = await bundle({
  entryPoint: "./src/index.ts",
  webpackOverride: (config) => enableTailwind(config),
});
console.log("Bundle complete.\n");

for (const comp of COMPOSITIONS) {
  console.log(`Rendering ${comp.id}...`);
  const composition = await selectComposition({
    serveUrl: bundleLocation,
    id: comp.id,
  });

  await renderMedia({
    composition,
    serveUrl: bundleLocation,
    codec: "h264",
    crf: 20,
    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  ${pct}%`);
    },
  });

  console.log(`\r  Done: ${comp.slug}.mp4`);
}

console.log(`\nAll ${COMPOSITIONS.length} videos rendered to ${OUTPUT_DIR}`);
  • Step 2: Test single render
cd apps/videos && npx remotion render PromoTeaser out/test-promo.mp4

Expected: MP4 file created in apps/videos/out/.

  • Step 3: Commit
git add apps/videos/scripts/render-all.mjs
git commit -m "feat(videos): add batch render script for all promo compositions"

Task 16: Final Verification

  • Step 1: TypeScript check
cd apps/videos && npx tsc --noEmit

Expected: No errors.

  • Step 2: Verify all compositions in Remotion Studio
pnpm --filter @snapotter/videos dev

Open browser. Confirm all compositions appear in the picker:

  • ComponentTest (existing)
  • FloatingTools (existing)
  • BrandGradient (existing)
  • FormatUniverse (existing)
  • PromoTeaser (new)
  • PromoTeaserVertical (new)
  • XLaunchVideo (new)
  • ProductDemo (new)

Scrub through each new composition end-to-end. Verify:

  • No blank frames or missing elements

  • Scene transitions are clean (no visual gaps between Series.Sequence boundaries)

  • Text is readable at each hold point

  • Animations feel smooth (spring overshoot, ease curves)

  • Colors match brand palette (amber accents, category colors)

  • Step 3: Test render one video

cd apps/videos && npx remotion render XLaunchVideo out/x-launch.mp4

Open the output MP4 and watch it. Verify it matches what was shown in Studio.

  • Step 4: Commit any fixes from verification
git add -A apps/videos/
git commit -m "fix(videos): polish from final verification pass"