mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(videos): add ToolGalaxy hero composition with burst, orbit, and grid phases
This commit is contained in:
@@ -14,6 +14,7 @@ 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 { OneCommand } from "./compositions/hero/OneCommand";
|
||||||
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
|
import { PrivacyPromise } from "./compositions/hero/PrivacyPromise";
|
||||||
|
import { ToolGalaxy } from "./compositions/hero/ToolGalaxy";
|
||||||
import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
|
import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
|
||||||
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
|
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
|
||||||
import { TEXT } from "./lib/fonts";
|
import { TEXT } from "./lib/fonts";
|
||||||
@@ -121,6 +122,14 @@ export const RemotionRoot: React.FC = () => (
|
|||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
/>
|
/>
|
||||||
|
<Composition
|
||||||
|
id="ToolGalaxy"
|
||||||
|
component={ToolGalaxy}
|
||||||
|
durationInFrames={660}
|
||||||
|
fps={30}
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
/>
|
||||||
<Composition
|
<Composition
|
||||||
id="PromoTeaser"
|
id="PromoTeaser"
|
||||||
component={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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user