// 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(""); else if (text.startsWith(""); 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("")) throw new Error("end tag"); return { name: localName(q), attrs, children }; } if (eat("")); else if (eat(""); else if (text.startsWith(""); 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)); }