Files
NODEDC_MISSION_CORE/scripts/build_camera_browser_probe.mjs
DCCONSTRUCTIONS e515ab1b8c feat(planning): consolidate recorded-route localization and spatial scene
Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
2026-09-21 08:47:19 +03:00

98 lines
7.8 KiB
JavaScript

/** Build a removable, offline-only browser probe around the production player.
* Input manifest/media must already be staged in outputRoot by a trusted local
* caller. No device API, live singleton or actual WebSocket is used by this probe.
*/
import {createRequire} from 'node:module';
import {readFile,writeFile} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const repository=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const app=path.join(repository,'apps/control-station');
const outputRoot=path.resolve(process.argv[2]);
if(!outputRoot.startsWith(path.join(app,'dist')+path.sep))throw new Error('Probe must remain below the canonical static root.');
const require=createRequire(path.join(app,'package.json'));
const {build}=require('esbuild');
const player=path.join(app,'src/components/MseFmp4WebSocketPlayer.tsx');
const source=`
import React from 'react';
import {createRoot} from 'react-dom/client';
import {MseFmp4WebSocketPlayer} from ${JSON.stringify(player)};
const base=new URL('.',location.href), nativeFetch=window.fetch.bind(window);
const manifest=await (await nativeFetch(new URL('manifest.json',base))).json();
const report={schema:'missioncore.offline-browser-camera-probe/v1',startedAt:new Date().toISOString(),cases:[],unexpectedRequests:[],hardware:false,realWebSocket:false,realMediaSource:true};
let current=null,finished=false;
const output=document.querySelector('pre');
function renderReport(){output.textContent=JSON.stringify(report,null,2);}
window.fetch=async(input,options={})=>{
const url=new URL(typeof input==='string'?input:input.url??input,location.href);
if(url.origin===base.origin&&url.pathname.startsWith(base.pathname)&&(options.method??'GET')==='GET')return nativeFetch(input,options);
if(url.pathname==='/api/v1/viewer/live-diagnostics'&&options.method==='POST'){
if(current)current.events.push({atMs:performance.now()-current.started,event:JSON.parse(options.body)});
return new Response('{}',{status:200});
}
report.unexpectedRequests.push({path:url.pathname,method:options.method??'GET'});renderReport();
throw new Error('Offline probe forbids all device and unrelated HTTP requests.');
};
class ArchiveSocket extends EventTarget{
static CONNECTING=0;static OPEN=1;static CLOSING=2;static CLOSED=3;
readyState=0;binaryType='arraybuffer';timers=[];
constructor(url){
super();
if(new URL(url).pathname!==base.pathname+'offline-socket')throw new Error('Unexpected socket destination');
this.owner=current;this.owner.sockets++;this.owner.activeSockets++;
this.timers.push(setTimeout(()=>this.open(),0));
}
open(){
if(this.readyState===3)return;
this.readyState=1;this.dispatchEvent(new Event('open'));
const elapsed=this.owner.streamStarted===undefined?0:performance.now()-this.owner.streamStarted;
this.owner.streamStarted??=performance.now();
// Every replacement gets init plus only subsequent media, like a disposable
// reader joining the existing stream. It cannot rewind the archived source.
this.sendBytes(this.owner.bytes[0]);
this.owner.entries.slice(1).forEach((row,index)=>{
if(row.atMs<elapsed)return;
this.timers.push(setTimeout(()=>this.sendBytes(this.owner.bytes[index+1]),Math.max(0,row.atMs-elapsed)));
});
}
sendBytes(bytes){if(this.readyState===1){this.owner.delivered++;this.dispatchEvent(new MessageEvent('message',{data:bytes.slice(0)}));}}
close(){if(this.readyState===3)return;this.readyState=3;this.timers.forEach(clearTimeout);this.owner.activeSockets--;this.dispatchEvent(new CloseEvent('close',{code:1000}));}
send(){throw new Error('Offline camera is read-only.');}
}
window.WebSocket=ArchiveSocket;
const root=createRoot(document.querySelector('#player'));
const sampleTimer=setInterval(()=>{
if(!current||finished)return;
const video=document.querySelector('video');
const quality=video?.getVideoPlaybackQuality?.();
current.samples.push({atMs:performance.now()-current.started,status:document.querySelector('.mse-fmp4-player')?.dataset.status,currentTime:video?.currentTime??null,readyState:video?.readyState??null,error:video?.error?.code??null,decoded:quality?.totalVideoFrames??video?.webkitDecodedFrameCount??null,dropped:quality?.droppedVideoFrames??null});
renderReport();
},500);
const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
for(const fixture of manifest.cases){
const bytes=await Promise.all(fixture.entries.map(async row=>(await nativeFetch(new URL(row.path,base))).arrayBuffer()));
current={name:fixture.name,started:performance.now(),entries:fixture.entries,bytes,samples:[],events:[],sockets:0,activeSockets:0,delivered:0};
// The public DOM report excludes binary bytes and internal timer state.
const publicCase={name:current.name,samples:current.samples,events:current.events};report.cases.push(publicCase);
document.querySelector('h1').textContent='Проверка браузерной камеры: '+fixture.name+' · только архив';
root.render(React.createElement(MseFmp4WebSocketPlayer,{key:fixture.name,label:'Сохранённая камера · тест декодера',delivery:{kind:'mse-fmp4-websocket',id:'offline-'+fixture.name,url:base.pathname+'offline-socket',mediaType:'video/mp4; codecs="avc1.641028"'},recoveryAuthorityIdentity:'offline-fixture-'+fixture.name}));
await wait(fixture.durationMs);
root.render(null);await wait(100);
Object.assign(publicCase,{sockets:current.sockets,activeSockets:current.activeSockets,delivered:current.delivered,expectedMedia:fixture.entries.length-1});
renderReport();
}
clearInterval(sampleTimer);root.unmount();finished=true;report.finishedAt=new Date().toISOString();
const gapSamples=report.cases[0].samples.filter(s=>s.atMs>10000&&s.atMs<20000);
report.checks={cleanPlayed:report.cases[0].samples.some(s=>s.status==='playing'&&s.decoded>0),cleanGapVisible:gapSamples.length>0&&gapSamples.every(s=>s.status==='buffering'),cleanNoRestarts:report.cases[0].sockets===1,cleanNoDecodeErrors:!report.cases[0].samples.some(s=>s.error)&&!report.cases[0].events.some(e=>e.event.event_code==='live_camera_transport_restart_requested'),corruptDetected:report.cases[1].samples.some(s=>s.error)||report.cases[1].events.some(e=>e.event.event_code==='live_camera_transport_restart_requested'),allReadersClosed:report.cases.every(c=>c.activeSockets===0),noUnexpectedRequests:report.unexpectedRequests.length===0};
document.querySelector('h1').textContent='Проверка браузерной камеры завершена · только архив';renderReport();
`;
await writeFile(path.join(outputRoot,'probe-source.jsx'),source);
await build({stdin:{contents:source,resolveDir:app,sourcefile:'offline-camera-probe.jsx',loader:'jsx'},absWorkingDir:app,bundle:true,format:'esm',target:'es2022',jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'},outfile:path.join(outputRoot,'probe.js')});
const shell=await readFile(path.join(app,'dist/index.html'),'utf8');
const css=shell.match(/href="([^\"]+\.css)"/)?.[1];
if(!css)throw new Error('Canonical built stylesheet is missing.');
await writeFile(path.join(outputRoot,'index.html'),
`<!doctype html><meta charset="utf-8"><title>Offline camera decoder qualification</title><link rel="stylesheet" href="${css}"><div data-nodedc-ui="true" data-nodedc-theme="dark" class="nodedc-ui-root" style="background:#101010;color:#eee;padding:24px;min-height:100vh"><h1>Проверка браузерной камеры · только архив</h1><p>Штатный плеер и настоящий MediaSource. Источник — локальные файлы. Подключения к K1 нет.</p><div id="player" style="width:640px;height:400px"></div><pre style="font-size:11px;white-space:pre-wrap"></pre></div><script type="module" src="./probe.js"></script>`);
console.log(JSON.stringify({outputRoot,productionPlayer:player,hardware:false}));