From 61358b22cac4dae666489864d03f54165d9e40ba Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 11 Oct 2026 11:35:36 -0500 Subject: [PATCH] av-test: add opt-in browser-local A/V review without media upload --- .../bstein-dev-home/av-observer/index.html | 9 ++- .../av-observer/kustomization.yaml | 2 + .../av-observer/local-recording.js | 78 +++++++++++++++++++ .../bstein-dev-home/av-observer/nginx.conf | 2 +- .../bstein-dev-home/av-observer/observer.js | 10 ++- .../av-observer/review-controls.js | 36 +++++++++ .../bstein-dev-home/av-observer/style.css | 4 + 7 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 services/bstein-dev-home/av-observer/local-recording.js create mode 100644 services/bstein-dev-home/av-observer/review-controls.js diff --git a/services/bstein-dev-home/av-observer/index.html b/services/bstein-dev-home/av-observer/index.html index a6101484..fd2382fd 100644 --- a/services/bstein-dev-home/av-observer/index.html +++ b/services/bstein-dev-home/av-observer/index.html @@ -4,15 +4,22 @@ A/V Test

A/V Test

-

Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.

+

Select a camera and microphone. Numeric metrics report automatically; audio and video never upload.

+

Stopped

Reporting starts with the test.

+

Not recording

+
No observations.

Audio/video sync and end-to-end delay: not measured by this check. Video callback gaps can include browser scheduling. Silence is not proof of audio loss. diff --git a/services/bstein-dev-home/av-observer/kustomization.yaml b/services/bstein-dev-home/av-observer/kustomization.yaml index 0c803895..6432d9e9 100644 --- a/services/bstein-dev-home/av-observer/kustomization.yaml +++ b/services/bstein-dev-home/av-observer/kustomization.yaml @@ -12,6 +12,8 @@ configMapGenerator: files: - index.html - observer.js + - local-recording.js + - review-controls.js - metrics.js - reporting.js - activity-worklet.js diff --git a/services/bstein-dev-home/av-observer/local-recording.js b/services/bstein-dev-home/av-observer/local-recording.js new file mode 100644 index 00000000..0c885494 --- /dev/null +++ b/services/bstein-dev-home/av-observer/local-recording.js @@ -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 {} + } +} diff --git a/services/bstein-dev-home/av-observer/nginx.conf b/services/bstein-dev-home/av-observer/nginx.conf index 6ec27a74..1957b287 100644 --- a/services/bstein-dev-home/av-observer/nginx.conf +++ b/services/bstein-dev-home/av-observer/nginx.conf @@ -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; } diff --git a/services/bstein-dev-home/av-observer/observer.js b/services/bstein-dev-home/av-observer/observer.js index c76a96a6..252eb9b7 100644 --- a/services/bstein-dev-home/av-observer/observer.js +++ b/services/bstein-dev-home/av-observer/observer.js @@ -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');}); diff --git a/services/bstein-dev-home/av-observer/review-controls.js b/services/bstein-dev-home/av-observer/review-controls.js new file mode 100644 index 00000000..34c2c10f --- /dev/null +++ b/services/bstein-dev-home/av-observer/review-controls.js @@ -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();}, + }; +} diff --git a/services/bstein-dev-home/av-observer/style.css b/services/bstein-dev-home/av-observer/style.css index 4f36b546..872440de 100644 --- a/services/bstein-dev-home/av-observer/style.css +++ b/services/bstein-dev-home/av-observer/style.css @@ -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; }