feat(videos): redesign all scenes with real app screenshots and proper scaling

This commit is contained in:
SnapOtter
2026-05-08 18:37:36 +08:00
parent 86e1fdeef5
commit c17d540072
15 changed files with 387 additions and 1210 deletions
@@ -1,206 +1,13 @@
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,
}}
/>
);
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="remove-bg.png"
caption="15 AI models. Your hardware."
captionFrame={120}
panX={10}
/>
);
};
@@ -1,84 +1,14 @@
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" },
];
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="api-docs.png"
caption="Every tool via REST API."
captionFrame={120}
topBarColor="#ffffff"
bodyColor="#ffffff"
/>
);
};
@@ -1,127 +1,12 @@
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" },
];
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="compress-tool.png"
caption="Unlimited batch. No caps."
captionFrame={160}
/>
);
};
@@ -1,149 +1,13 @@
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);
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="dashboard.png"
caption="49 tools. One dashboard."
captionFrame={120}
panX={-20}
/>
);
};
@@ -1,188 +1,12 @@
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",
];
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="editor.png"
caption="Full image editor built in."
captionFrame={100}
/>
);
};
@@ -1,121 +1,14 @@
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 },
];
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="automate.png"
caption="Chain tools into workflows."
captionFrame={120}
zoomFrom={1.05}
zoomTo={1.0}
/>
);
};
@@ -0,0 +1,97 @@
import type React from "react";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { AppWindow } from "@/components/AppWindow";
import { ClipReveal } from "@/components/ClipReveal";
import { TEXT } from "@/lib/fonts";
export const ScreenshotScene: React.FC<{
screenshot: string;
caption?: string;
captionFrame?: number;
zoomFrom?: number;
zoomTo?: number;
panX?: number;
panY?: number;
topBarColor?: string;
bodyColor?: string;
}> = ({
screenshot,
caption,
captionFrame = 60,
zoomFrom = 1.0,
zoomTo = 1.06,
panX = 0,
panY = 0,
topBarColor = "#1a1a2e",
bodyColor = "#0f172a",
}) => {
const frame = useCurrentFrame();
const totalFrames = 300;
const scale = interpolate(frame, [0, totalFrames], [zoomFrom, zoomTo], {
extrapolateRight: "clamp",
});
const translateX = interpolate(frame, [0, totalFrames], [0, panX], {
extrapolateRight: "clamp",
});
const translateY = interpolate(frame, [0, totalFrames], [0, panY], {
extrapolateRight: "clamp",
});
const windowOpacity = interpolate(frame, [0, 15], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{ justifyContent: "center", alignItems: "center", backgroundColor: "#f0f0f0" }}
>
<div style={{ opacity: windowOpacity }}>
<AppWindow
title="SnapOtter"
width={1700}
height={950}
topBarColor={topBarColor}
bodyColor={bodyColor}
>
<div style={{ overflow: "hidden", width: "100%", height: "100%" }}>
<Img
src={staticFile(`screenshots/${screenshot}`)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
}}
/>
</div>
</AppWindow>
</div>
{caption && (
<div
style={{
position: "absolute",
bottom: 30,
width: "100%",
textAlign: "center",
}}
>
<ClipReveal startFrame={captionFrame}>
<span
style={{
...TEXT.sectionTitle,
fontSize: 32,
color: "#1a1a2e",
backgroundColor: "rgba(255,255,255,0.85)",
padding: "8px 24px",
borderRadius: 8,
}}
>
{caption}
</span>
</ClipReveal>
</div>
)}
</AbsoluteFill>
);
};
@@ -1,195 +1,14 @@
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";
import { ScreenshotScene } from "./ScreenshotScene";
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>
<ScreenshotScene
screenshot="resize-tool.png"
caption="Upload. Configure. Process."
captionFrame={120}
zoomTo={1.08}
panY={-15}
/>
);
};
@@ -1,5 +1,5 @@
import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { GradientBlob } from "@/components/GradientBlob";
const BLOBS = [
@@ -41,6 +41,9 @@ const BLOBS = [
},
];
const SCREENSHOTS = ["dashboard.png", "resize-tool.png", "editor.png"];
const CYCLE_FRAMES = 30;
export const AmbientOpenScene: React.FC = () => {
const frame = useCurrentFrame();
const fadeIn = interpolate(frame, [0, 45], [0, 1], {
@@ -48,11 +51,65 @@ export const AmbientOpenScene: React.FC = () => {
extrapolateRight: "clamp",
});
const currentIdx = Math.floor(frame / CYCLE_FRAMES) % SCREENSHOTS.length;
const nextIdx = (currentIdx + 1) % SCREENSHOTS.length;
const cycleProgress = (frame % CYCLE_FRAMES) / CYCLE_FRAMES;
const currentOpacity = interpolate(cycleProgress, [0, 0.8, 1], [0.6, 0.6, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const nextOpacity = interpolate(cycleProgress, [0.7, 1], [0, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const scale = interpolate(frame % CYCLE_FRAMES, [0, CYCLE_FRAMES], [1.05, 1.0], {
extrapolateRight: "clamp",
});
const rotation = (currentIdx % 2 === 0 ? 1 : -1) * 3.5;
return (
<AbsoluteFill style={{ opacity: fadeIn }}>
{BLOBS.map((blob, i) => (
<GradientBlob key={`ambient-${i}`} config={blob} duration={600} />
))}
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div
style={{
width: 700,
height: 450,
position: "relative",
transform: `rotate(${rotation}deg) scale(${scale})`,
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
}}
>
<Img
src={staticFile(`screenshots/${SCREENSHOTS[currentIdx]}`)}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
opacity: currentOpacity,
}}
/>
<Img
src={staticFile(`screenshots/${SCREENSHOTS[nextIdx]}`)}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
opacity: nextOpacity,
}}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -1,13 +1,12 @@
import type React from "react";
import { AbsoluteFill, 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 },
{ number: "49", descriptor: "tools", frame: 0 },
{ number: "15", descriptor: "AI models", frame: 37 },
{ number: "55+", descriptor: "formats", frame: 74 },
{ number: "1", descriptor: "container", frame: 111 },
];
export const NumberPunchScene: React.FC = () => {
@@ -16,27 +15,33 @@ export const NumberPunchScene: React.FC = () => {
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;
const segmentEnd = i < PUNCHES.length - 1 ? PUNCHES[i + 1].frame : p.frame + 37;
const fadeInOpacity = interpolate(frame, [p.frame, p.frame + 4], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOutOpacity = interpolate(frame, [segmentEnd - 6, segmentEnd], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const isLastPunch = i === PUNCHES.length - 1;
const visible = frame >= p.frame && frame < segmentEnd;
const opacity = isLastPunch ? fadeInOpacity : Math.min(fadeInOpacity, fadeOutOpacity);
if (!visible) return null;
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
key={p.number}
style={{ justifyContent: "center", alignItems: "center", opacity }}
>
<NumberPunch
number={p.number}
descriptor={p.descriptor}
enterFrame={p.frame}
numberSize={200}
shakeIntensity={2}
/>
</AbsoluteFill>
);
})}
@@ -1,5 +1,5 @@
import type React from "react";
import { AbsoluteFill } from "remotion";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { RotatingTaglines } from "@/components/RotatingTaglines";
const TAGLINES = [
@@ -12,13 +12,33 @@ const TAGLINES = [
];
export const TaglineCascadeScene: React.FC = () => {
const frame = useCurrentFrame();
const totalFrames = 150;
const bgScale = interpolate(frame, [0, totalFrames], [1.0, 1.1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<Img
src={staticFile("screenshots/dashboard.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.15,
transform: `scale(${bgScale})`,
}}
/>
</AbsoluteFill>
<RotatingTaglines
lines={TAGLINES}
startFrame={0}
framesPerLine={25}
fontSize={40}
fontSize={56}
color="white"
/>
</AbsoluteFill>
@@ -1,127 +1,56 @@
import type React from "react";
import { AbsoluteFill, interpolate, Sequence, useCurrentFrame } from "remotion";
import { BeforeAfter } from "@/components/BeforeAfter";
import { ClipReveal } from "@/components/ClipReveal";
import { PhotoPlaceholder } from "@/components/PhotoPlaceholder";
import { COLOR } from "@/lib/colors";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
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 = () => {
const frame = useCurrentFrame();
const totalFrames = 180;
const crossfade = interpolate(frame, [80, 100], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const scale1 = interpolate(frame, [0, 90], [1.0, 1.05], {
extrapolateRight: "clamp",
});
const scale2 = interpolate(frame, [90, totalFrames], [1.0, 1.05], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const textOpacity = interpolate(frame, [30, 45], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<Sequence from={0} durationInFrames={60}>
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<BgRemovalDemo />
</AbsoluteFill>
</Sequence>
<AbsoluteFill>
<Img
src={staticFile("screenshots/remove-bg.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale1})`,
opacity: 1 - crossfade,
}}
/>
</AbsoluteFill>
<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>
<AbsoluteFill>
<Img
src={staticFile("screenshots/resize-tool.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale2})`,
opacity: crossfade,
}}
/>
</AbsoluteFill>
<div
style={{
@@ -132,7 +61,9 @@ export const AiShowcaseScene: React.FC = () => {
fontFamily: FONT.body,
fontWeight: 600,
fontSize: 24,
color: "rgba(255,255,255,0.8)",
color: "rgba(255,255,255,0.9)",
opacity: textOpacity,
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
}}
>
15 AI models. Your hardware. No cloud.
@@ -3,29 +3,42 @@ 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 },
{ label: "Batch Processing", col: 0, row: 0, from: "left" as const },
{ label: "Pipeline Automation", col: 1, row: 0, from: "right" as const },
{ label: "REST API", col: 0, row: 1, from: "left" as const },
{ label: "55+ Input Formats", col: 1, row: 1, from: "right" as const },
{ label: "Image Editor", col: 0, row: 2, from: "left" as const },
{ label: "One Container", col: 1, row: 2, from: "right" as const },
{ label: "Multi-arch", col: 0, row: 3, from: "left" as const },
{ label: "15 AI Models", col: 1, row: 3, from: "right" as const },
];
const PILL_WIDTH = 200;
const GAP_X = 20;
const GAP_Y = 16;
const PILL_HEIGHT = 50;
const GRID_WIDTH = PILL_WIDTH * 2 + GAP_X;
const GRID_HEIGHT = PILL_HEIGHT * 4 + GAP_Y * 3;
const START_X = (1080 - GRID_WIDTH) / 2;
const START_Y = (1080 - GRID_HEIGHT) / 2;
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}
/>
))}
{FEATURES.map((f, i) => {
const x = START_X + f.col * (PILL_WIDTH + GAP_X);
const y = START_Y + f.row * (PILL_HEIGHT + GAP_Y);
return (
<FeaturePill
key={f.label}
label={f.label}
enterFrame={i * 8}
targetX={x}
targetY={y}
fromDirection={f.from}
/>
);
})}
</AbsoluteFill>
);
};
@@ -1,5 +1,5 @@
import type React from "react";
import { AbsoluteFill } from "remotion";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { RotatingTaglines } from "@/components/RotatingTaglines";
import { COLOR } from "@/lib/colors";
@@ -11,13 +11,33 @@ const TAGLINES = [
];
export const PrivacyBeatScene: React.FC = () => {
const frame = useCurrentFrame();
const totalFrames = 120;
const bgScale = interpolate(frame, [0, totalFrames], [1.0, 1.05], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<AbsoluteFill>
<Img
src={staticFile("screenshots/dashboard.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0.12,
transform: `scale(${bgScale})`,
}}
/>
</AbsoluteFill>
<RotatingTaglines
lines={TAGLINES}
startFrame={0}
framesPerLine={30}
fontSize={36}
fontSize={48}
color={COLOR.accent}
/>
</AbsoluteFill>
@@ -1,32 +1,44 @@
import type React from "react";
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import { AppWindow } from "@/components/AppWindow";
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 totalFrames = 180;
const gridOpacity = interpolate(frame, [0, 20], [0, 1], {
const scale = interpolate(frame, [0, totalFrames], [1.0, 1.08], {
extrapolateRight: "clamp",
});
const windowOpacity = 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} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ opacity: windowOpacity }}>
<AppWindow
title="SnapOtter"
width={900}
height={900}
topBarColor="#1a1a2e"
bodyColor="#0f172a"
>
<div style={{ overflow: "hidden", width: "100%", height: "100%" }}>
<Img
src={staticFile("screenshots/dashboard.png")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale})`,
}}
/>
</div>
</AppWindow>
</div>
<div style={{ position: "absolute", bottom: 60, right: 80 }}>
@@ -37,7 +49,7 @@ export const ToolGridRevealScene: React.FC = () => {
duration={90}
style={{
...TEXT.counter,
fontSize: 64,
fontSize: 120,
color: COLOR.accent,
}}
/>