/* 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 };
})();