// 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}
{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 */} zedx {/* 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, });