// What the page records of each stream it receives, and when it ends one nobody can see // (WAI extensions/stream-termination.md), with the display declaration of // capability-constraints §4. // // For each media track it receives, the page keeps a stream log signed under a key it makes for // its session: the open record (how the stream started, the viewer's autoplay setting beside the // user agent's answer, the termination policy, the panel and the ladder), a segment receipt for // each span of objects it verified (their content hashes, the rendition and how it matched the // panel, each hop's energy: a page has no meter, so every hop it knows took place is the // unmetered marker), the observations of whether anyone could see the stream, and the // termination. A video stream whose document has been hidden, or whose canvas has been out of // the viewport, for the policy's grace is terminated: the termination is signed, the // subscription ended, and anything still in flight discarded unreceipted; its audio goes on. // When the page is seen again it opens a new log for the video. // // The records are written by wai-live-web's `StreamLog` (wai-rs's writer), so their bytes are // the reference's; the rules here are the ones the page applies: the declaration it reads, // which reason is due, and when a segment closes. They are pure functions, tested in Node by // termination.test.mjs. export const SIGNAL = { visibility: 1, occlusion: 2, display_power: 3, interaction: 4 }; export const SEEN = 1; export const UNSEEN = 2; // A segment of a MoQ track: the objects verified over this much media time (§4.5: at most 10 s). export const SEGMENT_US = 2_000_000; // At most one interaction observation a second (§4.2). export const INTERACTION_EVERY_US = 1_000_000; // The hops a page knows took place for each segment, none of which it measures (§4.6): the // origin's figure is the unmetered marker its object receipts carry, and the page decodes and // presents with no meter. The relay is absent: no relay states a figure per segment. export const PAGE_HOPS = [{ role: "origin", joules: 0 }, { role: "sink_decode", joules: 0 }, { role: "display", joules: 0 }]; // capability-constraints §4.1: the declaration from what a page can read. `width` and `height` // are CSSOM View's `screen.width` and `screen.height`, in CSS pixels; `dpr` its // `devicePixelRatio`; `colorDepth` its `screen.colorDepth`; `hdr` whether `(video-dynamic-range: // high)` or `(dynamic-range: high)` matches; `refreshMilli` what `refreshFromIntervals` gives. export function declarationFrom({ width, height, dpr, colorDepth, hdr, refreshMilli }) { const w = Math.round(width * dpr); const h = Math.round(height * dpr); return { long: Math.max(w, h), short: Math.min(w, h), refresh_milli: refreshMilli, bit_depth: Math.floor(colorDepth / 3), hdr: !!hdr, long_mm: 0, short_mm: 0 }; } // §4.1: 1000 times the whole number of pictures per second nearest to 10⁶ over the median // interval, in microseconds, between animation frames. The median of an even count is the // upper of the two middle intervals. export function refreshFromIntervals(intervalsUs) { if (!intervalsUs.length) return null; const s = [...intervalsUs].sort((a, b) => a - b); const m = s[Math.floor(s.length / 2)]; return m > 0 ? Math.round(1e6 / m) * 1000 : null; } // The refresh rate measured over `frames` animation frames (at least 61, so 60 intervals), or // null where the document draws none in `timeoutMs` (a hidden document). export function measureRefresh(win, frames = 61, timeoutMs = 3000) { return new Promise((resolve) => { const ts = []; const timer = setTimeout(() => resolve(null), timeoutMs); const step = (t) => { ts.push(t); if (ts.length >= frames) { clearTimeout(timer); resolve(refreshFromIntervals(ts.slice(1).map((x, i) => Math.round((x - ts[i]) * 1000)))); } else win.requestAnimationFrame(step); }; win.requestAnimationFrame(step); }); } // The declaration a page reads now. export function readDeclaration(win, refreshMilli) { const mq = (q) => { try { return win.matchMedia(q).matches; } catch { return false; } }; return declarationFrom({ width: win.screen.width, height: win.screen.height, dpr: win.devicePixelRatio || 1, colorDepth: win.screen.colorDepth || 24, hdr: mq("(video-dynamic-range: high)") || mq("(dynamic-range: high)"), refreshMilli, }); } export function sameDeclaration(a, b) { return ["long", "short", "refresh_milli", "bit_depth", "hdr", "long_mm", "short_mm"].every((k) => a[k] === b[k]); } // HTML §6.4: an activation-triggering input event. export function isActivation(e) { if (!e.isTrusted) return false; switch (e.type) { case "keydown": return e.key !== "Escape"; case "mousedown": return true; case "pointerdown": return e.pointerType === "mouse"; case "pointerup": return e.pointerType !== "mouse"; case "touchend": return true; default: return false; } } export const ACTIVATION_EVENTS = ["keydown", "mousedown", "pointerdown", "pointerup", "touchend"]; // The bit depth a codec string states, or 8: VP9's `vp09.PP.LL.DD`, AV1's `av01.P.LLT.DD`. export function bitDepthOf(codec) { const p = String(codec || "").split("."); if (p[0] === "vp09" && p[3]) return Number(p[3]) || 8; if (p[0] === "av01" && p[3]) return Number(p[3]) || 8; return 8; } // §4.5: the ladder of a catalog's video tracks (MSF `width`, `height`, `framerate`); transfer 1 // (standard dynamic range) where a track states no colour description. export function ladderOf(tracks) { return tracks.map((t) => ({ width: Number(t.width), height: Number(t.height), frame_rate_milli: Math.round(Number(t.framerate || 30) * 1000), bit_depth: bitDepthOf(t.codec), transfer: Number(t.transfer || 1) })); } // §6.2's state of a stream, as the page holds it while it writes the log. export function freshState(t0) { const s = (v) => ({ value: v, since: t0, observed: false }); return { t0, visibility: s(SEEN), occlusion: s(SEEN), display_power: s(SEEN), lastInteraction: null }; } // The state after an observation (§6.2: `since` moves only when the value changes, or at a // signal's first observation). export function observe(st, signal, value, t) { if (signal === SIGNAL.interaction) return { ...st, lastInteraction: t }; const name = signal === SIGNAL.visibility ? "visibility" : signal === SIGNAL.occlusion ? "occlusion" : "display_power"; const cur = st[name]; if (cur.observed && cur.value === value) return st; return { ...st, [name]: { value, since: t, observed: true } }; } // §4.3 and §6.2: the reason due now, or null. `policy` is the open record's (`media`, `start`, // `unseen_grace_ms`, `idle_ms`, `autoplay_grace_ms`). export function dueReason(policy, st, t) { if (policy.media === "video") { const grace = policy.unseen_grace_ms * 1000; for (const [name, reason] of [["visibility", "hidden"], ["occlusion", "occluded"], ["display_power", "display_off"]]) { if (st[name].value === UNSEEN && t - st[name].since >= grace) return reason; } } if (policy.idle_ms > 0 && t - (st.lastInteraction != null ? st.lastInteraction : st.t0) >= policy.idle_ms * 1000) return "idle"; if (policy.start === "autoplay" && policy.autoplay_grace_ms > 0 && st.lastInteraction == null && t - st.t0 >= policy.autoplay_grace_ms * 1000) return "autoplay_unattended"; return null; } // Whether the stream is unseen (§6.2): hidden, occluded or its display off. export function unseen(st) { return st.visibility.value === UNSEEN || st.occlusion.value === UNSEEN || st.display_power.value === UNSEEN; } // The segment being gathered, and when it closes (§4.5): the objects over at least `segmentUs` // of media time, closed when the first object past that arrives; a change of rendition, or a // gap of more than `segmentUs` between two objects, closes it too. It ends where the next // object starts, or where its last object ends if that is earlier, so a span is never more // than `segmentUs` and one object long. The last one, closed by a termination, ends where its // last object does. Spans never overlap: an object at or before the last one gathered, or // before the last segment's end, is not receipted (it is out of order, or repeated). export class Gatherer { constructor(segmentUs = SEGMENT_US) { this.segmentUs = segmentUs; this.pending = null; this.floor = null; } // An object received and verified: its content hash, its bytes, its media time and // duration, its rendition. Returns the segments that closed, in order. add({ hash, bytes, mediaUs, durUs, rendition }) { const out = []; const p = this.pending; if (p ? mediaUs <= p.last : this.floor != null && mediaUs < this.floor) return out; const gap = p && mediaUs - p.last > this.segmentUs; if (p && (p.rendition !== rendition || gap || mediaUs - p.media_start >= this.segmentUs)) out.push(this.close(Math.min(mediaUs, p.last + p.dur))); if (!this.pending) this.pending = { hashes: [], bytes: 0, media_start: mediaUs, last: mediaUs, dur: durUs, rendition }; const q = this.pending; q.hashes.push(hash); q.bytes += bytes; q.last = mediaUs; q.dur = durUs; return out; } close(end) { const p = this.pending; this.pending = null; this.floor = end; return { hashes: p.hashes, bytes: p.bytes, media_start: p.media_start, media_end: end, rendition: p.rendition }; } // The segment being gathered, closed where its last object ends; null when there is none. take() { return this.pending ? this.close(this.pending.last + this.pending.dur) : null; } } const big = (x) => BigInt(Math.round(x)); // One stream's log at the page. export class SinkStream { // `wai` is wai-live-web; `seed` the session key's seed in hexadecimal; `open` the open // record's JSON (wai-live-web's `StreamLog`); `now` microseconds since the epoch. constructor(wai, seed, open, now, { segmentUs = SEGMENT_US, hops = PAGE_HOPS, log = () => {} } = {}) { this.log = log; this.open = open; this.hops = hops; this.writer = new wai.StreamLog(seed, big(now), JSON.stringify(open)); this.state = freshState(now); this.gather = new Gatherer(segmentUs); this.lastSegment = null; this.segments = 0; this.discarded = 0; this.terminatedAs = null; // The records signed so far, in hexadecimal, the open record first. this.records = JSON.parse(this.writer.records); this.lastInteractionRecorded = null; } get terminated() { return this.terminatedAs != null; } note(rec) { this.records.push(rec); } // An observation, recorded where it changes the state (§4.2), interactions at most once a // second. observe(signal, value, now) { if (this.terminated) return; if (signal === SIGNAL.interaction) { if (this.lastInteractionRecorded != null && now - this.lastInteractionRecorded < INTERACTION_EVERY_US) return; this.lastInteractionRecorded = now; } else { const name = signal === SIGNAL.visibility ? "visibility" : signal === SIGNAL.occlusion ? "occlusion" : "display_power"; if (this.state[name].value === value && (this.state[name].observed || value === SEEN)) return; } this.state = observe(this.state, signal, value, now); this.note(this.writer.observe(big(now), signal, value)); } // A new declaration (§3.3): the panel changed. display(declaration, now) { if (this.terminated || this.open.media !== "video") return; this.flush(now); this.note(this.writer.display(big(now), JSON.stringify(declaration))); } // An object verified: gathered into its segment, whose receipt is signed when it closes. // False once the stream is terminated: the object is discarded, unreceipted (§4.3). received(obj, now) { if (this.terminated) { this.discarded++; return false; } for (const seg of this.gather.add(obj)) this.sign(seg, now); return true; } sign(seg, now) { const rendition = this.open.media === "video" ? seg.rendition : null; this.note(this.writer.segment(big(now), JSON.stringify({ ...seg, rendition, hops: this.hops }))); this.lastSegment = { start: seg.media_start, end: seg.media_end }; this.segments++; } flush(now) { const p = this.gather.take(); if (p && p.hashes.length) this.sign(p, now); } due(now) { return this.terminated ? null : dueReason(this.open, this.state, now); } // The termination, after the segment being gathered is receipted; the media time is where // the last segment ends, the last the page presented. terminate(reason, now) { if (this.terminated) return null; this.flush(now); const media = this.lastSegment ? this.lastSegment.end : 0; this.note(this.writer.terminate(big(now), reason, big(media))); this.terminatedAs = reason; this.terminatedAt = now; return { reason, media }; } verdict() { return JSON.parse(this.writer.verify()); } summary() { return { stream: this.open.stream, media: this.open.media, segments: this.segments, terminated: this.terminatedAs, discarded: this.discarded, open: { start: this.open.start, autoplay: this.open.autoplay, ua_policy: this.open.ua_policy, unseen_grace_ms: this.open.unseen_grace_ms, idle_ms: this.open.idle_ms, autoplay_grace_ms: this.open.autoplay_grace_ms, display: this.open.display, ladder: this.open.ladder }, state: { visibility: this.state.visibility.value, occlusion: this.state.occlusion.value, lastInteraction: this.state.lastInteraction }, records: [...this.records], }; } }