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]>
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
// 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));
|
||
}
|