mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 }}>✓</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",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
✓ 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user