2026-10-02 17:02:43 -05:00
|
|
|
// Metadata only. No frames or PCM are retained. These are OBSERVER metrics,
|
|
|
|
|
// never an automatically passing sync/freshness/artifact report.
|
|
|
|
|
export class VideoMetrics {
|
2026-10-22 21:47:13 -05:00
|
|
|
constructor(now=()=>performance.now()) {
|
|
|
|
|
this.last = null; this.intervals = []; this.callbacks = 0;
|
|
|
|
|
this.unobserved = 0; this.resets = 0; this.overflow = 0; this.maxGap = null;
|
|
|
|
|
this.now=now;this.lastObservedAt=null;this.clockInvalid=false;
|
|
|
|
|
}
|
2026-10-02 17:02:43 -05:00
|
|
|
observe(timeMs, presentedFrames) {
|
|
|
|
|
if (!Number.isFinite(timeMs) || !Number.isSafeInteger(presentedFrames) || presentedFrames < 0) return;
|
2026-10-22 21:47:13 -05:00
|
|
|
const receivedAt=this.now();
|
|
|
|
|
if(!Number.isFinite(receivedAt)||
|
|
|
|
|
(this.lastObservedAt!==null&&receivedAt<this.lastObservedAt))this.clockInvalid=true;
|
|
|
|
|
if(!this.clockInvalid&&this.lastObservedAt!==null)
|
|
|
|
|
this.maxGap=Math.max(this.maxGap??0,receivedAt-this.lastObservedAt);
|
|
|
|
|
this.lastObservedAt=receivedAt;
|
2026-10-02 17:02:43 -05:00
|
|
|
if (this.last) {
|
|
|
|
|
if (timeMs <= this.last.timeMs || presentedFrames <= this.last.presentedFrames) this.resets++;
|
|
|
|
|
else {
|
2026-10-22 21:47:13 -05:00
|
|
|
const gap=timeMs-this.last.timeMs;
|
|
|
|
|
this.maxGap=Math.max(this.maxGap??0,gap);
|
|
|
|
|
if (this.intervals.length < 15000) this.intervals.push(gap);
|
|
|
|
|
else this.overflow++;
|
2026-10-02 17:02:43 -05:00
|
|
|
this.unobserved += Math.max(0,presentedFrames-this.last.presentedFrames-1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.last = {timeMs,presentedFrames}; this.callbacks++;
|
|
|
|
|
}
|
|
|
|
|
summary() {
|
2026-10-22 21:47:13 -05:00
|
|
|
// Once truncated, the lifetime percentile is unavailable. Long watches
|
|
|
|
|
// must not repeatedly sort an old 15,000-entry history they cannot use.
|
|
|
|
|
const sorted = this.overflow ? [] : [...this.intervals].sort((a,b)=>a-b);
|
|
|
|
|
const now=this.now(),elapsed=this.lastObservedAt===null?null:now-this.lastObservedAt;
|
|
|
|
|
const age=!this.clockInvalid&&Number.isFinite(now)&&Number.isFinite(elapsed)&&elapsed>=0?elapsed:null;
|
|
|
|
|
// A stopped callback stream is visible without waiting for its next
|
|
|
|
|
// frame. This is observer silence, not proof of a transport freeze.
|
|
|
|
|
const gap=age===null?this.maxGap:Math.max(this.maxGap??0,age);
|
2026-10-02 17:02:43 -05:00
|
|
|
return {callbacks:this.callbacks,unobservedPresentedFrames:this.unobserved,clockOrCounterResets:this.resets,
|
2026-10-22 21:47:13 -05:00
|
|
|
callbackIntervalP95Ms:!this.overflow&&sorted.length ? sorted[Math.ceil(sorted.length*.95)-1] : null,
|
|
|
|
|
callbackGapMaxMs:gap,observationAgeMs:age,intervalSampleOverflow:this.overflow,
|
2026-10-02 17:02:43 -05:00
|
|
|
note:"Browser presentation callbacks; gaps are not proof of transport loss or corrupted video."};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function receipt(video,audio,settings,elapsedMs,reason) {
|
2026-10-02 21:47:13 -05:00
|
|
|
return {schema:"av-test.observer.v2",kind:"diagnostic-only",reason,elapsedMs,
|
2026-10-02 17:02:43 -05:00
|
|
|
settings,video:video.summary(),audio,
|
|
|
|
|
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
|
|
|
|
|
releaseEligible:false,recordedMedia:false};
|
|
|
|
|
}
|