/* film-kit.js: the moving parts of a launch film, for a paused GSAP timeline rendered frame by frame. * * FilmKit.mount({ duration, fonts, build: (k) => { ...use k.tl and the helpers... } }) * * Sets window.DURATION, window.ready and an async window.seek(t) that moves the timeline, then runs * per-frame work (footage frames, typing, anything registered with k.dyn) and waits for images to * decode, so every rendered frame is complete. Needs gsap and MotionPathPlugin loaded first. * Rules baked in (see references/launch-film.md): 2D transforms only, nothing with text scales slowly, * text slides always animate out, mutually exclusive states are computed per frame, not tweened. */ (function () { const RENDER = location.search.includes("render"); const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; const DYN = []; const CUES = { click: [] }; window.CUES = CUES; function fit(stage) { const go = () => (stage.style.transform = `scale(${RENDER ? 1 : Math.min(innerWidth / stage.offsetWidth, innerHeight / stage.offsetHeight)})`); go(); addEventListener("resize", go); } // stage-px box of an element (works inside scaled previews too) function boxer(stage) { return (el) => { if (typeof el === "string") el = $(el); const sr = stage.getBoundingClientRect(), k = sr.width / stage.offsetWidth, r = el.getBoundingClientRect(); return { x: (r.left - sr.left) / k, y: (r.top - sr.top) / k, w: r.width / k, h: r.height / k, cx: (r.left - sr.left + r.width / 2) / k, cy: (r.top - sr.top + r.height / 2) / k }; }; } function helpers(tl, stage) { const box = boxer(stage); const show = (sel, t) => tl.set(sel, { visibility: "visible" }, t); const hide = (sel, t) => tl.set(sel, { visibility: "hidden" }, t); const cut = (from, to, t) => { hide(from, t); show(to, t); }; // Headlines: every line is a mask (.ln) with the text in a span. In from below, out above and fading, // far enough that descenders never linger at the top of the mask. const rise = (sel, t, stagger = 0.08, d = 0.75) => tl.to(`${sel} .ln > span`, { yPercent: 0, opacity: 1, duration: d, ease: "expo.out", stagger }, t); const sink = (sel, t) => tl.to(`${sel} .ln > span`, { yPercent: -135, duration: 0.38, ease: "power3.in", stagger: 0.04 }, t); // Scene transitions. Slides (text on a colour) sit above the scene they cover (z-index), so a curtain // can lift off them and reveal what is underneath; never hard-cut away from a text slide. const curtainIn = (sel, t, d = 0.5) => tl.set(sel, { visibility: "visible", clipPath: "inset(100% 0% 0% 0%)" }, t) .to(sel, { clipPath: "inset(0% 0% 0% 0%)", duration: d, ease: "power4.inOut" }, t); const curtainOut = (sel, t, d = 0.5) => tl.to(sel, { clipPath: "inset(0% 0% 100% 0%)", duration: d, ease: "power4.inOut" }, t) .set(sel, { visibility: "hidden", clipPath: "inset(0% 0% 0% 0%)" }, t + d + 0.01); const whip = (from, to, t, d = 0.44) => { tl.set(to, { visibility: "visible", x: stage.offsetWidth }, t) .to(from, { x: -stage.offsetWidth, duration: d, ease: "power3.inOut" }, t).to(to, { x: 0, duration: d, ease: "power3.inOut" }, t) .to([from, to], { filter: "blur(22px)", duration: d / 2, ease: "power2.in" }, t).to([from, to], { filter: "blur(0px)", duration: d / 2, ease: "power2.out" }, t + d / 2) .set(from, { visibility: "hidden", x: 0 }, t + d + 0.01); }; const push = (from, to, t, d = 0.5) => { tl.set(to, { visibility: "visible", y: stage.offsetHeight }, t) .to(from, { y: -stage.offsetHeight, duration: d, ease: "power4.inOut" }, t).to(to, { y: 0, duration: d, ease: "power4.inOut" }, t) .set(from, { visibility: "hidden", y: 0 }, t + d + 0.01); }; // A circle opening from a point (a logo, a button) to reveal a layer; and the reverse. const openFrom = (sel, x, y, t, d = 0.5, r0 = 0) => tl.set(sel, { visibility: "visible", clipPath: `circle(${r0}px at ${x}px ${y}px)` }, t - 0.01) .to(sel, { clipPath: `circle(${Math.hypot(stage.offsetWidth, stage.offsetHeight)}px at ${x}px ${y}px)`, duration: d, ease: "power3.in" }, t); const closeTo = (sel, x, y, t, d = 0.6, r1 = 0) => tl.set(sel, { clipPath: `circle(${Math.hypot(stage.offsetWidth, stage.offsetHeight)}px at ${x}px ${y}px)` }, t - 0.02) .to(sel, { clipPath: `circle(${r1}px at ${x}px ${y}px)`, duration: d, ease: "power3.inOut" }, t); // Phrases that replace each other in one spot: outgoing and incoming move as one strip, a line apart. const roll = (out, inn, at, d = 0.42) => { if (out) tl.to(`${out} > span`, { yPercent: -128, duration: d, ease: "expo.out" }, at).to(`${out} > span`, { opacity: 0, duration: 0.07, ease: "none" }, at); tl.to(`${inn} > span`, { yPercent: 0, duration: d, ease: "expo.out" }, at); }; // Letters typed one by one into pre-laid-out spans (no reflow), with a caret following the last one. const typeLetters = (container, t0, per = 0.04, caret = null) => { const chars = $$(".ch", typeof container === "string" ? $(container) : container); const at = chars.map((c) => box(c)); DYN.push((t) => { const n = t < t0 ? 0 : Math.min(chars.length, Math.floor((t - t0) / per + 1e-6) + 1); chars.forEach((c, i) => (c.style.opacity = i < n ? 1 : 0)); if (caret) { const b = at[Math.max(0, n - 1)]; const done = t > t0 + chars.length * per + 0.2; caret.style.opacity = t < t0 - 0.15 ? 0 : done ? (Math.floor((t - t0) * 2.4) % 2 ? 0 : 1) : 1; caret.style.transform = `translate(${(n ? b.x + b.w + 4 : b.x - 8).toFixed(1)}px, ${b.y.toFixed(1)}px)`; } }); return t0 + chars.length * per; }; // Typing into a field: text grows from nothing (left-aligned fields only; centred text uses typeLetters). const typeInto = (el, text, t0, per = 0.03, until = Infinity) => { const esc = (s) => s.replace(/&/g, "&").replace(/ { if (t < t0 - 1 || t > until) return; const n = Math.max(0, Math.min(text.length, Math.floor((t - t0) / per))); const blink = n === text.length && Math.floor(t * 2.2) % 2 ? ' style="opacity:0"' : ""; el.innerHTML = esc(text.slice(0, n)) + ``; }); return t0 + text.length * per; }; // One-of-many states (a status line: Draft / Saving / Saved): computed per frame from a list // of changes, so two can never overlap the way competing tweens do. const exclusive = (els, initial = null) => { const ev = initial ? [[-1, initial]] : []; DYN.push((t) => { let k = -1; for (let j = 0; j < ev.length; j++) if (ev[j][0] <= t) k = j; const cur = ev[k], prev = ev[k - 1]; for (const [id, el] of Object.entries(els)) { let o = 0, y = 0; if (cur && id === cur[1]) { o = Math.min(1, Math.max(0, (t - cur[0] - 0.12) / 0.3)); y = 4 * (1 - o); } else if (prev && id === prev[1]) { o = 1 - Math.min(1, Math.max(0, (t - cur[0]) / 0.14)); y = -4 * (1 - o); } el.style.opacity = o.toFixed(3); el.style.transform = `translateY(${y.toFixed(2)}px)`; } }); return (id, t) => { if (!ev.length || ev[ev.length - 1][1] !== id) { ev.push([t, id]); ev.sort((a, b) => a[0] - b[0]); } }; }; // Cursor: an arrow whose tip is the click point; clicks and presses are recorded in CUES for the mix. const cur = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.85 }; gsap.set(["#cur", "#halo"], { x: cur.x, y: cur.y }); gsap.set("#cur", { scale: 0, opacity: 1 }); gsap.set("#halo", { scale: 0.4, opacity: 0 }); const cursor = { jump: (x, y, t) => { tl.set(["#cur", "#halo"], { x, y }, t); cur.x = x; cur.y = y; }, show: (t) => tl.to("#cur", { scale: 1, duration: 0.35, ease: "back.out(2)" }, t), hide: (t) => tl.to("#cur", { scale: 0, duration: 0.25, ease: "power2.in" }, t), move: (x, y, t, d = 0.6) => { const dx = x - cur.x, dy = y - cur.y, dist = Math.hypot(dx, dy) || 1; let px = -dy / dist, py = dx / dist; if (py > 0 || (Math.abs(py) < 0.2 && px < 0)) { px = -px; py = -py; } const bend = Math.min(0.16 * dist, 90), ctrl = { x: cur.x + dx / 2 + px * bend, y: cur.y + dy / 2 + py * bend }; tl.to(["#cur", "#halo"], { motionPath: { path: [{ x: cur.x, y: cur.y }, ctrl, { x, y }], type: "quadratic" }, duration: d, ease: "power3.inOut" }, t); cur.x = x; cur.y = y; }, click: (t) => { CUES.click.push(+t.toFixed(3)); tl.fromTo("#halo", { scale: 0.4, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: "back.out(2.2)", immediateRender: false }, t - 0.3) .to("#cur", { scale: 0.82, duration: 0.07, ease: "power1.in" }, t).to("#cur", { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.45)" }, t + 0.07) .to("#halo", { scale: 0.8, duration: 0.07, ease: "power1.in" }, t).to("#halo", { scale: 1.5, opacity: 0, duration: 0.45, ease: "power2.out" }, t + 0.07) .set("#rip", { x: cur.x, y: cur.y, scale: 0.35, opacity: 0.6 }, t + 0.03).to("#rip", { scale: 1.7, opacity: 0, duration: 0.6, ease: "power2.out" }, t + 0.04); }, }; const press = (el, t, s = 0.95) => { CUES.click.push(+t.toFixed(3)); tl.to(el, { scale: s, duration: 0.07, ease: "power1.in" }, t).to(el, { scale: 1, duration: 0.45, ease: "elastic.out(1, 0.5)" }, t + 0.07); }; // Labels in viewfinder brackets over footage: one label per cut, the brackets refit and punch. const viewfinder = (labels) => { const w = Object.fromEntries(labels.map(([id]) => [id, box(`#lab-${id}`).w])); gsap.set(".lab", { xPercent: -50, yPercent: -50, opacity: 0 }); gsap.set("#brk", { xPercent: -50, yPercent: -50, opacity: 0 }); labels.forEach(([id, t], i) => { const fitB = { width: w[id] + 150, height: 176 }; if (i) tl.set(`#lab-${labels[i - 1][0]}`, { opacity: 0 }, t); if (!i) tl.set("#brk", fitB, t).fromTo("#brk", { opacity: 0, scale: 1.3 }, { opacity: 1, scale: 1, duration: 0.45, ease: "expo.out", immediateRender: false }, t) .fromTo(`#lab-${id}`, { opacity: 0, scale: 1.06, filter: "blur(12px)" }, { opacity: 1, scale: 1, filter: "blur(0px)", duration: 0.4, ease: "power3.out", immediateRender: false }, t); else tl.to("#brk", { ...fitB, duration: 0.3, ease: "expo.out" }, t).fromTo("#brk", { scale: 1.08 }, { scale: 1, duration: 0.4, ease: "expo.out", immediateRender: false }, t) .fromTo(`#lab-${id}`, { opacity: 1, scale: 1.04 }, { scale: 1, duration: 0.35, ease: "expo.out", immediateRender: false }, t); }); }; return { tl, $, $$, box, show, hide, cut, rise, sink, curtainIn, curtainOut, whip, push, openFrom, closeTo, roll, typeLetters, typeInto, exclusive, cursor, press, viewfinder, dyn: (fn) => DYN.push(fn), cues: CUES }; } // Footage plates: [{ key, t0, t1, kind: "clip"|"still", frames }] with inside #weave. // Clips show plates//NNN.jpg for the current frame; stills push in slowly. Grain and a little gate // weave move per frame from a seeded hash, so renders are identical. function plates(k, sched, fps = 60) { const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }; const items = sched.map((p) => ({ ...p, img: $(`#pl-${p.key}`) })); for (const p of items) k.tl.fromTo(p.img, { scale: 1, x: 0 }, { scale: p.kind === "still" ? 1.07 : 1.035, x: p.kind === "still" ? -14 : 0, duration: p.t1 - p.t0, ease: "none", immediateRender: false }, p.t0); let shown = null; return async (t) => { const p = items.find((s) => t >= s.t0 - 1e-6 && t < s.t1 - 1e-6) || null; $("#plates").style.visibility = p ? "visible" : "hidden"; if (shown && shown !== p) shown.img.style.display = "none"; shown = p; if (!p) return; p.img.style.display = "block"; const f = Math.round(t * fps); $("#weave").style.translate = `${((hash(f) - 0.5) * 2.4).toFixed(2)}px ${((hash(f + 7) - 0.5) * 2.4).toFixed(2)}px`; const g = $("#plGrain"); if (g) g.style.backgroundPosition = `${Math.floor(hash(f + 3) * 240)}px ${Math.floor(hash(f + 5) * 240)}px`; if (p.kind === "clip") { const n = Math.max(1, Math.min(p.frames, Math.floor((t - p.t0) * fps + 1e-4) + 1)); const src = `plates/${p.key}/${String(n).padStart(3, "0")}.jpg`; if (p.img.getAttribute("src") !== src) { p.img.setAttribute("src", src); try { await p.img.decode(); } catch {} } } }; } function mount({ duration, fonts = [], build, stage = "#stage" }) { gsap.registerPlugin(MotionPathPlugin); gsap.config({ force3D: false }); const st = $(stage); fit(st); window.DURATION = duration; let tl, platesFn = null; const frame = async (t) => { for (const f of DYN) f(t); if (platesFn) await platesFn(t); }; window.seek = async (t) => { tl.time(t, false); await frame(t); }; window.ready = (async () => { await Promise.all(fonts.map((f) => document.fonts.load(f).catch(() => {}))); await document.fonts.ready; await Promise.all($$("img").map((i) => i.decode().catch(() => {}))); tl = gsap.timeline({ paused: true, defaults: { ease: "expo.out" } }); const k = helpers(tl, st); k.plates = (sched, fps) => (platesFn = plates(k, sched, fps)); gsap.set(".hd .ln > span", { yPercent: 108 }); build(k); tl.to({}, { duration: 0.01 }, duration - 0.01); tl.time(0); await frame(0); })(); if (!RENDER) window.ready.then(() => { tl.eventCallback("onUpdate", () => frame(tl.time())); tl.repeat(-1); tl.play(0); }); } window.FilmKit = { mount }; })();