av-test: add opt-in browser-local A/V review without media upload
This commit is contained in:
parent
cb2342bbe7
commit
61358b22ca
@ -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.
|
||||
|
||||
@ -12,6 +12,8 @@ configMapGenerator:
|
||||
files:
|
||||
- index.html
|
||||
- observer.js
|
||||
- local-recording.js
|
||||
- review-controls.js
|
||||
- metrics.js
|
||||
- reporting.js
|
||||
- activity-worklet.js
|
||||
|
||||
78
services/bstein-dev-home/av-observer/local-recording.js
Normal file
78
services/bstein-dev-home/av-observer/local-recording.js
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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&¤t)stop('page hidden; timing invalid');});
|
||||
|
||||
36
services/bstein-dev-home/av-observer/review-controls.js
Normal file
36
services/bstein-dev-home/av-observer/review-controls.js
Normal 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();},
|
||||
};
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user