feat(videos): add Product Demo scenes 1-8 and composition

Eight scenes replicating the SnapOtter UI in light theme: Dashboard
overview with search filtering, single tool resize flow, batch
processing with staggered progress, pipeline builder, AI tools
(bg removal, upscale, OCR), image editor, API docs, and end card.
This commit is contained in:
SnapOtter
2026-05-08 16:49:46 +08:00
parent 1a34acd56d
commit ce86a4a100
9 changed files with 1178 additions and 0 deletions
@@ -0,0 +1,46 @@
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>
);
};
@@ -0,0 +1,206 @@
import type React from "react";
import { AbsoluteFill, Sequence, useCurrentFrame } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { BeforeAfter } from "@/components/BeforeAfter";
import { ClipReveal } from "@/components/ClipReveal";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
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"
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%",
}}
/>
<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"
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"
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>
);
};
@@ -0,0 +1,84 @@
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"
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>
);
};
@@ -0,0 +1,127 @@
import type React from "react";
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { ClipReveal } from "@/components/ClipReveal";
import { ProgressBar } from "@/components/ProgressBar";
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"
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 }}>&#10003;</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>
);
};
@@ -0,0 +1,149 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { CATEGORY_LABELS, CATEGORY_ORDER, COLOR } 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>
);
};
@@ -0,0 +1,62 @@
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { COLOR } from "@/lib/colors";
import { FONT, TEXT } from "@/lib/fonts";
import { SPRING } from "@/lib/motion";
export const EndCardScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoSpring = spring({ frame, fps, config: SPRING.natural });
const fadeOut = interpolate(frame, [70, 90], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<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>
);
};
@@ -0,0 +1,188 @@
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"
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>
);
};
@@ -0,0 +1,121 @@
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 { EASE, SPRING } 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"
width={1600}
height={900}
topBarColor="#ffffff"
bodyColor="#ffffff"
>
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Blocks */}
{BLOCKS.map((block) => {
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",
}),
}}
>
&#10003; Pipeline completed -- 5 images processed
</div>
)}
</div>
</AppWindow>
</AbsoluteFill>
);
};
@@ -0,0 +1,195 @@
import type React from "react";
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
import { AppWindow } from "@/components/AppWindow";
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"
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>
);
};