// The composing sink in a page (wai/extensions/browser-sink.md §14). // // openPackage() opens a WAI package of several components, verifies its receipt and every // index it binds, builds this device's capability report, routes and chooses each piece's // alternative by §4.3 and §4.7, fetches the chosen objects and checks each against its // index entry, decodes them, and holds every decoded output with a signed digest to that // digest. Nothing is presented until every check holds; a package that fails one is // refused whole, with the check that failed. // // The result, a Composition, presents the components on one timeline whose master clock // is the lead (video) component. Each component is a run of pieces cut at the lead's // piece starts (packaging §5.8), each piece one object: // - video: an integer rung or a still, decoded in WebAssembly, or a floor of any // capability platform-decode maps (AV1, HEVC, AVC, VP9, VP8) through WebCodecs, drawn on // a canvas; // - graphics: wai.graphics.vector rasterised in WebAssembly at a size the payload lists, // the one nearest this display's overlay in device pixels, drawn on a canvas above; // each piece's frames held to its signed clip digest first; where the device has no // decoder of it, the piece's text floor (wai.text.zstd, graphics-vector §6.3): its // transcript, held to the piece's base, and the blocks active at the clock's tick drawn as // text, a caption or a subtitle left to the text layer and a forced narrative drawn only // while no cue shows it (browser-sink §14.9); // - timed text: each piece a WebVTT segment, drawn as text in the page's own text layer, // which presents one text component of the package, or none (opts.captions); // - score: synthesised in the tab in its contract's channels (one, or two interleaved), // each piece's samples held to its signed digest, and played end to end through Web // Audio, slaved to the video; a FLAC piece (a sound track's base, or a score's floor) // decoded in WebAssembly, held to its entry's digest where the entry states one, and // played the same way; an audio alternative routed to the user agent (Opus, AAC, or // FLAC on a profile without the page's own decoder of it) decoded through WebCodecs; // - feed state: each piece replayed from its signed ops to its seal and chained to the // piece before it, drawn as a table at the tick the clock is at; // - a field (wai.field.int_grid): rendered in WebAssembly at the view its payload pins, the // frame held to its signed digest and presented as the picture; // - a picture (wai.image.png in an image component): decoded in WebAssembly, its frame held // to its signed digest. // // A piece whose base has layers (refinement entries, staged-delivery) presents its base // first; Composition.refine() then takes each layer as it arrives, checks it against its // entry, computes the next stage in WebAssembly (a field's next level, neural-fields §7; a // picture refined by wai.image.int_cond_refine from the side planes its layer names, // staged-delivery §6.3), holds the stage to the digest its entry states and only then presents // it. A layer refused anywhere leaves the stage below presented, with the reason. // // Composition.dispatch is what this device dispatched, and why, as data: the device's // report, each component's choice table and routes, and what was presented. // Composition.checks lists every check made, with its outcome. const enc = new TextEncoder(); const dec = new TextDecoder(); const hexOf = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const unhex = (h) => Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16)); /// The layer each capability is presented in (§14.2): read from the capability, never /// from the component's media class. A capability platform-decode maps is presented in the /// layer of its decoder's media (`layerOf`). export const LAYERS = { "wai.video.int_motion": "video", "wai.image.png": "video", "wai.video.av1": "video", "wai.graphics.vector": "overlay", "wai.text.timed": "text", "wai.audio.score": "audio", "wai.audio.flac": "audio", "wai.feed.state": "state", "wai.field.int_grid": "video", }; /// The capabilities this page's WebAssembly decoders decode (browser-sink §3, `wasm`). export const WASM_DECODERS = ["wai.video.int_motion", "wai.image.png", "wai.graphics.vector", "wai.text.zstd", "wai.audio.score", "wai.audio.flac", "wai.feed.state", "wai.field.int_grid", "wai.splat.int_codec"]; /// The capability of a graphics piece's text floor (graphics-vector §6.3), and of the base it /// is the floor of. The page reads the floor's one frame and no other wai.text.zstd object. export const TEXT_FLOOR = "wai.text.zstd"; const VECTOR = "wai.graphics.vector"; /// The refinement capabilities whose layers this page applies in WebAssembly (staged-delivery /// §6.2, §6.3). A layer rides on the base it refines and is never an alternative (§14.1); the /// report lists these in its `wasm` leg beside the decoders. export const WASM_LAYERS = ["wai.field.int_refine", "wai.image.int_cond_refine"]; /// The splat capability whose sets a conditioned layer's render entries read (staged-delivery /// §3.1); its parameter set is one the page holds (opts.paramSets). const SPLAT = "wai.splat.int_codec"; /// Capabilities a page presents itself, with no decoder: records (SPEC §4 does not /// dispatch them), which §14.3 has the page draw. export const PAGE_PRESENTED = ["wai.text.timed"]; export class Refused extends Error { constructor(stage, code, detail, where) { super(`${stage}: ${code}${detail ? ` (${detail})` : ""}${where ? ` at ${where}` : ""}`); this.stage = stage; this.code = code; this.detail = detail || null; this.where = where || null; } } async function fetchBytes(url) { const r = await fetch(url, { cache: "no-store" }); if (!r.ok) throw new Refused("fetch", "fetch-failed", `HTTP ${r.status}`, url); return new Uint8Array(await r.arrayBuffer()); } /// The layer of `capability` (§14.2): LAYERS, else the media of the decoder platform-decode /// asks about it (`mapped`, from the bundle's platformMapped()). export function layerOf(capability, mapped) { return LAYERS[capability] || (mapped && mapped.get(capability)) || null; } /// The user agent's answer for one object's query (platform-decode §3, in the report's /// shape), with the configurations a decode may use: WebCodecs first, Media Capabilities /// only once WebCodecs said supported, its own `supported` not read. A video query is asked /// of VideoDecoder with each hardware-acceleration preference, an audio query of /// AudioDecoder with its description. export async function uaAnswer(q) { const none = { supported: false, smooth: null, power_efficient: null, probe: "not_run", configs: [] }; const configs = []; if (q.media === "audio") { if (typeof AudioDecoder === "undefined") return none; const c = { ...q.decoderConfig }; if (q.description) c.description = unhex(q.description); try { if ((await AudioDecoder.isConfigSupported(c)).supported) configs.push(c); } catch { /* an unsupported configuration */ } } else { if (typeof VideoDecoder === "undefined") return none; // Every configuration the user agent accepts, in the order a decode tries them. const soft = { ...q.decoderConfigNoPreference, hardwareAcceleration: "prefer-software" }; for (const c of [q.decoderConfigPreferHardware, q.decoderConfigNoPreference, soft]) { try { if ((await VideoDecoder.isConfigSupported(c)).supported) configs.push(c); } catch { /* an unsupported configuration */ } } } let smooth = null, pe = null; if (configs.length && q.decodingConfiguration && navigator.mediaCapabilities) { try { const d = await navigator.mediaCapabilities.decodingInfo(q.decodingConfiguration); smooth = d.smooth; pe = d.powerEfficient; } catch { /* not asked */ } } return { supported: configs.length > 0, smooth, power_efficient: pe, probe: "not_run", configs }; } /// The chunks of a payload as platform-decode §2.3 reads them (the bundle's platformChunks): /// [{data, key, samples}] and the description. export function payloadChunks(wai, capability, payload) { const pc = wai.platformChunks(capability, payload); const data = pc.data; const desc = pc.description; const index = JSON.parse(pc.index); pc.free(); const chunks = []; let at = 0; for (const [len, key, samples] of index) { chunks.push({ data: data.subarray(at, at + len), key, samples }); at += len; } return { description: desc || null, chunks }; } /// Decode a payload through the user agent's decoder with the first of `configs` under which /// it decodes: for video, one RGBA ImageData a frame in output order; for audio, each /// channel's samples as one Float32Array and the rate. `frameUs` spaces video timestamps. export async function decodeUa(wai, capability, payload, q, configs, frameUs) { const errors = []; const { chunks } = payloadChunks(wai, capability, payload); for (const config of configs || []) { try { if (q.media === "audio") return { audio: await decodeAudioWith(config, chunks), acceleration: null }; return { frames: await decodeVideoWith(config, chunks, frameUs), acceleration: config.hardwareAcceleration || "no-preference" }; } catch (e) { errors.push(`${config.hardwareAcceleration || q.codec}: ${e.detail || e}`); } } throw new Refused("decode", "ua-decode-error", errors.join("; ") || "no configuration the user agent accepts"); } async function decodeVideoWith(config, chunks, frameUs) { const out = []; let error = null; const d = new VideoDecoder({ output: (f) => { const c = new OffscreenCanvas(f.displayWidth, f.displayHeight); const g = c.getContext("2d"); g.drawImage(f, 0, 0); out.push(g.getImageData(0, 0, f.displayWidth, f.displayHeight)); f.close(); }, error: (e) => { error = String(e); }, }); d.configure(config); chunks.forEach((u, k) => d.decode(new EncodedVideoChunk({ type: u.key ? "key" : "delta", timestamp: k * frameUs, data: u.data }))); try { await d.flush(); } catch (e) { error = error || String(e); } if (d.state !== "closed") d.close(); if (error) throw new Refused("decode", "ua-decode-error", error); return out; } /// One channel of an AudioData as 32-bit floats: copied with the conversion copyTo() offers, /// or, where the user agent does not convert, copied in its own format and converted here. function planeF32(a, ch) { const n = a.numberOfFrames; try { const p = new Float32Array(n); a.copyTo(p, { planeIndex: ch, format: "f32-planar" }); return p; } catch { /* converted below */ } const planar = a.format.endsWith("-planar"); const base = a.format.replace("-planar", ""); const plane = planar ? ch : 0; const buf = new ArrayBuffer(a.allocationSize({ planeIndex: plane })); a.copyTo(buf, { planeIndex: plane }); const src = new ({ u8: Uint8Array, s16: Int16Array, s32: Int32Array, f32: Float32Array }[base])(buf); const scale = { u8: 128, s16: 32768, s32: 2147483648, f32: 1 }[base]; const out = new Float32Array(n); for (let i = 0; i < n; i++) { const v = src[planar ? i : i * a.numberOfChannels + ch]; out[i] = base === "u8" ? (v - 128) / scale : v / scale; } return out; } /// An Ogg Opus stream's pre-skip (its OpusHead) and its last page's granule position (RFC /// 7845 §4): a decoder handed the stream's packets applies neither, so the page drops the /// pre-skip and ends the samples at the granule position. A floor object of an Opus floor /// (packaging §7.9) states them so that it decodes alone to exactly its piece. export function oggOpusTrim(b) { const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); let at = 0, granule = null, preSkip = null; while (at + 27 <= b.length) { if (dv.getUint32(at) !== 0x4f676753) throw new Refused("decode", "ua-decode-error", "not an Ogg page"); const n = b[at + 26]; let body = at + 27 + n; if (preSkip === null) { if (body + 19 > b.length || dec.decode(b.subarray(body, body + 8)) !== "OpusHead") throw new Refused("decode", "ua-decode-error", "no OpusHead"); preSkip = dv.getUint16(body + 10, true); } granule = Number(dv.getBigUint64(at + 6, true)); for (let i = 0; i < n; i++) body += b[at + 27 + i]; at = body; } if (preSkip === null || granule === null || at !== b.length) throw new Refused("decode", "ua-decode-error", "not an Ogg Opus stream"); return { preSkip, granule }; } async function decodeAudioWith(config, chunks) { const parts = []; let error = null, rate = config.sampleRate, channels = config.numberOfChannels; const d = new AudioDecoder({ output: (a) => { rate = a.sampleRate; channels = a.numberOfChannels; const planes = []; for (let ch = 0; ch < a.numberOfChannels; ch++) planes.push(planeF32(a, ch)); parts.push(planes); a.close(); }, error: (e) => { error = String(e); }, }); d.configure(config); let t = 0; for (const u of chunks) { d.decode(new EncodedAudioChunk({ type: "key", timestamp: Math.round((t * 1e6) / config.sampleRate), data: u.data })); t += u.samples || 0; } try { await d.flush(); } catch (e) { error = error || String(e); } if (d.state !== "closed") d.close(); if (error) throw new Refused("decode", "ua-decode-error", error); const n = parts.reduce((s, p) => s + p[0].length, 0); const out = []; for (let ch = 0; ch < channels; ch++) { const c = new Float32Array(n); let at = 0; for (const p of parts) { c.set(p[ch], at); at += p[ch].length; } out.push(c); } return { sampleRate: rate, channels: out, frames: n }; } /// An RGB8 frame as RGBA ImageData. function rgbToImageData(rgb, w, h) { const px = new Uint8ClampedArray(w * h * 4); for (let i = 0, j = 0; i < w * h; i++, j += 3) { px[4 * i] = rgb[j]; px[4 * i + 1] = rgb[j + 1]; px[4 * i + 2] = rgb[j + 2]; px[4 * i + 3] = 255; } return new ImageData(px, w, h); } /// RGBA8P (premultiplied, graphics-vector §2.9) as the straight-alpha RGBA an ImageData /// holds; the canvas composites it source-over (graphics-vector §1.1). function premultipliedToImageData(px, w, h) { const o = new Uint8ClampedArray(px.length); for (let i = 0; i < px.length; i += 4) { const a = px[i + 3]; if (a === 0) continue; o[i] = Math.min(255, Math.round((px[i] * 255) / a)); o[i + 1] = Math.min(255, Math.round((px[i + 1] * 255) / a)); o[i + 2] = Math.min(255, Math.round((px[i + 2] * 255) / a)); o[i + 3] = a; } return new ImageData(o, w, h); } /// Open the package at `base` (a URL ending in "/"). /// /// opts: /// wai the initialised wai_compose module (its exports); /// receipt the receipt's URL, default base + "package.receipt.json"; /// sets URLs of the outline sets the page holds for wai.graphics.vector; /// without capabilities the device profile is forced without: no leg advertises them; /// overlayPx the overlay's size on this display, [w, h] in device pixels; /// captions the text component the text layer presents: a language tag, "off" for none; /// default the first text component the receipt lists (§14.9); /// paramSets the parameter sets the page holds, {name: URL} by the names SPEC §5 gives the /// files, for the side objects it decodes (wai.splat.int_codec's `g_s.whm`). export async function openPackage(base, opts) { const checks = []; try { return await open(base, opts, checks); } catch (e) { // The checks made before the refusal, the refused one last. if (e instanceof Refused) e.checks = checks; throw e; } } async function open(base, opts, checks) { const wai = opts.wai; const without = new Set(opts.without || []); const check = (what, ok, extra = {}) => { checks.push({ what, ok: !!ok, ...extra }); return ok; }; // 1. The receipt and the indexes it binds (packaging §9.1). const receiptUrl = opts.receipt || base + "package.receipt.json"; const receiptText = dec.decode(await fetchBytes(receiptUrl)); let listed; try { listed = JSON.parse(receiptText); } catch { throw new Refused("receipt", "receipt-json", "not JSON", receiptUrl); } if (!Array.isArray(listed.components)) throw new Refused("receipt", "receipt-json", "no components", receiptUrl); const indexBytes = await Promise.all(listed.components.map((c) => fetchBytes(base + c.index))); const sig = JSON.parse(wai.verifyPackageReceiptSignature(receiptText)); check("receipt: Ed25519 signature over its signing payload", sig.ok, { code: sig.code, signer: sig.signer_id || null }); const full = JSON.parse(wai.verifyPackageReceipt(receiptText, indexBytes)); check("receipt: indexes, digests, stage roots, counts and signature", full.ok, { code: full.code, detail: full.detail, receipt_hash: full.receipt_hash || null }); if (!full.ok) throw new Refused("receipt", full.code, full.detail, receiptUrl); // 2. Each component's index, read now that the receipt holds it. const components = listed.components.map((c, i) => { const ix = JSON.parse(wai.composeIndex(indexBytes[i])); if (!ix.ok) throw new Refused("index", ix.code, ix.detail, c.id); return { id: c.id, media_class: ix.media_class, index: indexBytes[i], timebase: ix.timebase, pieces: ix.pieces, entries: ix.entries }; }); // 3. This device's report (browser-sink §3): its wasm decoders, and the user agent's // answers for the codec strings of the alternatives platform-decode maps that the page // does not decode in WebAssembly itself (§14.3). const wasm = [...WASM_DECODERS, ...WASM_LAYERS].filter((c) => !without.has(c)); const mapped = new Map(JSON.parse(wai.platformMapped()).map((m) => [m.capability, m.media])); const ua = {}; const asked = new Map(); // codec string -> the answers to each configuration asked under it const queries = new Map(); // alternative name -> {q, configs} const objects = []; const fetched = new Map(); // content hash -> bytes const objectBytes = async (hash) => { if (!fetched.has(hash)) fetched.set(hash, await fetchBytes(`${base}objects/${hash}.wai`)); return fetched.get(hash); }; const altName = (c, e) => `${c.id}/${e.piece}/${e.role}/${e.capability}`; // The layer an alternative is presented in (§14.2): a wai.text.zstd floor of a piece whose // base is wai.graphics.vector is that piece's text floor; any other is its capability's. const baseOf = (c, e) => c.entries.find((x) => x.piece === e.piece && x.role === "base") || null; const layerFor = (c, e) => (e.capability === TEXT_FLOOR ? (e.role === "floor" && (baseOf(c, e) || {}).capability === VECTOR ? "floor" : null) : layerOf(e.capability, mapped)); const layerName = (c, L) => `${c.id}/${L.piece}/L${L.layer}/${L.capability}`; for (const c of components) { for (const e of c.entries) { if (e.role !== "base" && e.role !== "floor") continue; if (PAGE_PRESENTED.includes(e.capability)) continue; let codec = null; if (mapped.has(e.capability) && !wasm.includes(e.capability)) { // The query is read from the payload, so the floor object is fetched and checked // before anything is asked of the user agent. const bytes = await objectBytes(e.content_hash); const ok = JSON.parse(wai.composeCheckObject(c.index, e.k, bytes)); if (!check(`object ${e.content_hash.slice(0, 12)} (${c.id}, piece ${e.piece}, ${e.role} ${e.capability})`, ok.ok, { code: ok.code, detail: ok.detail })) { throw new Refused("object", ok.code, ok.detail, altName(c, e)); } const env = wai.composeEnvelope(bytes); const q = JSON.parse(wai.platformQuery(e.capability, env.payload, c.timebase[0], c.timebase[1])); codec = q.codec; if (!without.has(e.capability)) { // Every configuration is asked; the report holds one answer a codec string, // merged as platform-decode §3 has it. const a = await uaAnswer(q); queries.set(altName(c, e), { q, configs: a.configs }); if (!asked.has(codec)) asked.set(codec, []); asked.get(codec).push({ supported: a.supported, smooth: a.smooth, power_efficient: a.power_efficient, probe: a.probe }); } } objects.push({ name: altName(c, e), capability: e.capability, codec, need: "none", params: {}, choice: `${c.id}/${e.piece}` }); } } for (const [codec, answers] of asked) ua[codec] = JSON.parse(wai.platformMerge(JSON.stringify(answers))); const report = { browser_sink: 1, ua, host: {}, wasm, constraints: {}, energy: {}, objects }; const routed = JSON.parse(wai.composeRoute(JSON.stringify(report))); if (routed.outcome !== "routed") throw new Refused("dispatch", routed.reason, "the capability report", null); const routeOf = new Map(objects.map((o, i) => [o.name, routed.routes[i]])); const choiceOf = new Map(routed.choices.map((ch) => [ch.choice, ch])); // 4. Each component's dispatch: per piece, the alternative chosen, or the page's own // layer for a record. const dispatch = { device: { user_agent: navigator.userAgent, forced_without: [...without], wasm, ua, device_pixel_ratio: globalThis.devicePixelRatio || 1 }, receipt: { receipt_hash: full.receipt_hash, signer_id: full.signer_id, signer_pubkey: full.signer_pubkey, asset_id: full.asset_id }, components: [], }; const plan = []; // {c, piece, entry} for (const c of components) { const d = { id: c.id, media_class: c.media_class, pieces: [] }; for (const [pi, p] of c.pieces.entries()) { const alts = c.entries.filter((e) => e.piece === pi && (e.role === "base" || e.role === "floor")); const record = alts.find((e) => PAGE_PRESENTED.includes(e.capability)); if (record) { d.pieces.push({ piece: pi, chosen: altName(c, record), capability: record.capability, layer: layerOf(record.capability, mapped), route: "page", why: "a record: the page draws it; SPEC §4 dispatches no decoder for it", alternatives: [] }); plan.push({ c, pi, e: record, route: "page" }); continue; } const ch = choiceOf.get(`${c.id}/${pi}`); const alternatives = alts.map((e) => { const r = routeOf.get(altName(c, e)); const rank = ch && ch.order.indexOf(altName(c, e)) >= 0 ? ch.ranks[ch.order.indexOf(altName(c, e))] : null; return { name: altName(c, e), capability: e.capability, role: e.role, route: r.route, reason: r.reason, legs: r.legs, tier: r.tier, rank }; }); const chosen = ch ? ch.chosen : null; const e = alts.find((x) => altName(c, x) === chosen) || null; const base = alternatives.find((a) => a.role === "base"); let why; if (!e) why = `nothing chosen (${ch ? ch.reason : "no alternative"})`; else if (e.role === "floor") why = base && base.route === "none" ? `the floor: the base ${base.capability} has no route here (${base.reason}; ${Object.entries(base.legs).map(([k, v]) => `${k}: ${v}`).join(", ")})` : `the floor: first in §4.7's order (rank ${alternatives.find((a) => a.name === chosen).rank})`; else why = `the base: first in §4.7's order (rank ${alternatives.find((a) => a.name === chosen).rank}, route ${routeOf.get(chosen).route})`; d.pieces.push({ piece: pi, chosen, capability: e ? e.capability : null, layer: e ? layerFor(c, e) : null, route: e ? routeOf.get(chosen).route : "none", role: e ? e.role : null, why, order: ch ? ch.order : [], alternatives }); if (!e) { // Nothing this device can run: the component is not presented (§14.3), and the // lead, which carries the clock, cannot be left out. if (c.id === listed.components[0].id) throw new Refused("dispatch", ch ? ch.reason : "no_alternative", `the lead component ${c.id}, piece ${pi}`, null); continue; } // The alternatives after it in §4.7's order that have a route: tried in turn if it // does not decode (§4.7, *tried first, and the other after it*). const next = (ch ? ch.order.slice(ch.order.indexOf(chosen) + 1) : []) .filter((n) => routeOf.get(n).route !== "none") .map((n) => ({ e: alts.find((x) => altName(c, x) === n), route: routeOf.get(n).route })); const dp = d.pieces[d.pieces.length - 1]; // The piece's layers, in layer order (packaging §6.3 orders them so): each rides on the // base, so a floor chosen takes none of them. Its assets are the side objects a layer // may name (staged-delivery §3.1). const layered = c.entries.filter((x) => x.piece === pi && x.role === "refinement").sort((a, b) => a.layer - b.layer); const assets = c.entries.filter((x) => x.piece === pi && x.role === "asset"); if (layered.length) { dp.layers = layered.map((L) => { const route = e.role === "base" && wasm.includes(L.capability) ? "wasm" : "none"; const why = e.role !== "base" ? "the floor plays: a layer refines the base" : route === "none" ? `no_wasm: this page does not apply ${L.capability} here` : null; return { name: layerName(c, L), capability: L.capability, layer: L.layer, route, outcome: why ? "not-applied" : "pending", reason: why, stage: null, sha256: null, sides: [] }; }); } plan.push({ c, pi, e, route: routeOf.get(chosen).route, next, dp, layered: e.role === "base" ? layered : [], assets }); } dispatch.components.push(d); } // 5. Every chosen object fetched and checked against its entry before anything is // decoded (packaging §6.3). for (const { c, pi, e } of plan) { const bytes = await objectBytes(e.content_hash); const ok = JSON.parse(wai.composeCheckObject(c.index, e.k, bytes)); if (!check(`object ${e.content_hash.slice(0, 12)} (${c.id}, piece ${pi}, ${e.role} ${e.capability})`, ok.ok, { code: ok.code, detail: ok.detail })) { throw new Refused("object", ok.code, ok.detail, altName(c, e)); } } // 6. Decode, holding each output with a signed digest to it. const layers = { video: null, overlay: null, floor: null, text: null, audio: null, state: null }; // Every text component of the package, in the receipt's order; the text layer presents one. const texts = []; // A decoded output against the digest its entry states, where it states decode-equivalence. const holds = (e, got) => e.prefix_tier !== 2 || got === e.prefix_digest; // The pieces whose base has layers, for refine(). const staged = []; const lead = components.find((c) => c.id === listed.components[0].id) || components[0]; // The page's stage sets of pictures, and the side planes it holds for their layers. let stage = null; const stageSink = () => (stage = stage || new wai.ComposedStage()); const decodeVideo = async (c, pi, e, route, payload, bytes) => { const piece = c.pieces[pi]; const where = `${c.id}, piece ${pi}`; const held = (frame) => Array(piece.duration).fill(frame); if (e.capability === "wai.field.int_grid") { // A field: rendered here at the view its payload pins (neural-fields §6), the frame held // to the signed digest, which for a field is its stage hash (staged-delivery §5). let f; try { f = new wai.ComposedField(bytes); } catch (x) { throw new Refused("decode", String(x.message || x).split(":")[0], String(x.message || x), where); } const got = f.frameSha256; const ok = holds(e, got); check(`field ${where}: its render at its own view, ${f.width}x${f.height} from ${f.held} of its ${f.levels} levels, computed here, against the signed prefix digest`, ok, { expected: e.prefix_digest, got, stage: 0 }); if (!ok) throw new Refused("digest", "prefix-digest", `frame ${got}`, where); return { frames: held(rgbToImageData(f.frame(), f.width, f.height)), field: f, presented: { capability: e.capability, route, decoder: "WebAssembly", tier: "decode-equivalence", stage: 0, frame: got } }; } if (c.media_class === "image" && route !== "ua") { // A picture: decoded here, its digest its RGB8 frame's (packaging §6.5), and held as // the base of its stage set, which its layers refine. const st = stageSink(); const d = JSON.parse(st.decodePicture(bytes, JSON.stringify(wasm))); if (!d.ok) throw new Refused("decode", d.code, d.detail, where); const ok = holds(e, d.sha256); check(`picture ${where}: the RGB8 frame of the decoded ${d.capability} against the signed prefix digest`, ok, { expected: e.prefix_digest, got: d.sha256, stage: 0 }); if (!ok) throw new Refused("digest", "prefix-digest", `frame ${d.sha256}`, where); JSON.parse(st.holdBase(d.object)); return { frames: held(rgbToImageData(st.unitBytes(d.object), d.shape[1], d.shape[0])), picture: d.object, presented: { capability: e.capability, route, decoder: "WebAssembly", tier: d.tier, stage: 0, frame: d.sha256 } }; } if (route === "ua") { const { q, configs } = queries.get(altName(c, e)); const { frames, acceleration } = await decodeUa(wai, e.capability, payload, q, configs, Math.round((1e6 * c.timebase[1]) / c.timebase[0])); check(`video ${where}: ${frames.length} frames through the user agent's decoder (tier none: no digest holds it)`, frames.length === piece.duration, { frames: frames.length, expected: piece.duration, acceleration }); if (frames.length !== piece.duration) throw new Refused("decode", "compose_frames", `${frames.length} frames for ${piece.duration}`, where); return { frames, presented: { capability: e.capability, route, decoder: "WebCodecs VideoDecoder", acceleration, tier: "none" } }; } let v; try { v = wai.composeVideoBase(e.capability, payload, piece.duration); } catch (x) { throw new Refused("decode", String(x.message || x).split(":")[0], String(x.message || x), where); } const ok = e.prefix_tier !== 2 || v.clipDigest === e.prefix_digest; check(`video ${where}: clip digest of the decoded ${e.capability} against the signed prefix digest`, ok, { expected: e.prefix_digest, got: v.clipDigest }); if (!ok) throw new Refused("digest", "prefix-digest", `clip ${v.clipDigest}`, where); const frames = []; for (let i = 0; i < v.nFrames; i++) frames.push(rgbToImageData(v.frame(i), v.width, v.height)); const clip = v.clipDigest; v.free(); return { frames, presented: { capability: e.capability, route, decoder: "WebAssembly", tier: "decode-equivalence", clip } }; }; let setBytes = null; for (const item of plan) { const { c, pi, e, route } = item; const bytes = fetched.get(e.content_hash); const env = wai.composeEnvelope(bytes); const payload = env.payload; const piece = c.pieces[pi]; const where = `${c.id}, piece ${pi}`; const layer = layerFor(c, e); if (layer === "video") { if (!layers.video) layers.video = { component: c.id, timebase: c.timebase, pieces: [] }; const tried = []; let got = null; for (const cand of [{ e, route }, ...(item.next || [])]) { if (cand.e !== e) { // A later alternative is checked against its entry before it is decoded. const b2 = await objectBytes(cand.e.content_hash); const ok = JSON.parse(wai.composeCheckObject(c.index, cand.e.k, b2)); if (!check(`object ${cand.e.content_hash.slice(0, 12)} (${c.id}, piece ${pi}, ${cand.e.role} ${cand.e.capability})`, ok.ok, { code: ok.code, detail: ok.detail })) { throw new Refused("object", ok.code, ok.detail, altName(c, cand.e)); } } try { const b = fetched.get(cand.e.content_hash); got = { ...(await decodeVideo(c, pi, cand.e, cand.route, wai.composeEnvelope(b).payload, b)), e: cand.e }; break; } catch (x) { // The user agent's decoder may refuse what it said it supports: the next // alternative is tried. Any other refusal refuses the package. if (!(x instanceof Refused) || x.code !== "ua-decode-error") throw x; // Not a check of the package: the record says what was tried (dispatch, `tried`). tried.push({ name: altName(c, cand.e), error: x.detail }); } } if (!got) throw new Refused("decode", "ua-decode-error", tried.map((t) => `${t.name}: ${t.error}`).join(" | "), where); if (tried.length) { const dp = item.dp; dp.tried = tried; dp.chosen = altName(c, got.e); dp.capability = got.e.capability; dp.role = got.e.role; dp.route = got.presented.route; dp.why = `${dp.why}; it did not decode here (${tried.map((t) => t.error).join("; ")}), so the next in §4.7's order: the ${got.e.role} ${got.e.capability}`; } const vp = { start: piece.start, duration: piece.duration, frames: got.frames, ...got.presented }; layers.video.pieces.push(vp); if (item.layered.length && got.e.role === "base") staged.push({ c, pi, e: got.e, layered: item.layered, assets: item.assets, dp: item.dp, vp, field: got.field || null, picture: got.picture || null }); } else if (layer === "overlay") { if (!setBytes) setBytes = await Promise.all((opts.sets || []).map(fetchBytes)); let o; try { o = new wai.VectorOverlay(payload, setBytes); } catch (x) { throw new Refused("decode", String(x.message || x), "wai.graphics.vector", where); } const sizes = JSON.parse(o.sizes()); // The piece's frames at the component's rate: its window is the piece's (packaging // §5.8), so its frames are the whole programme's frames of the piece. const rate = JSON.parse(o.rates()).find(([num, den]) => num * c.timebase[1] === c.timebase[0] * den); if (!rate) throw new Refused("decode", "rate_unlisted", `the component's rate ${c.timebase.join("/")}`, where); const [num, den, first, last] = rate; if (e.prefix_tier === 2) { // The decoded output against the signed digest (packaging §6.5): the clip digest // of the piece's frames as RGBA8P at the first listed size and the component's rate. const [w0, h0] = sizes[0]; const cat = new Uint8Array(32 * (last - first + 1)); for (let g = first; g <= last; g++) cat.set(unhex(wai.sha256Hex(o.render(w0, h0, num, den, g))), 32 * (g - first)); const got = wai.sha256Hex(cat); const ok = got === e.prefix_digest; check(`overlay ${where}: clip digest of its ${last - first + 1} frames at ${w0}x${h0}, rasterised here, against the signed prefix digest`, ok, { expected: e.prefix_digest, got }); if (!ok) throw new Refused("digest", "prefix-digest", `clip ${got}`, where); } if (!layers.overlay) { // The listed size nearest this display's overlay: the smallest at least as large, // else the largest. const [ow, oh] = opts.overlayPx || [1280, 720]; const fit = sizes.filter(([w, h]) => w >= ow && h >= oh).sort((a, b) => a[0] * a[1] - b[0] * b[1]); const size = fit[0] || sizes.slice().sort((a, b) => b[0] * b[1] - a[0] * a[1])[0]; layers.overlay = { component: c.id, sizes, rate: [num, den], size, capability: e.capability, route, pieces: [] }; } layers.overlay.pieces.push({ overlay: o, first, last }); } else if (layer === "floor") { // The piece's text floor (§14.9): held to the piece's base, its frame the one // graphics-vector §6.3 writes, its content a transcript in its one encoding. const b = baseOf(c, e); const what = `floor ${where}: the transcript of its piece's base ${b.content_hash.slice(0, 12)}, in the raw frame, read here (graphics-vector §6.3)`; let f; try { f = new wai.TextFloor(env.manifest, payload, b.content_hash); } catch (x) { const code = String(x.message || x); check(what, false, { code }); throw new Refused("floor", code, TEXT_FLOOR, where); } check(what, true, { blocks: f.blocks, tb: f.tb, base: b.content_hash }); if (!layers.floor) layers.floor = { component: c.id, timebase: c.timebase, capability: e.capability, route, pieces: [] }; layers.floor.pieces.push({ piece: pi, start: piece.start, duration: piece.duration, floor: f, tb: f.tb, transcript: f.transcript() }); } else if (layer === "text") { // The language is the object's target's (packaging §5.8): a WebVTT segment does not // state it. let target = null; try { target = JSON.parse(env.manifest).target || null; } catch { /* no target */ } const lang = target && typeof target.lang === "string" ? target.lang : "en"; const r = JSON.parse(wai.composeCues(dec.decode(payload), lang)); check(`text ${where}: the WebVTT segment reads to its cues (timed-text §5.3)`, r.ok, { code: r.code || null, cues: r.ok ? r.cues.length : 0, lang }); if (!r.ok) throw new Refused("decode", r.code, "wai.text.timed", where); if (e.prefix_tier === 2) { const got = wai.sha256Hex(payload); const ok = got === e.prefix_digest; check(`text ${where}: SHA-256 of the segment against the signed prefix digest`, ok, { expected: e.prefix_digest, got }); if (!ok) throw new Refused("digest", "prefix-digest", `segment ${got}`, where); } let track = texts.find((x) => x.component === c.id); if (!track) texts.push((track = { component: c.id, lang, timebase: c.timebase, capability: e.capability, route, pieces: [] })); track.pieces.push({ start: piece.start, duration: piece.duration, cues: r.cues }); } else if (layer === "audio") { let mix, rate, channels = 1, got; if (route === "ua") { // The user agent's decoder: tier none (browser-sink §4.1), held to no digest. Its // samples join the layer as 16-bit integers, interleaved. const { q, configs } = queries.get(altName(c, e)); const { audio } = await decodeUa(wai, e.capability, payload, q, configs, 0); if (e.capability === "wai.audio.opus") { // An Ogg Opus stream: its pre-skip dropped and its samples ended at its last // granule position (RFC 7845 §4). A decoder that drops the codec's delay itself // has left fewer samples than the packets code, so only the rest is dropped here. const { preSkip, granule } = oggOpusTrim(payload); const coded = payloadChunks(wai, e.capability, payload).chunks.reduce((n, u) => n + (u.samples || 0), 0); const decoded = audio.frames; const drop = preSkip - (coded - decoded), keep = granule - preSkip; if (drop < 0 || keep < 0 || drop + keep > audio.frames) throw new Refused("decode", "ua-decode-error", `${audio.frames} samples decoded for a pre-skip of ${preSkip} and ${keep} samples`, where); audio.channels = audio.channels.map((x) => x.subarray(drop, drop + keep)); audio.frames = keep; // An Opus floor's object presents exactly its piece (packaging §7.9). const ok = e.role !== "floor" || keep === piece.duration; check(`audio ${where}: ${keep} sample frames from Opus after its pre-skip and end (the piece's ${piece.duration})`, ok, { pre_skip: preSkip, granule, coded, decoded }); if (!ok) throw new Refused("decode", "compose_frames", `${keep} samples for ${piece.duration}`, where); } rate = audio.sampleRate; channels = audio.channels.length; mix = new Int16Array(audio.frames * channels); for (let i = 0; i < audio.frames; i++) { for (let ch = 0; ch < channels; ch++) mix[i * channels + ch] = Math.max(-32768, Math.min(32767, Math.round(audio.channels[ch][i] * 32768))); } const pcm = new Uint8Array(mix.length * 2); const dv = new DataView(pcm.buffer); mix.forEach((v, i) => dv.setInt16(2 * i, v, true)); got = wai.sha256Hex(pcm); check(`audio ${where}: ${audio.frames} sample frames of ${channels} channel(s) through the user agent's decoder (tier none: no digest holds it)`, audio.frames > 0, { got, sample_rate: rate }); if (!audio.frames) throw new Refused("decode", "ua-decode-error", "no samples", where); } else if (e.capability === "wai.audio.flac") { // Decoded in WebAssembly: the decoding process's samples, so an entry that states a // digest (a sound track's base) is held to it; a score's floor states none. let a; try { a = wai.composeFlac(payload, piece.duration); } catch (x) { throw new Refused("decode", String(x.message || x).split(":")[0], String(x.message || x), where); } mix = a.samples(); rate = a.sampleRate; channels = a.channels; got = a.pcmSha256; a.free(); if (e.prefix_tier === 2) { const ok = got === e.prefix_digest; check(`audio ${where}: SHA-256 of the ${mix.length} samples decoded here from FLAC against the signed prefix digest`, ok, { expected: e.prefix_digest, got }); if (!ok) throw new Refused("digest", "prefix-digest", `samples ${got}`, where); } else { check(`audio ${where}: ${mix.length / channels} sample frames decoded here from FLAC (the ${e.role}: no digest states it)`, true, { got, channels }); } } else { let s; try { s = new wai.ScoreSink(payload); } catch (x) { throw new Refused("decode", String(x.message || x), "wai.audio.score", where); } // The contract's channels (score §2.1): two are interleaved left, right, and the // digest is over the interleaved samples (packaging §6.5). mix = s.mixdown(); rate = s.sample_rate(); channels = s.channels(); const pcm = new Uint8Array(mix.length * 2); const dv = new DataView(pcm.buffer); mix.forEach((v, i) => dv.setInt16(2 * i, v, true)); got = wai.sha256Hex(pcm); s.free(); const ok = e.prefix_tier === 2 && got === e.prefix_digest && mix.length === piece.duration * channels; check(`audio ${where}: SHA-256 of the ${mix.length / channels} sample frames of ${channels} channel(s) synthesised here against the signed prefix digest`, ok, { expected: e.prefix_digest, got, channels }); if (!ok) throw new Refused("digest", "prefix-digest", `samples ${got}`, where); } if (!layers.audio) layers.audio = { component: c.id, sampleRate: rate, channels, capability: e.capability, route, pieces: [] }; if (rate !== layers.audio.sampleRate || channels !== layers.audio.channels) throw new Refused("decode", "compose_audio_format", `${channels} channels at ${rate} Hz after ${layers.audio.channels} at ${layers.audio.sampleRate}`, where); layers.audio.pieces.push({ start: piece.start, samples: mix, pcmSha256: got, capability: e.capability }); } else if (layer === "state") { let f; try { f = new wai.FeedSink(payload); } catch (x) { throw new Refused("decode", String(x.message || x), "wai.feed.state", where); } // Each piece starts from the state the piece before it seals (packaging §5.8). const from = f.state_hash_hex(); const prev = layers.state && layers.state.pieces[layers.state.pieces.length - 1]; if (prev && prev.start + prev.duration === piece.start) { const ok = from === prev.seal; check(`state ${where}: it starts from the state piece ${pi - 1} seals`, ok, { expected: prev.seal, got: from }); if (!ok) throw new Refused("digest", "feed-chain", `starts from ${from}`, where); } const finalTick = Number(f.final_tick()); const sealed = f.verify_seal(); check(`state ${where}: the signed ops replayed to tick ${finalTick} give the sealed state hash`, sealed, { state_hash: f.state_hash_hex(), seal: f.seal_hash_hex() }); if (!sealed) throw new Refused("digest", "feed-seal", "replay does not reach the seal", where); if (!layers.state) layers.state = { component: c.id, timebase: c.timebase, capability: e.capability, route, pieces: [] }; layers.state.pieces.push({ feed: f, start: piece.start, duration: piece.duration, finalTick, seal: f.seal_hash_hex() }); } } // The text layer's component (§14.9): the one in the language asked for, else the first the // receipt lists; none where captions are off. Every text component was checked above. const lower = (x) => String(x).replace(/[A-Z]/g, (ch) => ch.toLowerCase()); layers.text = opts.captions === "off" ? null : (opts.captions && texts.find((x) => lower(x.lang) === lower(opts.captions))) || texts[0] || null; dispatch.text = { presented: layers.text ? layers.text.component : null, lang: layers.text ? layers.text.lang : null, asked: opts.captions || null, components: texts.map((x) => ({ id: x.component, lang: x.lang })) }; // The audio's pieces end to end: one buffer on the component's timeline, interleaved. if (layers.audio) { const a = layers.audio; const total = a.pieces.reduce((n, p) => Math.max(n, a.channels * p.start + p.samples.length), 0); a.samples = new Int16Array(total); for (const p of a.pieces) a.samples.set(p.samples, a.channels * p.start); a.frames = total / a.channels; const pcm = new Uint8Array(total * 2); const dv = new DataView(pcm.buffer); a.samples.forEach((v, i) => dv.setInt16(2 * i, v, true)); a.pcmSha256 = wai.sha256Hex(pcm); } for (const d of dispatch.components) { d.layer = d.pieces.map((p) => p.layer).find(Boolean) || null; d.presented = d.pieces.every((p) => p.chosen !== null); } // 7. The layers, each as it arrives (Composition.refine). A side object is fetched once a // layer names it, and held as a plane only after the side check; a render entry's frame // is rendered here from a splat set held so. const assetName = (c, a) => `${c.id}/${a.piece}/asset/${a.capability}`; const sides = new Map(); // content hash -> what the page holds of it const renders = new Map(); // object + pose -> the render let paramBytes = null; const paramSet = async () => { if (!paramBytes) { paramBytes = new Map(); for (const [name, url] of Object.entries(opts.paramSets || {})) { try { paramBytes.set(name, await fetchBytes(url)); } catch { /* a set this page does not hold */ } } } return paramBytes; }; // The side check: a side object is held only where this page decodes its capability here, // once its bytes have arrived and passed their entry's checks (packaging §6.3), and once its // decoded output holds the digest its entry states. Its bytes, or null with `s.why` set. const sideCheck = async (it, a, s) => { const cap = a.capability === SPLAT ? SPLAT : "wai.image.png"; if (!wasm.includes(cap)) { s.why = `no_wasm: this page does not decode ${cap} here`; return null; } let bytes; try { bytes = await fetchBytes(`${base}objects/${a.content_hash}.wai`); } catch (x) { check(`object ${a.content_hash.slice(0, 12)} (${it.c.id}, piece ${it.pi}, asset ${a.capability}): it arrives`, false, { code: "fetch-failed", detail: x.detail || String(x) }); s.why = `fetch-failed: ${x.detail || x}`; return null; } const ok = JSON.parse(wai.composeCheckObject(it.c.index, a.k, bytes)); if (!check(`object ${a.content_hash.slice(0, 12)} (${it.c.id}, piece ${it.pi}, asset ${a.capability})`, ok.ok, { code: ok.code, detail: ok.detail })) { s.why = `${ok.code}: ${ok.detail}`; return null; } return bytes; }; const holdSide = async (it, object) => { if (sides.has(object)) return sides.get(object); const s = { object, entry: null, held: false, exact: null, capability: null, via_fallback: null, tier: null, why: null }; sides.set(object, s); const a = it.assets.find((x) => x.content_hash === object); if (!a) { s.why = "the piece lists no such object"; return s; } s.entry = assetName(it.c, a); s.capability = a.capability; const bytes = await sideCheck(it, a, s); if (!bytes) return s; const st = stageSink(); const d = JSON.parse(a.capability === SPLAT ? st.decodeSplat(bytes, await paramSet()) : st.decodePicture(bytes, JSON.stringify(wasm))); if (!check(`side ${s.entry}: decoded here`, d.ok, { code: d.code || null, detail: d.detail || null })) { s.why = `${d.code}: ${d.detail}`; return s; } Object.assign(s, { capability: d.capability, via_fallback: d.via_fallback, tier: d.tier }); const dg = holds(a, d.sha256); check(`side ${s.entry}: its output, decoded here through ${d.capability}${d.via_fallback ? " (its fallback)" : ""}, ${a.prefix_tier === 2 ? "against the signed prefix digest" : "held to no digest (its entry states none)"}`, dg, { expected: a.prefix_tier === 2 ? a.prefix_digest : null, got: d.sha256, tier: d.tier }); if (!dg) { s.why = `prefix-digest: ${d.sha256}`; return s; } s.exact = st.holdSide(d.object); s.held = true; return s; }; const holdRender = async (it, x) => { const key = x.object + JSON.stringify(x.render); if (renders.has(key)) return renders.get(key); const s = await holdSide(it, x.object); const r = { object: x.object, entry: s.entry, render: x.render, held: false, exact: null, capability: s.capability, via_fallback: s.via_fallback, tier: s.tier, why: null }; renders.set(key, r); if (!s.held) r.why = `the splat set is not held (${s.why})`; else if (s.capability !== SPLAT) r.why = `${s.capability} is not a splat set`; else { const out = JSON.parse(stageSink().addRender(x.object, JSON.stringify(x.render))); check(`render of ${s.entry} at its pinned pose (${x.render.width}x${x.render.height}), by wai-splat-render:i1 here`, out.ok, { code: out.code || null, got: out.sha256 || null }); r.held = out.ok; r.exact = out.ok ? s.exact : null; if (!out.ok) r.why = out.code; } return r; }; const refine = async (comp, view, onStage) => { for (const it of staged) { const where = `${it.c.id}, piece ${it.pi}`; for (const L of it.layered) { const rec = it.dp.layers.find((x) => x.name === layerName(it.c, L)); if (rec.outcome !== "pending") continue; const refuse = (code, detail) => Object.assign(rec, { outcome: "refused", reason: code, detail: detail || null }); let bytes; try { bytes = await fetchBytes(`${base}objects/${L.content_hash}.wai`); } catch (x) { check(`object ${L.content_hash.slice(0, 12)} (${where}, layer ${L.layer} ${L.capability}): it arrives`, false, { code: "fetch-failed", detail: x.detail || String(x) }); refuse("fetch-failed", x.detail || String(x)); continue; } const ok = JSON.parse(wai.composeCheckObject(it.c.index, L.k, bytes)); if (!check(`object ${L.content_hash.slice(0, 12)} (${where}, layer ${L.layer} ${L.capability})`, ok.ok, { code: ok.code, detail: ok.detail })) { refuse(ok.code, ok.detail); continue; } let frame, got, k; if (it.field) { const r = JSON.parse(it.field.offer(bytes)); if (!check(`layer ${rec.name}: staged-delivery §4.1 applies it to the field as its next level`, r.ok, { code: r.reason || null, detail: r.detail || null })) { refuse(r.reason, r.detail); continue; } k = r.stage; got = it.field.frameSha256; frame = rgbToImageData(it.field.frame(), it.field.width, it.field.height); } else { // The side planes it names, each held by the page or not (staged-delivery §3.1); then // §4.1, whose step 4 refuses a plane not held (side-absent) or not exact // (side-not-exact). const sd = JSON.parse(wai.composeSide(bytes)); if (sd.ok) for (const x of sd.side) rec.sides.push(x.render ? await holdRender(it, x) : await holdSide(it, x.object)); const st = stageSink(); const rep = JSON.parse(st.offer(bytes, Math.round(performance.now()))); const r = rep.find((x) => x.object === L.content_hash) || rep[0] || { error: "no report" }; const reason = r.error || r.envelope || (r.refused && r.refused.length ? r.refused[0][1] : null) || (r.held && r.held.length ? "layer-gap" : null) || (r.applied && r.applied.includes(0) ? null : "not-applied"); if (!check(`layer ${rec.name}: staged-delivery §4.1 applies it to the picture`, !reason, { code: reason, detail: r.detail || null, sides: rec.sides.map((x) => `${x.entry || x.object.slice(0, 12)}${x.render ? " (render)" : ""}: ${x.held ? (x.exact ? "held, exact" : "held, not exact") : "not held"}`) })) { refuse(reason, r.detail); continue; } const u = JSON.parse(st.unit(it.picture)); k = u.stage; got = u.sha256; frame = rgbToImageData(st.unitBytes(it.picture), u.shape[1], u.shape[0]); } const dg = holds(L, got); check(`${it.field ? "field" : "picture"} ${where}: stage ${k}, computed here, against the signed prefix digest`, dg, { expected: L.prefix_digest, got, stage: k }); if (!dg) { refuse("prefix-digest", `stage ${k}: ${got}`); continue; } Object.assign(rec, { outcome: "applied", reason: null, stage: k, sha256: got }); comp.presentStage(it, frame, k, got, view, onStage); } } }; return new Composition({ dispatch, checks, layers, lead, receipt: full, staged, refine }); } /// The components presented on one timeline. The lead's video is the master clock: its /// frame index follows the presentation clock, and every other layer is placed by it. export class Composition { constructor({ dispatch, checks, layers, lead, receipt, staged, refine }) { this.dispatch = dispatch; this.checks = checks; this.layers = layers; this.lead = lead; this.receipt = receipt; this.staged = staged || []; this._refine = refine || null; // Every stage of a layered piece presented, stage 0 (its base) first. this.stages = this.staged.map((it) => ({ component: it.c.id, piece: it.pi, stage: 0, sha256: it.vp.frame, presented_sha256: null })); const v = layers.video; const last = v ? v.pieces[v.pieces.length - 1] : null; this.fps = v ? v.timebase[0] / v.timebase[1] : 25; this.frames = last ? last.start + last.duration : 0; this.duration = this.frames / this.fps; this.stats = { video_frames: 0, overlay_frames: 0, floor_blocks: 0, cues_shown: 0, state_updates: 0, audio_started: false, audio_resyncs: 0, clock_s: 0, ended: false }; // What the last present() showed: the overlay's frame or null, the floor's result // (§14.9) or null, and the text of each cue in the text layer. this.now = { overlay: null, floor: null, cues: [] }; this._announced = new Map(); this.playing = false; } /// Take each layer of every layered piece as it arrives (step 7 of openPackage): a stage is /// presented once it holds its signed digest; a layer refused anywhere leaves the stage below. /// `onStage(event)` is called after each stage is presented. async refine(view, onStage) { if (this._refine) await this._refine(this, view, onStage); } /// Present a layered piece's new stage: its frame replaces the piece's, and the clock is /// moved to the piece so that the stage is on screen. presentStage(it, frame, k, sha256, view, onStage) { it.vp.frames = it.vp.frames.map(() => frame); it.vp.stage = k; it.vp.frame = sha256; this._n = undefined; this.present(it.vp.start / this.fps, view); const ev = { component: it.c.id, piece: it.pi, stage: k, sha256, presented_sha256: view && view.readback ? view.readback() : null }; this.stages.push(ev); if (onStage) onStage(ev); } /// The decoded frame of the video at frame index `n`. videoFrame(n) { for (const p of this.layers.video.pieces) if (n >= p.start && n < p.start + p.duration) return p.frames[n - p.start]; return null; } /// The overlay piece that draws frame `g`, or null. overlayPiece(g) { const o = this.layers.overlay; return (o && o.pieces.find((p) => g >= p.first && g <= p.last)) || null; } /// Every overlay frame rendered and hashed in the page at the size it presents, for a /// check of the overlay against another sink's: the frames' SHA-256s in order, each /// piece's clip digest, and the clip digest of them all. overlayDigests(wai) { const o = this.layers.overlay; const [num, den] = o.rate; const first = o.pieces[0].first, last = o.pieces[o.pieces.length - 1].last; const out = []; const pieces = []; for (const p of o.pieces) { const cat = new Uint8Array(32 * (p.last - p.first + 1)); for (let g = p.first; g <= p.last; g++) { const d = wai.sha256Hex(p.overlay.render(o.size[0], o.size[1], num, den, g)); out.push(d); cat.set(unhex(d), 32 * (g - p.first)); } pieces.push({ frames: [p.first, p.last], clip: wai.sha256Hex(cat) }); } const all = new Uint8Array(32 * out.length); out.forEach((d, i) => all.set(unhex(d), 32 * i)); return { size: o.size, rate: [num, den], frames: [first, last], digests: out, pieces, clip: wai.sha256Hex(all) }; } /// Present at time `t` seconds on the timeline: the video frame, the overlay frame, the /// cues and the state at `t`. present(t, view) { const n = Math.min(this.frames - 1, Math.max(0, Math.floor(t * this.fps + 1e-9))); if (n !== this._n) { this._n = n; const f = this.videoFrame(n); if (f && view.video) { if (view.video.width !== f.width || view.video.height !== f.height) { view.video.width = f.width; view.video.height = f.height; } view.video.getContext("2d").putImageData(f, 0, 0); this.stats.video_frames++; } } const o = this.layers.overlay; if (o && view.overlay) { const [num, den] = o.rate; const g = Math.floor((t * num) / den + 1e-9); if (g !== this._g) { this._g = g; const ctx = view.overlay.getContext("2d"); if (view.overlay.width !== o.size[0] || view.overlay.height !== o.size[1]) { view.overlay.width = o.size[0]; view.overlay.height = o.size[1]; } const op = this.overlayPiece(g); if (op) { const px = op.overlay.render(o.size[0], o.size[1], num, den, g); ctx.putImageData(premultipliedToImageData(px, o.size[0], o.size[1]), 0, 0); this.stats.overlay_frames++; } else ctx.clearRect(0, 0, view.overlay.width, view.overlay.height); this.now.overlay = op ? g : null; } } const tx = this.layers.text; // The cues of the piece of the text component the clock is in, as §14.9 compares them. let cuesHere = []; if (tx) { const u = Math.floor((t * tx.timebase[0]) / tx.timebase[1] + 1e-9); const tp = tx.pieces.find((p) => u >= p.start && u < p.start + p.duration) || tx.pieces[tx.pieces.length - 1]; cuesHere = tp.cues; this.now.cues = tp.cues.filter((c) => t * 1000 >= c.start && t * 1000 < c.end).map((c) => c.text); } else this.now.cues = []; const fl = this.layers.floor; if (fl) { // The floor of the piece the clock is in, at the tick ⌊t · tb⌋ (§14.9): a piece that // presents its overlay has no floor here, so the two are never drawn together. const u = Math.floor((t * fl.timebase[0]) / fl.timebase[1] + 1e-9); const fp = fl.pieces.find((p) => u >= p.start && u < p.start + p.duration) || null; let shown = null; if (fp) { const tick = Math.floor(t * fp.tb + 1e-6); shown = { piece: fp.piece, tick, ...JSON.parse(fp.floor.present(tick, JSON.stringify(cuesHere.map((c) => ({ lang: c.lang, start: c.start, end: c.end, text: c.text }))))) }; } this.now.floor = shown; const blocks = shown ? shown.presented : []; const key = JSON.stringify(blocks); if (view.floor && key !== this._floor) { this._floor = key; // Each block as text, never as markup (graphics-vector §5.5), with its language, its // direction and its writing mode. view.floor.replaceChildren( ...blocks.map((b) => { const el = document.createElement("div"); el.className = "floor-block"; el.lang = b.lang; el.dir = b.dir ? "rtl" : "ltr"; el.dataset.index = String(b.index); el.dataset.purpose = String(b.purpose); if (b.mode) el.style.writingMode = b.mode === 1 ? "vertical-rl" : "vertical-lr"; if (b.purpose === 1) { el.setAttribute("role", "heading"); el.setAttribute("aria-level", "2"); } el.textContent = b.text; return el; }), ); this.stats.floor_blocks += blocks.length; if (view.floorAnnounce) { // Announced politely, once for the interval a block entered in: a node joins the // live region when its block enters and leaves when the block does. const live = new Set(blocks.filter((b) => b.announce).map((b) => b.index)); for (const [i, el] of this._announced) { if (!live.has(i)) { el.remove(); this._announced.delete(i); } } for (const b of blocks) { if (!b.announce || this._announced.has(b.index)) continue; const el = document.createElement("div"); el.lang = b.lang; el.dir = b.dir ? "rtl" : "ltr"; el.textContent = b.text; view.floorAnnounce.appendChild(el); this._announced.set(b.index, el); } } } } if (tx && view.text) { // The cues of the piece the clock is in: a cue across a piece start is in both // pieces' segments, each with its whole interval (timed-text §5.2). const ms = t * 1000; const u = Math.floor((t * tx.timebase[0]) / tx.timebase[1] + 1e-9); const tp = tx.pieces.find((p) => u >= p.start && u < p.start + p.duration) || tx.pieces[tx.pieces.length - 1]; const active = tp.cues.filter((c) => ms >= c.start && ms < c.end); const key = active.map((c) => c.start).join(","); if (key !== this._cues) { this._cues = key; view.text.replaceChildren( ...active.map((c) => { const box = document.createElement("div"); box.className = `cue ${c.top ? "top" : "bottom"} align-${c.align}`; c.lines.forEach((line, i) => { if (i) box.appendChild(document.createElement("br")); for (const [italic, s] of line) { const span = document.createElement(italic ? "i" : "span"); span.textContent = s; box.appendChild(span); } }); return box; }), ); if (active.length) this.stats.cues_shown += active.length; } } const st = this.layers.state; if (st && view.state) { // The piece the tick is in, and the state at the tick, never past its seal. const u = Math.floor((t * st.timebase[0]) / st.timebase[1] + 1e-9); const sp = st.pieces.find((p) => u >= p.start && u < p.start + p.duration) || st.pieces[st.pieces.length - 1]; const tick = Math.min(sp.finalTick, u); if (tick !== this._tick) { this._tick = tick; sp.feed.advance_to(BigInt(tick)); view.state(tick, JSON.parse(sp.feed.records_json()), sp.feed.state_hash_hex()); this.stats.state_updates++; } } this.stats.clock_s = t; } /// Play from the start: the video clock runs from the page's clock, the score is /// scheduled on Web Audio at the video's position and re-placed when it drifts more /// than a frame from it. async play(view) { if (this.playing) return; this.playing = true; this.stats.ended = false; this._n = this._g = this._cues = this._tick = this._floor = undefined; const a = this.layers.audio; let ctx = null, buf = null, src = null, audioAt = 0, audioFrom = 0; if (a) { try { ctx = new AudioContext(); // An engine with no audio device may leave resume() pending: the clock never // waits on it. if (ctx.state === "suspended") await Promise.race([ctx.resume(), new Promise((ok) => setTimeout(ok, 1000))]); const n = a.samples.length / a.channels; buf = ctx.createBuffer(a.channels, n, a.sampleRate); for (let c = 0; c < a.channels; c++) { const ch = buf.getChannelData(c); for (let i = 0; i < n; i++) ch[i] = a.samples[a.channels * i + c] / 32768; } } catch { ctx = null; } } const startAudio = (from) => { if (!ctx || from >= buf.duration) return; if (src) try { src.stop(); } catch { /* already stopped */ } src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); audioAt = ctx.currentTime; audioFrom = from; src.start(audioAt, from); this.stats.audio_started = true; this.stats.audio_context = ctx.state; }; const t0 = performance.now(); startAudio(0); await new Promise((done) => { const tick = () => { const t = (performance.now() - t0) / 1000; if (t >= this.duration) { this.present(this.duration - 1e-6, view); this.stats.ended = true; done(); return; } this.present(t, view); if (ctx && src && ctx.state === "running") { const at = audioFrom + (ctx.currentTime - audioAt); if (at < buf.duration && Math.abs(at - t) > 1 / this.fps) { startAudio(t); this.stats.audio_resyncs++; } } requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); if (src) try { src.stop(); } catch { /* ended */ } if (ctx) await ctx.close(); this.playing = false; } }