first deployment of kikomiko with url photos
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8d98304137
commit
ae3ed7c7b7
Vendored
+444
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Atelier showroom — scroll drives story cards + video scrub on all devices.
|
||||
* Light MP4, http(s) src only (no blob/fastSeek), seeked + short fallback unlock.
|
||||
*/
|
||||
export function initAtelierHero() {
|
||||
const root = document.querySelector("[data-atelier]");
|
||||
if (!(root instanceof HTMLElement)) return;
|
||||
|
||||
const track = root.querySelector(".atelier-track");
|
||||
const fill = root.querySelector("[data-atelier-fill]");
|
||||
const hint = root.querySelector("[data-atelier-hint]");
|
||||
const stage = root.querySelector(".atelier-stage");
|
||||
const bg = root.querySelector(".atelier-bg");
|
||||
const motion = root.querySelector("[data-atelier-motion]");
|
||||
const video = root.querySelector("[data-atelier-video]");
|
||||
const allFeats = [...root.querySelectorAll("[data-atelier-feat]")];
|
||||
const cards = allFeats.filter((el) => el.getAttribute("data-atelier-feat") !== "finale");
|
||||
const finaleEl = allFeats.find((el) => el.getAttribute("data-atelier-feat") === "finale");
|
||||
const dots = [...root.querySelectorAll("[data-atelier-dot]")];
|
||||
|
||||
if (!(track instanceof HTMLElement) || !cards.length) return;
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
const storyMode = root.hasAttribute("data-atelier-story");
|
||||
const pairCount = storyMode ? cards.length : Math.ceil(cards.length / 2);
|
||||
const stepCount = pairCount + (finaleEl ? 1 : 0);
|
||||
const finaleStep = stepCount - 1;
|
||||
|
||||
let active = true;
|
||||
let raf = 0;
|
||||
let ticking = false;
|
||||
let lastStep = -1;
|
||||
let lastAppliedP = -1;
|
||||
let hintHidden = false;
|
||||
let videoReady = false;
|
||||
let scrubDuration = 0;
|
||||
let seeking = false;
|
||||
let seekFallback = 0;
|
||||
let unlocking = false;
|
||||
let decoderUnlocked = false;
|
||||
let targetP = 0;
|
||||
let smoothP = 0;
|
||||
let targetTime = 0;
|
||||
let rootTop = 0;
|
||||
let trackHeight = 0;
|
||||
let metricsDirty = true;
|
||||
let scrollScrub = !reduce;
|
||||
|
||||
const SCRUB_END_TRIM_SEC = 0.08;
|
||||
const SEEK_EPS = coarsePointer ? 0.08 : 0.04;
|
||||
const VIDEO_SRC = "/videos/kiko-hero-motion-v3.mp4";
|
||||
|
||||
const viewportH = () =>
|
||||
window.visualViewport?.height ?? window.innerHeight;
|
||||
|
||||
const refreshDuration = () => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
if (!(Number.isFinite(video.duration) && video.duration > 0)) return;
|
||||
scrubDuration = Math.max(0.5, video.duration - SCRUB_END_TRIM_SEC);
|
||||
};
|
||||
|
||||
const measure = () => {
|
||||
trackHeight = track.offsetHeight;
|
||||
rootTop = root.getBoundingClientRect().top;
|
||||
metricsDirty = false;
|
||||
};
|
||||
|
||||
const lockVideoUi = () => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
video.controls = false;
|
||||
video.removeAttribute("controls");
|
||||
video.setAttribute("controlslist", "nodownload nofullscreen noremoteplayback");
|
||||
try {
|
||||
video.disablePictureInPicture = true;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
video.muted = true;
|
||||
video.defaultMuted = true;
|
||||
video.playsInline = true;
|
||||
video.loop = false;
|
||||
video.removeAttribute("autoplay");
|
||||
video.removeAttribute("loop");
|
||||
video.setAttribute("muted", "");
|
||||
video.setAttribute("playsinline", "");
|
||||
video.setAttribute("webkit-playsinline", "");
|
||||
video.setAttribute("disablepictureinpicture", "");
|
||||
// metadata keeps LCP bandwidth free; bump to auto on first scrub gesture
|
||||
video.preload = "metadata";
|
||||
};
|
||||
|
||||
const preferFullVideoBuffer = () => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
if (video.preload === "auto") return;
|
||||
video.preload = "auto";
|
||||
};
|
||||
|
||||
const clearSeekFallback = () => {
|
||||
if (seekFallback) {
|
||||
window.clearTimeout(seekFallback);
|
||||
seekFallback = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const unlockDecoder = () => {
|
||||
if (!(video instanceof HTMLVideoElement) || decoderUnlocked || unlocking) return;
|
||||
unlocking = true;
|
||||
const unlock = video.play();
|
||||
const done = () => {
|
||||
try {
|
||||
video.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
unlocking = false;
|
||||
decoderUnlocked = true;
|
||||
refreshDuration();
|
||||
if (scrollScrub && active) startLoop();
|
||||
};
|
||||
if (unlock && typeof unlock.then === "function") {
|
||||
unlock.then(done).catch(done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
const applySeek = () => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
if (!scrubDuration || seeking || unlocking) return;
|
||||
if (Math.abs(video.currentTime - targetTime) < SEEK_EPS) return;
|
||||
|
||||
seeking = true;
|
||||
clearSeekFallback();
|
||||
seekFallback = window.setTimeout(() => {
|
||||
seeking = false;
|
||||
seekFallback = 0;
|
||||
if (scrollScrub && active) startLoop();
|
||||
}, coarsePointer ? 260 : 200);
|
||||
|
||||
try {
|
||||
if (!video.paused) video.pause();
|
||||
video.currentTime = targetTime;
|
||||
} catch {
|
||||
seeking = false;
|
||||
clearSeekFallback();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureVideo = () => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
if (!videoReady) {
|
||||
lockVideoUi();
|
||||
videoReady = true;
|
||||
const src =
|
||||
video.getAttribute("data-src") ||
|
||||
video.getAttribute("src") ||
|
||||
VIDEO_SRC;
|
||||
if ((video.getAttribute("src") || "") !== src) {
|
||||
video.src = src;
|
||||
}
|
||||
video.load();
|
||||
}
|
||||
refreshDuration();
|
||||
};
|
||||
|
||||
const onMeta = () => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
refreshDuration();
|
||||
if (scrubDuration > 0) {
|
||||
unlockDecoder();
|
||||
if (scrollScrub && active) startLoop();
|
||||
}
|
||||
};
|
||||
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
video.addEventListener("loadedmetadata", onMeta);
|
||||
video.addEventListener("loadeddata", onMeta);
|
||||
video.addEventListener("canplay", onMeta);
|
||||
video.addEventListener("durationchange", refreshDuration);
|
||||
video.addEventListener("seeked", () => {
|
||||
seeking = false;
|
||||
clearSeekFallback();
|
||||
if (!scrollScrub || !active) return;
|
||||
if (Math.abs(video.currentTime - targetTime) > SEEK_EPS) applySeek();
|
||||
else startLoop();
|
||||
});
|
||||
video.addEventListener("error", () => {
|
||||
seeking = false;
|
||||
clearSeekFallback();
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
video.src = VIDEO_SRC;
|
||||
video.load();
|
||||
});
|
||||
}
|
||||
|
||||
const scrubToward = (progress) => {
|
||||
if (!(video instanceof HTMLVideoElement)) return;
|
||||
if (!scrubDuration) {
|
||||
ensureVideo();
|
||||
if (!scrubDuration) return;
|
||||
}
|
||||
const end = Math.max(0.05, scrubDuration - 0.04);
|
||||
targetTime = Math.min(end, Math.max(0, progress * end));
|
||||
applySeek();
|
||||
};
|
||||
|
||||
const setStep = (index) => {
|
||||
const idx = Math.max(0, Math.min(finaleStep, index | 0));
|
||||
if (idx === lastStep) return;
|
||||
lastStep = idx;
|
||||
const isFinale = idx >= pairCount;
|
||||
root.classList.toggle("is-finale", isFinale);
|
||||
root.dataset.scene = String(idx);
|
||||
root.dataset.pair = isFinale ? "finale" : String(idx);
|
||||
|
||||
cards.forEach((el, i) => {
|
||||
const step = storyMode ? i : Math.floor(i / 2);
|
||||
const on = !isFinale && step === idx;
|
||||
el.classList.toggle("is-active", on);
|
||||
el.classList.toggle("is-passed", !isFinale ? step < idx : true);
|
||||
el.setAttribute("aria-hidden", on ? "false" : "true");
|
||||
});
|
||||
|
||||
if (finaleEl instanceof HTMLElement) {
|
||||
finaleEl.classList.toggle("is-active", isFinale);
|
||||
finaleEl.setAttribute("aria-hidden", isFinale ? "false" : "true");
|
||||
}
|
||||
|
||||
dots.forEach((d, i) => {
|
||||
d.classList.toggle("is-active", i === idx);
|
||||
d.classList.toggle("is-done", i < idx);
|
||||
});
|
||||
|
||||
if (motion instanceof HTMLElement) {
|
||||
motion.classList.toggle("is-exit", isFinale);
|
||||
}
|
||||
};
|
||||
|
||||
setStep(0);
|
||||
|
||||
if (reduce) {
|
||||
scrollScrub = false;
|
||||
root.classList.add("is-reduced");
|
||||
track.style.display = "none";
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
lockVideoUi();
|
||||
ensureVideo();
|
||||
video.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const applyVisual = (p) => {
|
||||
const nextStep = Math.min(finaleStep, Math.floor(p * stepCount));
|
||||
const stepChanged = nextStep !== lastStep;
|
||||
if (lastAppliedP >= 0 && Math.abs(p - lastAppliedP) < 0.0004 && !stepChanged) {
|
||||
scrubToward(p);
|
||||
return;
|
||||
}
|
||||
lastAppliedP = p;
|
||||
|
||||
if (p > 0.012 && !hintHidden) {
|
||||
hintHidden = true;
|
||||
root.classList.add("is-past-hint");
|
||||
if (hint instanceof HTMLElement) hint.hidden = true;
|
||||
}
|
||||
|
||||
if (fill instanceof HTMLElement) fill.style.transform = `scaleY(${p})`;
|
||||
if (bg instanceof HTMLElement) bg.style.setProperty("--atelier-p", p.toFixed(4));
|
||||
if (stage instanceof HTMLElement) {
|
||||
stage.style.setProperty("--atelier-p", p.toFixed(4));
|
||||
const isFinale = nextStep >= pairCount;
|
||||
const exit = isFinale ? 1 : p > 0.86 ? (p - 0.86) / 0.14 : 0;
|
||||
stage.style.setProperty("--atelier-exit", exit.toFixed(4));
|
||||
}
|
||||
|
||||
setStep(nextStep);
|
||||
scrubToward(p);
|
||||
};
|
||||
|
||||
const readTarget = () => {
|
||||
if (metricsDirty) measure();
|
||||
else rootTop = root.getBoundingClientRect().top;
|
||||
|
||||
const scrollable = Math.max(1, trackHeight || root.offsetHeight - viewportH());
|
||||
const scrolled = Math.min(scrollable, Math.max(0, -rootTop));
|
||||
return scrolled / scrollable;
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
raf = 0;
|
||||
if (!active) {
|
||||
ticking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
targetP = readTarget();
|
||||
const gap = Math.abs(targetP - smoothP);
|
||||
const ease = gap > 0.06 ? 0.88 : gap > 0.015 ? 0.68 : 0.48;
|
||||
smoothP += (targetP - smoothP) * ease;
|
||||
if (gap < 0.00008) smoothP = targetP;
|
||||
|
||||
applyVisual(smoothP);
|
||||
|
||||
const needsSeek =
|
||||
scrollScrub &&
|
||||
(seeking || Math.abs((video?.currentTime || 0) - targetTime) > SEEK_EPS);
|
||||
|
||||
if (Math.abs(targetP - smoothP) > 0.0001 || needsSeek) {
|
||||
raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
ticking = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startLoop = () => {
|
||||
if (!active || !scrollScrub) return;
|
||||
if (!ticking) {
|
||||
ticking = true;
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToStep = (index) => {
|
||||
const idx = Math.max(0, Math.min(finaleStep, index | 0));
|
||||
metricsDirty = true;
|
||||
measure();
|
||||
const scrollable = Math.max(1, trackHeight || root.offsetHeight - viewportH());
|
||||
const top =
|
||||
window.scrollY +
|
||||
root.getBoundingClientRect().top +
|
||||
((idx + 0.2) / stepCount) * scrollable;
|
||||
window.scrollTo({ top, behavior: "smooth" });
|
||||
startLoop();
|
||||
};
|
||||
|
||||
const onUserScroll = () => {
|
||||
preferFullVideoBuffer();
|
||||
if (!decoderUnlocked) unlockDecoder();
|
||||
startLoop();
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onUserScroll, { passive: true });
|
||||
window.addEventListener(
|
||||
"resize",
|
||||
() => {
|
||||
metricsDirty = true;
|
||||
startLoop();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
window.visualViewport?.addEventListener(
|
||||
"resize",
|
||||
() => {
|
||||
metricsDirty = true;
|
||||
startLoop();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
window.visualViewport?.addEventListener("scroll", onUserScroll, { passive: true });
|
||||
window.addEventListener(
|
||||
"touchstart",
|
||||
() => {
|
||||
preferFullVideoBuffer();
|
||||
ensureVideo();
|
||||
unlockDecoder();
|
||||
startLoop();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
window.addEventListener("touchmove", onUserScroll, { passive: true });
|
||||
window.addEventListener("wheel", onUserScroll, { passive: true });
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const ro = new ResizeObserver(() => {
|
||||
metricsDirty = true;
|
||||
startLoop();
|
||||
});
|
||||
ro.observe(root);
|
||||
ro.observe(track);
|
||||
}
|
||||
|
||||
dots.forEach((dot, i) => {
|
||||
if (!(dot instanceof HTMLElement)) return;
|
||||
dot.setAttribute("role", "button");
|
||||
dot.setAttribute("tabindex", "0");
|
||||
dot.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollToStep(i);
|
||||
});
|
||||
});
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
active = e.isIntersecting;
|
||||
document.documentElement.classList.toggle("is-atelier-scrub", active && scrollScrub);
|
||||
if (active) {
|
||||
metricsDirty = true;
|
||||
ensureVideo();
|
||||
startLoop();
|
||||
} else {
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
try {
|
||||
video.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ticking = false;
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "180px 0px", threshold: [0, 0.01, 0.1] }
|
||||
);
|
||||
io.observe(root);
|
||||
|
||||
if (window.matchMedia("(pointer: fine)").matches) {
|
||||
cards.forEach((mod) => {
|
||||
if (!(mod instanceof HTMLElement)) return;
|
||||
mod.addEventListener("pointermove", (ev) => {
|
||||
if (!mod.classList.contains("is-active")) return;
|
||||
const r = mod.getBoundingClientRect();
|
||||
mod.style.setProperty("--mx", (((ev.clientX - r.left) / r.width - 0.5) * 10).toFixed(2));
|
||||
mod.style.setProperty("--my", (((ev.clientY - r.top) / r.height - 0.5) * 8).toFixed(2));
|
||||
});
|
||||
mod.addEventListener("pointerleave", () => {
|
||||
mod.style.setProperty("--mx", "0");
|
||||
mod.style.setProperty("--my", "0");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ensureVideo();
|
||||
measure();
|
||||
startLoop();
|
||||
// Decoder unlock (play/pause) pulls media bytes — wait for first scrub gesture
|
||||
}
|
||||
Reference in New Issue
Block a user