av-test: add opt-in browser-local A/V review without media upload

This commit is contained in:
jenkins 2026-10-11 11:35:36 -05:00
parent cb2342bbe7
commit 61358b22ca
7 changed files with 137 additions and 4 deletions

View File

@ -4,15 +4,22 @@
<title>A/V Test</title>
<link rel="stylesheet" href="style.css">
<main><h1>A/V Test</h1>
<p>Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.</p>
<p>Select a camera and microphone. Numeric metrics report automatically; audio and video never upload.</p>
<div><button id="devices">Choose devices…</button>
<label>Video <select id="video-device"></select></label>
<label>Microphone <select id="audio-device"></select></label></div>
<div><button id="start" disabled>Start 2-minute check</button><button id="stop" disabled>Stop</button><button id="export" disabled>Save metrics</button></div>
<label title="Recognize externally supplied coded colors and tones. Reports a diagnostic processing-time estimate, not calibrated lip sync or latency."><input id="paired-signal" type="checkbox"> Paired test signal</label>
<label title="Optional browser-only A/V replay and download. Off by default. Cleared on the next test or page close. Recording uses extra browser resources and can affect timing; it does not establish calibrated sync. Limited to 3 minutes and 128 MiB."><input id="record-review" type="checkbox"> Record for review</label>
<p id="status" role="status">Stopped</p>
<p id="report-status" role="status">Reporting starts with the test.</p>
<video id="preview" autoplay muted playsinline></video>
<p id="record-status" role="status">Not recording</p>
<section id="review" hidden aria-label="Local recording review">
<h2>Review</h2>
<video id="replay" controls playsinline></video>
<div><a id="download-recording">Download clip</a> <button id="discard-recording">Clear clip</button></div>
</section>
<pre id="metrics">No observations.</pre>
<p>Audio/video sync and end-to-end delay: <strong>not measured by this check</strong>.
Video callback gaps can include browser scheduling. Silence is not proof of audio loss.

View File

@ -12,6 +12,8 @@ configMapGenerator:
files:
- index.html
- observer.js
- local-recording.js
- review-controls.js
- metrics.js
- reporting.js
- activity-worklet.js

View File

@ -0,0 +1,78 @@
// An opt-in, bounded browser recording for human review. It never owns capture
// tracks or a network client; stopping it must not stop the numeric observer.
export class LocalRecording {
constructor({Recorder=globalThis.MediaRecorder,onStatus=()=>{},onReady=()=>{},
maxBytes=128*1024*1024,maxMs=180000}={}) {
Object.assign(this,{Recorder,onStatus,onReady,maxBytes,maxMs});
this.serial=0;this.active=null;
}
get supported() {return typeof this.Recorder==='function';}
start(stream) {
this.discard();
if(!this.supported){this.onStatus('Recording unavailable in this browser.');return false;}
const serial=this.serial;
const mimeType=['video/webm;codecs=vp8,opus','video/webm','video/mp4'].find(
type=>this.Recorder.isTypeSupported(type));
try {
const recorder=new this.Recorder(stream,{...(mimeType?{mimeType}:{}),
videoBitsPerSecond:4000000,audioBitsPerSecond:128000});
const run={recorder,chunks:[],bytes:0,failed:false};
this.active=run;
recorder.ondataavailable=({data})=>{
if(serial!==this.serial||run.failed||!data.size)return;
if(run.bytes+data.size>this.maxBytes) {
// A truncated container may not be playable: fail explicitly,
// clear it, and leave measurement running rather than claim success.
run.failed=true;run.chunks=[];
this.onStatus('Recording exceeded its memory limit; no clip saved.');
this.finish();return;
}
run.chunks.push(data);run.bytes+=data.size;
};
recorder.onerror=()=>{
if(serial!==this.serial)return;
run.failed=true;run.chunks=[];
this.onStatus('Recording failed; numeric measurement continues.');
this.finish();
};
recorder.onstop=()=>{
clearTimeout(run.timer);clearTimeout(run.deadline);
if(serial!==this.serial)return;
this.active=null;
if(!run.failed&&run.bytes) {
const blob=new Blob(run.chunks,{type:recorder.mimeType||mimeType||'video/webm'});
this.onReady(blob);
this.onStatus('Ready for local replay · nothing uploaded');
} else if(!run.failed)this.onStatus('No recording data was produced.');
run.chunks=[];
};
recorder.start(1000);
run.timer=setTimeout(()=>this.finish(),this.maxMs);
this.onStatus('Recording locally · nothing uploaded');
return true;
} catch {
this.discard();this.onStatus('Recording could not start; numeric measurement continues.');
return false;
}
}
finish() {
const run=this.active;
if(!run||run.finishing)return;
run.finishing=true;clearTimeout(run.timer);
if(!run.failed)this.onStatus('Preparing local replay…');
run.deadline=setTimeout(()=>{
if(this.active!==run)return;
this.discard();this.onStatus('Recording could not finalize; no clip saved.');
},10000);
try {if(run.recorder.state!=='inactive')run.recorder.stop();}
catch {this.discard();this.onStatus('Recording could not finalize; no clip saved.');}
}
discard() {
this.serial++;
const run=this.active;this.active=null;
if(!run)return;
clearTimeout(run.timer);clearTimeout(run.deadline);run.chunks=[];
run.recorder.ondataavailable=null;run.recorder.onstop=null;run.recorder.onerror=null;
try {if(run.recorder.state!=='inactive')run.recorder.stop();}catch {}
}
}

