// transitions.js: changes of scene, and an optional film finish, for the video skill's HTML pages. // // transition(t, {at, kind, dur, ...}, outEl, inEl) both scenes' looks for one change of scene // scenes([el, spec, el, spec, el]) a whole film of scenes: film(t), film.cuts // finish(el, t, {grain, vignette, sweeps}) grain, vignette and light sweeps; off by default // // A scene root is one element that holds one shot: an absolutely positioned box that fills the // stage, with its own background. Put the roots side by side in one container (#scenes), and keep // captions and the finish layer outside it. transition() and scenes() own each root's opacity, // transform, filter, clip-path, mask, z-index and visibility: animate the content inside a root, // never the root itself. // // Like motion.js, every look is a pure function of t, so any frame renders on its own, in any // order. The kinds draw on ideas from AbubakrChan/product-launch-motion (MIT, the zoom-through's // numbers and the finish), haidrrrry/claude-remotion-skill (MIT, the whip) and cth9191/animate // (MIT, the morph bridge). The code is this skill's own. See references/transitions.md. import { clamp, lerp, rand, $ } from "./motion.js"; // ---------- kinds ---------- // Default durations follow animation-principles.md: a scene change in a teaser, loop or launch // film takes 0.8–1.2 s. Explainers and training take the longer column (1.0–1.8 s); pass `dur`. // `handoff` is how far through the transition the new scene takes over. review.mjs cuts compares // the motion on each side of it, and of the start and the end. export const KINDS = { cut: { dur: 0, handoff: 0 }, dissolve: { dur: 0.8, handoff: 0.5 }, push: { dur: 1.0, handoff: 0.5, dir: "left" }, "zoom-through": { dur: 0.8, handoff: 0.5 }, whip: { dur: 0.8, handoff: 0.5, dir: "left" }, wipe: { dur: 0.9, handoff: 0.5, dir: "right" }, "light-leak": { dur: 1.0, handoff: 0.5, dir: "right" }, morph: { dur: 1.2, handoff: 0.475 }, // the middle of the shape's own change (MORPH_AT below) }; // The direction the picture moves. A push "left" moves both scenes left, so the new one enters // from the right; a wipe "right" moves its edge to the right. const DIRS = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] }; function resolve(spec) { if (!spec || typeof spec !== "object") throw new Error("transition: the spec must be an object like {at: 4.2, kind: \"dissolve\"}"); const kind = spec.kind ?? "cut"; const k = KINDS[kind]; if (!k) throw new Error(`transition: unknown kind ${JSON.stringify(kind)}; use ${Object.keys(KINDS).join(", ")}`); if (!Number.isFinite(spec.at)) throw new Error(`transition ${kind}: "at" must be a time in seconds, not ${JSON.stringify(spec.at)}`); const dur = kind === "cut" ? 0 : spec.dur ?? k.dur; if (kind !== "cut" && !(dur > 0)) throw new Error(`transition ${kind} at ${spec.at}: "dur" must be more than 0 (use kind "cut" for no overlap)`); const dir = spec.dir ?? k.dir; if (dir !== undefined && !DIRS[dir] && !(kind === "wipe" && Number.isFinite(dir))) { throw new Error(`transition ${kind} at ${spec.at}: dir must be ${Object.keys(DIRS).join(", ")}${kind === "wipe" ? " or an angle in degrees" : ""}, not ${JSON.stringify(dir)}`); } return { ...spec, kind, dur, dir, end: spec.at + dur, handoff: spec.at + dur * k.handoff, key: `${kind}@${spec.at}` }; } // ---------- easing ---------- const easeIn = (p, k = 3) => p ** k; const easeOut = (p, k = 3) => 1 - (1 - p) ** k; const easeInOut = (p, k = 3) => (p < 0.5 ? 2 ** (k - 1) * p ** k : 1 - (2 - 2 * p) ** k / 2); const smooth = (a, b, x) => { const u = clamp((x - a) / (b - a)); return u * u * (3 - 2 * u); }; // ---------- looks ---------- const element = (el, name) => { const e = typeof el === "string" ? $(el) : el; if (!e) throw new Error(`transition: ${name} ${JSON.stringify(el)} matches nothing on the page`); return e; }; // Every property a transition owns is written on every frame, so nothing carries over. function put(el, { hidden = false, opacity = 1, x = 0, y = 0, scale = 1, origin = "", blur = 0, filter = "", clip = "none", mask = "none", z = "" } = {}) { const s = el.style; s.visibility = hidden ? "hidden" : "visible"; s.opacity = hidden ? "0" : opacity.toFixed(4); s.transform = x || y || scale !== 1 ? `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${scale.toFixed(4)})` : "none"; s.transformOrigin = origin; s.filter = filter || (blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : "none"); s.clipPath = clip; s.maskImage = s.webkitMaskImage = mask; s.zIndex = z; } const HIDDEN = { hidden: true }; const origin = (o) => (Array.isArray(o) ? `${o[0]}px ${o[1]}px` : o ?? ""); const LOOKS = { // The new scene fades in over the old one, which stays opaque underneath, so the picture never // dips toward the stage color halfway (two fades at once would). dissolve(p, s, out, inn) { put(out, { z: 1 }); put(inn, { z: 2, opacity: easeInOut(p, 2) }); }, // Both scenes travel one frame together, easing in and out like any move across the frame. push(p, s, out, inn) { const [dx, dy] = DIRS[s.dir], W = out.offsetWidth, H = out.offsetHeight; const e = easeInOut(p, 3); put(out, { z: 1, x: dx * W * e, y: dy * H * e }); put(inn, { z: 2, x: -dx * W * (1 - e), y: -dy * H * (1 - e) }); }, // The old scene rushes past the camera (to 2.5x, blur 8, ease-in) while the new one arrives from // behind it (from 0.5x, blur 8, ease-out). The old one blurs from 20% of the way and fades from 40% // to 90%: fading later leaves a fast ghost that vanishes at the end, fading earlier reads as a // double exposure. The new one is nearly full size before the old one thins, so the stage never // shows around it. Scale moves in log space, as a zoom looks. "zoom-through"(p, s, out, inn) { const big = s.outScale ?? 2.5, small = s.inScale ?? 0.5, blur = s.blur ?? 8, o = origin(s.origin); const eo = easeIn(p, 3), ei = easeOut(p, 5); put(out, { z: 2, origin: o, scale: big ** eo, blur: blur * smooth(0.2, 0.9, p), opacity: 1 - smooth(0.4, 0.9, p) }); put(inn, { z: 1, origin: o, scale: small ** (1 - ei), blur: blur * (1 - ei), opacity: easeOut(p, 3) }); }, // A camera whip: a hard pull to a peak speed at the handoff, then a long settle (quartic in and // out, so most of the travel happens in the middle third). The scenes sit side by side, so the // seam crosses the frame at the peak, hidden in streaks: a blur along the move, as wide as the // speed. The renderer's motion blur adds to it. There is no counter-move first: moving the old // scene back would show the stage at its trailing edge. whip(p, s, out, inn, fx) { const [dx, dy] = DIRS[s.dir], size = dx ? out.offsetWidth : out.offsetHeight; const o = easeInOut(p, 4); const perFrame = (Math.abs(easeInOut(clamp(p + 1e-3), 4) - easeInOut(clamp(p - 1e-3), 4)) / 2e-3) * size / s.dur / 60; const streak = Math.min(s.streak ?? 48, 0.25 * perFrame); // px of blur per px/frame at 60 fps put(out, { z: 1, x: dx * size * o, y: dy * size * o, filter: fx.streak(0, streak, dx !== 0) }); put(inn, { z: 2, x: -dx * size * (1 - o), y: -dy * size * (1 - o), filter: fx.streak(1, streak, dx !== 0) }); }, // The new scene is revealed behind a moving edge: "linear" (the default, along dir or an angle in // degrees), "iris" (a circle from `origin`, default the center), or a function (p, {width, // height}) => a CSS clip-path for any shape. `soft` is the edge's width in px; `band` adds a // solid band in that color leading a linear edge. wipe(p, s, out, inn, fx) { const W = inn.offsetWidth, H = inn.offsetHeight, soft = s.soft ?? 2, shape = s.shape ?? "linear"; const e = easeInOut(p, 3); put(out, { z: 1 }); if (typeof shape === "function") return put(inn, { z: 2, clip: shape(e, { width: W, height: H }) }); if (shape === "iris") { const [cx, cy] = Array.isArray(s.origin) ? s.origin : [W / 2, H / 2]; const R = Math.max(...[[0, 0], [W, 0], [0, H], [W, H]].map(([x, y]) => Math.hypot(x - cx, y - cy))) + soft; const r = R * e; return put(inn, { z: 2, mask: `radial-gradient(circle at ${cx}px ${cy}px, #000 ${(r - soft).toFixed(1)}px, transparent ${r.toFixed(1)}px)` }); } if (shape !== "linear") throw new Error(`wipe: shape must be "linear", "iris" or a function, not ${JSON.stringify(shape)}`); // CSS gradient angles: 0deg points up, 90deg right. The gradient line spans the whole box. const deg = Number.isFinite(s.dir) ? s.dir : { up: 0, right: 90, down: 180, left: 270 }[s.dir]; const a = (deg * Math.PI) / 180, L = Math.abs(W * Math.sin(a)) + Math.abs(H * Math.cos(a)); const band = s.band ? s.bandWidth ?? 0.06 * L : 0; const edge = lerp(-(band + soft), L + soft, e); put(inn, { z: 2, mask: `linear-gradient(${deg}deg, #000 ${(edge - soft).toFixed(1)}px, transparent ${edge.toFixed(1)}px)` }); if (band) fx.band(`linear-gradient(${deg}deg, transparent ${edge.toFixed(1)}px, ${s.band} ${edge.toFixed(1)}px, ${s.band} ${(edge + band).toFixed(1)}px, transparent ${(edge + band).toFixed(1)}px)`); }, // Warm light washes over the frame from one side, peaks, and recedes. The scenes swap underneath // the peak, so the change is never seen. Plain alpha only: see references/transitions.md. "light-leak"(p, s, out, inn, fx) { put(out, { z: 1 }); put(inn, { z: 2, opacity: easeInOut(clamp((p - 0.36) / 0.28), 2) }); fx.leak(p); }, // A shape bridges the two scenes. The old scene shrinks into a window shaped like `from` (an // outline in it), the outline morphs into `to` (an outline in the new scene) on the bare stage, // and the new scene opens out of it. See morph() below. morph(p, s, out, inn, fx) { morph(p, s, out, inn, fx); }, }; // ---------- effects: layers a kind adds, created once per transition ---------- // Decorative layers carry data-fx, so review.mjs cuts leaves them out of the picture's motion. The // morph's shape is the picture while it is on screen, so it carries data-morph instead. const fxCache = new WeakMap(); let fxSerial = 0; function effects(s, out, inn) { const stage = out.parentElement; const byKey = fxCache.get(stage) ?? fxCache.set(stage, new Map()).get(stage); if (byKey.has(s.key)) return byKey.get(s.key); const layers = []; const layer = (tag = "div", css = "") => { const ns = tag === "svg" ? "http://www.w3.org/2000/svg" : null; const el = ns ? document.createElementNS(ns, tag) : document.createElement(tag); el.setAttribute(s.kind === "morph" ? "data-morph" : "data-fx", s.kind); el.setAttribute("style", `position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:3;display:none;${css}`); stage.appendChild(el); layers.push(el); return el; }; const fx = { hide() { for (const el of layers) el.style.display = "none"; } }; if (s.kind === "whip") { // One horizontal (or vertical) blur per scene, kept inside the scene's own box with its edge // pixels repeated, so each scene stays opaque to its edge and the stage never shows at the seam. const svg = layer("svg", "width:0;height:0;"); svg.style.display = "block"; const ids = [0, 1].map(() => `fx-streak-${++fxSerial}`); svg.innerHTML = ids.map((id) => `` + ``).join(""); const blurs = [...svg.querySelectorAll("feGaussianBlur")]; fx.streak = (i, sigma, horizontal) => { if (sigma < 0.3) return ""; blurs[i].setAttribute("stdDeviation", horizontal ? `${sigma.toFixed(2)} 0` : `0 ${sigma.toFixed(2)}`); return `url(#${ids[i]})`; }; fx.hide = () => {}; // the filters do nothing unless a scene uses them } if (s.kind === "wipe") { const band = layer(); fx.band = (bg) => { band.style.display = "block"; band.style.background = bg; }; } if (s.kind === "light-leak") { // Light that strikes the film near one edge: a hot core inside an amber bloom inside a red // fringe, entering along dir and receding. A seeded height, so each leak differs but every // render of it is the same. It covers about half the frame at its peak, never all of it. const root = layer("div", "overflow:hidden;"); const r = rand(s.seed ?? s.key); const [dx, dy] = DIRS[s.dir]; const across = lerp(-0.22, 0.22, r()); const blobs = [ // [size as a share of the frame's shorter side, length across the move, travel along it, colors] [1.15, 1.3, [-0.34, 0.22], "rgba(214,72,46,0.42) 0%, rgba(210,68,44,0.16) 55%, rgba(210,66,44,0) 100%"], [0.82, 1.45, [-0.26, 0.28], "rgba(255,170,92,0.58) 0%, rgba(246,132,70,0.24) 52%, rgba(240,116,60,0) 100%"], [0.46, 1.75, [-0.14, 0.26], "rgba(255,248,236,0.9) 0%, rgba(255,222,180,0.62) 34%, rgba(255,178,110,0.2) 66%, rgba(255,160,96,0) 100%"], ].map(([size, tall, travel, stops], i) => { const el = document.createElement("div"); el.style.cssText = `position:absolute;left:0;top:0;border-radius:50%;background:radial-gradient(closest-side, ${stops});`; root.appendChild(el); return { el, size, tall, travel, lag: i * 0.06 + r() * 0.04, across: across + lerp(-0.05, 0.05, r()) }; }); const haze = document.createElement("div"); haze.style.cssText = "position:absolute;inset:0;background:rgb(255,190,140);"; root.prepend(haze); fx.leak = (p) => { root.style.display = "block"; // before measuring: a hidden layer has no size const W = root.offsetWidth, H = root.offsetHeight, base = Math.min(W, H); const env = Math.sin(Math.PI * p) ** 2; // peaks at the handoff haze.style.opacity = (0.1 * env).toFixed(4); for (const b of blobs) { const q = clamp((p - b.lag) / (1 - b.lag)); const along = lerp(b.travel[0], b.travel[1], easeOut(q, 2)); const w = b.size * base * (dx ? 1 : b.tall), h = b.size * base * (dx ? b.tall : 1); const cx = dx ? (dx > 0 ? along : 1 - along) * W : (0.5 + b.across) * W; const cy = dy ? (dy > 0 ? along : 1 - along) * H : (0.5 + b.across) * H; Object.assign(b.el.style, { width: `${w.toFixed(1)}px`, height: `${h.toFixed(1)}px`, transform: `translate(${(cx - w / 2).toFixed(1)}px, ${(cy - h / 2).toFixed(1)}px)`, opacity: (env * Math.sin(Math.PI * q) ** 0.6).toFixed(4) }); } }; } if (s.kind === "morph") { const svg = layer("svg", "overflow:visible;"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); svg.appendChild(path); fx.blob = (pts, color, alpha) => { svg.style.display = alpha > 0.002 ? "block" : "none"; path.setAttribute("d", pathData(pts)); path.setAttribute("fill", color); path.setAttribute("fill-opacity", alpha.toFixed(4)); }; } byKey.set(s.key, fx); return fx; } // ---------- morph ---------- // Outlines are resampled to 96 points, evenly along their length, turned the same way round, and // lined up so point i of one is nearest point i of the other; then each point moves straight across. const MORPH_POINTS = 96; const MORPH_AT = [0.3, 0.65]; // shrink until 30% through, morph until 65%, then open the new scene function morph(p, s, out, inn, fx) { if (!s.from || !s.to) throw new Error(`morph at ${s.at}: give both "from" (an outline in the old scene) and "to" (one in the new)`); const stage = out.parentElement.getBoundingClientRect(); const A = outline(s.from, stage), B = alignTo(A, outline(s.to, stage)); const cA = centroid(A), cB = centroid(B); const colorA = s.fromColor ?? colorOf(s.from) ?? "#1f1e1b", colorB = s.toColor ?? colorOf(s.to) ?? colorA; const [m0, m1] = MORPH_AT; if (p < m0) { // The old scene seen through a window shaped like A, closing from beyond the frame onto A. // The outline's own fill takes over just before it lands, so the morph starts from a solid shape. const u = easeInOut(p / m0, 3); put(out, { z: 1, clip: windowPath(out, A, cA, u, stage) }); put(inn, HIDDEN); fx.blob(A, colorA, smooth(0.72, 1, u)); } else if (p < m1) { const u = easeInOut((p - m0) / (m1 - m0), 3); put(out, HIDDEN); put(inn, HIDDEN); // The shape travels on a gentle arc, bowed by 12% of the distance, as anything that travels does. const dx = cB[0] - cA[0], dy = cB[1] - cA[1], bow = 0.12 * Math.sin(Math.PI * u); const c = [lerp(cA[0], cB[0], u) - dy * bow, lerp(cA[1], cB[1], u) + dx * bow]; const pts = A.map((a, i) => [c[0] + lerp(a[0] - cA[0], B[i][0] - cB[0], u), c[1] + lerp(a[1] - cA[1], B[i][1] - cB[1], u)]); fx.blob(pts, mixColor(colorA, colorB, u), 1); } else { // The new scene opens out of a window shaped like B; B's fill fades as the window starts to grow. const u = easeInOut((p - m1) / (1 - m1), 3); put(out, HIDDEN); put(inn, { z: 1, clip: windowPath(inn, B, cB, 1 - u, stage) }); fx.blob(B, colorB, 1 - smooth(0, 0.3, u)); } } // The outline scaled about its center by k (1 at u = 1, large enough to cover the scene at u = 0), // in log space, as a clip-path in the scene root's own pixels. function windowPath(root, pts, c, u, stage) { const box = root.getBoundingClientRect(); const ox = box.left - stage.left, oy = box.top - stage.top, W = root.offsetWidth, H = root.offsetHeight; const near = Math.max(4, Math.min(...pts.map(([x, y]) => Math.hypot(x - c[0], y - c[1])))); const far = Math.max(...[[ox, oy], [ox + W, oy], [ox, oy + H], [ox + W, oy + H]].map(([x, y]) => Math.hypot(x - c[0], y - c[1]))); const k = Math.exp(lerp(Math.log((far / near) * 1.06), 0, u)); if (u <= 0) return "none"; return `path("${pathData(pts.map(([x, y]) => [c[0] + (x - c[0]) * k - ox, c[1] + (y - c[1]) * k - oy]))}")`; } const pathData = (pts) => `M${pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join("L")}Z`; const centroid = (pts) => pts.reduce((c, [x, y]) => [c[0] + x / pts.length, c[1] + y / pts.length], [0, 0]); // A closed outline as MORPH_POINTS points in the stage's pixels. `src` is a list of [x, y] stage // points, an SVG shape (path, circle, rect, ellipse, polygon), or any other element, which gives // its border box with its corner radius. function outline(src, stage = { left: 0, top: 0 }, n = MORPH_POINTS) { let pts; if (Array.isArray(src)) pts = src; else { const el = element(src, "morph outline"); if (typeof SVGGeometryElement !== "undefined" && el instanceof SVGGeometryElement) { const L = el.getTotalLength(), m = 400, M = el.getScreenCTM(); pts = Array.from({ length: m }, (_, i) => { const q = el.getPointAtLength((L * i) / m); return [M.a * q.x + M.c * q.y + M.e - stage.left, M.b * q.x + M.d * q.y + M.f - stage.top]; }); } else { const r = el.getBoundingClientRect(), radius = getComputedStyle(el).borderTopLeftRadius; // A percentage is of the box's own size ("50%" makes a circle of a square). const px = (parseFloat(radius) || 0) * (radius.endsWith("%") ? Math.min(r.width, r.height) / 100 : 1); const rad = Math.min(px, r.width / 2, r.height / 2); pts = roundRect(r.left - stage.left, r.top - stage.top, r.width, r.height, rad); } } return resample(pts, n); } function roundRect(x, y, w, h, r) { const pts = [], k = 24; const corners = [[x + w - r, y + r, -Math.PI / 2], [x + w - r, y + h - r, 0], [x + r, y + h - r, Math.PI / 2], [x + r, y + r, Math.PI]]; for (const [cx, cy, a0] of corners) { for (let i = 0; i <= k; i++) { const a = a0 + (i / k) * (Math.PI / 2); pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); } } return pts; } // Evenly spaced along the closed outline, clockwise on screen, starting at the top. function resample(pts, n) { if (pts.length < 3) throw new Error("morph: an outline needs at least 3 points"); let area = 0; for (let i = 0; i < pts.length; i++) { const [x0, y0] = pts[i], [x1, y1] = pts[(i + 1) % pts.length]; area += x0 * y1 - x1 * y0; } if (area < 0) pts = [...pts].reverse(); let top = 0; pts.forEach(([x, y], i) => { if (y < pts[top][1] - 1e-6 || (Math.abs(y - pts[top][1]) < 1e-6 && x < pts[top][0])) top = i; }); pts = [...pts.slice(top), ...pts.slice(0, top)]; const seg = pts.map((a, i) => Math.hypot(pts[(i + 1) % pts.length][0] - a[0], pts[(i + 1) % pts.length][1] - a[1])); const total = seg.reduce((a, b) => a + b, 0); const out = []; let i = 0, walked = 0; for (let j = 0; j < n; j++) { const want = (j / n) * total; while (walked + seg[i] < want && i < seg.length - 1) walked += seg[i++]; const a = pts[i], b = pts[(i + 1) % pts.length], f = seg[i] ? (want - walked) / seg[i] : 0; out.push([lerp(a[0], b[0], f), lerp(a[1], b[1], f)]); } return out; } // B turned so its points pair with A's: the rotation with the least total distance, both centered. function alignTo(A, B) { const ca = centroid(A), cb = centroid(B), n = A.length; let best = 0, bestD = Infinity; for (let s = 0; s < n; s++) { let d = 0; for (let i = 0; i < n && d < bestD; i++) { const b = B[(i + s) % n]; d += (A[i][0] - ca[0] - b[0] + cb[0]) ** 2 + (A[i][1] - ca[1] - b[1] + cb[1]) ** 2; } if (d < bestD) { bestD = d; best = s; } } return B.map((_, i) => B[(i + best) % n]); } function colorOf(src) { if (Array.isArray(src)) return null; const el = element(src, "morph outline"); const cs = getComputedStyle(el); const c = el instanceof SVGElement ? cs.fill : cs.backgroundColor; const v = rgba(c); return v && v[3] > 0 ? `rgb(${v[0]},${v[1]},${v[2]})` : null; } function rgba(c) { if (!c) return null; if (c[0] === "#") { const h = c.length === 4 ? [...c.slice(1)].map((x) => x + x).join("") : c.slice(1, 7); return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).concat(1); } const m = c.match(/[\d.]+/g); return m && m.length >= 3 ? [+m[0], +m[1], +m[2], m[3] === undefined ? 1 : +m[3]] : null; } function mixColor(a, b, u) { const x = rgba(a), y = rgba(b); if (!x || !y) return u < 0.5 ? a : b; return `rgb(${[0, 1, 2].map((i) => Math.round(lerp(x[i], y[i], u))).join(",")})`; } // ---------- one transition, and a film of them ---------- // Every transition the page uses, for review.mjs cuts: {kind, at, dur, end, handoff, dir, out, in}. function register(s, out, inn) { const list = (window.TRANSITIONS ||= []); if (list.some((x) => x.key === s.key)) return; const name = (el) => (el.id ? `#${el.id}` : el.className ? `.${String(el.className).split(" ")[0]}` : el.tagName.toLowerCase()); // A wipe's shape names it better than a direction when it is not linear. const shape = s.kind === "wipe" && s.shape && s.shape !== "linear" ? (typeof s.shape === "string" ? s.shape : "custom") : null; list.push({ key: s.key, kind: s.kind, at: s.at, dur: s.dur, end: s.end, handoff: s.handoff, dir: shape ?? s.dir ?? null, out: name(out), in: name(inn) }); list.sort((a, b) => a.at - b.at); } // Sets both scenes' looks at time t. Before `at` only the old scene shows; from `at + dur` only the // new one. Call it on every frame. Returns {p, kind}: how far through the transition t is. export function transition(t, spec, outEl, inEl) { const s = resolve(spec); const out = element(outEl, "outEl"), inn = element(inEl, "inEl"); register(s, out, inn); const fx = effects(s, out, inn); const p = s.dur > 0 ? clamp((t - s.at) / s.dur) : t >= s.at ? 1 : 0; fx.hide(); if (p <= 0 || p >= 1) { put(p >= 1 ? inn : out); put(p >= 1 ? out : inn, HIDDEN); } else { LOOKS[s.kind](p, s, out, inn, fx); } return { p, kind: s.kind }; } // A film: scenes([root, spec, root, spec, root, ...]), alternating scene roots (elements or // selectors) and transition specs, in time order. Returns film(t), which shows only the scene (or // the pair of scenes in a transition) on screen at t and hides the rest. film.cuts lists the hard // cuts, for VIDEO.cuts; transitions overlap their scenes, so they are not hard cuts. export function scenes(list) { if (!Array.isArray(list) || list.length % 2 === 0) { throw new Error("scenes: pass [root, spec, root, ...]: scene roots with one transition spec between each pair"); } const roots = list.filter((_, i) => i % 2 === 0).map((el, i) => element(el, `scene ${i + 1}`)); const specs = list.filter((_, i) => i % 2 === 1).map(resolve); specs.forEach((s, i) => { if (i && s.at < specs[i - 1].end - 1e-9) { throw new Error(`scenes: the ${s.kind} at ${s.at} s starts before the ${specs[i - 1].kind} at ${specs[i - 1].at} s ends (${specs[i - 1].end.toFixed(3)} s)`); } register(s, roots[i], roots[i + 1]); }); const fx = specs.map((s, i) => effects(s, roots[i], roots[i + 1])); // every layer exists from the first frame const film = (t) => { let active = -1, shown = 0; specs.forEach((s, i) => { if (s.dur > 0 && t >= s.at && t < s.end) active = i; if (t >= s.end) shown = i + 1; }); fx.forEach((f, i) => i !== active && f.hide()); roots.forEach((r, k) => { if (active >= 0 ? k !== active && k !== active + 1 : k !== shown) put(r, HIDDEN); }); if (active >= 0) return transition(t, specs[active], roots[active], roots[active + 1]); put(roots[shown]); return { p: 1, kind: null }; }; film.cuts = specs.filter((s) => s.kind === "cut").map((s) => s.at); film.transitions = specs; return film; } // ---------- film finish ---------- // For launch films and social posts only. Never in a walkthrough, training or explainer: grain and // a vignette lower the contrast of the text the viewer must read. Every layer is plain alpha over // the picture; never use mix-blend-mode on an overlay (references/transitions.md). // // finish("#finish", t, { grain: true, vignette: true, sweeps: { "title lands": 2.2, "end card": {at: 24, dur: 1} } }) // // el is an empty layer the page adds above everything, captions included. Each treatment is off // unless named; `true` picks the strength in FINISH, or pass a number. // grain SVG noise, a new field about 12 times a second (`rate`), never every frame: fresh // noise on every frame makes the video about 10x larger and reads as sizzle. // vignette corners darkened by this alpha (0.15), the center untouched. // sweeps a soft band of light across the frame, once per named beat: {name: at | {at, dur}}. // `fps` is the render's frame rate (default 60). Every subframe of frame n rounds to n, so the // grain changes only between frames and never blends two fields into one frame. // Open the page with ?finish=0 to turn the finish off, for example to check contrast. export const FINISH = { grain: 0.05, vignette: 0.15, sweep: 0.12 }; const finishOff = typeof location !== "undefined" && new URLSearchParams(location.search).get("finish") === "0"; export function finish(el, t, { grain = false, vignette = false, sweeps = {}, rate = 12, fps = 60, seed = 1, grainSize = 0.55 } = {}) { if (typeof el === "string") el = $(el); if (!el) return; if (finishOff) { el.style.display = "none"; return; } if (Array.isArray(sweeps)) throw new Error('finish: name each sweep\'s beat, like sweeps: { "title lands": 2.2 }'); const f = finishLayers(el); const g = grain === true ? FINISH.grain : Number(grain) || 0; const v = vignette === true ? FINISH.vignette : Number(vignette) || 0; f.grain.style.display = g > 0 ? "block" : "none"; if (g > 0) { const hold = Math.max(1, Math.round(fps / rate)); const field = Math.floor(Math.round(t * fps) / hold); f.turbulence.setAttribute("seed", String(seed + field)); f.turbulence.setAttribute("baseFrequency", String(grainSize)); // Noise above the middle darkens, below it lightens: grain with no overall shift in brightness. const k = (g / 0.12).toFixed(4), h = (g / 0.24).toFixed(4); f.dark.setAttribute("values", `0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${k} 0 0 0 -${h}`); f.light.setAttribute("values", `0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 -${k} 0 0 0 ${h}`); } f.vignette.style.display = v > 0 ? "block" : "none"; if (v > 0) { f.vignette.style.background = `radial-gradient(ellipse farthest-corner at 50% 50%, rgba(14,11,9,0) 38%, ` + `rgba(14,11,9,${(v * 0.32).toFixed(4)}) 72%, rgba(14,11,9,${v.toFixed(4)}) 100%)`; } // The sweep for the beat at t, if any. let on = null; for (const [name, b] of Object.entries(sweeps)) { const at = typeof b === "number" ? b : b?.at, dur = (typeof b === "object" && b?.dur) || 0.9; if (!Number.isFinite(at)) throw new Error(`finish: sweep ${JSON.stringify(name)} needs a time`); if (t >= at && t < at + dur) on = { at, dur }; } f.sweep.style.display = on ? "block" : "none"; if (on) { const W = el.offsetWidth, q = (t - on.at) / on.dur; const x = lerp(-0.62, 1.22, easeInOut(q, 2)) * W; f.sweep.style.transform = `translateX(${x.toFixed(1)}px) skewX(-14deg)`; f.sweep.style.opacity = (smooth(0, 0.14 / on.dur, q) * (1 - smooth(1 - 0.2 / on.dur, 1, q))).toFixed(4); } } const finishState = new WeakMap(); function finishLayers(el) { if (finishState.has(el)) return finishState.get(el); el.setAttribute("data-fx", "finish"); Object.assign(el.style, { position: "absolute", left: "0", top: "0", width: "100%", height: "100%", pointerEvents: "none", overflow: "hidden", zIndex: el.style.zIndex || "1000" }); const div = (css) => el.appendChild(Object.assign(document.createElement("div"), { style: `position:absolute;display:none;${css}` })); // The sweep keeps its gradient at 90deg and skews the element instead: an angled gradient on a // tall box puts its stops at different heights on each side and shows a hard diagonal seam. const sweep = div(`left:0;top:-25%;width:36%;height:150%;background:linear-gradient(90deg, rgba(255,255,255,0), ` + `rgba(255,255,255,${FINISH.sweep}) 50%, rgba(255,255,255,0));`); const vignette = div("inset:0;"); const id = `fx-grain-${++fxSerial}`; const grain = div("inset:0;"); // No will-change here: a separately composited grain layer rasterizes in GPU tiles that differ by a // few levels from run to run on Metal, so two renders of one frame would not match. grain.innerHTML = `` + `` + ``; const [dark, light] = grain.querySelectorAll("feColorMatrix"); const state = { sweep, vignette, grain, turbulence: grain.querySelector("feTurbulence"), dark, light }; finishState.set(el, state); return state; }