import { previewUrl } from "@/config/assets";
const KEYS = [3, 5, 6, 8, 11, 12, 15, 18, 21].map(
(n) => `dts/unwrapped/DTS_Unwrapped_by_FRANCO_DUPUY_${n}.jpg`
damp: [0.09, 0.02, 0.3, 5e-3],
flick: [140, 0, 400, 10],
const s = document.createElement("style");
s.dataset.labStyle = "light-table";
.lt { position: absolute; inset: 0; overflow: hidden; cursor: grab;
user-select: none; touch-action: pan-y; --vel: 0; }
.lt.is-dragging { cursor: grabbing; }
/* The emulsion band: one continuous strip across the viewport. Reserved
rows, top to bottom: holes / roll marks / frame / tags / holes — nothing
shares a row, so nothing can overlap. Perforations are two repeating
gradient rows whose background-position rides the same scroll offset as
the frames (--shift), so the film moves as ONE object. */
.lt__film { position: absolute; inset-inline: 0; top: 50%; translate: 0 -50%;
height: calc(var(--lt-frame-h) + 64px); background: #161615;
border-block: 1px solid rgba(247, 247, 247, 0.08); }
.lt__film::before, .lt__film::after { content: ""; position: absolute;
inset-inline: 0; height: 11px;
background-image: radial-gradient(4px 5px at 14px 50%, var(--paper) 96%, transparent);
background-size: 28px 11px;
background-position-x: var(--shift);
.lt__film::before { top: 7px; }
.lt__film::after { bottom: 7px; }
.lt__frame { position: absolute; top: 32px; left: 0;
width: var(--lt-frame-w); will-change: transform; }
.lt__im { overflow: hidden; aspect-ratio: 3 / 2; background: #0c0c0c; }
.lt__im img { display: block; width: 112%; height: 100%; max-width: none;
object-fit: cover; pointer-events: none; will-change: transform;
filter: grayscale(1) invert(1) contrast(0.92) blur(calc(var(--vel) * 4px));
/* develop is the SHOW (slow, expo.out); un-develop is the HIDE (quick,
cubic-in) — the house asymmetry */
transition: filter 0.25s var(--ease-in-cubic); }
/* roll marking above every third frame — lives in its own reserved row */
.lt__roll { position: absolute; top: -13px; left: 4px;
font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.16em;
color: rgba(247, 247, 247, 0.4); text-transform: uppercase;
white-space: nowrap; pointer-events: none; }
/* frame tag in its own row between frame and bottom holes */
.lt__tag { position: absolute; inset-inline-start: 2px; bottom: -16px;
font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.16em;
color: rgba(247, 247, 247, 0.4); text-transform: uppercase;
white-space: nowrap; pointer-events: none;
transition: color 0.25s var(--ease-in-cubic); }
/* idle + parked at center → the loupe develops it */
.lt__frame.is-loupe img, .lt__frame.is-hot img {
filter: grayscale(1) invert(0) contrast(1) blur(calc(var(--vel) * 4px));
transition: filter 0.55s var(--ease-out-expo); }
.lt__frame.is-loupe .lt__tag, .lt__frame.is-hot .lt__tag {
color: rgba(247, 247, 247, 0.92);
transition: color 0.55s var(--ease-out-expo); }
/* in motion NOTHING develops — the moving strip is all negative */
.lt.is-moving .lt__frame img {
filter: grayscale(1) invert(1) contrast(0.92) blur(calc(var(--vel) * 4px));
transition: filter 0.25s var(--ease-in-cubic); }
.lt.is-moving .lt__tag { color: rgba(247, 247, 247, 0.4); }
.lt__hud { position: absolute; inset-inline: var(--s-16);
inset-block-end: var(--s-12); display: flex; justify-content: space-between;
font-family: var(--font-mono); font-size: var(--fs-mono-meta);
letter-spacing: 0.06em; color: var(--ink-muted); text-transform: uppercase;
@media (prefers-reduced-motion: reduce) {
.lt__im img, .lt__tag { transition: none; }
.lt__im img { filter: grayscale(1) invert(1); }
.lt__frame.is-loupe img, .lt__frame.is-hot img { filter: grayscale(1); }
const pad = (n) => String(n).padStart(2, "0");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const wrap = document.createElement("div");
const film = document.createElement("div");
film.className = "lt__film";
KEYS.forEach((key, i) => {
const f = document.createElement("figure");
f.className = "lt__frame";
<div class="lt__im"><img src="${previewUrl(key, 720)}" alt="" loading="eager" decoding="async" draggable="false" /></div>
${i % 3 === 0 ? '<span class="lt__roll">C·O LAB 400</span>' : ""}
<span class="lt__tag">▶ ${pad(i + 1)}A</span>`;
const hud = document.createElement("div");
hud.className = "lt__hud";
hud.innerHTML = `<span data-lt-count>01/${pad(N)}</span><span>DRAG::WHEEL::THROW</span>`;
const count = hud.querySelector("[data-lt-count]");
viewW = wrap.clientWidth;
frameW = Math.min(300, Math.max(200, viewW * 0.26));
max = Math.max(0, total - GAP - viewW + 80);
wrap.style.setProperty("--lt-frame-w", `${frameW}px`);
wrap.style.setProperty("--lt-frame-h", `${frameH}px`);
const ro = new ResizeObserver(measure);
const mod = (v, m) => (v % m + m) % m;
const clampFinite = (v) => Math.max(-step * 0.6, Math.min(max + step * 0.6, v));
if (!P.loop) tar = Math.max(0, Math.min(max, tar));
tar = Math.round(tar / step) * step;
if (!P.loop) tar = Math.max(0, Math.min(max, tar));
const armSettle = () => {
settleT = window.setTimeout(snapTo, 300);
const d = e.deltaMode === 1 ? e.deltaY * 0.8333 * 16 : e.deltaY;
if (!P.loop) tar = clampFinite(tar);
wrap.addEventListener("pointerdown", (e) => {
lastT = performance.now();
wrap.classList.add("is-dragging");
window.addEventListener("pointermove", onMove);
const now = performance.now();
const dx = start - e.clientX;
flickV = flickV * 0.7 + dx / Math.max(now - lastT, 1) * 0.3;
if (tar < 0) tar = tar / 2;
else if (tar > max) tar = max + (tar - max) / 2;
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
wrap.classList.remove("is-dragging");
wrap.classList.remove("is-dragging");
if (!reduced && Math.abs(flickV) > 0.15) {
tar += flickV * P.flick * P.drag;
if (!P.loop) tar = clampFinite(tar);
wrap.style.outline = "none";
wrap.addEventListener("keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const base = Math.round(tar / step) * step;
tar = base + (e.key === "ArrowRight" ? step : -step);
if (!P.loop) tar = Math.max(0, Math.min(max, tar));
wrap.addEventListener("pointermove", (e) => {
mx = e.clientX - wrap.getBoundingClientRect().left;
wrap.addEventListener("pointerleave", () => mx = -1);
if (hot >= 0) frames[hot].classList.remove("is-hot");
cur += (tar - cur) * (reduced ? 1 : P.damp);
if (Math.abs(tar - cur) < 0.05) cur = tar;
const vel = Math.min(1, Math.abs(tar - cur) / (step * 1.2));
if (P.latent && !reduced && Math.abs(vel - lastVel) > 0.01) {
wrap.style.setProperty("--vel", vel.toFixed(3));
if (!moving && vel > 0.02) {
wrap.classList.add("is-moving");
} else if (moving && vel < 8e-3) {
wrap.classList.remove("is-moving");
const centre = viewW / 2 - frameW / 2;
for (let i = 0; i < N; i++) {
x = centre + (mod(i * step - cur + total / 2, total) - total / 2);
frames[i].style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
const n = (x + frameW / 2 - viewW / 2) / viewW;
if (Math.abs(n) < 0.75) {
const img = frames[i].firstElementChild?.firstElementChild;
img.style.transform = `translate3d(${(-n * 10).toFixed(2)}%, 0, 0)`;
wrap.style.setProperty("--shift", `${(-cur).toFixed(2)}px`);
const li = P.loop ? mod(Math.round(cur / step), N) : Math.max(0, Math.min(N - 1, Math.round(cur / step)));
if (loupe >= 0) frames[loupe].classList.remove("is-loupe");
frames[loupe].classList.add("is-loupe");
count.textContent = `${pad(loupe + 1)}/${pad(N)}`;
if (!dragging && !moving && mx >= 0) {
for (let i = 0; i < N; i++) {
const m = frames[i].style.transform.match(/([-\d.]+)px/);
const x = m ? parseFloat(m[1]) : -9999;
if (mx >= x && mx <= x + frameW) {
if (idx !== hotCandidate) {
hotSince = performance.now();
} else if (idx !== hot && performance.now() - hotSince > 120) {
if (hot >= 0) frames[hot].classList.remove("is-hot");
if (hot >= 0) frames[hot].classList.add("is-hot");
} else if (hot >= 0 && (dragging || moving)) {
gsap.ticker.remove(tick);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
if (key === "snap" || key === "latent" || key === "loop") {
P[key] = value === "on" || value === true ? 1 : 0;
if (key === "latent" && !P.latent) wrap.style.setProperty("--vel", "0");
if (key === "loop" && !P.loop) {
tar = Math.max(0, Math.min(max, mod(tar, total)));
experiment.mount(document.querySelector(".stage"));