wai.video.stitched_presentation (extensions/stitched-presentation.md): a WPS1 claim names an ad by the marker the stream carries for it (an EXT-X-DATERANGE tag, an interstitial's asset, a DASH event) in the manifest it states the SHA-256 of; a WPI1 pod-input record, signed by the stitcher or a proxy in front of the ad server, binds the response digest, the chosen ads, their creatives and prices, the avail, the frame and the measurement terms; a verifier recomputes each ad's presentations, quartiles and the spend that counts by currency. A form-1 wai.video.ssai receipt gains a JSON carriage (its signed bytes unchanged) so it can be a record's response. live-compose gains the squeeze and double-box ad frames (§5.9). Corpus live-conformance/stitched/, its independent verifier stitched_presentation_verify.py, and the browser producer web/ads/stitched.js with tests against the corpus. Co-Authored-By: Claude Opus 5.5 <[email protected]>
137 lines
7.3 KiB
JavaScript
137 lines
7.3 KiB
JavaScript
// The reference producer of stitched presentation claims (wai/extensions/stitched-presentation.md
|
|
// §8), against the corpus wai/live-conformance/stitched/ (`node --test web/ads/stitched.test.mjs`):
|
|
// markers read from the corpus's own manifests, observation sequences recorded into spans, the
|
|
// ad's region measured in a composited frame, and the claims written and signed from them, byte
|
|
// for byte as the corpus holds them.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import * as ap from "./presentation.js";
|
|
import * as st from "./stitched.js";
|
|
|
|
const DIR = new URL("../../../../../../wai/live-conformance/stitched/", import.meta.url);
|
|
const file = (n) => readFileSync(new URL(n, DIR), "utf8");
|
|
const doc = JSON.parse(file("vectors.json"));
|
|
const vector = (name) => doc.vectors.find((v) => v.name === name);
|
|
const hex = (s) => Uint8Array.from(s.match(/../g) ?? [], (h) => parseInt(h, 16));
|
|
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
const creative = (k) => doc.creatives[k].objects.map((o) => ({ contentHash: hex(o.content_hash), durationMs: o.duration_ms }));
|
|
|
|
/** Session key `i` of the corpus: SHA-256 of its label and `i` (its README). */
|
|
const key = async (i) => ap.sessionKeyFromSeed(await ap.sha256(new TextEncoder().encode("wai:stitched-presentation/corpus/player\x00"), Uint8Array.of(i)));
|
|
|
|
/** Observations every 100 ms of the ad's media time from `from` to `to`, at normal speed. */
|
|
function play(rec, from, to, visibleBp, audible = true) {
|
|
for (let p = from; p <= to; p += 100) rec.observe({ positionMs: p, playing: true, visibleBp, audible });
|
|
}
|
|
|
|
function ended(rec, at, visibleBp) {
|
|
rec.observe({ positionMs: at, playing: false, visibleBp, audible: true });
|
|
return rec.spans();
|
|
}
|
|
|
|
async function claim({ player, found, manifest, objects, spans, geometry = ap.GEOMETRY.AD, shareBp = 10_000, attestation }) {
|
|
const k = await key(player);
|
|
const c = {
|
|
marker: found.marker,
|
|
adId: found.adId,
|
|
manifest: await st.manifestDigest(manifest),
|
|
session: await st.sessionOf(k.publicKey),
|
|
creativeHash: await st.creativeDigest(objects),
|
|
player: k.publicKey,
|
|
geometry,
|
|
shareBp,
|
|
frames: st.NOT_EXPOSED,
|
|
objects: st.touched(objects, spans),
|
|
spans,
|
|
attestation,
|
|
};
|
|
return { hex: toHex(await st.signClaim(c, k.privateKey)), claim: c };
|
|
}
|
|
|
|
test("a stitched ad under a date range: the marker read from the playlist, the corpus's claim", async () => {
|
|
const playlist = file("hls_stitched.viewer1.m3u8");
|
|
const ads = st.hlsAds(playlist, "X-AD-ID");
|
|
assert.deepEqual(ads.map((a) => a.adId), ["srv-v1-0", "srv-v1-1"], "the date ranges that carry an ad identity, and not the break's own");
|
|
const rec = new ap.SpanRecorder();
|
|
play(rec, 0, 15_000, 10_000);
|
|
const spans = ended(rec, 15_000, 10_000);
|
|
const got = await claim({ player: 1, found: ads[0], manifest: playlist, objects: creative(0), spans });
|
|
assert.equal(got.hex, vector("hls_stitched").claims[0]);
|
|
});
|
|
|
|
test("scrolled partly out of view to the third quartile: a span at each change", async () => {
|
|
const playlist = file("hls_stitched.viewer1.m3u8");
|
|
const rec = new ap.SpanRecorder();
|
|
play(rec, 0, 3_900, 10_000);
|
|
play(rec, 4_000, 7_900, 6_000);
|
|
play(rec, 8_000, 11_250, 3_000);
|
|
rec.observe({ positionMs: 11_250, playing: true, visibleBp: 3_000, audible: true });
|
|
const spans = ended(rec, 11_250, 3_000);
|
|
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.visibleBp]), [[0, 4_000, 10_000], [4_000, 4_000, 6_000], [8_000, 3_250, 3_000]]);
|
|
const got = await claim({ player: 1, found: st.hlsAds(playlist, "X-AD-ID")[1], manifest: playlist, objects: creative(1), spans });
|
|
assert.equal(got.hex, vector("hls_stitched").claims[1]);
|
|
});
|
|
|
|
test("an interstitial's asset: its date range from the playlist, its identity from the asset list", async () => {
|
|
const v = vector("hls_interstitial");
|
|
const [dr] = st.interstitials(file("hls_interstitial.m3u8"));
|
|
assert.equal(dr.id, "break-300");
|
|
const list = file("hls_interstitial.assets.json");
|
|
const ads = st.interstitialAds(dr, list);
|
|
const want = hex(v.claims[0]);
|
|
// The token is the issuer's (the corpus signs it with RFC 9578's test key); the claim carries
|
|
// it as the long field before its signature.
|
|
const tokenLen = 98 + 256;
|
|
const token = want.subarray(want.length - 64 - tokenLen, want.length - 64);
|
|
const rec = new ap.SpanRecorder();
|
|
play(rec, 0, 15_000, 10_000);
|
|
const spans = ended(rec, 15_000, 10_000);
|
|
const got = await claim({ player: 11, found: ads[0], manifest: list, objects: creative(3), spans, geometry: ap.GEOMETRY.SCREEN, attestation: token });
|
|
assert.equal(got.hex, v.claims[0]);
|
|
const challenge = ap.tokenChallenge(2, doc.issuers[0].name, await st.redemptionContext(got.claim));
|
|
assert.equal(toHex(token.subarray(34, 66)), toHex(await ap.sha256(challenge)), "the token answers this presentation's own challenge");
|
|
});
|
|
|
|
test("a squeeze: the ad's region from the frame, 70% of it in view, the corpus's claim", async () => {
|
|
const mpd = file("squeezeback.mpd");
|
|
const [found] = st.mpdAds(mpd, "messageData");
|
|
assert.equal(found.adId, "srv-sq-1");
|
|
const frame = { kind: 3, width: 1920, height: 1080, corner: 4, windowW: 1440, windowH: 810, gap: 0, aspect: 0 };
|
|
const { region } = st.adFrame(frame);
|
|
assert.deepEqual(region, [[0, 0, 1920, 270], [0, 270, 480, 810]]);
|
|
assert.equal(st.shareBp(frame), 4375);
|
|
// The picture drawn at its own size, and the viewport cut 513 pixels down: the whole top band
|
|
// and 243 of the side band's 810 rows are in view, 635 040 of 907 200 pixels.
|
|
const displayed = { x: 0, y: 0, width: 1920, height: 1080 };
|
|
const vis = st.regionVisibleBp(region, 1920, 1080, displayed, { x: 0, y: 0, width: 1920, height: 513 });
|
|
assert.equal(vis, 7_000);
|
|
assert.equal(st.regionVisibleBp(null, 1920, 1080, displayed, { x: 0, y: 0, width: 1920, height: 513 }), 4_750, "the whole picture, not the region");
|
|
const rec = new ap.SpanRecorder();
|
|
play(rec, 0, 20_000, vis);
|
|
const spans = ended(rec, 20_000, vis);
|
|
const got = await claim({ player: 22, found, manifest: mpd, objects: creative(5), spans, shareBp: st.shareBp(frame) });
|
|
assert.equal(got.hex, vector("squeezeback").claims[1]);
|
|
});
|
|
|
|
test("a double box: the ad's box, and its share", async () => {
|
|
const frame = { kind: 4, width: 1920, height: 1080, corner: 1, windowW: 0, windowH: 0, gap: 16, aspect: 1 };
|
|
assert.deepEqual(st.adFrame(frame).ad, [968, 272, 952, 535]);
|
|
assert.equal(st.shareBp(frame), 2456);
|
|
const playlist = file("double_box.m3u8");
|
|
const [found] = st.hlsAds(playlist, "X-AD-ID");
|
|
const rec = new ap.SpanRecorder();
|
|
play(rec, 0, 10_000, 10_000);
|
|
const got = await claim({ player: 31, found, manifest: playlist, objects: creative(6), spans: ended(rec, 10_000, 10_000), shareBp: st.shareBp(frame) });
|
|
assert.equal(got.hex, vector("double_box").claims[0]);
|
|
});
|
|
|
|
test("the readers refuse what the subset and the attribute list leave out", () => {
|
|
assert.equal(st.readXml("<!DOCTYPE x><x/>"), null);
|
|
assert.equal(st.readXml('<x a="1" a="2"/>'), null);
|
|
assert.equal(st.readXml("<x> </x>"), null);
|
|
assert.deepEqual(st.readXml("<a><![CDATA[ b ]]></a>").children, [" b "]);
|
|
assert.equal(st.attributeList('ID="a",,X=1'), null);
|
|
assert.equal(st.attributeList("ID=a b"), null);
|
|
assert.equal(st.hlsAds("not a playlist", "X-AD-ID"), null);
|
|
});
|