open-standards/wai/wai-web/compose/compose.js
Transaction Science d314fb6497
Some checks failed
wai-quantum for JavaScript / does this change touch it (push) Successful in 10s
WAI Studio / does this change touch it (push) Successful in 25s
wai-quantum studio and desktop / does this change touch it (push) Successful in 25s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 10s
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 10s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 5m28s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m16s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 37s
wai-quantum for JavaScript / javascript · linux (push) Successful in 5m41s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 10m17s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 5m58s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 4m55s
wai-quantum studio and desktop / studio · browser (push) Successful in 6m33s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 11m26s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 6m37s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 7m10s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 12m1s
WAI Studio / Studio in WebKit · linux (push) Successful in 14m7s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 14m25s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Has been cancelled
wai-web compose: fields and conditioned refinements computed in the page, each stage held to its signed digest
The composing page now computes two capabilities that so far ran only natively, in
WebAssembly, from the wai-rs decoders compiled to wasm32 (the compose feature gains
wai/field_int and wai/splat_exact; nothing is written again in JavaScript):

- src/compose_stage.rs: ComposedField, a wai.field.int_grid base and its
  wai.field.int_refine levels (int_field::FieldStages under staged-delivery §4.1, a hold
  bound of zero), rendered at the field's own view after every stage, its render work
  limited before it renders; ComposedStage, staged::StageSink with the side planes a
  wai.image.int_cond_refine layer reads held by the page: PNG pictures through an
  envelope's capability or its fallback, splat sets through wai.splat.int_codec with the
  parameter set the page holds, and a render entry's frame rendered in the page by
  wai-splat-render:i1; composeSide, the side planes a layer names.
- compose.js: a field is presented as the picture and a picture of an image component
  decoded in the tab, each held to its signed digest. A piece whose base has layers
  presents the base, then takes each layer as it arrives: checked against its entry, the
  next stage computed in WebAssembly, held to the stage hash its entry states, then
  presented. A side object is held as a plane only where the page decodes it under the
  profile, its bytes pass their entry's checks and its output holds its digest. A layer
  refused anywhere (side-absent, side-not-exact, layer-gap, object-hash, prefix-digest)
  leaves the stage below presented, and the page says why. The dispatch record lists
  each piece's layers; the field and the side objects route wasm, never asked of the
  user agent, and the refinement capabilities are in the report's wasm leg.
- compose/staged/: four packages sealed with the package's key from objects of
  volumetric-conformance/fields/ and staged-conformance/cond/, byte for byte, every
  prefix digest computed natively and held to the corpus's statement; the native tests
  (compose_stage::) require the committed bytes and replay both corpora through the
  bindings' pure half.
- run.mjs: the staged cases (--only staged runs them alone), held to the corpora and the
  signed entries: each field renders to its scene's frame digest, read back from the
  canvas; each level, held back in delivery and released one at a time, presents its
  stage hash before the next is asked for; a wrong signed stage digest, a flipped level,
  a withheld or flipped side object, the splat decoder left out of the profile and a
  plane held only through its fallback are each refused with their code, the stage below
  presented.
- browser-sink §14: the field in the layers table, the dispatch rule for fields, layers
  and side objects, the dispatch record's layers, the new §14.5 Refinement layers, the
  staged packages (§14.7) and their conformance (§14.8).
- CI: wai-web-wasm.yml runs compose_stage:: natively and triggers on
  volumetric-conformance/; its native test count is 38, which main already has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:22:49 -05:00

1136 lines
59 KiB
JavaScript

// 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;
// - timed text: each piece a WebVTT segment, drawn as text in the page's own text layer;
// - 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.audio.score", "wai.audio.flac", "wai.feed.state", "wai.field.int_grid", "wai.splat.int_codec"];
/// 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;
/// 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}`;
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 ? layerOf(e.capability, mapped) : 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, text: null, audio: null, state: null };
// 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 = layerOf(e.capability, mapped);
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 === "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);
}
if (!layers.text) layers.text = { component: c.id, timebase: c.timebase, capability: e.capability, route, pieces: [] };
layers.text.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 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, cues_shown: 0, state_updates: 0, audio_started: false, audio_resyncs: 0, clock_s: 0, ended: false };
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);
}
}
const tx = this.layers.text;
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 = 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;
}
}