51 lines
2.9 KiB
JavaScript
Raw Normal View History

// Metadata only. No frames or PCM are retained. These are OBSERVER metrics,
// never an automatically passing sync/freshness/artifact report.
export class VideoMetrics {
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;
}
observe(timeMs, presentedFrames) {
if (!Number.isFinite(timeMs) || !Number.isSafeInteger(presentedFrames) || presentedFrames < 0) return;
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;
if (this.last) {
if (timeMs <= this.last.timeMs || presentedFrames <= this.last.presentedFrames) this.resets++;
else {
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++;
this.unobserved += Math.max(0,presentedFrames-this.last.presentedFrames-1);
}
}
this.last = {timeMs,presentedFrames}; this.callbacks++;
}
summary() {
// 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);
return {callbacks:this.callbacks,unobservedPresentedFrames:this.unobserved,clockOrCounterResets:this.resets,
callbackIntervalP95Ms:!this.overflow&&sorted.length ? sorted[Math.ceil(sorted.length*.95)-1] : null,
callbackGapMaxMs:gap,observationAgeMs:age,intervalSampleOverflow:this.overflow,
note:"Browser presentation callbacks; gaps are not proof of transport loss or corrupted video."};
}
}
export function receipt(video,audio,settings,elapsedMs,reason) {
return {schema:"av-test.observer.v2",kind:"diagnostic-only",reason,elapsedMs,
settings,video:video.summary(),audio,
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
releaseEligible:false,recordedMedia:false};
}