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.
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
styleobjects (not Tailwind classes) - Design system imports:
@/lib/colors(COLOR),@/lib/fonts(FONT, TEXT),@/lib/motion(EASE, SPRING, TIMING) - Components use
useCurrentFrame()+interpolate()fromremotion AbsoluteFillfor full-frame positioningstaticFile()forpublic/assetsImgfromremotionfor 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"