feat(videos): add ToolGalaxy hero composition with burst, orbit, and grid phases

This commit is contained in:
SnapOtter
2026-05-08 16:44:13 +08:00
parent ccf4653b96
commit fe4309bad9
2 changed files with 495 additions and 0 deletions
+9
View File
@@ -14,6 +14,7 @@ import { FormatUniverse } from "./compositions/features/FormatUniverse";
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 { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
import { TEXT } from "./lib/fonts";
@@ -121,6 +122,14 @@ export const RemotionRoot: React.FC = () => (
width={1920}
height={1080}
/>
<Composition
id="ToolGalaxy"
component={ToolGalaxy}
durationInFrames={660}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="PromoTeaser"
component={PromoTeaser}
@@ -0,0 +1,486 @@
import { Trail } from "@remotion/motion-blur";
import type React from "react";
import {
AbsoluteFill,
Img,
interpolate,
random,
spring,
staticFile,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { ClipReveal } from "@/components/ClipReveal";
import { Counter } from "@/components/Counter";
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 { TOOLS } from "@/lib/tools";
/* ------------------------------------------------------------------ */
/* Pre-computed pill data */
/* ------------------------------------------------------------------ */
interface PillData {
index: number;
name: string;
category: string;
categoryIndex: number;
rowInCategory: number;
angle: number;
burstRadius: number;
rotationOffset: number;
}
const RING_RADII = [160, 210, 260, 310, 360, 410, 460, 510];
/** Speed in rad/frame for each ring (inner faster, outer slower) */
const RING_SPEEDS = RING_RADII.map(
(_, i) => 0.008 - i * ((0.008 - 0.003) / (RING_RADII.length - 1)),
);
/** Alternating rotation direction: even rings CW, odd rings CCW */
const RING_DIRECTIONS = RING_RADII.map((_, i) => (i % 2 === 0 ? 1 : -1));
/** Category counts for computing per-category row indices */
const categoryCounts: Record<string, number> = {};
for (const cat of CATEGORY_ORDER) {
categoryCounts[cat] = 0;
}
const PILLS: PillData[] = TOOLS.map((tool, i) => {
const catIdx = CATEGORY_ORDER.indexOf(tool.category as (typeof CATEGORY_ORDER)[number]);
const categoryIndex = catIdx >= 0 ? catIdx : 0;
const rowInCategory = categoryCounts[tool.category] ?? 0;
categoryCounts[tool.category] = rowInCategory + 1;
return {
index: i,
name: tool.name,
category: tool.category,
categoryIndex,
rowInCategory,
angle: random(`angle-${i}`) * Math.PI * 2,
burstRadius: 200 + random(`radius-${i}`) * 400,
rotationOffset: (random(`rot-${i}`) - 0.5) * 30,
};
});
/* ------------------------------------------------------------------ */
/* Grid layout constants */
/* ------------------------------------------------------------------ */
const GRID_COLS = CATEGORY_ORDER.length; // 8
const CELL_W = 120;
const CELL_H = 32;
const CELL_GAP = 8;
/** Max rows across all categories (AI has 15) */
const MAX_ROWS = Math.max(
...CATEGORY_ORDER.map((cat) => TOOLS.filter((t) => t.category === cat).length),
);
const GRID_TOTAL_W = GRID_COLS * CELL_W + (GRID_COLS - 1) * CELL_GAP;
const GRID_TOTAL_H = MAX_ROWS * CELL_H + (MAX_ROWS - 1) * CELL_GAP;
const GRID_LEFT = (1920 - GRID_TOTAL_W) / 2;
const GRID_TOP = (1080 - GRID_TOTAL_H) / 2 + 30; // offset down slightly for label room
/* ------------------------------------------------------------------ */
/* Position helpers */
/* ------------------------------------------------------------------ */
const CX = 1920 / 2;
const CY = 1080 / 2;
function getBurstPosition(
frame: number,
fps: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
const entryFrame = 60 + pill.index * 2;
const s = spring({
frame: frame - entryFrame,
fps,
config: SPRING.popIn,
});
const x = CX + Math.cos(pill.angle) * pill.burstRadius * s;
const y = CY + Math.sin(pill.angle) * pill.burstRadius * s;
return {
x,
y,
scale: s,
opacity: Math.min(s * 2, 1),
rotation: pill.rotationOffset * (1 - s * 0.5),
};
}
function getOrbitPosition(
frame: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
const catIdx = pill.categoryIndex;
const ringRadius = RING_RADII[catIdx];
const ringSpeed = RING_SPEEDS[catIdx];
const direction = RING_DIRECTIONS[catIdx];
// Distribute pills evenly within their ring
const toolsInCat = TOOLS.filter((t) => t.category === pill.category).length;
const baseAngle = (pill.rowInCategory / toolsInCat) * Math.PI * 2;
// Accumulated angle: integrate speed from frame 210 to current frame
// For simplicity, use frame-based angle with deceleration applied
let orbitAngle: number;
if (frame < 360) {
orbitAngle = baseAngle + (frame - 210) * ringSpeed * direction;
} else {
// Angle accumulated up to frame 360 + decelerating portion
const preDecAngle = (360 - 210) * ringSpeed * direction;
const decProgress = interpolate(frame, [360, 420], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.exit,
});
// Deceleration: linearly decreasing speed from full to 0
// Integral of (1-t) from 0 to decProgress = decProgress - decProgress^2/2
const decAngle = (frame - 360) * ringSpeed * direction * (1 - decProgress * 0.5);
orbitAngle = baseAngle + preDecAngle + decAngle;
}
const rawX = Math.cos(orbitAngle) * ringRadius;
const rawY = Math.sin(orbitAngle) * ringRadius * 0.6; // elliptical perspective
// Depth: pills at "back" of orbit (positive sin component) are dimmer/smaller
const depthFactor = Math.sin(orbitAngle);
const depthOpacity = interpolate(depthFactor, [-1, 1], [1, 0.7]);
const depthScale = interpolate(depthFactor, [-1, 1], [1, 0.85]);
// Transition from burst to orbit (frame 180-210)
const transitionProgress = interpolate(frame, [180, 210], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
// If still transitioning, we need the burst end position
if (transitionProgress < 1) {
const burstX = CX + Math.cos(pill.angle) * pill.burstRadius;
const burstY = CY + Math.sin(pill.angle) * pill.burstRadius;
const orbitX = CX + rawX;
const orbitY = CY + rawY;
return {
x: burstX + (orbitX - burstX) * transitionProgress,
y: burstY + (orbitY - burstY) * transitionProgress,
scale: 1 + (depthScale - 1) * transitionProgress,
opacity: 1 + (depthOpacity - 1) * transitionProgress,
rotation: pill.rotationOffset * (1 - transitionProgress),
};
}
return {
x: CX + rawX,
y: CY + rawY,
scale: depthScale,
opacity: depthOpacity,
rotation: 0,
};
}
function getGridPosition(
frame: number,
fps: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
const col = pill.categoryIndex;
const row = pill.rowInCategory;
const targetX = GRID_LEFT + col * (CELL_W + CELL_GAP) + CELL_W / 2;
const targetY = GRID_TOP + row * (CELL_H + CELL_GAP) + CELL_H / 2;
// Spring animation from orbit position to grid position
const staggerDelay = pill.index * 1.5;
const settleFrame = 420 + staggerDelay;
const s = spring({
frame: frame - settleFrame,
fps,
config: SPRING.settle,
});
// Get last orbit position (frozen at deceleration end)
const orbitPos = getOrbitPosition(420, pill);
const x = orbitPos.x + (targetX - orbitPos.x) * s;
const y = orbitPos.y + (targetY - orbitPos.y) * s;
// Act 5: grid fades to 40% opacity
const gridOpacity = interpolate(frame, [540, 570], [1, 0.4], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
return {
x,
y,
scale: orbitPos.scale + (1 - orbitPos.scale) * s,
opacity: gridOpacity,
rotation: 0,
};
}
function getPillPosition(
frame: number,
fps: number,
pill: PillData,
): { x: number; y: number; scale: number; opacity: number; rotation: number } {
if (frame < 180) return getBurstPosition(frame, fps, pill);
if (frame < 420) return getOrbitPosition(frame, pill);
return getGridPosition(frame, fps, pill);
}
/* ------------------------------------------------------------------ */
/* AnimatedPill - renders a single pill at its computed position */
/* ------------------------------------------------------------------ */
const AnimatedPill: React.FC<{ pill: PillData }> = ({ pill }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pos = getPillPosition(frame, fps, pill);
// Don't render before the pill's entry frame
const entryFrame = 60 + pill.index * 2;
if (frame < entryFrame) return null;
return (
<div
style={{
position: "absolute",
left: pos.x,
top: pos.y,
transform: `translate(-50%, -50%) scale(${pos.scale}) rotate(${pos.rotation}deg)`,
opacity: pos.opacity,
}}
>
<ToolPill name={pill.name} category={pill.category} />
</div>
);
};
/* ------------------------------------------------------------------ */
/* BurstPhase - pills wrapped in Trail for motion blur */
/* ------------------------------------------------------------------ */
const BurstPills: React.FC = () => (
<AbsoluteFill>
{PILLS.map((pill) => (
<AnimatedPill key={pill.index} pill={pill} />
))}
</AbsoluteFill>
);
/* ------------------------------------------------------------------ */
/* Category labels for the grid */
/* ------------------------------------------------------------------ */
const CategoryLabels: React.FC = () => {
const frame = useCurrentFrame();
if (frame < 500) return null;
return (
<>
{CATEGORY_ORDER.map((cat, i) => {
const labelX = GRID_LEFT + i * (CELL_W + CELL_GAP) + CELL_W / 2;
const labelY = GRID_TOP - 20;
const catColor = COLOR.category[cat] ?? COLOR.accent;
// Act 5 grid opacity
const gridOpacity = interpolate(frame, [540, 570], [1, 0.4], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.smooth,
});
return (
<div
key={cat}
style={{
position: "absolute",
left: labelX,
top: labelY,
transform: "translateX(-50%)",
opacity: gridOpacity,
}}
>
<ClipReveal startFrame={500 + i * 3}>
<span style={{ ...TEXT.label, fontSize: 11, color: catColor }}>
{CATEGORY_LABELS[cat]}
</span>
</ClipReveal>
</div>
);
})}
</>
);
};
/* ------------------------------------------------------------------ */
/* Main composition */
/* ------------------------------------------------------------------ */
export const ToolGalaxy: React.FC = () => {
const frame = useCurrentFrame();
/* ================================================================ */
/* Act 1: Logo Pulse (frame 0-60) */
/* ================================================================ */
const logoOpacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const logoPulseScale = frame >= 30 ? Math.sin(frame * 0.15) * 0.05 + 1 : 1;
// Logo fades out as burst starts
const logoFadeOut = interpolate(frame, [60, 90], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Radial glow breathes with the pulse
const glowOpacity = frame >= 30 ? 0.15 + Math.sin(frame * 0.15) * 0.05 : 0.05;
// Ambient radial glow (always present, fades during burst)
const ambientGlow = interpolate(frame, [0, 20], [0.05, 0.15], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
/* ================================================================ */
/* Act 5: Counter + Tagline (frame 540-660) */
/* ================================================================ */
const counterOpacity = interpolate(frame, [540, 555], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Counter scale bump: subtle pulse as numbers tick
const counterProgress = interpolate(frame, [540, 590], [0, 48], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: EASE.enter,
});
const counterBump = frame >= 540 && frame <= 595 ? 1 + Math.sin(counterProgress * 0.8) * 0.03 : 1;
/* ================================================================ */
/* Determine rendering phase */
/* ================================================================ */
// During burst (frame 60-180), wrap pills in Trail for motion blur
const inBurstPhase = frame >= 60 && frame < 180;
return (
<AbsoluteFill
style={{
background: `radial-gradient(ellipse 60% 50% at 50% 50%, rgba(245,158,11,${ambientGlow}) 0%, #0c0a09 70%)`,
overflow: "hidden",
}}
>
{/* ---- Radial amber glow behind logo (Act 1) ---- */}
{frame < 90 && (
<div
style={{
position: "absolute",
left: CX,
top: CY,
width: 400,
height: 400,
transform: "translate(-50%, -50%)",
background:
"radial-gradient(circle at 50% 50%, rgba(245,158,11,0.3) 0%, transparent 70%)",
opacity: glowOpacity * logoFadeOut,
filter: "blur(100px)",
pointerEvents: "none",
}}
/>
)}
{/* ---- Logo (Act 1) ---- */}
{frame < 120 && (
<div
style={{
position: "absolute",
left: CX,
top: CY,
transform: `translate(-50%, -50%) scale(${logoPulseScale})`,
opacity: logoOpacity * logoFadeOut,
}}
>
<Img
src={staticFile("logo.png")}
width={120}
height={120}
style={{ objectFit: "contain" }}
/>
</div>
)}
{/* ---- Pills ---- */}
{frame >= 60 &&
(inBurstPhase ? (
<Trail layers={6} lagInFrames={0.12} trailOpacity={0.4}>
<BurstPills />
</Trail>
) : (
<BurstPills />
))}
{/* ---- Category labels (Act 4) ---- */}
<CategoryLabels />
{/* ---- Counter + Tagline (Act 5) ---- */}
{frame >= 540 && (
<div
style={{
position: "absolute",
left: CX,
top: CY - 30,
transform: `translate(-50%, -50%) scale(${counterBump})`,
textAlign: "center",
opacity: counterOpacity,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
<Counter from={0} to={TOOLS.length} startFrame={540} duration={50} style={TEXT.counter} />
</div>
)}
{/* ---- Tagline text (Act 5) ---- */}
{frame >= 580 && (
<div
style={{
position: "absolute",
left: CX,
top: CY + 40,
transform: "translateX(-50%)",
textAlign: "center",
}}
>
<ClipReveal startFrame={580}>
<span style={{ ...TEXT.sectionTitle }}>tools. Zero cloud dependency.</span>
</ClipReveal>
</div>
)}
<GrainOverlay />
</AbsoluteFill>
);
};