// Zedx hero animation — scenes
// 18s cinematic loop, 1920x1080
//
// Six products flow one-by-one through a continuous stellar current, then
// converge into a final ensemble shot with the brand lockup.
// 0.0 → 2.6 Z-Boom Speaker
// 2.6 → 5.2 Power Dock X
// 5.2 → 7.8 Magnetic Mount
// 7.8 → 10.4 Open-Ear Buds
// 10.4 → 13.0 Lumen 100 Headphones
// 13.0 → 15.6 AT24 Ultra Watch
// 15.6 → 18.0 Collection finale (all 6 + logo + tagline)
const STAGE_W = 1920;
const STAGE_H = 1080;
const BRAND = {
cyan: '#00a0e3',
cyanSoft: '#7ddcff',
cyanDeep: '#0078ad',
ink: '#04070d',
inkSoft: '#0a1220',
white: '#f4f8fb',
mute: 'rgba(225,238,255,0.55)',
};
// ── Product catalog (single source of truth — used by both per-product
// scenes and the final ensemble shot) ──────────────────────────────────────
const PRODUCTS = [
{ id: 'speaker', src: 'assets/speaker.png', eyebrow: 'Audio', shortName: 'Z-Boom', name: ['Z-Boom', 'Speaker'], tagline: 'Studio-grade sound. Portable form.', glow: '#c34dff' },
{ id: 'dock', src: 'assets/dock.png', eyebrow: 'Charge', shortName: 'Power Dock', name: ['Power', 'Dock X'], tagline: '105W GaN Pro. Three ports. One brick.', glow: '#ff8a3b' },
{ id: 'mount', src: 'assets/mount.png', eyebrow: 'Drive', shortName: 'Mag Mount', name: ['Magnetic', 'Mount'], tagline: 'Snap-lock magnets. 15W wireless.', glow: '#1eb6ff' },
{ id: 'earbuds', src: 'assets/earbuds.png', eyebrow: 'Audio', shortName: 'Open-Ear', name: ['Open-Ear', 'Buds'], tagline: 'Surround sound. Aware of the world.', glow: '#7c9fff' },
{ id: 'headphones', src: 'assets/headphones.png', eyebrow: 'Audio', shortName: 'Lumen 100', name: ['Lumen 100', 'Headphones'], tagline: 'Active noise cancel. 40h playtime.', glow: '#d6a64a' },
{ id: 'watch', src: 'assets/watch.png', eyebrow: 'Wear', shortName: 'AT24 Ultra', name: ['AT24', 'Ultra'], tagline: 'Titanium frame. Built to go further.', glow: '#ff7a3b' },
];
// ── Scene color keyframes — one per product + loop-back ────────────────────
const SCENE_KEYS = [
{ t: 0.0, rgb: [195, 77, 255] }, // speaker (magenta)
{ t: 2.6, rgb: [255, 138, 59] }, // dock (amber)
{ t: 5.2, rgb: [ 0, 160, 227] }, // mount (brand blue)
{ t: 7.8, rgb: [124, 159, 255] }, // earbuds (cool blue)
{ t: 10.4, rgb: [214, 166, 74] }, // headphones (gold)
{ t: 13.0, rgb: [255, 122, 59] }, // watch (amber)
{ t: 15.6, rgb: [ 0, 160, 227] }, // collection (brand blue)
{ t: 18.0, rgb: [195, 77, 255] }, // loop back
];
const BOUNDARIES = [2.6, 5.2, 7.8, 10.4, 13.0, 15.6];
function sceneColor(time, alpha = 1) {
const t = ((time % 18) + 18) % 18;
let i = 0;
while (i < SCENE_KEYS.length - 1 && t > SCENE_KEYS[i + 1].t) i++;
const a = SCENE_KEYS[i];
const b = SCENE_KEYS[Math.min(i + 1, SCENE_KEYS.length - 1)];
const span = Math.max(0.001, b.t - a.t);
const local = clamp((t - a.t) / span, 0, 1);
const holdFrac = 0.48;
let mix;
if (local < holdFrac) mix = 0;
else {
const x = (local - holdFrac) / (1 - holdFrac);
mix = x * x * (3 - 2 * x);
}
const r = Math.round(a.rgb[0] + (b.rgb[0] - a.rgb[0]) * mix);
const g = Math.round(a.rgb[1] + (b.rgb[1] - a.rgb[1]) * mix);
const bl = Math.round(a.rgb[2] + (b.rgb[2] - a.rgb[2]) * mix);
return `rgba(${r},${g},${bl},${alpha})`;
}
function transitionStrength(time, halfWidth = 0.7) {
const t = ((time % 18) + 18) % 18;
let max = 0;
for (const b of BOUNDARIES) {
const d = Math.abs(t - b);
if (d < halfWidth) {
const s = 1 - d / halfWidth;
const eased = s * s * (3 - 2 * s);
if (eased > max) max = eased;
}
}
return max;
}
// ── Nebula background ──────────────────────────────────────────────────────
function NebulaBackground() {
const t = useTime();
const ax = 32 + Math.sin(t * 0.16) * 10 + t * 0.18;
const ay = 38 + Math.cos(t * 0.14) * 8;
const bx = 68 + Math.cos(t * 0.12) * 11 - t * 0.14;
const by = 62 + Math.sin(t * 0.18) * 8;
const c1 = sceneColor(t, 0.38);
const c2 = sceneColor(t + 0.6, 0.28);
return (
);
}
// ── Aurora ribbon ──────────────────────────────────────────────────────────
function AuroraRibbon({ baseY = 540, amp = 90, period = 7, thickness = 140, opacity = 0.45, phase = 0 }) {
const t = useTime();
const cy = baseY + Math.sin((t + phase) / period * Math.PI * 2) * amp;
const cy2 = baseY + Math.cos((t + phase) / (period * 1.3) * Math.PI * 2) * amp * 0.8;
const d = `M -200,${cy} C 480,${cy - 220} 1440,${cy2 + 240} 2120,${cy2}`;
const colMain = sceneColor(t, 0.95);
const colEdge = sceneColor(t + 0.4, 0);
const gradId = `aurora-grad-${phase}`;
return (
);
}
// ── Stellar stream ─────────────────────────────────────────────────────────
function StellarStream({ count = 95, seed = 7 }) {
const t = useTime();
const particles = React.useMemo(() => {
const out = [];
let s = seed;
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
for (let i = 0; i < count; i++) {
out.push({
baseY: -10 + r() * 120,
amp: 2 + r() * 8,
period: 3 + r() * 7,
phase: r() * 6.28,
speed: 5 + r() * 14,
offset: r() * 130,
size: 1 + r() * 2.6,
depth: 0.35 + r() * 0.65,
streak: r() < 0.22,
rise: -2 + r() * 4,
});
}
return out;
}, [count, seed]);
const transition = transitionStrength(t, 1.1);
const speedMult = 0.72 + transition * 0.24;
return (
{particles.map((p, i) => {
let x = ((p.offset + t * p.speed * speedMult) % 130) - 15;
let y = p.baseY + Math.sin(t / p.period * 6.28 + p.phase) * p.amp + t * p.rise * 0.5;
y = ((y % 120) + 120) % 120 - 10;
const col = sceneColor(t, (0.35 + p.depth * 0.45));
const colCore = sceneColor(t, 0.95);
if (p.streak) {
const len = 44 + p.size * 22 + transition * 34;
return (
);
}
return (
);
})}
);
}
// ── Warp burst ─────────────────────────────────────────────────────────────
function WarpBurst() {
const t = useTime();
const tt = ((t % 18) + 18) % 18;
return (
{BOUNDARIES.map((b, i) => {
const dt = tt - b;
if (dt < -0.5 || dt > 1.4) return null;
const local = clamp((dt + 0.5) / 1.9, 0, 1);
const eased = Easing.easeOutQuart(local);
const ringOut = sceneColor(b - 0.05, 0.85);
const ringSize = 100 + eased * 2400;
const ringOpacity = Math.max(0, 1 - local) * 0.7;
const ringIn = sceneColor(b + 0.4, 0.9);
const ringInLocal = clamp((dt + 0.1) / 0.8, 0, 1);
const ringInEased = 1 - Easing.easeInCubic(1 - ringInLocal);
const ringInSize = 1800 - ringInEased * 1500;
const ringInOpacity = ringInLocal < 1 ? (1 - ringInLocal) * 0.5 : 0;
const flareT = clamp(1 - Math.abs(dt) / 0.5, 0, 1);
const flareSize = 600 + flareT * 800;
const flareOpacity = flareT * 0.7;
const flareCol = sceneColor(b, 1);
return (
{ringInOpacity > 0 && (
)}
);
})}
);
}
function sceneMix(localTime, duration, entry = 0.75, exit = 0.5) {
const exitStart = duration - exit;
let enterT = clamp(localTime / entry, 0, 1);
let exitT = clamp((localTime - exitStart) / exit, 0, 1);
const holdSpan = Math.max(0.001, exitStart - entry);
let holdT = clamp((localTime - entry) / holdSpan, 0, 1);
return { enterT, holdT, exitT };
}
// ── Persistent collection rail ─────────────────────────────────────────────
// A small "constellation" of all 6 products tucked into the bottom edge,
// visible throughout the whole video. The active one is highlighted with
// the scene's glow color; the rest sit dim. This satisfies the "show all
// products even while animation is going" intent — the viewer always
// knows the full lineup, even mid-zoom.
function CollectionRail() {
const t = useTime();
const tt = ((t % 18) + 18) % 18;
// Active product index (matches the scene timing)
let active = 0;
if (tt < 2.6) active = 0;
else if (tt < 5.2) active = 1;
else if (tt < 7.8) active = 2;
else if (tt < 10.4) active = 3;
else if (tt < 13.0) active = 4;
else if (tt < 15.6) active = 5;
else active = -1; // collection finale — no single active item
// Hide rail during the finale (it appears properly in the finale)
const finaleFade = tt < 15.0 ? 1 : tt < 15.6 ? 1 - (tt - 15.0) / 0.6 : 0;
return (
{PRODUCTS.map((p, i) => {
const isActive = i === active;
const scale = isActive ? 1.15 : 0.92;
const pulse = isActive ? 0.6 + 0.4 * Math.sin(t * 3) : 0;
return (
{isActive && (
)}
{p.shortName}
);
})}
);
}
// ── Product reveal scene ───────────────────────────────────────────────────
function ProductScene({ src, name, tagline, eyebrow, glowColor = BRAND.cyan, rotateDir = 1, startScale = 1, endScale = 1.08 }) {
const { localTime, duration } = useSprite();
const { enterT, holdT, exitT } = sceneMix(localTime, duration, 1.15, 1.1);
const eEnter = Easing.easeOutSine(enterT);
const eExit = Easing.easeInOutSine(exitT);
const driftT = Easing.easeInOutSine(holdT);
const scale = startScale + (endScale - startScale) * driftT - (1 - eEnter) * 0.08 - eExit * 0.035;
const opacity = eEnter * (1 - eExit);
const rotY = rotateDir * (-3.5 + 7 * driftT) + Math.sin(localTime * 0.32) * 0.55;
const rotX = -1.2 + Math.cos(localTime * 0.28) * 0.9;
const ty = (1 - eEnter) * 18 + Math.sin(localTime * 0.42) * 3 - eExit * 16;
const tx = (1 - eEnter) * -10 * rotateDir + eExit * 12 * rotateDir;
const glowPulse = 0.92 + 0.08 * Math.sin(localTime * 0.62);
const glowOpacity = eEnter * (1 - eExit * 0.45);
const captionEase = Easing.easeOutSine(clamp(localTime / 1.25, 0, 1));
const captionY = (1 - captionEase) * 18;
const captionOp = captionEase * (1 - eExit);
return (
{eyebrow}
{name[0]} {name[1]}
{tagline}
);
}
// ── Collection finale ──────────────────────────────────────────────────────
// All 6 products converge into a single hero frame. Logo + tagline + CTAs
// occupy the upper-middle; the 6 products line up across the lower band,
// each lit by its signature glow. Caption: "The Zedx Collection".
function CollectionFinale() {
const { localTime, duration } = useSprite();
// Title block (top)
const titleT = Easing.easeOutCubic(clamp(localTime / 1.1, 0, 1));
const titleOp = titleT;
const titleY = (1 - titleT) * 30;
// Logo
const logoT = Easing.easeOutCubic(clamp((localTime - 0.2) / 1.0, 0, 1));
const logoBlur = (1 - logoT) * 18;
const logoScale = 0.85 + 0.15 * logoT;
// Tagline
const tagT = Easing.easeOutCubic(clamp((localTime - 0.7) / 0.9, 0, 1));
// CTAs
const ctaT = Easing.easeOutCubic(clamp((localTime - 1.3) / 0.7, 0, 1));
// Product row enters with stagger
const productStagger = 0.14;
const productBase = 0.5;
// Exit (last 0.4s, looping back to scene 1)
const exitT = clamp((localTime - (duration - 0.5)) / 0.5, 0, 1);
const groupOp = 1 - exitT * 0.95;
const groupScale = 1 + exitT * 0.04;
return (
{/* big central glow */}
{/* Eyebrow */}
The Zedx Collection
{/* Logo */}
{/* Tagline */}
Engineered for the everyday.
{/* CTA row */}
Shop the range
Learn more →
{/* Product line-up */}
{PRODUCTS.map((p, i) => {
const delay = productBase + i * productStagger;
const pT = Easing.easeOutCubic(clamp((localTime - delay) / 0.8, 0, 1));
const pY = (1 - pT) * 70;
const pOp = pT;
const float = Math.sin(localTime * 1.0 + i * 0.7) * 5;
const pulse = 0.85 + 0.15 * Math.sin(localTime * 1.6 + i * 0.9);
return (
{/* outer halo */}
{/* inner punch */}
);
})}
);
}
// ── Bottom ticker ──────────────────────────────────────────────────────────
function BottomTicker() {
const t = useTime();
const tt = ((t % 18) + 18) % 18;
// Hide in finale (collection has its own UI)
const fade = tt < 15.0 ? 1 : tt < 15.6 ? 1 - (tt - 15.0) / 0.6 : 0;
return (
Engineered · Tested · Verified
);
}
// ── Scene counter ──────────────────────────────────────────────────────────
function SceneIndexCounter() {
const t = useTime();
const tt = ((t % 18) + 18) % 18;
const TOTAL = 7;
let idx;
if (tt < 2.6) idx = 1;
else if (tt < 5.2) idx = 2;
else if (tt < 7.8) idx = 3;
else if (tt < 10.4) idx = 4;
else if (tt < 13.0) idx = 5;
else if (tt < 15.6) idx = 6;
else idx = 7;
// Hide during finale
const fade = tt < 15.0 ? 1 : tt < 15.6 ? 1 - (tt - 15.0) / 0.6 : 0;
return (
{String(idx).padStart(2, '0')}
{String(TOTAL).padStart(2, '0')}
);
}
// ── Timestamp label (for review/comments) ──────────────────────────────────
function TimestampLabel() {
const t = useTime();
const sec = Math.floor(t);
React.useEffect(() => {
const root = document.querySelector('[data-video-root]');
if (root) root.setAttribute('data-screen-label', `t=${sec}s`);
}, [sec]);
return null;
}
// ── Master scene composition ──────────────────────────────────────────────
function HeroVideo() {
return (
{/* Per-product scenes use long overlaps for a slower product-launch flow. */}
{/* Collection finale */}
);
}
Object.assign(window, {
HeroVideo, STAGE_W, STAGE_H, BRAND, PRODUCTS,
sceneColor, transitionStrength,
});