mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(videos): add OneCommand hero composition with terminal-to-app morph
This commit is contained in:
@@ -12,6 +12,7 @@ import { FloatingTools } from "./compositions/ambient/FloatingTools";
|
|||||||
import { AiMagicReel } from "./compositions/features/AiMagicReel";
|
import { AiMagicReel } from "./compositions/features/AiMagicReel";
|
||||||
import { FormatUniverse } from "./compositions/features/FormatUniverse";
|
import { FormatUniverse } from "./compositions/features/FormatUniverse";
|
||||||
import { PipelineFlow } from "./compositions/features/PipelineFlow";
|
import { PipelineFlow } from "./compositions/features/PipelineFlow";
|
||||||
|
import { OneCommand } from "./compositions/hero/OneCommand";
|
||||||
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
|
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
|
||||||
import { TEXT } from "./lib/fonts";
|
import { TEXT } from "./lib/fonts";
|
||||||
|
|
||||||
@@ -110,5 +111,13 @@ export const RemotionRoot: React.FC = () => (
|
|||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
/>
|
/>
|
||||||
|
<Composition
|
||||||
|
id="OneCommand"
|
||||||
|
component={OneCommand}
|
||||||
|
durationInFrames={390}
|
||||||
|
fps={30}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
interpolate,
|
||||||
|
interpolateColors,
|
||||||
|
spring,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
} from "remotion";
|
||||||
|
import { AppMockup } from "@/components/AppMockup";
|
||||||
|
import { ClipReveal } from "@/components/ClipReveal";
|
||||||
|
import { GrainOverlay } from "@/components/GrainOverlay";
|
||||||
|
import { TerminalWindow } from "@/components/TerminalWindow";
|
||||||
|
import { TypeWriter } from "@/components/TypeWriter";
|
||||||
|
import { FONT, TEXT } from "@/lib/fonts";
|
||||||
|
import { EASE, SPRING } from "@/lib/motion";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Docker command segments with syntax highlighting */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const DOCKER_SEGMENTS = [
|
||||||
|
{ text: "docker", color: "#ff7b72" },
|
||||||
|
{ text: " run ", color: "#7ee787" },
|
||||||
|
{ text: "-d ", color: "#79c0ff" },
|
||||||
|
{ text: "--name ", color: "#79c0ff" },
|
||||||
|
{ text: "snapotter ", color: "#a5d6ff" },
|
||||||
|
{ text: "-p ", color: "#79c0ff" },
|
||||||
|
{ text: "1349:1349 ", color: "#a5d6ff" },
|
||||||
|
{ text: "snapotter/snapotter", color: "#7ee787" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Spinner characters */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const SPINNER_CHARS = ["|", "/", "-", "\\"];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Address bar component for the morph */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const AddressBar: React.FC<{ opacity: number }> = ({ opacity }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
opacity,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "3px 12px",
|
||||||
|
height: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Green lock icon */}
|
||||||
|
<svg width={12} height={12} viewBox="0 0 12 12" role="img" aria-label="Secure">
|
||||||
|
<rect x={2} y={5.5} width={8} height={5.5} rx={1} fill="#28c840" />
|
||||||
|
<path
|
||||||
|
d="M3.5 5.5V4a2.5 2.5 0 0 1 5 0v1.5"
|
||||||
|
stroke="#28c840"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: FONT.body,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
localhost:1349
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main composition */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const OneCommand: React.FC = () => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps, width, height } = useVideoConfig();
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Act 1: Terminal slides up (frame 0-30) */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
const terminalEntrySpring = spring({
|
||||||
|
frame,
|
||||||
|
fps,
|
||||||
|
config: SPRING.snappy,
|
||||||
|
});
|
||||||
|
const terminalSlideY = interpolate(terminalEntrySpring, [0, 1], [600, 0]);
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Act 2: Typing (frame 30-180) */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
/* Prompt "$ " visible once terminal is in */
|
||||||
|
const promptOpacity = interpolate(frame, [28, 30], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Cursor blink at the prompt before typing starts (frame 30-35) */
|
||||||
|
const preTypingCursorVisible = frame >= 30 && frame < 35 && Math.floor(frame / 4) % 2 === 0;
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Act 3: Processing lines (frame 180-215) */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
/* "Pulling..." text appears */
|
||||||
|
const pullingOpacity = interpolate(frame, [180, 185], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Spinner cycles every 3 frames */
|
||||||
|
const spinnerChar =
|
||||||
|
frame >= 185 && frame < 210
|
||||||
|
? SPINNER_CHARS[Math.floor((frame - 185) / 3) % SPINNER_CHARS.length]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/* Checkmark line */
|
||||||
|
const checkmarkSpring = spring({
|
||||||
|
frame: frame - 210,
|
||||||
|
fps,
|
||||||
|
config: SPRING.popIn,
|
||||||
|
});
|
||||||
|
const showCheckmark = frame >= 210;
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Act 4: Terminal-to-App Morph (frame 215-280) */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
/* Body background color morph */
|
||||||
|
const bodyBg =
|
||||||
|
frame >= 215 ? interpolateColors(frame, [215, 250], ["#0d1117", "#ffffff"]) : "#0d1117";
|
||||||
|
|
||||||
|
/* Terminal text fade out */
|
||||||
|
const textFadeOut = interpolate(frame, [215, 235], [1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Window size morph */
|
||||||
|
const windowWidth = interpolate(frame, [215, 260], [900, 1100], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
easing: EASE.smooth,
|
||||||
|
});
|
||||||
|
const windowHeight = interpolate(frame, [215, 260], [500, 650], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
easing: EASE.smooth,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Dots fade out */
|
||||||
|
const dotsOpacity = interpolate(frame, [220, 240], [1, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Address bar fade in */
|
||||||
|
const addressBarOpacity = interpolate(frame, [240, 255], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Top bar color morph */
|
||||||
|
const topBarColor =
|
||||||
|
frame >= 215 ? interpolateColors(frame, [215, 250], ["#1e1e2e", "#2d2d3f"]) : "#1e1e2e";
|
||||||
|
|
||||||
|
/* App mockup appears at frame 260 */
|
||||||
|
const showAppMockup = frame >= 258;
|
||||||
|
const appMockupOpacity = interpolate(frame, [258, 270], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ================================================================ */
|
||||||
|
/* Act 5: Tagline + float (frame 280-390) */
|
||||||
|
/* ================================================================ */
|
||||||
|
|
||||||
|
/* Amber glow behind browser */
|
||||||
|
const glowOpacity = interpolate(frame, [320, 340], [0, 0.05], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Float animation (subtle oscillation) */
|
||||||
|
const floatY = frame >= 350 ? Math.sin((frame - 350) * 0.15) * 3 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#0c0a09", overflow: "hidden" }}>
|
||||||
|
{/* Amber glow behind browser (Act 5) */}
|
||||||
|
{frame >= 320 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: cx,
|
||||||
|
top: cy - 30,
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 50% 50% at 50% 50%, rgba(245,158,11,0.4) 0%, transparent 70%)",
|
||||||
|
opacity: glowOpacity,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal / Browser window */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: cx,
|
||||||
|
top: cy - 30,
|
||||||
|
transform: `translate(-50%, -50%) translateY(${terminalSlideY + floatY}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalWindow
|
||||||
|
width={windowWidth}
|
||||||
|
height={windowHeight}
|
||||||
|
title="Terminal"
|
||||||
|
dotsOpacity={dotsOpacity}
|
||||||
|
topBarColor={topBarColor}
|
||||||
|
bodyColor={bodyBg}
|
||||||
|
titleElement={frame >= 240 ? <AddressBar opacity={addressBarOpacity} /> : undefined}
|
||||||
|
>
|
||||||
|
{/* Terminal text content (fades out during morph) */}
|
||||||
|
<div style={{ opacity: textFadeOut }}>
|
||||||
|
{/* Prompt line */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...TEXT.mono,
|
||||||
|
fontSize: 18,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: promptOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#8b949e", marginRight: 8 }}>$</span>
|
||||||
|
{frame >= 35 ? (
|
||||||
|
<TypeWriter
|
||||||
|
segments={DOCKER_SEGMENTS}
|
||||||
|
startFrame={35}
|
||||||
|
speed={2}
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
preTypingCursorVisible && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#7ee787",
|
||||||
|
width: 10,
|
||||||
|
height: 20,
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processing output (Act 3) */}
|
||||||
|
{frame >= 180 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...TEXT.mono,
|
||||||
|
fontSize: 16,
|
||||||
|
marginTop: 12,
|
||||||
|
opacity: pullingOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#8b949e" }}>Pulling snapotter/snapotter:latest...</div>
|
||||||
|
|
||||||
|
{/* Spinner line */}
|
||||||
|
{spinnerChar && !showCheckmark && (
|
||||||
|
<div style={{ color: "#58a6ff", marginTop: 4 }}>{spinnerChar}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checkmark line */}
|
||||||
|
{showCheckmark && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#7ee787",
|
||||||
|
marginTop: 4,
|
||||||
|
transform: `scale(${checkmarkSpring})`,
|
||||||
|
transformOrigin: "left center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Container started on :1349
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App mockup (appears during morph) */}
|
||||||
|
{showAppMockup && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
opacity: appMockupOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppMockup startFrame={260} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TerminalWindow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagline (Act 5) */}
|
||||||
|
{frame >= 310 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: cx,
|
||||||
|
top: cy + windowHeight / 2 + 20 + floatY,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipReveal startFrame={320}>
|
||||||
|
<span style={{ ...TEXT.sectionTitle, color: "white" }}>
|
||||||
|
One command. That's it.
|
||||||
|
</span>
|
||||||
|
</ClipReveal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GrainOverlay />
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user