mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(videos): add The48 rapid montage showcase composition
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user