// FLIPS: a cut built of pairs -- a line from THEN and the opposite line from // NOW, back to back -- with a panel down the frame's left that holds the pair // while it plays: the topic, the THEN card (when, and the words) lit as the // first clip plays, then the NOW card slamming in under it as the second // starts, the years between them counting up. When the pair ends it lifts // away and the next one comes in. Nothing is said for her: the panel's words // are hers, verbatim, a few of them. // // render.chrome: "flips": { "pairs": [ { "id": "vax", "topic": "Vaccines", // "then": { "entry": "f-vax-then", "when": "2019", "words": "…" }, // "now": { "entry": "f-vax-now", "when": "2025", "words": "…" } } ] } // // With flips on, the footage box moves to the frame's right edge and the panel // takes the left, as the thread rail's does (deck.mjs deckGeometry, // threadsGeometry); the two are one region and a cut has one or the other. // // PURE, like threads.mjs. chrome-flips.mjs draws the panel. import { planCues } from "./threads.mjs"; /** The limits: pairs, a topic's, a `when`'s and a side's words' characters. */ export const FLIPS_LIMITS = Object.freeze({ pairs: Object.freeze([1, 24]), topic: 32, when: 18, words: 110 }); /** * The panel's motion, in seconds: a pair rises in over `enter`, its NOW card * slams over `slam` (from `slamScale`) with a flash, the gap counts over * `count`, the THEN card dims over `dim`; a pair lifts away over `leave`. */ export const FLIP_MOTION = Object.freeze({ enter: 0.35, slam: 0.22, slamScale: 1.35, flashUp: 0.05, flashDown: 0.5, count: 0.6, dim: 0.3, leave: 0.3, aside: 0.4, }); /** How bright the THEN card rests once NOW has landed. */ export const THEN_DIM = 0.5; const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/; const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const oneLine = (v, max) => typeof v === "string" && v.trim() !== "" && !/[\n\r]/.test(v) && v.length <= max; /** Is the flips panel on: a `render.chrome.flips` with at least one pair. */ export function flipsOn(render) { const f = render?.chrome?.flips; return isObj(f) && Array.isArray(f.pairs) && f.pairs.length > 0; } /** * Every reason `render.chrome.flips` cannot be built, as sentences. Shape * only; `validateFlipEntries` checks the pairs against the timeline. * * @returns {string[]} */ export function validateFlips(f, where = "render.chrome.flips") { if (f === undefined) return []; if (!isObj(f)) return [`${where} must be an object`]; const errors = []; for (const k of Object.keys(f)) if (k !== "pairs") errors.push(`${where}.${k} is not a flips setting`); const [lo, hi] = FLIPS_LIMITS.pairs; if (!Array.isArray(f.pairs) || f.pairs.length < lo || f.pairs.length > hi) return [...errors, `${where}.pairs must be ${lo} to ${hi} pairs`]; const ids = new Set(); f.pairs.forEach((p, i) => { const w = `${where}.pairs[${i}]`; if (!isObj(p)) { errors.push(`${w} must be an object`); return; } for (const k of Object.keys(p)) if (!["id", "topic", "then", "now"].includes(k)) errors.push(`${w}.${k} is not a pair key`); if (typeof p.id !== "string" || !ID_RE.test(p.id)) errors.push(`${w}.id must be letters, digits, _ or -`); else if (ids.has(p.id)) errors.push(`${w}.id ${p.id} is listed twice`); else ids.add(p.id); if (!oneLine(p.topic, FLIPS_LIMITS.topic)) errors.push(`${w}.topic must be one line of at most ${FLIPS_LIMITS.topic} characters`); for (const side of ["then", "now"]) { const s = p[side]; const ws = `${w}.${side}`; if (!isObj(s)) { errors.push(`${ws} must be an object`); continue; } for (const k of Object.keys(s)) if (!["entry", "when", "words"].includes(k)) errors.push(`${ws}.${k} is not a side key`); if (typeof s.entry !== "string" || !s.entry) errors.push(`${ws}.entry must name a timeline entry`); if (!oneLine(s.when, FLIPS_LIMITS.when)) errors.push(`${ws}.when must be one line of at most ${FLIPS_LIMITS.when} characters`); if (!oneLine(s.words, FLIPS_LIMITS.words)) errors.push(`${ws}.words must be one line of at most ${FLIPS_LIMITS.words} characters`); } }); return errors; } /** * The pairs against the timeline: each side names an entry of the cut, a clip * (not a teaser), the THEN side before the NOW side, and no entry in two pairs. * * @returns {string[]} */ export function validateFlipEntries(manifest) { const render = manifest?.render ?? {}; if (!flipsOn(render)) return []; const errors = []; const at = new Map((manifest.timeline ?? []).map((e, i) => [e?.id, { e, i }])); const used = new Map(); render.chrome.flips.pairs.forEach((p, i) => { const w = `render.chrome.flips.pairs[${i}] (${p?.id ?? "?"})`; const idx = {}; for (const side of ["then", "now"]) { const id = p?.[side]?.entry; const hit = at.get(id); if (!hit) { errors.push(`${w}.${side}.entry ${id} is not in the timeline`); continue; } if (hit.e.type === "teaser") errors.push(`${w}.${side}.entry ${id} is a teaser, not a clip`); if (used.has(id)) errors.push(`${w}.${side}.entry ${id} is already in pair ${used.get(id)}`); used.set(id, p.id); idx[side] = hit.i; } if (idx.then !== undefined && idx.now !== undefined && idx.then >= idx.now) errors.push(`${w}: its THEN entry must come before its NOW entry`); }); return errors; } /** The years between two `when`s, when both carry one (`2019`, `May 2019`): else null. */ export function yearsBetween(a, b) { const y = (s) => { const m = /(19|20)\d\d/.exec(String(s ?? "")); return m ? Number(m[0]) : null; }; const ya = y(a), yb = y(b); return ya != null && yb != null && yb > ya ? yb - ya : null; } /** * The panel's schedule, in the cut's clock: each pair with its two sides' * spans (`from`: the clip's start; `to`: the next clip's start, or the cut's * end) and the years between; `asides` as the thread rail's (a popup post's * move to the end of its clip). * * @returns {{ pairs: Array<{ id: string, topic: string, index: number, of: number, years: number|null, * then: { segment: string, from: number, to: number, when: string, words: string }, * now: { segment: string, from: number, to: number, when: string, words: string } }>, * asides: Array<{ from: number, to: number }> }} */ export function flipSchedule({ segments, total, render, moves = [] }) { const R = (v) => Math.round(v * 1000) / 1000; const endOf = (i) => (i + 1 < segments.length ? segments[i + 1].start : total); const idx = new Map(segments.map((s, i) => [s.id, i])); const list = flipsOn(render) ? render.chrome.flips.pairs : []; const side = (s) => { const i = idx.get(s.entry); return i === undefined ? null : { segment: s.entry, from: R(segments[i].start), to: R(endOf(i)), when: s.when, words: s.words }; }; const pairs = list .map((p) => ({ id: p.id, topic: p.topic, then: side(p.then), now: side(p.now), years: yearsBetween(p.then.when, p.now.when) })) .filter((p) => p.then && p.now) .sort((a, b) => a.then.from - b.then.from) .map((p, i, all) => ({ ...p, index: i + 1, of: all.length })); const asides = moves.map((m) => { const i = idx.get(m.segment); return { from: R(m.at), to: R(i === undefined ? total : endOf(i)) }; }); return { pairs, asides }; } /** * Everything the panel's timeline does, as data. Pair j is `p` (autoAlpha, * y); its THEN card `t` (opacity), NOW card `n` (autoAlpha, scale, x) * with its flash `nf`, the gap `g` (autoAlpha) and its count `gc` * (an odometer strip of 0..years, rolled by yPercent -- a transform, so a * seek from anywhere lands on the same digit); the whole panel is `panel`. * * @returns {{ init: Record, cues: Array }} */ export function flipCues(sched) { const m = FLIP_MOTION; const init = { panel: { autoAlpha: 1, x: 0 } }; const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at, dur, to, ease, why }); sched.pairs.forEach((p, j) => { init[`p${j}`] = { autoAlpha: 0, y: 60 }; init[`t${j}`] = { opacity: 1 }; init[`n${j}`] = { autoAlpha: 0, scale: m.slamScale, x: 40 }; init[`nf${j}`] = { opacity: 0 }; init[`g${j}`] = { autoAlpha: 0 }; init[`gc${j}`] = { yPercent: 0 }; add(`p${j}`, p.then.from, m.enter, { autoAlpha: 1, y: 0 }, "power3.out", `${p.id} in`); add(`n${j}`, p.now.from, m.slam, { autoAlpha: 1, scale: 1, x: 0 }, "power4.in", `${p.id} now`); add(`nf${j}`, p.now.from + m.slam, m.flashUp, { opacity: 1 }, "none", `${p.id} flash`); add(`nf${j}`, p.now.from + m.slam + m.flashUp, m.flashDown, { opacity: 0 }, "power2.out", `${p.id} flash`); add(`t${j}`, p.now.from, m.dim, { opacity: THEN_DIM }, "power2.out", `${p.id} then dims`); if (p.years != null) { add(`g${j}`, p.now.from, m.slam, { autoAlpha: 1 }, "power2.out", `${p.id} gap`); add(`gc${j}`, p.now.from + 0.05, m.count, { yPercent: -100 * (p.years / (p.years + 1)) }, "power2.out", `${p.id} count`); } add(`p${j}`, p.now.to - m.leave, m.leave, { autoAlpha: 0, y: -50 }, "power2.in", `${p.id} out`); }); for (const a of sched.asides) { add("panel", a.from, m.aside, { autoAlpha: 0, x: -40 }, "power2.in", "aside for a post"); add("panel", a.to, m.aside, { autoAlpha: 1, x: 0 }, "power2.out", "back after a post"); } return { init, cues: planCues(init, ev) }; }