Vendor tested Lesavka observer assets and numeric receipt schema from 6b97ceb1. Verified 65 JS tests, 10 service tests, kustomize render and Kubernetes client dry-run. No media upload, capture activation or changes to the homepage and private OCR.
193 lines
10 KiB
JavaScript
193 lines
10 KiB
JavaScript
import {VideoMetrics,receipt} from './metrics.js';
|
|
import {RunReporter} from './reporting.js';
|
|
import {PairedObserver} from './paired-observer.js';
|
|
import {reviewControls} from './review-controls.js';
|
|
import {releaseCapture,stopTracks} from './capture-lifecycle.js';
|
|
import {ContinuityWatch,WATCH_MS} from './continuity-watch.js';
|
|
const el=id=>document.getElementById(id);
|
|
const review=reviewControls(document);
|
|
let generation=0, current=null, last=null, reportingRun=null;
|
|
function modeControls() {
|
|
const watch=el('observation-mode').value==='watch';
|
|
el('paired-signal').disabled=watch||current!==null;
|
|
review.lock(watch||current!==null);
|
|
el('watch-help').hidden=!watch;
|
|
el('start').textContent=watch?'Start 10-hour watch':'Start 2-minute check';
|
|
}
|
|
el('observation-mode').onchange=()=>{
|
|
if(current)return;
|
|
if(el('observation-mode').value==='watch') {
|
|
el('paired-signal').checked=false;el('record-review').checked=false;review.discard();
|
|
}
|
|
modeControls();
|
|
};
|
|
function release(run) {
|
|
releaseCapture(run,el('preview'),()=>review.finish());
|
|
}
|
|
function update(run) {
|
|
if(current!==run)return;
|
|
const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing');
|
|
if(run.paired)sample.paired=run.paired.summary();
|
|
if(run.watch) {
|
|
run.watch.observe(sample,run.audioReceivedAt);
|
|
sample.watch=run.watch.summary();
|
|
}
|
|
el('metrics').textContent=JSON.stringify(sample,null,2);
|
|
if(run.observing)run.reporter.observe(sample);
|
|
}
|
|
function stop(reason='operator') {
|
|
generation++;
|
|
const run=current; current=null;
|
|
if(run) {
|
|
release(run);
|
|
last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
|
|
if(run.paired)last.paired=run.paired.summary();
|
|
if(run.watch) {
|
|
run.watch.observe(last,run.audioReceivedAt);run.watch.finish(reason);
|
|
last.watch=run.watch.summary(true);
|
|
}
|
|
run.reporter.finish(last);
|
|
const display=run.watch?{...last,watch:run.watch.summary()}:last;
|
|
el('metrics').textContent=JSON.stringify(display,null,2);
|
|
el('export').disabled=false;
|
|
}
|
|
el('preview').srcObject=null;
|
|
el('start').disabled=!el('audio-device').value || !el('video-device').value;
|
|
el('stop').disabled=true; el('devices').disabled=false;
|
|
el('observation-mode').disabled=false;modeControls();
|
|
el('status').textContent=`Stopped · ${reason}`;
|
|
}
|
|
el('devices').onclick=async()=>{
|
|
stop('selecting devices');
|
|
const ticket=++generation;
|
|
el('devices').disabled=true; el('start').disabled=true; el('stop').disabled=false;
|
|
el('status').textContent='Requesting device permission…';
|
|
const timer=setTimeout(()=>{if(generation===ticket)stop('permission timeout');},20000);
|
|
try {
|
|
const stream=await navigator.mediaDevices.getUserMedia({audio:true,video:true});
|
|
stopTracks(stream);
|
|
if(generation!==ticket)return;
|
|
const devices=await navigator.mediaDevices.enumerateDevices();
|
|
if(generation!==ticket)return;
|
|
for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
|
|
const select=el(id), saved=select.value;
|
|
select.replaceChildren(new Option('Select a device',''));
|
|
for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
|
|
select.value=saved;
|
|
}
|
|
el('status').textContent='Select a camera and microphone, then Start.';
|
|
el('devices').disabled=false; el('stop').disabled=true;
|
|
} catch { if(generation===ticket)stop('device permission unavailable'); }
|
|
finally {clearTimeout(timer);}
|
|
};
|
|
for(const id of ['audio-device','video-device']) el(id).onchange=()=>{
|
|
if(current)stop('device selection changed');
|
|
el('start').disabled=!el('audio-device').value||!el('video-device').value;
|
|
};
|
|
el('start').onclick=async()=>{
|
|
if(current || !el('audio-device').value || !el('video-device').value)return;
|
|
if(!el('preview').requestVideoFrameCallback || !window.AudioWorkletNode) {
|
|
el('status').textContent='This browser lacks the required observation APIs.';return;
|
|
}
|
|
const ticket=++generation;
|
|
const watchEnabled=el('observation-mode').value==='watch';
|
|
const run={started:performance.now(),video:new VideoMetrics(),audio:null,audioReceivedAt:null,
|
|
settings:{},watchEnabled,pairedEnabled:!watchEnabled&&el('paired-signal').checked};
|
|
current=run; last=null;
|
|
reportingRun=run;
|
|
run.reporter=watchEnabled?{
|
|
observe(){},finish(){if(reportingRun===run)el('report-status').textContent='Local watch · Save metrics before closing';},
|
|
}:new RunReporter(message=>{
|
|
if(reportingRun===run)el('report-status').textContent=message;
|
|
});
|
|
if(watchEnabled){review.discard();el('report-status').textContent='Local watch · metrics are not uploaded';}
|
|
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
|
|
el('paired-signal').disabled=true;
|
|
el('observation-mode').disabled=true;
|
|
review.lock(true);
|
|
el('status').textContent='Starting…';
|
|
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
|
|
try {
|
|
const stream=await navigator.mediaDevices.getUserMedia({
|
|
video:{deviceId:{exact:el('video-device').value}},
|
|
audio:{deviceId:{exact:el('audio-device').value},echoCancellation:false,noiseSuppression:false,autoGainControl:false},
|
|
});
|
|
if(generation!==ticket){stopTracks(stream);return;}
|
|
run.stream=stream;
|
|
for(const track of stream.getTracks())track.onended=()=>{if(current===run)stop('device ended');};
|
|
const vs=stream.getVideoTracks()[0].getSettings(),as=stream.getAudioTracks()[0].getSettings();
|
|
run.settings={video:{width:vs.width,height:vs.height,frameRate:vs.frameRate},
|
|
audio:{sampleRate:as.sampleRate,channelCount:as.channelCount,echoCancellation:as.echoCancellation,
|
|
noiseSuppression:as.noiseSuppression,autoGainControl:as.autoGainControl}};
|
|
run.context=new AudioContext();
|
|
await run.context.audioWorklet.addModule(run.pairedEnabled?'paired-worklet.js':'activity-worklet.js');
|
|
if(generation!==ticket)return;
|
|
if(run.pairedEnabled)run.paired=new PairedObserver(run.context,el('preview'));
|
|
run.node=new AudioWorkletNode(run.context,run.pairedEnabled?'av-paired-signals':'av-activity',
|
|
{processorOptions:{continuityWatch:watchEnabled}});
|
|
run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');};
|
|
run.node.port.onmessage=({data})=>{
|
|
if(current!==run)return;
|
|
if(watchEnabled&&data.watchStopped===true){stop('audio observer deadline or lost owner');return;}
|
|
run.audioReceivedAt=performance.now();
|
|
if(run.paired){run.audio=data.activity;run.paired.audioMessage(data);}else run.audio=data;
|
|
run.node.port.postMessage('ack');
|
|
if(run.observing&&run.paired&&!run.signalStarted) {
|
|
const paired=run.paired.summary();
|
|
if(paired.continuity.frames>=2&&paired.pilot?.windows>0) {
|
|
run.signalStarted=true;clearTimeout(run.signalTimer);
|
|
run.started=performance.now();run.video=new VideoMetrics();
|
|
run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000);
|
|
el('status').textContent='Measuring known A/V signals · 120 seconds';
|
|
}
|
|
}
|
|
update(run);
|
|
};
|
|
run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
|
|
run.source.connect(run.node);run.node.connect(run.context.destination);
|
|
await run.context.resume();
|
|
if(generation!==ticket)return;
|
|
if(run.context.state!=='running')throw new Error('audio context did not start');
|
|
el('preview').srcObject=stream;
|
|
await el('preview').play();
|
|
if(generation!==ticket)return;
|
|
clearTimeout(run.setupTimer);
|
|
run.started=performance.now();
|
|
if(watchEnabled)run.watch=new ContinuityWatch();
|
|
run.observing=true;
|
|
// Reporting and freshness cannot depend on the audio path still working.
|
|
run.updateTimer=setInterval(()=>update(run),1000);
|
|
run.recording=watchEnabled?false:review.begin(stream);
|
|
function frame(now,metadata) {
|
|
if(current!==run)return;
|
|
try {
|
|
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
|
|
run.paired?.frame(metadata.expectedDisplayTime);
|
|
run.callback=el('preview').requestVideoFrameCallback(frame);
|
|
} catch {if(current===run)stop('video observer error');}
|
|
}
|
|
run.callback=el('preview').requestVideoFrameCallback(frame);
|
|
if(watchEnabled) {
|
|
run.timer=setTimeout(()=>{if(current===run)stop('10 hours observed');},WATCH_MS);
|
|
el('status').textContent='Watching continuity · 10-hour limit · local metrics only';
|
|
} else if(run.paired) {
|
|
run.signalTimer=setTimeout(()=>{if(current===run)stop('test signal timeout');},60000);
|
|
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=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); }
|
|
};
|
|
el('stop').onclick=()=>stop();
|
|
el('export').onclick=()=>{
|
|
if(!last)return;
|
|
const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
|
|
const link=document.createElement('a');link.href=url;
|
|
link.download=last.watch?'av-continuity-watch.json':'av-test-metrics.json';link.click();
|
|
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
|
};
|
|
window.addEventListener('pagehide',()=>{stop('page closed');review.discard();});
|
|
document.addEventListener('visibilitychange',()=>{if(document.hidden&¤t)stop('page hidden; timing invalid');});
|