feat(videos): add The48 rapid montage showcase composition

This commit is contained in:
SnapOtter
2026-05-08 16:49:34 +08:00
parent d59be53709
commit 1a34acd56d
2 changed files with 659 additions and 0 deletions
+18
View File
@@ -15,8 +15,10 @@ import { PipelineFlow } from "./compositions/features/PipelineFlow";
import { OneCommand } from "./compositions/hero/OneCommand";
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
import { ToolGalaxy } from "./compositions/hero/ToolGalaxy";
import { ProductDemo } from "./compositions/product-demo/ProductDemo";
import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
import { The48 } from "./compositions/showcase/The48";
import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo";
import { TEXT } from "./lib/fonts";
@@ -155,5 +157,21 @@ export const RemotionRoot: React.FC = () => (
width={1080}
height={1080}
/>
<Composition
id="The48"
component={The48}
durationInFrames={840}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="ProductDemo"
component={ProductDemo}
durationInFrames={2250}
fps={30}
width={1920}
height={1080}
/>
</>
);
@@ -0,0 +1,641 @@
import { Trail } from "@remotion/motion-blur";
import type React from "react";
import {
AbsoluteFill,
interpolate,
Sequence,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { GrainOverlay } from "@/components/GrainOverlay";
import { ToolPill } from "@/components/ToolPill";
import { CATEGORY_LABELS, CATEGORY_ORDER, COLOR } from "@/lib/colors";
import { TEXT } from "@/lib/fonts";
import { EASE, SPRING } from "@/lib/motion";
import { getToolsByCategory } from "@/lib/tools";
/* ------------------------------------------------------------------ */
/* Grid layout constants (from spec) */
/* ------------------------------------------------------------------ */
const COLS = 8;
const ROWS = 6;
const CELL_W = 190;
const CELL_H = 44;
const GAP = 8;
const GRID_W = COLS * CELL_W + (COLS - 1) * GAP; // 1576
const GRID_H = ROWS * CELL_H + (ROWS - 1) * GAP; // 304
const GRID_X = (1920 - GRID_W) / 2;
const GRID_Y = (1080 - GRID_H) / 2 + 40;
const CX = 1920 / 2;
const CY = 1080 / 2;
const FRAMES_PER_TOOL = 15;
/* ------------------------------------------------------------------ */
/* Pre-compute tool order and grid positions */
/* ------------------------------------------------------------------ */
interface ToolEntry {
index: number;
name: string;
category: string;
col: number;
row: number;
gridX: number;
gridY: number;
}
const TOOL_ENTRIES: ToolEntry[] = [];
const colCounts: number[] = new Array(COLS).fill(0);
for (const cat of CATEGORY_ORDER) {
const colIdx = CATEGORY_ORDER.indexOf(cat);
const tools = getToolsByCategory(cat);
for (const tool of tools) {
const row = colCounts[colIdx];
colCounts[colIdx] = row + 1;
TOOL_ENTRIES.push({
index: TOOL_ENTRIES.length,
name: tool.name,
category: tool.category,
col: colIdx,
row,
gridX: GRID_X + colIdx * (CELL_W + GAP) + CELL_W / 2,
gridY: GRID_Y + row * (CELL_H + GAP) + CELL_H / 2,
});
}
}
/* ------------------------------------------------------------------ */
/* Micro-visualization categories */
/* ------------------------------------------------------------------ */
type MicroViz = "scale" | "clip" | "rotate" | "sparkle" | "squeeze" | "stamp" | "badge" | "pulse";
function getMicroViz(name: string, category: string): MicroViz {
const lower = name.toLowerCase();
if (lower.includes("resize") || lower.includes("upscal")) return "scale";
if (lower.includes("crop") || lower.includes("split")) return "clip";
if (lower.includes("rotate") || lower.includes("flip")) return "rotate";
if (
lower.includes("convert") ||
lower.includes("svg") ||
lower.includes("pdf") ||
lower.includes("gif")
)
return "badge";
if (lower.includes("compress") || lower.includes("optimi")) return "squeeze";
if (lower.includes("watermark") || lower.includes("stamp") || lower.includes("overlay"))
return "stamp";
if (category === "ai") return "sparkle";
return "pulse";
}
/* ------------------------------------------------------------------ */
/* Entry direction helper */
/* ------------------------------------------------------------------ */
type Direction = "left" | "right" | "top";
function getEntryDirection(index: number): Direction {
if (index % 8 === 0 && index > 0) return "top";
return index % 2 === 0 ? "left" : "right";
}
function getEntryPosition(dir: Direction): { x: number; y: number } {
switch (dir) {
case "left":
return { x: -200, y: CY };
case "right":
return { x: 1920 + 200, y: CY };
case "top":
return { x: CX, y: -80 };
}
}
/* ------------------------------------------------------------------ */
/* Sparkle dots component */
/* ------------------------------------------------------------------ */
const SparkleDots: React.FC<{ progress: number }> = ({ progress }) => {
const offsets = [
{ dx: -16, dy: -16 },
{ dx: 16, dy: -12 },
{ dx: -12, dy: 16 },
{ dx: 18, dy: 14 },
];
const spread = interpolate(progress, [0, 1], [0, 1]);
const opacity = interpolate(progress, [0, 0.3, 1], [0, 1, 0], {
extrapolateRight: "clamp",
});
return (
<>
{offsets.map((o) => (
<div
key={`${o.dx}-${o.dy}`}
style={{
position: "absolute",
left: `calc(50% + ${o.dx * spread * 2}px)`,
top: `calc(50% + ${o.dy * spread * 2}px)`,
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: COLOR.accent,
opacity,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
/>
))}
</>
);
};
/* ------------------------------------------------------------------ */
/* AnimatedToolPill -- self-contained 15-frame animation */
/* ------------------------------------------------------------------ */
const AnimatedToolPill: React.FC<{ tool: ToolEntry }> = ({ tool }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const dir = getEntryDirection(tool.index);
const entry = getEntryPosition(dir);
const viz = getMicroViz(tool.name, tool.category);
// Phase 1: entrance (frame 0-3) -- edge to center
// Phase 2: center flash (frame 3-5) -- micro-visualization
// Phase 3: grid snap (frame 5-15) -- center to grid position
let x: number;
let y: number;
let extraTransform = "";
let clipPath: string | undefined;
let sparkleProgress = 0;
let showSparkle = false;
if (frame < 3) {
// Entrance: edge to center
const t = interpolate(frame, [0, 3], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.snap,
});
x = entry.x + (CX - entry.x) * t;
y = entry.y + (CY - entry.y) * t;
} else if (frame < 5) {
// Center flash with micro-visualization
x = CX;
y = CY;
const vizProgress = interpolate(frame, [3, 5], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
switch (viz) {
case "scale": {
const s = interpolate(vizProgress, [0, 0.5, 1], [1.3, 0.8, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
case "clip": {
const inset = interpolate(vizProgress, [0, 0.5, 1], [0, 15, 0]);
clipPath = `inset(${inset}% ${inset}% ${inset}% ${inset}%)`;
break;
}
case "rotate": {
const deg = interpolate(vizProgress, [0, 1], [0, 90]);
extraTransform = ` rotate(${deg}deg)`;
break;
}
case "badge": {
const s = interpolate(vizProgress, [0, 0.3, 0.7, 1], [0.9, 1.15, 1.15, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
case "squeeze": {
const sx = interpolate(vizProgress, [0, 0.5, 1], [1, 0.7, 1]);
const sy = interpolate(vizProgress, [0, 0.5, 1], [1, 1.2, 1]);
extraTransform = ` scaleX(${sx}) scaleY(${sy})`;
break;
}
case "stamp": {
const ty = interpolate(vizProgress, [0, 0.4, 0.6, 1], [-20, 4, -2, 0]);
const s = interpolate(vizProgress, [0, 0.4, 0.6, 1], [1.2, 0.95, 1.02, 1]);
extraTransform = ` translateY(${ty}px) scale(${s})`;
break;
}
case "sparkle": {
showSparkle = true;
sparkleProgress = vizProgress;
const s = interpolate(vizProgress, [0, 0.5, 1], [1.0, 1.1, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
default: {
const s = interpolate(vizProgress, [0, 0.5, 1], [1.0, 1.1, 1.0]);
extraTransform = ` scale(${s})`;
break;
}
}
} else {
// Grid snap: center to grid position via spring
const s = spring({
frame: frame - 5,
fps,
config: SPRING.settle,
});
x = CX + (tool.gridX - CX) * s;
y = CY + (tool.gridY - CY) * s;
}
return (
<div
style={{
position: "absolute",
left: x,
top: y,
transform: `translate(-50%, -50%)${extraTransform}`,
clipPath,
}}
>
<ToolPill name={tool.name} category={tool.category} />
{showSparkle && <SparkleDots progress={sparkleProgress} />}
</div>
);
};
/* ------------------------------------------------------------------ */
/* Entrance phase wrapper -- uses Trail for motion blur frames 0-3 */
/* ------------------------------------------------------------------ */
const ToolEntrance: React.FC<{ tool: ToolEntry }> = ({ tool }) => {
const frame = useCurrentFrame();
const inMotion = frame < 5;
if (inMotion) {
return (
<Trail layers={4} lagInFrames={0.15} trailOpacity={0.3}>
<AbsoluteFill>
<AnimatedToolPill tool={tool} />
</AbsoluteFill>
</Trail>
);
}
return <AnimatedToolPill tool={tool} />;
};
/* ------------------------------------------------------------------ */
/* Running counter (top-right, amber) */
/* ------------------------------------------------------------------ */
const RunningCounter: React.FC = () => {
const frame = useCurrentFrame();
// Counter shows how many tools have landed (reached frame 15 in their local sequence)
// Tool i starts at frame 30 + i*15, lands at frame 30 + i*15 + 15 = 45 + i*15
let landed = 0;
for (let i = 0; i < TOOL_ENTRIES.length; i++) {
const landFrame = 30 + i * FRAMES_PER_TOOL + FRAMES_PER_TOOL;
if (frame >= landFrame) landed = i + 1;
}
// Don't show counter before any tool has started
if (frame < 45) return null;
// During Act 3 (frame 770-810), counter transitions to "48 tools" and moves to center-top
const isAct3Text = frame >= 770;
// Fade in
const opacity = interpolate(frame, [45, 50], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
if (isAct3Text) {
const moveProgress = interpolate(frame, [770, 800], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const xPos = interpolate(moveProgress, [0, 1], [1920 - 100, CX]);
const yPos = interpolate(moveProgress, [0, 1], [40, 30]);
return (
<div
style={{
position: "absolute",
left: xPos,
top: yPos,
transform: "translateX(-50%)",
opacity,
}}
>
<span
style={{
...TEXT.counter,
fontSize: 48,
color: COLOR.accent,
}}
>
48 tools
</span>
</div>
);
}
return (
<div
style={{
position: "absolute",
right: 60,
top: 40,
opacity,
}}
>
<span
style={{
...TEXT.counter,
fontSize: 48,
color: COLOR.accent,
}}
>
{landed}
</span>
</div>
);
};
/* ------------------------------------------------------------------ */
/* Grid glow wave (Act 3, frame 750-770) */
/* ------------------------------------------------------------------ */
const GridGlowWave: React.FC = () => {
const frame = useCurrentFrame();
if (frame < 750 || frame > 780) return null;
return (
<>
{CATEGORY_ORDER.map((cat, colIdx) => {
const glowStart = 750 + colIdx * 3;
const glowOpacity = interpolate(
frame,
[glowStart, glowStart + 6, glowStart + 12],
[0, 0.2, 0],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
const colX = GRID_X + colIdx * (CELL_W + GAP);
return (
<div
key={cat}
style={{
position: "absolute",
left: colX,
top: GRID_Y - 10,
width: CELL_W,
height: GRID_H + 20,
backgroundColor: "white",
opacity: glowOpacity,
borderRadius: 8,
pointerEvents: "none",
filter: "blur(8px)",
}}
/>
);
})}
</>
);
};
/* ------------------------------------------------------------------ */
/* Category labels (Act 3, frame 770-810) */
/* ------------------------------------------------------------------ */
const CategoryLabelsRow: React.FC = () => {
const frame = useCurrentFrame();
if (frame < 770) return null;
// Act 4 fade
const act4Opacity = interpolate(frame, [810, 820], [1, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<>
{CATEGORY_ORDER.map((cat, i) => {
const labelX = GRID_X + i * (CELL_W + GAP) + CELL_W / 2;
const labelY = GRID_Y - 24;
const catColor = COLOR.category[cat] ?? COLOR.accent;
return (
<div
key={cat}
style={{
position: "absolute",
left: labelX,
top: labelY,
transform: "translateX(-50%)",
opacity: act4Opacity,
}}
>
<ClipReveal startFrame={770 + i * 3}>
<span style={{ ...TEXT.label, fontSize: 12, color: catColor }}>
{CATEGORY_LABELS[cat]}
</span>
</ClipReveal>
</div>
);
})}
</>
);
};
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const The48: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
/* ================================================================ */
/* Act 1: Title (frame 0-30) */
/* ================================================================ */
const titleScale =
frame < 30
? interpolate(spring({ frame, fps, config: SPRING.settle }), [0, 1], [1.1, 1.0])
: 1.0;
// Title moves to top and shrinks during frames 30-60
const titleY = interpolate(frame, [0, 30, 60], [CY - 48, CY - 48, 60], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const titleFontSize = interpolate(frame, [30, 60], [96, 48], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
// Title as persistent header after frame 30 (fades down to subtle)
const headerOpacity =
frame >= 30
? interpolate(frame, [30, 60], [1, 0.7], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 0;
// During Act 4, header fades out
const headerAct4Opacity =
frame >= 810
? interpolate(frame, [810, 820], [0.7, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: headerOpacity;
/* ================================================================ */
/* Act 3: Grid pulse (frame 750-770) */
/* ================================================================ */
const gridPulseScale =
frame >= 750 && frame < 770
? 1 +
0.015 *
Math.sin(
interpolate(frame, [750, 770], [0, Math.PI], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}),
)
: 1.0;
/* ================================================================ */
/* Act 4: Grid fades, tagline (frame 810-840) */
/* ================================================================ */
const gridOpacity =
frame >= 810
? interpolate(frame, [810, 825], [1, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
: 1;
return (
<AbsoluteFill style={{ backgroundColor: COLOR.dark, overflow: "hidden" }}>
{/* ---- Act 1: Title ---- */}
{frame < 30 && (
<div
style={{
position: "absolute",
left: CX,
top: titleY,
transform: `translate(-50%, -50%) scale(${titleScale})`,
textAlign: "center",
}}
>
<ClipReveal startFrame={0} duration={12}>
<span style={{ ...TEXT.counter, fontSize: 96 }}>
48 tools<span style={{ color: COLOR.accent }}>.</span>
</span>
</ClipReveal>
</div>
)}
{/* ---- Title as header (frame 30+) ---- */}
{frame >= 30 && frame < 820 && (
<div
style={{
position: "absolute",
left: CX,
top: titleY,
transform: "translate(-50%, -50%)",
textAlign: "center",
opacity: headerAct4Opacity,
}}
>
<span
style={{
...TEXT.counter,
fontSize: titleFontSize,
}}
>
48 tools<span style={{ color: COLOR.accent }}>.</span>
</span>
</div>
)}
{/* ---- Act 2: Rapid montage ---- */}
<div
style={{
position: "absolute",
inset: 0,
transform: `scale(${gridPulseScale})`,
transformOrigin: "center center",
opacity: gridOpacity,
}}
>
{TOOL_ENTRIES.map((tool) => (
<Sequence
key={tool.index}
from={30 + tool.index * FRAMES_PER_TOOL}
durationInFrames={810 - (30 + tool.index * FRAMES_PER_TOOL)}
layout="none"
>
<ToolEntrance tool={tool} />
</Sequence>
))}
</div>
{/* ---- Grid glow wave (Act 3) ---- */}
<GridGlowWave />
{/* ---- Category labels (Act 3) ---- */}
<div style={{ opacity: gridOpacity }}>
<CategoryLabelsRow />
</div>
{/* ---- Running counter ---- */}
<RunningCounter />
{/* ---- Act 4: Tagline ---- */}
{frame >= 810 && (
<div
style={{
position: "absolute",
left: CX,
top: GRID_Y + GRID_H + 60,
transform: "translateX(-50%)",
textAlign: "center",
}}
>
<ClipReveal startFrame={810} duration={18}>
<span style={{ ...TEXT.sectionTitle }}>
One container. <span style={{ color: COLOR.accent }}>Zero cloud.</span>
</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};