open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/stitched.js
Transaction Science 9151295ff6 wai: register wai.video.stitched_presentation; its records take the magics WSI1 and WSC1
The pod-input record and the claim were first written as WPI1 and WPS1, which co-performance
already defines (§5.1, §7.7); the registry's wire-identifier table showed the collision. They
are WSI1 and WSC1 now, the corpus regenerated. The capability is registered in
codecs/registry.rs with its two wire identifiers, the registry export regenerated, SPEC §5
gains its paragraph, the CHANGELOG its entries, and wai-conformance a step that runs the
module's tests, the corpus through the tool, the independent verifier and the browser
producer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:42:10 -04:00

371 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The reference producer of WAI stitched presentation claims
// (wai/extensions/stitched-presentation.md): a player's claim that it presented an ad another
// party wrote into the stream, stitched into its segments or served as an interstitial.
//
// hlsAds, interstitialAds, mpdAds the markers and ad identities a manifest carries (§3)
// markerBody, markerDigest a marker's body and digest (§3.1)
// adFrame, shareBp a composited frame's ad region and its share (§6)
// regionVisibleBp the fraction of the ad's region in view, as displayed (§6)
// sessionOf, redemptionContext the session a key names (§5.2) and a token's context (§5.5)
// claimBody, signClaim the claim's bytes (§5.1) and its signature (§5.3)
//
// Spans come from ad-presentation's SpanRecorder (./presentation.js), over the ad's own media
// time. Nothing here needs a player of a particular kind: an integration hands it the manifest
// it fetched, the ad's media position and the observations ad-presentation §10 lists. The
// platform's WebCrypto does the hashing and signing; no dependency.
import { CREATIVE_DOMAIN, NOT_EXPOSED, creativeDigest, sha256, touched } from "./presentation.js";
export { creativeDigest, touched, NOT_EXPOSED, CREATIVE_DOMAIN };
const enc = new TextEncoder();
const subtle = globalThis.crypto.subtle;
export const CLAIM_MAGIC = enc.encode("WSC1");
export const CLAIM_DOMAIN = enc.encode("wai:stitched-presentation-claim\x01");
export const SESSION_DOMAIN = enc.encode("wai:stitched-presentation-session\x01");
export const ATTEST_DOMAIN = enc.encode("wai:stitched-presentation-attest\x01");
export const MARKER_DOMAIN = enc.encode("wai:stitched-marker\x01");
export const CARRIAGE = { DATERANGE: 1, INTERSTITIAL: 2, EVENT: 3 };
export const INTERSTITIAL_CLASS = "com.apple.hls.interstitial"; // allow-name: the interstitial date-range class, a protocol identifier
const concat = (...parts) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) {
out.set(p, at);
at += p.length;
}
return out;
};
const u16le = (v) => Uint8Array.of(v & 0xff, (v >>> 8) & 0xff);
const u32le = (v) => {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, v >>> 0, true);
return b;
};
const bytes = (s) => (typeof s === "string" ? enc.encode(s) : s);
const long = (s) => {
const b = bytes(s);
return concat(u16le(b.length), b);
};
// ------------------------------------------------------------------ §3.1 markers
/** A marker's body (§3.1): its fields as long fields, and an interstitial's index. */
export function markerBody(m) {
if (m.carriage === CARRIAGE.EVENT) return concat(long(m.period ?? ""), long(m.scheme), long(m.value ?? ""), long(m.id));
const b = concat(long(m.id), long(m.start));
return m.carriage === CARRIAGE.INTERSTITIAL ? concat(b, u16le(m.index)) : b;
}
/** The marker digest (§3.1). */
export async function markerDigest(carriage, body) {
return sha256(MARKER_DOMAIN, Uint8Array.of(carriage), body);
}
// ------------------------------------------------------------------ §3.2, §3.3 HLS
/** An attribute list (§3.2): a Map of name to { value, quoted }, or null. */
export function attributeList(s) {
const out = new Map();
let rest = s;
for (;;) {
const eq = rest.indexOf("=");
if (eq < 1) return null;
const name = rest.slice(0, eq);
if (!/^[A-Z0-9-]+$/.test(name) || out.has(name)) return null;
let after = rest.slice(eq + 1);
if (after.startsWith('"')) {
const close = after.indexOf('"', 1);
if (close < 0) return null;
const v = after.slice(1, close);
if (/[\r\n]/.test(v)) return null;
out.set(name, { value: v, quoted: true });
after = after.slice(close + 1);
} else {
const end = after.indexOf(",") < 0 ? after.length : after.indexOf(",");
const v = after.slice(0, end);
if (!v || /["\s]/.test(v)) return null;
out.set(name, { value: v, quoted: false });
after = after.slice(end);
}
if (!after) return out;
if (!after.startsWith(",") || after === ",") return null;
rest = after.slice(1);
}
}
function dateRanges(playlist) {
const lines = playlist.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
if (lines[0] !== "#EXTM3U") return null;
return lines
.filter((l) => l.startsWith("#EXT-X-DATERANGE:"))
.map((l) => attributeList(l.slice("#EXT-X-DATERANGE:".length)))
.filter((a) => a && a.get("ID")?.quoted && a.get("START-DATE")?.quoted);
}
/** Carriage 1 (§3.2): the ads a Media Playlist's date ranges carry under `attr`. */
export function hlsAds(playlist, attr) {
const tags = dateRanges(playlist);
if (!tags) return null;
return tags
.filter((a) => a.has(attr))
.map((a) => ({ marker: { carriage: CARRIAGE.DATERANGE, id: a.get("ID").value, start: a.get("START-DATE").value }, adId: a.get(attr).value }));
}
/** Carriage 2 (§3.3): the interstitial date ranges of a playlist, and the ads an asset list
* served for one of them names, by index. */
export function interstitials(playlist) {
const tags = dateRanges(playlist);
if (!tags) return null;
return tags.filter((a) => a.get("CLASS")?.quoted && a.get("CLASS").value === INTERSTITIAL_CLASS).map((a) => ({ id: a.get("ID").value, start: a.get("START-DATE").value }));
}
export function interstitialAds(dateRange, assetList) {
const v = JSON.parse(assetList);
if (!v || !Array.isArray(v.ASSETS)) return null;
return v.ASSETS.map((a, index) => ({ marker: { carriage: CARRIAGE.INTERSTITIAL, id: dateRange.id, start: dateRange.start, index }, adId: a.URI }));
}
// ------------------------------------------------------------------ §3.4, §3.5 DASH
const WS = " \t\r\n";
const NAMED = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
function unescape(t) {
return t.replace(/&([^;]*);?/g, (all, r) => {
if (!all.endsWith(";")) throw new Error("reference");
if (r in NAMED) return NAMED[r];
if (/^#x[0-9a-fA-F]+$/.test(r)) return String.fromCodePoint(parseInt(r.slice(2), 16));
if (/^#[0-9]+$/.test(r)) return String.fromCodePoint(parseInt(r.slice(1), 10));
throw new Error("reference");
});
}
const localName = (n) => n.slice(n.lastIndexOf(":") + 1);
/** An XML document over the subset of §3.5: its root element, or null. */
export function readXml(text) {
let p = 0;
const ws = () => {
while (p < text.length && WS.includes(text[p])) p++;
};
const eat = (t) => (text.startsWith(t, p) ? ((p += t.length), true) : false);
const until = (t) => {
const j = text.indexOf(t, p);
if (j < 0) throw new Error("unterminated");
const out = text.slice(p, j);
p = j + t.length;
return out;
};
const misc = () => {
for (;;) {
ws();
if (eat("<!--")) until("-->");
else if (text.startsWith("<!", p)) throw new Error("outside the subset");
else if (eat("<?")) until("?>");
else return;
}
};
const name = () => {
let j = p;
while (j < text.length && !(WS + "/>=").includes(text[j])) j++;
const n = text.slice(p, j);
if (!/^[\p{L}_:][\p{L}\p{N}_.:-]*$/u.test(n)) throw new Error("name");
p = j;
return n;
};
const element = (depth) => {
if (depth > 64 || !eat("<")) throw new Error("element");
const q = name();
const attrs = new Map();
for (;;) {
const before = p;
ws();
if (eat("/>")) return { name: localName(q), attrs, children: [] };
if (eat(">")) break;
if (p === before) throw new Error("attribute");
const an = name();
ws();
if (!eat("=")) throw new Error("attribute");
ws();
const quote = eat('"') ? '"' : eat("'") ? "'" : null;
if (!quote) throw new Error("attribute");
const raw = until(quote);
if (raw.includes("<") || attrs.has(localName(an))) throw new Error("attribute");
attrs.set(localName(an), unescape(raw));
}
const children = [];
for (;;) {
if (eat("</")) {
const c = name();
ws();
if (c !== q || !eat(">")) throw new Error("end tag");
return { name: localName(q), attrs, children };
}
if (eat("<![CDATA[")) children.push(until("]]>"));
else if (eat("<!--")) until("-->");
else if (text.startsWith("<!", p)) throw new Error("outside the subset");
else if (eat("<?")) until("?>");
else if (text.startsWith("<", p)) children.push(element(depth + 1));
else {
const j = text.indexOf("<", p);
if (j < 0) throw new Error("unterminated");
children.push(unescape(text.slice(p, j)));
p = j;
}
}
};
try {
if (text.startsWith("")) return null;
misc();
const root = element(1);
misc();
return p === text.length ? root : null;
} catch {
return null;
}
}
const childrenNamed = (e, n) => e.children.filter((c) => typeof c === "object" && c.name === n);
/** Carriage 3 (§3.4): the ads an MPD's events carry under `attr`. */
export function mpdAds(mpd, attr) {
const root = readXml(mpd);
if (!root || root.name !== "MPD") return null;
const out = [];
for (const period of childrenNamed(root, "Period")) {
for (const es of childrenNamed(period, "EventStream")) {
if (!es.attrs.has("schemeIdUri")) continue;
for (const ev of childrenNamed(es, "Event")) {
if (!ev.attrs.has("id") || !ev.attrs.has(attr)) continue;
out.push({
marker: { carriage: CARRIAGE.EVENT, period: period.attrs.get("id") ?? "", scheme: es.attrs.get("schemeIdUri"), value: es.attrs.get("value") ?? "", id: ev.attrs.get("id") },
adId: ev.attrs.get(attr),
});
}
}
}
return out;
}
// ------------------------------------------------------------------ §6 composited frames
const fit = (bw, bh, a, b) => (bw * b <= bh * a ? [bw, Math.max(1, Math.floor((bw * b) / a))] : [Math.max(1, Math.floor((bh * a) / b)), bh]);
const REFERENCE = { 2: [1, 1], 3: [3, 4] };
/** live-compose §5.9's ad frame of a record's frame (§4.3): { program, ad, region }, each
* rectangle [x, y, w, h]; null for the full picture. */
export function adFrame(f) {
if (f.kind === 0) return null;
const { width: w, height: h } = f;
if (f.kind === 3) {
const [pw, ph] = [f.windowW, f.windowH];
const left = f.corner === 1 || f.corner === 3;
const top = f.corner === 1 || f.corner === 2;
const y = top ? 0 : h - ph;
const side = [left ? pw : 0, y, w - pw, ph];
const band = [0, top ? ph : 0, w, h - ph];
return { program: [left ? 0 : w - pw, y, pw, ph], ad: [0, 0, w, h], region: top ? [side, band] : [band, side] };
}
const bw = Math.floor((w - f.gap) / 2);
const boxes = [
[0, 0, bw, h],
[bw + f.gap, 0, w - bw - f.gap, h],
];
const tile = (b) => {
if (f.aspect === 0) return b;
const [a, r] = REFERENCE[f.aspect] ?? [16, 9];
const [tw, th] = fit(b[2], b[3], a, r);
return [b[0] + Math.floor((b[2] - tw) / 2), b[1] + Math.floor((b[3] - th) / 2), tw, th];
};
const [program, ad] = f.corner === 1 ? [tile(boxes[0]), tile(boxes[1])] : [tile(boxes[1]), tile(boxes[0])];
return { program, ad, region: [ad] };
}
/** The share of the picture the ad's region holds, in basis points (§6). */
export function shareBp(f) {
const fr = adFrame(f);
if (!fr) return 10_000;
const area = fr.region.reduce((n, r) => n + r[2] * r[3], 0);
return Math.floor((area * 10_000) / (f.width * f.height));
}
/**
* The fraction of the ad's region in view, in basis points (§6): the share of the region's
* area, as the picture is displayed, that lies within the visible part of the player.
* `displayed` is where the picture's pixels are drawn on the page and `visible` the part of
* the player in view (an IntersectionObserver entry's intersectionRect), both
* { x, y, width, height } in CSS pixels; `region` is in the picture's pixels, of `width` ×
* `height`. With no region, the fraction of the whole picture.
*/
export function regionVisibleBp(region, width, height, displayed, visible) {
const rects = region ?? [[0, 0, width, height]];
const sx = displayed.width / width;
const sy = displayed.height / height;
let total = 0;
let seen = 0;
for (const [x, y, w, h] of rects) {
const r = { x: displayed.x + x * sx, y: displayed.y + y * sy, w: w * sx, h: h * sy };
total += r.w * r.h;
const ix = Math.max(0, Math.min(r.x + r.w, visible.x + visible.width) - Math.max(r.x, visible.x));
const iy = Math.max(0, Math.min(r.y + r.h, visible.y + visible.height) - Math.max(r.y, visible.y));
seen += ix * iy;
}
return total > 0 ? Math.min(10_000, Math.floor((seen * 10_000) / total + 1e-9)) : 0;
}
// ------------------------------------------------------------------ §5 the claim
/** The session a session key names (§5.2). */
export async function sessionOf(key) {
return (await sha256(SESSION_DOMAIN, key)).subarray(0, 16);
}
/** The redemption context a token answers (§5.5). */
export async function redemptionContext(c) {
const body = markerBody(c.marker);
const ad = bytes(c.adId);
return sha256(ATTEST_DOMAIN, Uint8Array.of(c.marker.carriage), await markerDigest(c.marker.carriage, body), c.session, c.creativeHash, c.player, Uint8Array.of(ad.length), ad);
}
/** The claim's bytes before `sig` (§5.1). */
export function claimBody(c) {
const ad = bytes(c.adId);
if (ad.length < 1 || ad.length > 255) throw new Error("an ad identity of 1 to 255 bytes");
const spans = c.spans.flatMap((s) => [u32le(s.startMs), u32le(s.lengthMs), u16le(s.visibleBp), Uint8Array.of(s.flags)]);
const att = c.attestation ?? new Uint8Array(0);
return concat(
CLAIM_MAGIC,
Uint8Array.of(1, c.marker.carriage),
c.manifest,
c.session,
c.creativeHash,
c.player,
Uint8Array.of(c.geometry),
u16le(c.shareBp),
u32le(c.frames ?? NOT_EXPOSED),
long(markerBody(c.marker)),
Uint8Array.of(ad.length),
ad,
u16le(c.objects.length),
...c.objects,
u16le(c.spans.length),
...spans,
u16le(att.length),
att,
);
}
/** The claim, signed (§5.3). */
export async function signClaim(c, privateKey) {
const body = claimBody(c);
const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, concat(CLAIM_DOMAIN, body)));
return concat(body, sig);
}
/** SHA-256 of a manifest's bytes as fetched: the claim's `manifest` (§5.1). */
export async function manifestDigest(b) {
return sha256(bytes(b));
}