View File

@ -42,7 +42,7 @@ http {
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
try_files /av-test/index.html =404;
}
location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|paired-observer\.js|paired-worklet\.js|paired-signals\.js|test-signals\.js|continuity-signals\.js|style\.css)$ {
location ~ ^/av-test/(index\.html|observer\.js|local-recording\.js|review-controls\.js|metrics\.js|reporting\.js|activity-worklet\.js|paired-observer\.js|paired-worklet\.js|paired-signals\.js|test-signals\.js|continuity-signals\.js|style\.css)$ {
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
try_files $uri =404;
}

View File

@ -1,12 +1,15 @@
import {VideoMetrics,receipt} from './metrics.js';
import {RunReporter} from './reporting.js';
import {PairedObserver} from './paired-observer.js';
import {reviewControls} from './review-controls.js';
const el=id=>document.getElementById(id);
const review=reviewControls(document);
let generation=0, current=null, last=null, reportingRun=null;
const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
function release(run) {
if(run.released)return;
run.released=true;
if(run.recording)review.finish();
clearTimeout(run.timer); clearTimeout(run.setupTimer); clearTimeout(run.signalTimer);
if(run.callback)el('preview').cancelVideoFrameCallback(run.callback);
run.node?.port.postMessage('stop'); run.node?.disconnect(); run.source?.disconnect();
@ -29,6 +32,7 @@ function stop(reason='operator') {
el('start').disabled=!el('audio-device').value || !el('video-device').value;
el('stop').disabled=true; el('devices').disabled=false;
el('paired-signal').disabled=false;
review.lock(false);
el('status').textContent=`Stopped · ${reason}`;
}
el('devices').onclick=async()=>{
@ -72,6 +76,7 @@ el('start').onclick=async()=>{
});
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
el('paired-signal').disabled=true;
review.lock(true);
el('status').textContent='Starting…';
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
try {
@ -120,6 +125,7 @@ el('start').onclick=async()=>{
clearTimeout(run.setupTimer);
run.started=performance.now();
run.observing=true;
run.recording=review.begin(stream);
function frame(now,metadata) {
if(current!==run)return;
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
@ -132,7 +138,7 @@ el('start').onclick=async()=>{
el('status').textContent='Waiting for known A/V signals · start the source now';
} else {
run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000);
el('status').textContent='Observing both devices · no recording';
el('status').textContent=run.recording?'Observing both devices · recording locally':'Observing both devices · no recording';
}
} catch { if(current===run)stop('capture/setup failed'); }
finally { if(current!==run)release(run); }
@ -144,5 +150,5 @@ el('export').onclick=()=>{
const link=document.createElement('a');link.href=url;link.download='av-test-metrics.json';link.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
};
window.addEventListener('pagehide',()=>stop('page closed'));
window.addEventListener('pagehide',()=>{stop('page closed');review.discard();});
document.addEventListener('visibilitychange',()=>{if(document.hidden&&current)stop('page hidden; timing invalid');});

View File

@ -0,0 +1,36 @@
import {LocalRecording} from './local-recording.js';
// Blob URLs live only as long as this page's review. No storage persistence,
// automatic download, media upload, or access to the numeric reporting channel.
export function reviewControls(document) {
const el=id=>document.getElementById(id);
let url=null;
function clear() {
el('replay').pause();el('replay').removeAttribute('src');el('replay').load();
el('download-recording').removeAttribute('href');el('review').hidden=true;
if(url)URL.revokeObjectURL(url);
url=null;
}
const recorder=new LocalRecording({
onStatus:message=>{el('record-status').textContent=message;},
onReady:blob=>{
clear();url=URL.createObjectURL(blob);
el('replay').src=url;el('download-recording').href=url;
el('download-recording').download=`av-review.${blob.type.includes('mp4')?'mp4':'webm'}`;
el('review').hidden=false;
},
});
el('record-review').disabled=!recorder.supported;
if(!recorder.supported)el('record-status').textContent='Local recording is unavailable in this browser.';
el('discard-recording').onclick=()=>{recorder.discard();clear();el('record-status').textContent='Clip cleared.';};
return {
begin(stream) {
recorder.discard();clear();
if(el('record-review').checked)return recorder.start(stream);
el('record-status').textContent='Not recording';return false;
},
lock(locked){el('record-review').disabled=locked||!recorder.supported;},
finish(){recorder.finish();},
discard(){recorder.discard();clear();},
};
}

View File

@ -5,3 +5,7 @@ button,select { padding: 8px; margin: 4px; max-width: 100%; }
label { display: inline-block; }
video { width: 100%; max-height: 55vh; background: #000; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
#review { padding: 16px; border: 1px solid #425363; border-radius: 12px; background: #202832; }
#review[hidden] { display: none; }
#review h2 { margin-top: 0; font-size: 1.05rem; }
#download-recording { color: #9adfd7; padding: 8px; }