290 lines
13 KiB
JavaScript
290 lines
13 KiB
JavaScript
// 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],
|
|
};
|
|
}
|
|
}
|