79 lines
3.7 KiB
JavaScript

// 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 {}
}
}