The registry lists the record and the WPC1 magic (the export regenerated), SPEC §5 describes the claim and the billing rule, and the CHANGELOG records them. joule-moq-web gains web/ads/presentation.js, a span recorder over a video element and the claim's writer and signer on WebCrypto's Ed25519, whose node tests turn observation sequences into the corpus's claims byte for byte; no page plays ads yet. wai-conformance runs the module's tests, the corpus test, ad_presentation_verify.py and the producer's tests; joule-moq-web's e2e script runs the producer's tests with the page's other unit tests. Co-Authored-By: Claude Opus 5.5 <[email protected]>
242 lines
9.8 KiB
JavaScript
242 lines
9.8 KiB
JavaScript
// The reference producer of WAI ad-presentation claims (wai/extensions/ad-presentation.md):
|
|
// what a player measures while it presents one ad of a live-ads break, turned into spans by
|
|
// the rules of §10, and the WPC1 claim (§5) written and signed with the session key (§3).
|
|
//
|
|
// SpanRecorder observations (media position, playing, visible fraction, audible) to spans
|
|
// attach a video element's observations, every 200 ms and on its events
|
|
// claimBody the claim's bytes before its signature (§5.1, §5.2)
|
|
// signClaim the claim, signed with the platform's Ed25519 (§5.3)
|
|
// sessionOf, creativeDigest, touched, redemptionContext, tokenChallenge (§3, §4, §7.2)
|
|
//
|
|
// Everything here is the platform's: WebCrypto's SHA-256 and Ed25519, IntersectionObserver,
|
|
// the media element. No dependency. An IntersectionObserver measures the element's box against
|
|
// the viewport, not what other elements cover; a page that can cover its player states
|
|
// geometry 3 (the player's area) or measures what covers it itself (§10).
|
|
|
|
const enc = new TextEncoder();
|
|
const subtle = globalThis.crypto.subtle;
|
|
|
|
export const CLAIM_MAGIC = enc.encode("WPC1");
|
|
export const CLAIM_DOMAIN = enc.encode("wai:ad-presentation-claim\x01");
|
|
export const SESSION_DOMAIN = enc.encode("wai:ad-presentation-session\x01");
|
|
export const ATTEST_DOMAIN = enc.encode("wai:ad-presentation-attest\x01");
|
|
export const CREATIVE_DOMAIN = enc.encode("wai:ad-creative\x01");
|
|
export const NOT_EXPOSED = 0xffffffff;
|
|
export const GEOMETRY = { AD: 1, SCREEN: 2, PLAYER: 3 };
|
|
export const AUDIBLE = 1;
|
|
export const RESUMED = 2;
|
|
|
|
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;
|
|
};
|
|
|
|
export async function sha256(...parts) {
|
|
return new Uint8Array(await subtle.digest("SHA-256", concat(...parts)));
|
|
}
|
|
|
|
/** The session a session key names in a room (§3). */
|
|
export async function sessionOf(room, key) {
|
|
return (await sha256(SESSION_DOMAIN, room, key)).subarray(0, 16);
|
|
}
|
|
|
|
/** The creative digest of objects `[{ contentHash, durationMs }]` (§4). */
|
|
export async function creativeDigest(objects) {
|
|
return sha256(CREATIVE_DOMAIN, u32le(objects.length), ...objects.flatMap((o) => [o.contentHash, u32le(o.durationMs)]));
|
|
}
|
|
|
|
/** The content hashes of the objects the spans touch, in order (§9.2 step 6). */
|
|
export function touched(objects, spans) {
|
|
const out = [];
|
|
let s = 0;
|
|
for (const o of objects) {
|
|
const e = s + o.durationMs;
|
|
if (spans.some((x) => x.startMs < e && x.startMs + x.lengthMs > s)) out.push(o.contentHash);
|
|
s = e;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The redemption context binding an attestation to one impression and session key (§7.2). */
|
|
export async function redemptionContext(c) {
|
|
return sha256(ATTEST_DOMAIN, c.room, c.cue, c.session, Uint8Array.of(c.slot), c.creativeHash, c.player);
|
|
}
|
|
|
|
/** RFC 9577 §2.1.1's TokenChallenge with this redemption context and no origin_info (§7.2). */
|
|
export function tokenChallenge(tokenType, issuer, context) {
|
|
const name = typeof issuer === "string" ? enc.encode(issuer) : issuer;
|
|
return concat(Uint8Array.of(tokenType >>> 8, tokenType & 0xff, name.length >>> 8, name.length & 0xff), name, Uint8Array.of(32), context, Uint8Array.of(0, 0));
|
|
}
|
|
|
|
/**
|
|
* Spans by the rules of §10: a span is media time presented at normal speed; a new one begins
|
|
* whenever the quantised visible fraction or the audible state changes, or playback stops,
|
|
* skips or is seeked (then the next one is a resumption); its visible fraction is the least
|
|
* observed over it; and media time already presented is never stated again.
|
|
*/
|
|
export class SpanRecorder {
|
|
/** `quantumBp`: the visible fraction's step, in basis points (100 is OMID's whole percent). */
|
|
constructor({ quantumBp = 100 } = {}) {
|
|
this.quantum = quantumBp;
|
|
this.done = [];
|
|
this.cur = null; // { startMs, endMs, visibleBp, cls, audible }
|
|
this.maxEnd = 0; // the end of the media time stated so far
|
|
this.resumeNext = false;
|
|
}
|
|
|
|
close() {
|
|
if (this.cur && this.cur.endMs > this.cur.startMs) {
|
|
const flags = (this.cur.audible ? AUDIBLE : 0) | (this.cur.resumed ? RESUMED : 0);
|
|
this.done.push({ startMs: this.cur.startMs, lengthMs: this.cur.endMs - this.cur.startMs, visibleBp: this.cur.visibleBp, flags });
|
|
this.maxEnd = Math.max(this.maxEnd, this.cur.endMs);
|
|
}
|
|
this.cur = null;
|
|
}
|
|
|
|
/** One observation: `{ positionMs, playing, visibleBp, audible, seeked }`. */
|
|
observe({ positionMs, playing, visibleBp, audible, seeked = false }) {
|
|
const pos = Math.max(0, Math.floor(positionMs));
|
|
if (!playing || seeked) {
|
|
this.close();
|
|
if (this.done.length > 0) this.resumeNext = true;
|
|
if (!playing) return;
|
|
}
|
|
const vis = Math.max(0, Math.min(10_000, Math.round(visibleBp)));
|
|
const cls = Math.floor(vis / this.quantum);
|
|
if (this.cur) {
|
|
if (pos < this.cur.endMs) return; // no media time stated twice
|
|
if (cls !== this.cur.cls || !!audible !== this.cur.audible) {
|
|
this.cur.endMs = pos;
|
|
this.close();
|
|
} else {
|
|
this.cur.endMs = pos;
|
|
this.cur.visibleBp = Math.min(this.cur.visibleBp, vis);
|
|
return;
|
|
}
|
|
}
|
|
if (pos < this.maxEnd) return; // still within media time already presented
|
|
const continues = !this.resumeNext && this.done.length > 0 && pos === this.maxEnd;
|
|
const resumed = this.done.length > 0 && !continues;
|
|
this.cur = { startMs: pos, endMs: pos, visibleBp: vis, cls, audible: !!audible, resumed };
|
|
this.resumeNext = false;
|
|
}
|
|
|
|
/** The spans, the open one closed at its last observation. */
|
|
spans() {
|
|
this.close();
|
|
return this.done.slice();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Observe a video element: every `intervalMs` (at most the 200 ms of §10) and on its playback,
|
|
* volume and seek events, and on the page's visibility and focus. A fraction of 0 while the
|
|
* page is hidden or unfocused; audible while unmuted at a volume above 0; normal speed only.
|
|
*/
|
|
export function attach(video, recorder, { intervalMs = 200 } = {}) {
|
|
let ratio = 0;
|
|
let seeked = false;
|
|
const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);
|
|
const io = new IntersectionObserver((entries) => {
|
|
for (const e of entries) ratio = e.intersectionRatio;
|
|
}, { threshold: thresholds });
|
|
io.observe(video);
|
|
const tick = () => {
|
|
const shown = document.visibilityState === "visible" && document.hasFocus();
|
|
recorder.observe({
|
|
positionMs: video.currentTime * 1000,
|
|
playing: !video.paused && !video.ended && video.readyState >= 3 && video.playbackRate === 1,
|
|
visibleBp: shown ? ratio * 10_000 : 0,
|
|
audible: !video.muted && video.volume > 0,
|
|
seeked,
|
|
});
|
|
seeked = false;
|
|
};
|
|
const onSeek = () => {
|
|
seeked = true;
|
|
tick();
|
|
};
|
|
const events = ["play", "pause", "ended", "waiting", "playing", "volumechange", "ratechange"];
|
|
for (const e of events) video.addEventListener(e, tick);
|
|
video.addEventListener("seeking", onSeek);
|
|
document.addEventListener("visibilitychange", tick);
|
|
window.addEventListener("blur", tick);
|
|
window.addEventListener("focus", tick);
|
|
const timer = setInterval(tick, Math.min(intervalMs, 200));
|
|
return {
|
|
/** Frames presented and not dropped, where the element exposes them (§5.1 `frames`). */
|
|
frames() {
|
|
const q = video.getVideoPlaybackQuality?.();
|
|
return q ? Math.max(0, q.totalVideoFrames - q.droppedVideoFrames) : NOT_EXPOSED;
|
|
},
|
|
stop() {
|
|
tick();
|
|
clearInterval(timer);
|
|
io.disconnect();
|
|
for (const e of events) video.removeEventListener(e, tick);
|
|
video.removeEventListener("seeking", onSeek);
|
|
document.removeEventListener("visibilitychange", tick);
|
|
window.removeEventListener("blur", tick);
|
|
window.removeEventListener("focus", tick);
|
|
return recorder.spans();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The claim's bytes before `sig` (§5.1, §5.2). */
|
|
export function claimBody(c) {
|
|
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.room,
|
|
c.cue,
|
|
c.session,
|
|
Uint8Array.of(c.slot),
|
|
c.creativeHash,
|
|
c.player,
|
|
Uint8Array.of(c.geometry),
|
|
u32le(c.frames ?? NOT_EXPOSED),
|
|
u16le(c.objects.length),
|
|
...c.objects,
|
|
u16le(c.spans.length),
|
|
...spans,
|
|
u16le(att.length),
|
|
att,
|
|
);
|
|
}
|
|
|
|
/** A session key from 32 bytes of seed (the corpus's keys; a player makes a fresh one). */
|
|
export async function sessionKeyFromSeed(seed) {
|
|
const pkcs8 = concat(Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20), seed);
|
|
const privateKey = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
|
|
const jwk = await subtle.exportKey("jwk", privateKey);
|
|
const b64 = jwk.x.replace(/-/g, "+").replace(/_/g, "/");
|
|
const pub = Uint8Array.from(atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)), (ch) => ch.charCodeAt(0));
|
|
return { privateKey, publicKey: pub };
|
|
}
|
|
|
|
/** A fresh session key for one playback session of one room (§3). */
|
|
export async function newSessionKey() {
|
|
const k = await subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
|
|
return { privateKey: k.privateKey, publicKey: new Uint8Array(await subtle.exportKey("raw", k.publicKey)) };
|
|
}
|
|
|
|
/** The claim, signed (§5.3): its body and Ed25519 over the domain and the body. */
|
|
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);
|
|
}
|