// The teaser's composition: one full-frame HyperFrames page per `teaser` // entry -- a season teaser's "coming soon" card, its words the manifest's. // // PURE, like chrome-deck.mjs: an entry and a render block in, an HTML string // out. compose-chrome.mjs copies the face and GSAP in beside it, writes it and // renders it; the build encodes the frames into the entry's segment. // // --------------------------------------------------------------------------- // Why it is reached only through a dynamic import // --------------------------------------------------------------------------- // TEASER_FONT_FILE is `new URL(…, import.meta.url)`, which umtool's bundler // turns into an asset reference. build-video must not import a page module at // load (docs/quirks.md), and compose-chrome -- which umtool's preview helper // imports statically -- loads this one only when a teaser is composed. // // --------------------------------------------------------------------------- // Why the timeline is a cue list computed here // --------------------------------------------------------------------------- // The deck's reason (chrome-deck.mjs): a render is a seek per frame, from // parallel workers, in any order. Every cue is a fromTo whose FROM is stated, // carried forward from the cue before it on the same element; the page is a // dumb interpreter of `teaserCues`, so the tests read every time it uses. // The blur is a CSS variable (`--blur`) read by `filter`, tweened like any // other number; the grain's jitter is a seeded sequence of instant sets. import { fileURLToPath } from "node:url"; import { DIP_RISE, dipOf, pageDuration, TEASER_MOTION, teaserLead, teaserLines, teaserMotionOf, teaserSeconds, teaserTail, teaserTimes, transitionOf, } from "./deck.mjs"; export { TEASER_MOTION }; import { mix, rgba } from "./chrome-deck.mjs"; /** * The display face: Archivo, a variable font (wght 100–900, wdth 62–125), * vendored beside the cards' faces. Copied in as `assets/TeaserDisplay.ttf` * under a private family name, as every chrome face is. */ export const TEASER_FONT_FILE = fileURLToPath(new URL("./fonts/Archivo[wdth,wght].ttf", import.meta.url)); /** The face's name in the page and in the project's assets. */ export const TEASER_FONT_ASSET = "assets/TeaserDisplay.ttf"; /** Instant cues still take a millisecond, as on the deck. */ const INSTANT = 0.001; const r4 = (v) => Math.round(v * 10000) / 10000; const esc = (s) => String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); /** A small seeded PRNG (mulberry32): the grain jitters the same way on every seek of every render. */ export function seeded(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * Everything the teaser's timeline does, as data. * * `lines` are `teaserLines(entry)`, `tail` the tail ("" for none), `seconds` * the card's length (`teaserSeconds`), `motion` the entry's (`teaserMotion` * of its `beat`). Keys name elements by `data-k`: `stage` (the slow push-in * over the whole card), `barT`/`barB` (the letterbox closing in), `leak` (a * soft light drifting across), `grain`, and per line i `l.o` (its * visibility), `l` (the slam's scale), `l.t` (its blur), `l.flash`, * `l.streak`, `l.rules` (an overline's accent rules), `l.sub` and * `l.subt` (the second tier); `tail`, `tail.t`, `tail.glow`. * * `dip` (`{ lead }`, a teaser that dips; `motion` is then `teaserMotionOf`'s, * its lines already after the lead) opens the card out of black like a * trailer: the letterbox is closed from the first frame and comes up with the * light; `veil`, a black layer over the ground and the light leak (under the * words), holds the frame black for the `lead` and lifts around the first * line's impact (DIP_RISE: from `before` ahead of it, slowly at first, to * `after` past it, the bloom), so the first hit is the moment the light comes * on; the leak's entrance waits for the lead. Without `dip` the cues are * exactly what they always were. * * @returns {{ init: Record, cues: Array<{ k: string, at: number, dur: number, * from: object, to: object, ease: string, why: string }>, beats: object, scale: number }} */ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION, dip = null }) { const m = motion; // The times are deck.mjs's, the same the build places the hits by. const beats = teaserTimes(lines, tail, m); const { T } = beats; const init = {}; const put = (k, v) => { init[k] = { ...(init[k] ?? {}), ...v }; }; const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: r4(at), dur: r4(Math.max(INSTANT, dur)), to, ease, why }); // ---- the ground: letterbox, push-in, light, grain ---------------------- put("stage", { scale: 1 }); add("stage", 0, seconds, { scale: m.push }, "none", "push-in"); if (dip) { // Out of black: the bars are closed already and come up with the light; // the veil over the ground lifts in two strokes either side of the first // impact -- slowly, then the bloom. const lead = r4(dip.lead); const hit = beats.lines[0]?.impact ?? r4(lead + DIP_RISE.before); const up = r4(hit + DIP_RISE.after); put("barT", { yPercent: 0, autoAlpha: 0 }); put("barB", { yPercent: 0, autoAlpha: 0 }); add("barT", lead, up - lead, { autoAlpha: 1 }, "power2.in", "letterbox up"); add("barB", lead, up - lead, { autoAlpha: 1 }, "power2.in", "letterbox up"); put("veil", { autoAlpha: 1 }); add("veil", lead, hit - lead, { autoAlpha: 0.45 }, "power2.in", "rise"); add("veil", hit, up - hit, { autoAlpha: 0 }, "power2.out", "bloom"); put("leak", { x: -420, autoAlpha: 0 }); add("leak", lead, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); add("leak", lead + T(1.4), Math.max(INSTANT, seconds - lead - T(1.4)), { x: 420 }, "none", "leak drift"); } else { put("barT", { yPercent: -100 }); put("barB", { yPercent: 100 }); add("barT", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); add("barB", T(0.15), T(0.9), { yPercent: 0 }, "power3.inOut", "letterbox"); put("leak", { x: -420, autoAlpha: 0 }); add("leak", 0, T(1.4), { autoAlpha: 1 }, "power1.out", "leak in"); add("leak", T(1.4), Math.max(INSTANT, seconds - T(1.4)), { x: 420 }, "none", "leak drift"); } const rnd = seeded(0x7ea5e); put("grain", { x: 0, y: 0 }); const steps = Math.floor(seconds * m.grainHz); for (let s = 1; s < steps; s += 1) { add("grain", s / m.grainHz, INSTANT, { x: Math.round((rnd() - 0.5) * 360), y: Math.round((rnd() - 0.5) * 220) }, "none", "grain"); } // ---- the lines, top to bottom ------------------------------------------ lines.forEach((l, i) => { const b = beats.lines[i]; const why = `line ${i}`; const slam = l.role === "overline" ? 1 + (m.slam - 1) * 0.6 : m.slam; put(`l${i}.o`, { autoAlpha: 0 }); put(`l${i}`, { scale: slam }); put(`l${i}.t`, { "--blur": `${m.blur}px` }); put(`l${i}.flash`, { autoAlpha: 0, scaleX: 0.55 }); put(`l${i}.streak`, { autoAlpha: 0, scaleX: 0 }); add(`l${i}.o`, b.at, T(0.12), { autoAlpha: 1 }, "power1.out", `${why} in`); // The slam: down past rest by the hit, then a soft settle up to it. add(`l${i}`, b.at, T(m.hit), { scale: m.under }, "power3.in", `${why} slam`); add(`l${i}`, b.at + T(m.hit), T(m.settle), { scale: 1 }, "power2.out", `${why} settle`); add(`l${i}.t`, b.at, T(m.hit + 0.12), { "--blur": "0px" }, "power2.out", `${why} focus`); // The hit: a flash of the accent behind the words and a streak through them. const hit = b.impact; add(`l${i}.flash`, hit - T(0.04), T(0.08), { autoAlpha: 1, scaleX: 1 }, "power2.out", `${why} flash`); add(`l${i}.flash`, hit + T(0.04), T(0.75), { autoAlpha: 0, scaleX: 1.25 }, "power2.out", `${why} flash out`); add(`l${i}.streak`, hit - T(0.06), T(0.32), { autoAlpha: 1, scaleX: 1 }, "expo.out", `${why} streak`); add(`l${i}.streak`, hit + T(0.26), T(0.5), { autoAlpha: 0 }, "power2.in", `${why} streak out`); if (l.role === "overline") { put(`l${i}.rules`, { scaleX: 0 }); add(`l${i}.rules`, hit - T(0.04), T(0.6), { scaleX: 1 }, "expo.out", `${why} rules`); } // Pushed out: the line after it takes its row (`replace`), and as that // one slams in this one rises, shrinks a little, blurs and is gone. if (b.outAt != null) { put(`l${i}.o`, { y: 0 }); add(`l${i}.o`, b.outAt - T(0.02), T(m.out), { autoAlpha: 0, y: -m.outRise }, "power2.in", `${why} pushed out`); add(`l${i}`, b.outAt - T(0.02), T(m.out), { scale: m.outScale }, "power2.in", `${why} pushed out (scale)`); add(`l${i}.t`, b.outAt - T(0.02), T(m.out), { "--blur": `${m.outBlur}px` }, "power2.in", `${why} pushed out (blur)`); } // A lead -- the small tier above the line -- drops in with the line's pop, // as a second tier rises in under one. if (l.lead) { put(`l${i}.lead`, { autoAlpha: 0, y: -16, scale: 1.12 }); put(`l${i}.leadt`, { "--blur": "10px" }); add(`l${i}.lead`, b.at, T(0.5), { autoAlpha: 1, y: 0, scale: 1 }, "expo.out", `${why} lead`); add(`l${i}.leadt`, b.at, T(0.32), { "--blur": "0px" }, "power2.out", `${why} lead focus`); } if (l.sub && b.subAt != null) { put(`l${i}.sub`, { autoAlpha: 0, y: 16, scale: 1.12 }); put(`l${i}.subt`, { "--blur": "10px" }); add(`l${i}.sub`, b.subAt, T(0.5), { autoAlpha: 1, y: 0, scale: 1 }, "expo.out", `${why} second tier`); add(`l${i}.subt`, b.subAt, T(0.32), { "--blur": "0px" }, "power2.out", `${why} second tier focus`); } }); // ---- the tail: slowly, on its own, after the last line has settled ------ if (tail && beats.tailAt != null) { const d = beats.tailDur; put("tail", { autoAlpha: 0, scale: 1.18 }); put("tail.t", { "--blur": "12px" }); put("tail.glow", { autoAlpha: 0 }); add("tail", beats.tailAt, d, { autoAlpha: 1, scale: 1 }, "sine.inOut", "tail"); add("tail.t", beats.tailAt, d * 0.85, { "--blur": "0px" }, "power2.out", "tail focus"); add("tail.glow", beats.tailAt + d * 0.3, d * 0.9, { autoAlpha: 1 }, "sine.inOut", "tail glow"); } // ---- order, clamp, state the froms (the deck's walk) -------------------- ev.forEach((e, n) => { e.n = n; }); ev.sort((x, y) => x.at - y.at || x.n - y.n); const state = Object.fromEntries(Object.entries(init).map(([k, v]) => [k, { ...v }])); const freeAt = new Map(); const cues = []; for (const e of ev) { const free = freeAt.get(e.k) ?? 0; let { at, dur } = e; if (at < free) { const end = at + dur; at = r4(free); dur = r4(Math.max(INSTANT, end - at)); } const cur = state[e.k] ?? (state[e.k] = {}); const from = {}; for (const p of Object.keys(e.to)) from[p] = cur[p]; Object.assign(cur, e.to); freeAt.set(e.k, r4(at + dur)); cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why }); } // `need` is the validator's; the page's data is what it always was. const { T: _T, need: _need, ...times } = beats; return { init, cues, beats: times, scale: beats.scale }; } /** Each role's type: size (px, the most it may be), weight, width (%), tracking (em), and the fit floor. */ export const TEASER_TYPE = Object.freeze({ overline: Object.freeze({ size: 34, weight: 600, stretch: 125, tracking: 0.48, floor: 18 }), title: Object.freeze({ size: 148, weight: 900, stretch: 112, tracking: -0.006, floor: 56 }), sub: Object.freeze({ size: 38, weight: 600, stretch: 125, tracking: 0.4, floor: 18 }), kicker: Object.freeze({ size: 84, weight: 800, stretch: 118, tracking: 0.04, floor: 32 }), }); /** * The teaser composition's HTML: 1920×1080 (the render's frame), opaque, * `seconds` long. `font` is the display face's asset path, `gsap` the * vendored script's. `?still=` seeks to t and holds, as the deck's does. */ export function teaserHtml(entry, render, opts = {}) { const pal = render.palette; const W = render.width ?? 1920; const H = render.height ?? 1080; // The cut's transition, read only for a dip: its lead is the dissolve and the black. const D = opts.transition ?? transitionOf(render); const fps = render.fps ?? 30; const seconds = teaserSeconds(entry, D, fps); if (!(seconds > 0)) throw new Error(`teaser ${entry.id}: seconds must be positive`); const font = opts.font ?? TEASER_FONT_ASSET; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const lines = teaserLines(entry); if (!lines.length) throw new Error(`teaser ${entry.id}: no lines`); const tail = teaserTail(entry); const dipped = !!dipOf(entry, fps); const { init, cues, beats } = teaserCues({ lines, tail, seconds, motion: teaserMotionOf(entry, D, fps), dip: dipped ? { lead: teaserLead(entry, D, fps) } : null, }); // The ground: the palette's bg, lifted a touch toward the accent at the // centre and falling toward black at the edges. const black = "#000000"; const core = mix(mix(pal.bg, pal.accent, 0.13), pal.fg, 0.02); const mid = pal.bg; const edge = mix(pal.bg, black, 0.62); const bar = mix(pal.bg, black, 0.72); const barH = Math.round(H * 0.105); const maxW = Math.round(W * 0.8); const ty = TEASER_TYPE; const lineHtmls = lines .map((l, i) => { const k = `l${i}`; const isLast = i === lines.length - 1; const rules = l.role === "overline" ? `
` : ""; // The tail hangs off the right of its row in a zero-width box, so the // row is centred by its own words and the tail adds nothing to it. const tailHtml = isLast && tail ? `` + `${esc(tail)}` : ""; return ( `
` + `
` + `
` + rules + (l.lead ? `
${esc(l.lead)}
` : "") + `
` + `${esc(l.head)}${l.sub ? "" : tailHtml}
` + (l.sub ? `
${esc(l.sub)}${tailHtml}
` : "") + `
` ); }); // A row that several lines take in turn (`replace`) stacks them in one // cell, so the row is as tall as its tallest and each pushes the last out // in place. A row of one line is the line itself, as it always was. const rowsOf = []; lines.forEach((l, i) => { (rowsOf[l.row] ??= []).push(i); }); const lineHtml = rowsOf .map((members) => members.length === 1 ? lineHtmls[members[0]] : `
\n ${members.map((i) => lineHtmls[i]).join("\n ")}\n
`) .join("\n "); const slotted = rowsOf.some((m) => m.length > 1); const data = { seconds, maxW, init, cues: cues.map(({ why, ...c }) => c), beats, floors: Object.fromEntries(Object.entries(ty).map(([r, t]) => [r, t.floor])), }; // `` in a JSON string would close the tag; the words may say anything. const json = JSON.stringify(data).replace(/ `${sel} { font-size: ${t.size}px; font-weight: ${t.weight}; font-stretch: ${t.stretch}%; letter-spacing: ${t.tracking}em; }`; return `
${dipped ? '\n
' : ""}
${lineHtml}
`; }