79 lines
3.7 KiB
JavaScript
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 {}
|
|
}
|
|
}
|