@rig-c/review 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +376 -2
- package/docs/DESIGN.md +72 -0
- package/docs/REVIEW_PACKAGE.md +627 -0
- package/examples/demo/SOURCE.md +5 -0
- package/examples/demo/chime.wav +0 -0
- package/examples/demo/lantern.svg +10 -0
- package/examples/demo/pose.json +244 -0
- package/examples/demo/review.json +67 -0
- package/package.json +49 -4
- package/src/animation-selection.ts +29 -0
- package/src/build.ts +8 -0
- package/src/cli.ts +116 -0
- package/src/compare-playhead.ts +55 -0
- package/src/compare-presentation.ts +43 -0
- package/src/compare-server.ts +63 -0
- package/src/compare-session.ts +26 -0
- package/src/compare.ts +57 -0
- package/src/comparison-eligibility.ts +54 -0
- package/src/control-server.ts +83 -0
- package/src/control.ts +132 -0
- package/src/feedback-export.ts +197 -0
- package/src/feedback-validation.ts +38 -0
- package/src/frame-clock.ts +36 -0
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/manifest.ts +140 -0
- package/src/open-protocol.ts +31 -0
- package/src/open-selection.ts +11 -0
- package/src/open-shell.ts +2 -0
- package/src/open-worker.ts +2 -0
- package/src/open.ts +142 -0
- package/src/pose.ts +27 -0
- package/src/probe-reference.ts +16 -0
- package/src/reference-frame-index.ts +16 -0
- package/src/reference-frames.ts +62 -0
- package/src/reference.ts +63 -0
- package/src/report.ts +88 -0
- package/src/server.ts +62 -0
- package/src/session-history.ts +99 -0
- package/src/session.ts +58 -0
- package/src/strip-export.ts +69 -0
- package/src/strip-render.ts +171 -0
- package/src/strip.ts +51 -0
- package/src/versions.ts +20 -0
- package/web/camera-view.ts +44 -0
- package/web/compare.css +140 -0
- package/web/compare.html +7 -0
- package/web/compare.ts +546 -0
- package/web/control.ts +67 -0
- package/web/index.html +13 -0
- package/web/inspection-persistence.ts +219 -0
- package/web/instance-access.ts +23 -0
- package/web/main.ts +972 -0
- package/web/part-selection.ts +136 -0
- package/web/renderer-status.ts +31 -0
- package/web/report-view.ts +51 -0
- package/web/rig-overlays.ts +150 -0
- package/web/styles.css +139 -0
- package/web/view-controls.ts +69 -0
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
package/src/open.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {parseControlCommand,type ControlCommand,type ControlReceipt} from './control.ts';
|
|
2
|
+
/** One fixed-package worker per exact binding. There is no general daemon API. */
|
|
3
|
+
import {createHash,randomBytes,randomUUID} from 'node:crypto';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {homedir} from 'node:os';
|
|
6
|
+
import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,open as openFile} from 'node:fs/promises';
|
|
7
|
+
import {constants} from 'node:fs';
|
|
8
|
+
import {basename,dirname,join,relative,resolve,isAbsolute} from 'node:path';
|
|
9
|
+
import {fileURLToPath} from 'node:url';
|
|
10
|
+
import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
|
|
11
|
+
import {loadReview,type ReviewPackage} from './manifest.ts';
|
|
12
|
+
import {loadCompare,assertCompareUnchanged,type ComparePackage} from './compare.ts';
|
|
13
|
+
import {startServer} from './server.ts';
|
|
14
|
+
import {startCompareServer} from './compare-server.ts';
|
|
15
|
+
import {selectAnimation,type AnimationSelection} from './animation-selection.ts';
|
|
16
|
+
import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
|
|
17
|
+
import {ensureFront,frontPort} from './front.ts';
|
|
18
|
+
import {playPackagePath} from './versions.ts';
|
|
19
|
+
/** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
|
|
20
|
+
export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean}
|
|
21
|
+
export type OpenPackage=({mode:'single';p:ReviewPackage;inputs:OpenInputs;contentHash:string}|{mode:'compare';p:ComparePackage;inputs:OpenInputs;contentHash:string})&{selection:AnimationSelection|null};
|
|
22
|
+
/** Private 0600 record. `capability` is the 256-bit page write capability, handed out only in the printed `#cap=` fragment. */
|
|
23
|
+
export interface RecordFile extends OpenIdentity {schema:'rig-review-open-instance/1';pid:number;origin:string;mode:'single'|'compare';capability:string;controlToken?:string}
|
|
24
|
+
interface WorkerRequest {schema:'rig-review-open-request/1';inputs:OpenInputs;identity:OpenIdentity;stateDir:string;registry:string;request:string;idleMs:number;controlToken?:string}
|
|
25
|
+
const appRoot=fileURLToPath(new URL('..',import.meta.url));
|
|
26
|
+
const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
27
|
+
const sleep=(ms:number)=>new Promise(resolveSleep=>setTimeout(resolveSleep,ms));
|
|
28
|
+
export const defaultStateDir=()=>process.env.RIG_REVIEW_STATE_DIR??join(homedir(),'.local','state','rig-c-review');
|
|
29
|
+
export async function loadOpenPackage(inputs:OpenInputs):Promise<OpenPackage>{
|
|
30
|
+
if(!inputs.compare){const p=await loadReferencedReview(inputs.input,inputs.reference),selection=selectAnimation([{label:'single',catalog:p.feedbackCatalog,report:p.report}],inputs.animation,true);return {mode:'single',p,selection,contentHash:p.sourceHash,inputs:{input:p.manifestPath,...(selectedReference(p)?{reference:selectedReference(p)!.path}:{}),...(selection?{animation:selection.name}:{}),...(inputs.control?{control:true}:{})}};}
|
|
31
|
+
if(!inputs.reference)throw new Error('open compare: explicit --reference required');
|
|
32
|
+
const [a,b]=await Promise.all([loadReview(inputs.input),loadReview(inputs.compare)]),selection=selectAnimation([{label:'a',catalog:a.feedbackCatalog,report:a.report},{label:'b',catalog:b.feedbackCatalog,report:b.report}],inputs.animation)!;
|
|
33
|
+
const p=await loadCompare({a:inputs.input,b:inputs.compare,reference:inputs.reference,animation:selection.name});
|
|
34
|
+
if(p.a.sourceHash!==a.sourceHash||p.b.sourceHash!==b.sourceHash)throw new Error('open inputs changed while selecting animation');
|
|
35
|
+
return {mode:'compare',p,selection,contentHash:p.response.sourceHash,inputs:{input:p.inputs.a,compare:p.inputs.b,reference:p.inputs.reference,animation:selection.name,...(inputs.control?{control:true}:{})}};
|
|
36
|
+
}
|
|
37
|
+
/** Flat tool source/static files are small; no package or producer recursive scan. */
|
|
38
|
+
export async function openFingerprint():Promise<string>{
|
|
39
|
+
const hash=createHash('sha256');for(const directory of ['src','web'])for(const entry of (await readdir(join(appRoot,directory),{withFileTypes:true})).sort((a,b)=>a.name<b.name?-1:1))if(entry.isFile()&&/\.(ts|css|html)$/.test(entry.name)){hash.update(directory+'/'+entry.name+'\0');hash.update(await readFile(join(appRoot,directory,entry.name)));}
|
|
40
|
+
// An installed copy ships no lockfile; the resolved runtime manifest still binds its dependency.
|
|
41
|
+
hash.update('package.json\0');hash.update(await readFile(join(appRoot,'package.json')));
|
|
42
|
+
hash.update('bun.lock\0');try{hash.update(await readFile(join(appRoot,'bun.lock')));}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;hash.update('(absent)');}
|
|
43
|
+
hash.update('@rig-c/play/package.json\0');hash.update(await readFile(playPackagePath()));return hash.digest('hex');
|
|
44
|
+
}
|
|
45
|
+
export function openKey(p:OpenPackage,fingerprint:string):string {return digest({fingerprint,installation:appRoot,mode:p.mode,inputs:p.inputs,content:p.contentHash,identity:p.mode==='single'?p.p.reviewedSource:p.p.response.binding});}
|
|
46
|
+
/** Handoff URL: the managed front's `/instances/<key>/` path (or the worker's own origin when direct). The
|
|
47
|
+
* capability rides in the fragment, which browsers never send to a server; the page stores and strips it. */
|
|
48
|
+
export function openUrl(record:Pick<RecordFile,'origin'|'key'|'contentHash'|'capability'>,front?:string):string {const url=new URL(front?`${front}/instances/${record.key}/`:record.origin);url.searchParams.set('input',record.contentHash);url.searchParams.set('handoff',record.key);url.hash='cap='+record.capability;return url.href;}
|
|
49
|
+
export async function smallJson(file:string):Promise<unknown>{const handle=await openFile(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const before=await handle.stat();if(!before.isFile()||before.size>65536)throw new Error('open state: bounded regular file required');const buffer=Buffer.alloc(before.size+1);let size=0;while(size<buffer.length){const next=await handle.read(buffer,size,buffer.length-size,size);if(!next.bytesRead)break;size+=next.bytesRead;}const after=await handle.stat();if(size!==before.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)throw new Error('open state: changed while reading');return JSON.parse(buffer.subarray(0,size).toString('utf8'));}finally{await handle.close();}}
|
|
50
|
+
function recordValue(value:unknown,key:string):RecordFile|null {if(!value||typeof value!=='object')return null;const v=value as Partial<RecordFile>;return v.schema==='rig-review-open-instance/1'&&v.key===key&&/^[a-f0-9]{64}$/.test(v.contentHash??'')&&/^[a-f0-9]{64}$/.test(v.fingerprint??'')&&typeof v.nonce==='string'&&/^[a-f0-9-]{36}$/.test(v.nonce)&&typeof v.origin==='string'&&/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(v.origin)&&Number(v.origin.slice(v.origin.lastIndexOf(':')+1))<=65535&&Number.isSafeInteger(v.pid)&&v.pid!>0&&(v.mode==='single'||v.mode==='compare')&&capabilityPattern.test(v.capability??'')?v as RecordFile:null;}
|
|
51
|
+
export async function readRecord(file:string,key:string):Promise<RecordFile|null>{try{return recordValue(await smallJson(file),key);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
|
|
52
|
+
/** Hard byte ceiling and body deadline, including stalled or chunked responses. */
|
|
53
|
+
export async function readOpenStatus(response:Response,timeoutMs=1500):Promise<unknown>{
|
|
54
|
+
if(!response.body)throw new Error('open status: body required');const reader=response.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
|
|
55
|
+
try{return await Promise.race([(async()=>{const chunks:Uint8Array[]=[];let size=0;while(true){const next=await reader.read();if(next.done)break;size+=next.value.length;if(size>65536)throw new Error('open status: exceeds 64 KiB');chunks.push(next.value);}const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(bytes));})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('open status: body timeout')),timeoutMs);})]);}
|
|
56
|
+
finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});try{reader.releaseLock();}catch{}}
|
|
57
|
+
}
|
|
58
|
+
export async function alive(record:RecordFile):Promise<boolean>{try{const response=await fetch(record.origin+'/api/open-status',{headers:{authorization:`Bearer ${record.nonce}`},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await readOpenStatus(response) as OpenIdentity&{schema:string};return v.schema==='rig-review-open-status/1'&&v.key===record.key&&v.contentHash===record.contentHash&&v.nonce===record.nonce&&v.fingerprint===record.fingerprint;}catch{return false;}}
|
|
59
|
+
|
|
60
|
+
export function running(pid:number):boolean{try{process.kill(pid,0);return true;}catch(e){return (e as NodeJS.ErrnoException).code==='EPERM';}}
|
|
61
|
+
async function claimOwner(claim:string):Promise<{file:string;pid:number;nonce:string}|null>{
|
|
62
|
+
const info=await lstat(claim);if(info.isSymbolicLink()||!info.isDirectory())throw new Error('open.state: claim must be an owned directory');const names=await readdir(claim);if(!names.length)return null;if(names.length!==1||!/^owner(?:\.[a-f0-9-]{36})?\.json$/.test(names[0]!))throw new Error('open.state: invalid startup claim');const file=join(claim,names[0]!),owner=await smallJson(file) as {pid?:number;nonce?:string};if(!Number.isSafeInteger(owner.pid)||owner.pid!<=0||typeof owner.nonce!=='string'||!/^([a-f0-9-]{36})$/.test(owner.nonce)||names[0]!=='owner.json'&&names[0]!==`owner.${owner.nonce}.json`)throw new Error('open.state: invalid startup claimant');return {file,pid:owner.pid!,nonce:owner.nonce};
|
|
63
|
+
}
|
|
64
|
+
async function removeClaimFile(claim:string,owner:{file:string;nonce:string}){try{const current=await smallJson(owner.file) as {nonce?:string};if(current.nonce!==owner.nonce)return;await unlink(owner.file);await rmdir(claim);}catch(e){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((e as NodeJS.ErrnoException).code??''))throw e;}}
|
|
65
|
+
export async function releaseClaim(claim:string,nonce:string){await removeClaimFile(claim,{file:join(claim,`owner.${nonce}.json`),nonce});}
|
|
66
|
+
async function canonicalDestination(path:string):Promise<string>{try{return await realpath(path);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;const parent=dirname(path);if(parent===path)throw e;return join(await canonicalDestination(parent),basename(path));}}
|
|
67
|
+
/** Single-owner startup: publish a populated claim directory atomically (no live owner ever exposes an empty
|
|
68
|
+
* claim; unique owner filenames make stale cleanup safe after replacement). A ready reuse wins while waiting. */
|
|
69
|
+
export async function acquireClaim<T>(control:string,name:string,nonce:string,reuse:()=>Promise<T|null>,deadline:number,busy:string):Promise<{row:T}|{claim:string}>{
|
|
70
|
+
const claim=join(control,name+'.claim'),candidate=join(control,`${name}.${nonce}.claiming`);await mkdir(candidate,{mode:0o700});await writeFile(join(candidate,`owner.${nonce}.json`),JSON.stringify({pid:process.pid,nonce}),{mode:0o600,flag:'wx'});
|
|
71
|
+
try{while(true){try{try{await lstat(claim);const occupied=new Error('claim exists') as NodeJS.ErrnoException;occupied.code='EEXIST';throw occupied;}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}await rename(candidate,claim);return {claim};}
|
|
72
|
+
catch(e){if(!['EEXIST','ENOTEMPTY'].includes((e as NodeJS.ErrnoException).code??''))throw e;const row=await reuse();if(row){await rm(candidate,{recursive:true});return {row};}
|
|
73
|
+
try{const before=await lstat(claim),owner=await claimOwner(claim);if(owner&&!running(owner.pid)){const again=await claimOwner(claim);if(again?.nonce===owner.nonce&&(await lstat(claim)).ino===before.ino)await removeClaimFile(claim,owner);}else if(!owner&&Date.now()-before.mtimeMs>=250&&(await lstat(claim)).ino===before.ino){try{await rmdir(claim);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((error as NodeJS.ErrnoException).code??''))throw error;}}}
|
|
74
|
+
catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error(busy);await sleep(30);}}}
|
|
75
|
+
catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
|
|
76
|
+
}
|
|
77
|
+
/** Managed is the default: the printed URL goes through the one loopback front (127.0.0.1:4318, or
|
|
78
|
+
* RIG_REVIEW_FRONT_PORT) so browser storage keeps one origin across reopens. `direct` keeps the worker's own port. */
|
|
79
|
+
export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number;direct?:boolean;frontPort?:number;frontIdleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null;direct:boolean}>{
|
|
80
|
+
const direct=options.direct??inputs.direct??false,port=direct?0:frontPort(options.frontPort);
|
|
81
|
+
const p=await loadOpenPackage(inputs),fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
|
|
82
|
+
const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?[p.p.root]:[p.p.a.root,p.p.b.root];
|
|
83
|
+
for(const root of roots){const rel=relative(root,stateDir);if(!rel||rel!=='..'&&!rel.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&!isAbsolute(rel))throw new Error('open.state: must be outside input packages');}
|
|
84
|
+
const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');
|
|
85
|
+
const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
|
|
86
|
+
const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
|
|
87
|
+
return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct};
|
|
88
|
+
}
|
|
89
|
+
async function ensureWorker(p:OpenPackage,key:string,fingerprint:string,stateDir:string,canonicalControl:string,idleMs:number):Promise<{row:RecordFile;reused:boolean}>{
|
|
90
|
+
const registry=join(canonicalControl,key+'.json'),claim=join(canonicalControl,key+'.claim'),nonce=randomUUID(),deadline=performance.now()+20000;
|
|
91
|
+
const reuse=async()=>{const row=await readRecord(registry,key);return row&&row.fingerprint===fingerprint&&row.contentHash===p.contentHash&&row.mode===p.mode&&await alive(row)?row:null;};
|
|
92
|
+
const ready=await reuse();if(ready)return {row:ready,reused:true};
|
|
93
|
+
const claimed=await acquireClaim(canonicalControl,key,nonce,reuse,deadline,'OPEN_BUSY: instance startup claim did not become ready');if('row' in claimed)return {row:claimed.row,reused:true};
|
|
94
|
+
let request:string|undefined,spawned:ReturnType<typeof spawn>|undefined,handedOff=false;
|
|
95
|
+
try{
|
|
96
|
+
const row=await reuse();if(row)return {row,reused:true};
|
|
97
|
+
const identity:OpenIdentity={key,contentHash:p.contentHash,nonce,fingerprint};request=join(canonicalControl,`${key}.${nonce}.request.json`);const log=join(canonicalControl,`${key}.${nonce}.log`);
|
|
98
|
+
const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs,...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
|
|
99
|
+
const logHandle=await openFile(log,'a',0o600);let child;try{child=spawn(process.execPath,[join(appRoot,'src/open-worker.ts'),request],{cwd:canonicalControl,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});spawned=child;child.unref();}finally{await logHandle.close();}
|
|
100
|
+
let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
|
|
101
|
+
while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {row:record,reused:false};}if(exited)throw new Error(`OPEN_START: ${exited}; worker log ${log}`);await sleep(30);}throw new Error(`OPEN_TIMEOUT: worker did not become ready; log ${log}`);
|
|
102
|
+
}finally{if(spawned&&!handedOff&&spawned.exitCode===null&&spawned.signalCode===null)spawned.kill('SIGTERM');await releaseClaim(claim,nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
|
|
103
|
+
}
|
|
104
|
+
export async function runOpenWorker(requestPath:string):Promise<void>{
|
|
105
|
+
const raw=await smallJson(requestPath) as WorkerRequest;if(raw.schema!=='rig-review-open-request/1'||!raw.identity||!recordValue({schema:'rig-review-open-instance/1',...raw.identity,pid:process.pid,origin:'http://127.0.0.1:1',mode:'single',capability:'0'.repeat(64)},raw.identity.key))throw new Error('open worker: invalid request');
|
|
106
|
+
const spec=raw,control=await realpath(join(spec.stateDir,'open'));if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.registry!==join(control,spec.identity.key+'.json')||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('open worker: invalid state locations or idle limit');
|
|
107
|
+
const p=await loadOpenPackage(spec.inputs),fingerprint=await openFingerprint();if(fingerprint!==spec.identity.fingerprint||openKey(p,fingerprint)!==spec.identity.key||p.contentHash!==spec.identity.contentHash)throw new Error('open worker: inputs changed before startup');
|
|
108
|
+
if(spec.inputs.control&&(!spec.controlToken||!/^[a-f0-9-]{36}$/.test(spec.controlToken)))throw new Error('open control: invalid private capability');
|
|
109
|
+
// The worker mints its own page capability; it lives only in this process and the private record.
|
|
110
|
+
const capability=randomBytes(32).toString('hex');let last=performance.now();const options={port:0,stateDir:spec.stateDir,open:spec.identity,capability,activity:()=>{last=performance.now();},...(spec.inputs.control?{control:{token:spec.controlToken!,instanceKey:spec.identity.key}}:{})};
|
|
111
|
+
const server=p.mode==='single'?await startServer(p.p,{...options,initialAnimation:p.selection?.name}):await startCompareServer(p.p,options);
|
|
112
|
+
const check=()=>p.mode==='single'?assertReviewUnchanged(p.p):assertCompareUnchanged(p.p);
|
|
113
|
+
await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}`,mode:p.mode,capability,...(spec.inputs.control?{controlToken:spec.controlToken}:{})};const tmp=spec.registry+'.'+spec.identity.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.registry);
|
|
114
|
+
await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
|
|
115
|
+
let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;server.stop(true);clearInterval(timer);const current=await readRecord(spec.registry,spec.identity.key);if(current?.nonce===spec.identity.nonce)await unlink(spec.registry).catch(()=>{});};
|
|
116
|
+
const timer=setInterval(()=>{if(performance.now()-last>=spec.idleMs)void stop();},Math.min(1000,spec.idleMs));process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read/control an already running, explicitly opted-in exact binding. Never starts a worker. */
|
|
120
|
+
export async function controlReview(inputs:OpenInputs,commandOptions:{pageId?:string;expectedRevision?:number;requestId?:string;command?:ControlCommand;waitMs?:number;stateDir?:string}={}):Promise<unknown>{
|
|
121
|
+
const wait=commandOptions.waitMs??10000;if(!Number.isSafeInteger(wait)||wait<0||wait>30000)throw new Error('CONTROL_WAIT: expected integer 0..30000');
|
|
122
|
+
const p=await loadOpenPackage({...inputs,control:true}),fingerprint=await openFingerprint(),key=openKey(p,fingerprint),stateDir=await canonicalDestination(resolve(commandOptions.stateDir??defaultStateDir()));
|
|
123
|
+
const controlDir=join(stateDir,'open');
|
|
124
|
+
try{if((await lstat(controlDir)).isSymbolicLink())throw new Error('CONTROL_STATE: control directory cannot be a symlink');}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');throw error;}
|
|
125
|
+
const file=join(controlDir,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)throw new Error('CONTROL_STATE: owner-only regular capability record required');}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');throw error;}
|
|
126
|
+
const record=await readRecord(file,key);
|
|
127
|
+
if(!record||!record.controlToken||!await alive(record))throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');
|
|
128
|
+
const headers={Authorization:'Bearer '+record.controlToken,Origin:record.origin,'Content-Type':'application/json',[capabilityHeader]:record.capability};
|
|
129
|
+
const get=async(path:string,body?:unknown)=>{const response=await fetch(record.origin+path,{method:body===undefined?'GET':'POST',headers,...(body===undefined?{}:{body:JSON.stringify(body)}),redirect:'error',signal:AbortSignal.timeout(5000)});const value=await readOpenStatus(response,5000);if(!response.ok)throw new Error((value as {error?:string}).error??'CONTROL_REQUEST_FAILED');return value;};
|
|
130
|
+
const state=await get('/api/control') as {sourceHash:string;instanceKey:string;pages:{pageId:string;revision:number}[]};
|
|
131
|
+
if(state.sourceHash!==p.contentHash||state.instanceKey!==key)throw new Error('CONTROL_STALE_BINDING');
|
|
132
|
+
if(!commandOptions.command)return state;
|
|
133
|
+
const page=commandOptions.pageId?state.pages.find(row=>row.pageId===commandOptions.pageId):state.pages.length===1?state.pages[0]:undefined;
|
|
134
|
+
if(!page)throw new Error(state.pages.length>1?'CONTROL_PAGE_REQUIRED: select exact --page from current state':'CONTROL_NO_PAGE');
|
|
135
|
+
if(!Number.isSafeInteger(commandOptions.expectedRevision)||commandOptions.expectedRevision!<0)throw new Error('CONTROL_REVISION_REQUIRED: use the observed --expected-revision');
|
|
136
|
+
const command=parseControlCommand(commandOptions.command),request={schema:'rig-review-control-command/1',requestId:commandOptions.requestId??randomUUID(),pageId:page.pageId,instanceKey:key,sourceHash:p.contentHash,expectedRevision:commandOptions.expectedRevision,command};
|
|
137
|
+
const receipt=await get('/api/control/commands',request) as ControlReceipt;
|
|
138
|
+
if(wait===0)return receipt;
|
|
139
|
+
const deadline=performance.now()+wait;let current=receipt;
|
|
140
|
+
while(['queued','dispatched'].includes(current.status)&&performance.now()<deadline){await sleep(Math.min(100,Math.max(1,deadline-performance.now())));current=await get('/api/control/receipts/'+encodeURIComponent(current.commandId)) as ControlReceipt;}
|
|
141
|
+
return {...current,...(['queued','dispatched'].includes(current.status)?{timedOut:true}:{} )};
|
|
142
|
+
}
|
package/src/pose.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The canonical source-pose boundary; no retargeting or invented observations. */
|
|
2
|
+
export function validatePoseSequence(input:unknown,label:string):void {
|
|
3
|
+
const fail=(field:string,expectation:string):never=>{throw new Error(`${label} ${field}: expected ${expectation}`);};
|
|
4
|
+
const obj=(v:unknown,f:string):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail(f,'object');return v as Record<string,unknown>;};
|
|
5
|
+
const finite=(v:unknown,f:string):number=>{if(typeof v!=='number'||!Number.isFinite(v))return fail(f,'finite number');return v;};
|
|
6
|
+
const seq=obj(input,'sequence');if(seq.spec!=='rig-pose-sequence/1')fail('spec','rig-pose-sequence/1');
|
|
7
|
+
const space=obj(seq.space,'space');if(space.units!=='picture-px'||space.origin!=='top-left'||space.y!=='down')fail('space','picture-px, top-left, y down');
|
|
8
|
+
if(finite(seq.width,'width')<=0||finite(seq.height,'height')<=0)fail('dimensions','positive width and height');
|
|
9
|
+
if(!Array.isArray(seq.frames)||!seq.frames.length)fail('frames','nonempty frame array');
|
|
10
|
+
let previous=-Infinity;
|
|
11
|
+
for(const [index,raw] of (seq.frames as unknown[]).entries()){
|
|
12
|
+
const frame=obj(raw,`frames[${index}]`);const t=finite(frame.t,`frames[${index}].t`);if(t<=previous)fail(`frames[${index}].t`,'strictly increasing time in seconds');previous=t;
|
|
13
|
+
if(!Array.isArray(frame.people))fail(`frames[${index}].people`,'array');const ids=new Set<string>();
|
|
14
|
+
for(const personRaw of frame.people as unknown[]){const person=obj(personRaw,'person');const personId=typeof person.id==='string'&&person.id?person.id:fail('person.id','nonempty string');if(ids.has(personId))fail('person.id','unique string per frame');ids.add(personId);const joints=obj(person.joints,'joints');
|
|
15
|
+
for(const [name,jointRaw] of Object.entries(joints)){const joint=obj(jointRaw,`joint ${name}`);if(!['observed','occluded','missing','inferred'].includes(String(joint.state)))fail(`joint ${name}.state`,'observed | occluded | missing | inferred');
|
|
16
|
+
if(joint.at!==undefined){const at=Array.isArray(joint.at)?joint.at:fail(`joint ${name}.at`,'[x,y]');if(at.length!==2)fail(`joint ${name}.at`,'[x,y]');finite(at[0],`joint ${name}.at[0]`);finite(at[1],`joint ${name}.at[1]`);}else if(joint.state==='inferred')fail(`joint ${name}.at`,'[x,y] for inferred joint');
|
|
17
|
+
if(joint.score!==undefined)finite(joint.score,`joint ${name}.score`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if(seq.camera!==undefined){
|
|
22
|
+
if(!Array.isArray(seq.camera)||seq.camera.length!==(seq.frames as unknown[]).length)fail('camera','one camera entry per frame');
|
|
23
|
+
for(const [index,item] of (seq.camera as unknown[]).entries()){const c=obj(item,`camera[${index}]`);if(finite(c.s,'camera.s')<=0)fail('camera.s','positive scale');finite(c.tx,'camera.tx');finite(c.ty,'camera.ty');const inliers=finite(c.inliers,'camera.inliers');if(!Number.isInteger(inliers)||inliers<0)fail('camera.inliers','integer >= 0');}
|
|
24
|
+
}
|
|
25
|
+
if(seq.travel!==undefined&&seq.travel!=='in-place')fail('travel','in-place');
|
|
26
|
+
if(seq.cameraSource!==undefined&&(typeof seq.cameraSource!=='string'||!seq.cameraSource.trim()||seq.camera===undefined))fail('cameraSource','nonempty string with camera array');
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {execFile} from 'node:child_process';
|
|
2
|
+
import type {VideoInfo} from './reference-frames.ts';
|
|
3
|
+
function fail(code:string,text:string):never {throw new Error(`${code}: ${text}`);}
|
|
4
|
+
function command(binary:string,args:string[],kind:string):Promise<Buffer>{
|
|
5
|
+
return new Promise((resolve,reject)=>execFile(binary,args,{encoding:'buffer',timeout:60000,maxBuffer:32*1024*1024,windowsHide:true},(error,stdout,stderr)=>{if(error){reject(new Error(`STRIP_${kind}: ${binary}: ${error.message}; ${stderr.toString('utf8').slice(0,1200)}`));return;}resolve(stdout);}));
|
|
6
|
+
}
|
|
7
|
+
export async function probeReference(referencePath:string,binary:string):Promise<VideoInfo>{
|
|
8
|
+
const bytes=await command(binary,['-v','error','-threads','1','-select_streams','v:0','-show_frames','-show_streams','-show_format','-show_entries','frame=best_effort_timestamp_time:stream=duration:format=duration','-of','json',referencePath],'FFPROBE');
|
|
9
|
+
const value=JSON.parse(bytes.toString('utf8')) as {frames?:{best_effort_timestamp_time?:string}[];streams?:{duration?:string}[];format?:{duration?:string}};
|
|
10
|
+
if(!Array.isArray(value.frames)||!value.frames.length)fail('STRIP_REFERENCE','no decoded video frames');
|
|
11
|
+
const frames=value.frames.map((frame,index)=>({index,time:Number(frame.best_effort_timestamp_time)}));
|
|
12
|
+
for(let i=0;i<frames.length;i++)if(!Number.isFinite(frames[i]!.time)||frames[i]!.time<0||(i>0&&frames[i]!.time<frames[i-1]!.time))fail('STRIP_REFERENCE','expected finite ordered nonnegative decoded PTS');
|
|
13
|
+
const duration=Number(value.streams?.[0]?.duration??value.format?.duration);
|
|
14
|
+
if(!Number.isFinite(duration)||duration<frames.at(-1)!.time)fail('STRIP_REFERENCE','finite video duration covering decoded frames required');
|
|
15
|
+
return {frames,duration};
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** One fixed server-selected video capability. Cache only after byte verification. */
|
|
2
|
+
import {hashLocalVideo} from './reference.ts';
|
|
3
|
+
import {probeReference} from './probe-reference.ts';
|
|
4
|
+
import {requireUniqueReferencePts,type ReferenceFrameIndex,type VideoInfo} from './reference-frames.ts';
|
|
5
|
+
export function referenceFrameIndex(path:string,sha256:string,sourceHash:string,binary=process.env.RIG_REVIEW_FFPROBE??'ffprobe'):()=>Promise<ReferenceFrameIndex>{
|
|
6
|
+
let cached:VideoInfo|null=null,pending:Promise<VideoInfo>|null=null;
|
|
7
|
+
const unchanged=async()=>{try{const current=await hashLocalVideo(path);if(current.path!==path||current.sha256!==sha256)throw new Error('hash differs');}catch{throw new Error('Reference inputs changed. Restart review to bind current video bytes.');}};
|
|
8
|
+
return async()=>{
|
|
9
|
+
await unchanged();
|
|
10
|
+
if(!cached){
|
|
11
|
+
if(!pending)pending=(async()=>{const video=await probeReference(path,binary);requireUniqueReferencePts(video);await unchanged();return video;})();
|
|
12
|
+
try{cached=await pending;}catch(error){await unchanged();return {available:false,reason:error instanceof Error&&error.message.includes('REFERENCE_FRAME_PTS_AMBIGUOUS')?'REFERENCE_FRAME_PTS_AMBIGUOUS: exact frame selection unavailable for duplicate PTS':'REFERENCE_FRAME_INDEX_UNAVAILABLE: ffprobe must decode an unambiguous timestamp index; basic seconds playback remains available'};}finally{pending=null;}
|
|
13
|
+
}
|
|
14
|
+
await unchanged();return {available:true,sourceHash,referenceSha256:sha256,video:cached};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Actual reference PTS and explicit producer-sample associations. No guessed rate. */
|
|
2
|
+
import { lastFrame,timeAtFrame,type FrameClock } from './frame-clock.ts';
|
|
3
|
+
import {requireCommonTime,type CompareDomain} from './compare-playhead.ts';
|
|
4
|
+
export interface VideoFrame {index:number;time:number}
|
|
5
|
+
export interface VideoInfo {frames:VideoFrame[];duration:number}
|
|
6
|
+
export type ReferenceFrameIndex = {available:true;sourceHash:string;referenceSha256:string;video:VideoInfo}|{available:false;reason:string};
|
|
7
|
+
/** Keep the strip's decoded selection contract, including its earlier-PTS tie. */
|
|
8
|
+
export function nearestReferenceFrame(frames:readonly VideoFrame[],time:number):VideoFrame {
|
|
9
|
+
if(!Number.isFinite(time)||!frames.length)throw new Error('STRIP_REFERENCE_TIME: finite time and decoded frames required');
|
|
10
|
+
let lo=0,hi=frames.length;while(lo<hi){const mid=lo+Math.floor((hi-lo)/2);if(frames[mid]!.time<time)lo=mid+1;else hi=mid;}
|
|
11
|
+
const after=frames[lo],before=frames[lo-1];if(!before)return after!;if(!after)return before;
|
|
12
|
+
return time-before.time<=after.time-time?before:after;
|
|
13
|
+
}
|
|
14
|
+
export function requireUniqueReferencePts(video:VideoInfo):void {
|
|
15
|
+
if(!video.frames.length||!Number.isFinite(video.duration))throw new Error('REFERENCE_FRAME_INDEX_INVALID: finite duration and frames required');
|
|
16
|
+
for(let i=0;i<video.frames.length;i++){const frame=video.frames[i]!;if(frame.index!==i||!Number.isFinite(frame.time)||frame.time<0||frame.time>=video.duration)throw new Error('REFERENCE_FRAME_INDEX_INVALID: ordered finite frames inside video duration required');if(i&&frame.time<=video.frames[i-1]!.time)throw new Error('REFERENCE_FRAME_PTS_AMBIGUOUS: duplicate or unordered PTS cannot identify an exact decoded frame');}
|
|
17
|
+
}
|
|
18
|
+
/** Seek inside the selected real frame's interval, not onto rounded PTS boundary. */
|
|
19
|
+
export function referenceSeekPosition(video:VideoInfo,frame:VideoFrame):number {
|
|
20
|
+
const next=video.frames[frame.index+1]?.time??video.duration;
|
|
21
|
+
if(video.frames[frame.index]!==frame||!Number.isFinite(next)||next<=frame.time)throw new Error('REFERENCE_FRAME_SEEK_UNAVAILABLE: no real frame interval');
|
|
22
|
+
const middle=frame.time+(next-frame.time)/2;
|
|
23
|
+
if(!(middle>frame.time&&middle<next))throw new Error('REFERENCE_FRAME_SEEK_UNAVAILABLE: frame interval has no representable interior');
|
|
24
|
+
return middle;
|
|
25
|
+
}
|
|
26
|
+
/** Browser PTS can retain more precision than ffprobe decimal output. Match the
|
|
27
|
+
* nearest indexed timestamp inside the actual video domain; no epsilon or rate. */
|
|
28
|
+
export function referenceFrameAtTime(video:VideoInfo,time:number):VideoFrame|null {
|
|
29
|
+
if(!Number.isFinite(time)||!Number.isFinite(video.duration)||!video.frames.length||time<0||time>=video.duration)return null;
|
|
30
|
+
return nearestReferenceFrame(video.frames,time);
|
|
31
|
+
}
|
|
32
|
+
/** CPU submission budget estimate from adjacent decoded timestamps. The browser
|
|
33
|
+
* reports the current display time only; this is not a compositor acknowledgement. */
|
|
34
|
+
export function nextReferenceInterval(video:VideoInfo|null,time:number,playbackRate:number):number|null {
|
|
35
|
+
if(!video||!Number.isFinite(playbackRate)||playbackRate<=0)return null;
|
|
36
|
+
const current=referenceFrameAtTime(video,time);if(!current)return null;
|
|
37
|
+
const next=video.frames[current.index+1];if(!next)return null;
|
|
38
|
+
const interval=(next.time-current.time)/playbackRate;
|
|
39
|
+
return Number.isFinite(interval)&&interval>0?interval:null;
|
|
40
|
+
}
|
|
41
|
+
/** Invert explicit producer sample→nearest decoded index only when one-to-one.
|
|
42
|
+
* This is a review sample association, not frameAtTime's floor interpretation. */
|
|
43
|
+
export function producerFrameAssociations(clock:FrameClock|null|undefined,video:VideoInfo):Map<number,number|null> {
|
|
44
|
+
const result=new Map<number,number|null>(),last=lastFrame(clock);if(last===null)return result;if(last>=100000)throw new Error('REFERENCE_SAMPLE_LIMIT: exact review association supports at most 100000 explicit samples');
|
|
45
|
+
for(let frame=0;frame<=last;frame++){const time=timeAtFrame(clock,frame);if(time===null||time<0||time>video.duration)continue;const index=nearestReferenceFrame(video.frames,time).index;result.set(index,result.has(index)?null:frame);}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ReferenceSelectionUnavailable extends Error {constructor(detail:string){super(`REFERENCE_SELECTION_UNAVAILABLE: ${detail}`);this.name='ReferenceSelectionUnavailable';}}
|
|
50
|
+
/** Refuse an unsupported selection before the caller changes video or inspection. */
|
|
51
|
+
export function inspectionReferenceFrame(video:VideoInfo,time:number,domain:CompareDomain):VideoFrame {
|
|
52
|
+
requireCommonTime(time,domain);
|
|
53
|
+
if(time<0||time>video.duration)throw new ReferenceSelectionUnavailable('requested time is outside video duration');
|
|
54
|
+
const selected=nearestReferenceFrame(video.frames,time);
|
|
55
|
+
if(selected.time<domain.start||selected.time>domain.end)throw new ReferenceSelectionUnavailable(`nearest decoded frame at ${selected.time} s is outside the shared clip domain; choose an earlier supported reference frame`);
|
|
56
|
+
return selected;
|
|
57
|
+
}
|
|
58
|
+
/** Explicit playback stop selection, distinct from nearest-request selection. */
|
|
59
|
+
export function lastSupportedReferenceFrame(video:VideoInfo,domain:CompareDomain):VideoFrame|null {
|
|
60
|
+
let lo=0,hi=video.frames.length;while(lo<hi){const mid=lo+Math.floor((hi-lo)/2);if(video.frames[mid]!.time<=domain.end)lo=mid+1;else hi=mid;}
|
|
61
|
+
const frame=video.frames[lo-1];return frame&&frame.time>=domain.start?frame:null;
|
|
62
|
+
}
|
package/src/reference.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Explicit CLI video capability; package manifests never gain filesystem escapes. */
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {constants,createReadStream} from 'node:fs';
|
|
4
|
+
import {open,realpath,stat} from 'node:fs/promises';
|
|
5
|
+
import {basename,resolve} from 'node:path';
|
|
6
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
7
|
+
import {loadReview,type ReviewPackage} from './manifest.ts';
|
|
8
|
+
export interface ReferenceBinding {packageSourceHash:string;reference:{id:string;name:string;sha256:string}}
|
|
9
|
+
export interface PinnedReference {path:string;binding:ReferenceBinding}
|
|
10
|
+
const pinned=Symbol('explicit CLI reference');
|
|
11
|
+
type ReferencedPackage=ReviewPackage&{[pinned]?:PinnedReference};
|
|
12
|
+
export const selectedReference=(p:ReviewPackage):PinnedReference|undefined=>(p as ReferencedPackage)[pinned];
|
|
13
|
+
export const referenceBinding=(p:ReviewPackage):ReferenceBinding|undefined=>selectedReference(p)?.binding;
|
|
14
|
+
export const packageSourceHash=(p:ReviewPackage):string=>referenceBinding(p)?.packageSourceHash??p.sourceHash;
|
|
15
|
+
export async function hashLocalVideo(input:string):Promise<{path:string;name:string;sha256:string}>{
|
|
16
|
+
const path=await realpath(resolve(input));if(!/\.(mp4|mov|webm)$/i.test(path))throw new Error('reference: expected local mp4, mov or webm file');
|
|
17
|
+
const before=await stat(path);if(!before.isFile())throw new Error('reference: expected regular local video');
|
|
18
|
+
const hash=createHash('sha256');for await(const chunk of createReadStream(path,{highWaterMark:65536}))hash.update(chunk);
|
|
19
|
+
const after=await stat(path);if(await realpath(input)!==path||before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new Error('Reference inputs changed while hashing');
|
|
20
|
+
return {path,name:basename(path),sha256:hash.digest('hex')};
|
|
21
|
+
}
|
|
22
|
+
export async function loadReferencedReview(input:string,reference?:string):Promise<ReviewPackage>{
|
|
23
|
+
const base=await loadReview(input);if(reference===undefined)return base;
|
|
24
|
+
const video=await hashLocalVideo(reference),id='open-reference-'+video.sha256;
|
|
25
|
+
if(base.manifest.references.some(r=>r.id===id))throw new Error('reference: selected reference id collides with package reference');
|
|
26
|
+
const binding:ReferenceBinding={packageSourceHash:base.sourceHash,reference:{id,name:video.name,sha256:video.sha256}};
|
|
27
|
+
const sourceHash=createHash('sha256').update(JSON.stringify({schema:'rig-review-reference/1',...binding})).digest('hex');
|
|
28
|
+
return {...base,sourceHash,manifest:{...base.manifest,references:[{id,label:video.name+' · selected reference',type:'video',path:'__review_selected_reference__.mp4'},...base.manifest.references]},[pinned]:{path:video.path,binding}} as ReferencedPackage;
|
|
29
|
+
}
|
|
30
|
+
export async function assertReviewUnchanged(p:ReviewPackage):Promise<void>{
|
|
31
|
+
try{const current=await loadReferencedReview(p.manifestPath,selectedReference(p)?.path);if(current.sourceHash!==p.sourceHash||!isDeepStrictEqual(current.reviewedSource,p.reviewedSource)||!isDeepStrictEqual(referenceBinding(current),referenceBinding(p)))throw new Error('source binding differs');}
|
|
32
|
+
catch{throw new Error('Package inputs changed or are unavailable. Restart review to bind feedback to current inputs.');}
|
|
33
|
+
}
|
|
34
|
+
export async function safeSelectedReference(p:ReviewPackage):Promise<string>{
|
|
35
|
+
const selected=selectedReference(p);if(!selected)throw new Error('reference: no explicit video selected');
|
|
36
|
+
try{const current=await realpath(selected.path);if(current!==selected.path||!(await stat(current)).isFile())throw new Error('not original file');return current;}
|
|
37
|
+
catch{throw new Error('reference: originally selected canonical regular file unavailable (no symlink replacement)');}
|
|
38
|
+
}
|
|
39
|
+
/** Hash and serve the same opened file, bounded to 64 KiB buffers; never reopen a checked path. */
|
|
40
|
+
export async function serveReference(req:Request,path:string,sha256:string,base:Record<string,string>):Promise<Response>{
|
|
41
|
+
const file=await open(path,constants.O_RDONLY|constants.O_NOFOLLOW).catch(()=>{throw new Error('Reference inputs changed or unavailable. Restart review to bind current video bytes.');});
|
|
42
|
+
let closed=false;const close=async()=>{if(!closed){closed=true;await file.close();}};
|
|
43
|
+
try{
|
|
44
|
+
const before=await file.stat();if(!before.isFile())throw new Error('Reference inputs changed: expected regular video');
|
|
45
|
+
const hash=createHash('sha256'),buffer=Buffer.alloc(65536);let position=0;
|
|
46
|
+
while(position<before.size){const {bytesRead}=await file.read(buffer,0,Math.min(buffer.length,before.size-position),position);if(!bytesRead)break;hash.update(buffer.subarray(0,bytesRead));position+=bytesRead;}
|
|
47
|
+
const after=await file.stat(),current=await stat(path);
|
|
48
|
+
if(await realpath(path)!==path||current.dev!==after.dev||current.ino!==after.ino||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||hash.digest('hex')!==sha256)throw new Error('Reference inputs changed. Restart review to bind current video bytes.');
|
|
49
|
+
const size=after.size,headers={...base,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':Bun.file(path).type||'application/octet-stream','Accept-Ranges':'bytes'};
|
|
50
|
+
const range=req.headers.get('range');let start=0,end=size-1,status=200;
|
|
51
|
+
if(range){const match=/^bytes=(\d*)-(\d*)$/.exec(range);if(match&&(match[1]||match[2])){if(!match[1])start=Math.max(0,size-Number(match[2]));else{start=Number(match[1]);if(match[2])end=Math.min(end,Number(match[2]));}}else start=-1;
|
|
52
|
+
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=size||end<start){await close();return new Response(null,{status:416,headers:{...headers,'Content-Range':`bytes */${size}`}});}status=206;
|
|
53
|
+
}
|
|
54
|
+
const responseHeaders={...headers,'Content-Length':String(Math.max(0,end-start+1)),...(status===206?{'Content-Range':`bytes ${start}-${end}/${size}`}:{})};
|
|
55
|
+
if(req.method==='HEAD'||size===0){await close();return new Response(null,{status,headers:responseHeaders});}
|
|
56
|
+
position=start;
|
|
57
|
+
const body=new ReadableStream<Uint8Array>({
|
|
58
|
+
async pull(controller){try{const chunk=new Uint8Array(Math.min(65536,end-position+1)),{bytesRead}=await file.read(chunk,0,chunk.length,position),now=await file.stat();if(!bytesRead||now.size!==after.size||now.mtimeMs!==after.mtimeMs||now.ctimeMs!==after.ctimeMs)throw new Error('Reference changed during streaming');position+=bytesRead;controller.enqueue(chunk.subarray(0,bytesRead));if(position>end){await close();controller.close();}}catch(error){await close();controller.error(error);}},
|
|
59
|
+
async cancel(){await close();}
|
|
60
|
+
});
|
|
61
|
+
return new Response(body,{status,headers:responseHeaders});
|
|
62
|
+
}catch{await close();throw new Error('Reference inputs changed or unavailable. Restart review to bind current video bytes.');}
|
|
63
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { FrameClock } from './frame-clock.ts';
|
|
2
|
+
/** Read-only rig-pose findings. Frame indices never imply a sampling rate. */
|
|
3
|
+
export const reportFields = ['switches','raised','short','endOn','cleaned.holds','cleaned.seams','feet.jumps','feet.supports','landings','shear','artRange','uncovered','flips','turns','range','holds'] as const;
|
|
4
|
+
export type ReportField = typeof reportFields[number];
|
|
5
|
+
export interface ReportTarget { kind:'bone'|'slot'; name:string }
|
|
6
|
+
export interface ReportEvent {
|
|
7
|
+
id:string; source:{path:string;pointer:string}; field:ReportField;
|
|
8
|
+
frames?:[number,number]; times?:number[];
|
|
9
|
+
timeInterval?:{start:number;end:number;endKind:'boundary'|'frame'};
|
|
10
|
+
seek?:{time:number;source:'event'|'frameTimes'|'fps'};
|
|
11
|
+
timing:'mapped'|'frames-unmapped'|'unavailable'; durationSeconds?:number;
|
|
12
|
+
targets:ReportTarget[];
|
|
13
|
+
provenance:{joint?:string;point?:string;chain?:string;chains?:string[];side?:string;set?:string;attachment?:string;line?:string;joints?:string[]};
|
|
14
|
+
details:Record<string,unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface ReportCategory { field:ReportField; state:'events'|'empty'; events:ReportEvent[] }
|
|
17
|
+
export interface ReportSeeds { spec:'rig-pose-report/1';path:string;animation?:string;rig?:string;frameCount?:number;durationSeconds?:number;clock:FrameClock|null;categories:ReportCategory[] }
|
|
18
|
+
|
|
19
|
+
function fail(field:string,expected:string):never { throw new Error(`${field}: expected ${expected}`); }
|
|
20
|
+
function record(v:unknown,field:string):Record<string,unknown>{if(!v||typeof v!=='object'||Array.isArray(v))fail(field,'object');return v as Record<string,unknown>;}
|
|
21
|
+
function text(v:unknown,field:string):string{if(typeof v!=='string'||!v.trim())fail(field,'nonempty string');return v;}
|
|
22
|
+
function finite(v:unknown,field:string):number{if(typeof v!=='number'||!Number.isFinite(v)||v<0)fail(field,'finite number >= 0');return v;}
|
|
23
|
+
function frame(v:unknown,field:string):number{const n=finite(v,field);if(!Number.isSafeInteger(n))fail(field,'nonnegative safe integer');return n;}
|
|
24
|
+
function numbers(v:unknown,field:string,strict=true):number[]{if(!Array.isArray(v))fail(field,'array of times in seconds');const result=v.map((n,i)=>finite(n,`${field}[${i}]`));for(let i=1;i<result.length;i++)if(strict?result[i]!<=result[i-1]!:result[i]!<result[i-1]!)fail(field,strict?'strictly increasing times in seconds':'nondecreasing times in seconds');return result;}
|
|
25
|
+
function frames(v:unknown,field:string):[number,number]{if(!Array.isArray(v)||v.length!==2)fail(field,'inclusive [first,last] frame pair');const a=frame(v[0],field+'[0]'),b=frame(v[1],field+'[1]');if(b<a)fail(field,'first <= last');return [a,b];}
|
|
26
|
+
function names(v:unknown,field:string):string[]{if(!Array.isArray(v))fail(field,'array of names');return v.map((n,i)=>text(n,`${field}[${i}]`));}
|
|
27
|
+
|
|
28
|
+
/** Unrelated report documents remain evidence rather than invented findings. */
|
|
29
|
+
export function parseReport(input:unknown,path='report.json'):ReportSeeds|null {
|
|
30
|
+
const root=record(input,path);if(root.spec!=='rig-pose-report/1')return null;
|
|
31
|
+
const report:ReportSeeds={spec:'rig-pose-report/1',path,clock:null,categories:[]};
|
|
32
|
+
if(root.animation!==undefined)report.animation=text(root.animation,path+'.animation');
|
|
33
|
+
if(root.rig!==undefined)report.rig=text(root.rig,path+'.rig');
|
|
34
|
+
if(root.frames!==undefined)report.frameCount=frame(root.frames,path+'.frames');
|
|
35
|
+
if(root.duration!==undefined)report.durationSeconds=finite(root.duration,path+'.duration');
|
|
36
|
+
let frameTimes:number[]|undefined;
|
|
37
|
+
if(root.frameTimes!==undefined){frameTimes=numbers(root.frameTimes,path+'.frameTimes');if(report.frameCount!==undefined&&frameTimes.length!==report.frameCount)fail(path+'.frameTimes','one time per reported frame');}
|
|
38
|
+
let fps:number|undefined,frame0:number|undefined;
|
|
39
|
+
if(root.fps!==undefined||root.frame0!==undefined){fps=finite(root.fps,path+'.fps');if(fps===0)fail(path+'.fps','number > 0');frame0=finite(root.frame0,path+'.frame0');}
|
|
40
|
+
if(frameTimes&&fps!==undefined)for(let i=0;i<frameTimes.length;i++)if(Math.abs(frameTimes[i]!-(frame0!+i/fps))>1e-6)fail(path+'.frameTimes','times matching declared fps/frame0');
|
|
41
|
+
if(frameTimes)report.clock={kind:'samples',frameTimes};
|
|
42
|
+
else if(fps!==undefined){
|
|
43
|
+
if(report.frameCount!==undefined&&report.frameCount>0){const last=report.frameCount-1;const end=finite(frame0!+last/fps,path+'.fps frame time');
|
|
44
|
+
// A finite endpoint pair cannot prove every sample differs: rounding can
|
|
45
|
+
// collapse an interior pair. Conservatively require a step wider than the
|
|
46
|
+
// division/addition rounding budget across the whole declared domain.
|
|
47
|
+
if(last>0&&1/fps<=4*Number.EPSILON*Math.max(frame0!,end))fail(path+'.fps','distinct representable frame times across the clock domain');
|
|
48
|
+
}
|
|
49
|
+
report.clock={kind:'uniform',fps,frame0:frame0!,...(report.frameCount===undefined?{}:{frameCount:report.frameCount})};
|
|
50
|
+
}
|
|
51
|
+
// Producer contract rig-c/pose#209: frame0 is seconds; t = frame0 + frame/fps.
|
|
52
|
+
const mapped=(n:number):{time:number;source:'frameTimes'|'fps'}|undefined=>{
|
|
53
|
+
if(frameTimes){if(n>=frameTimes.length)fail(path+'.frameTimes',`time for event frame ${n}`);return {time:frameTimes[n]!,source:'frameTimes'};}
|
|
54
|
+
if(fps===undefined)return undefined;return {time:finite(frame0!+n/fps,path+'.fps frame time'),source:'fps'};
|
|
55
|
+
};
|
|
56
|
+
for(const field of reportFields){
|
|
57
|
+
let value:unknown=root;let absent=false;
|
|
58
|
+
for(const segment of field.split('.')){if(value===null){absent=true;break;}const parent=record(value,path+'.'+field);if(parent[segment]===undefined){absent=true;break;}value=parent[segment];}
|
|
59
|
+
if(absent)continue;
|
|
60
|
+
const fieldPath=path+'.'+field;if(!Array.isArray(value))fail(fieldPath,'event array');
|
|
61
|
+
const events=value.map((raw,index):ReportEvent=>{
|
|
62
|
+
const where=`${fieldPath}[${index}]`;const pointer='/'+field.replaceAll('.','/')+'/'+index;
|
|
63
|
+
const row=Array.isArray(raw)&&field==='feet.jumps'?{frames:raw}:record(raw,where);
|
|
64
|
+
const event:ReportEvent={id:`report-${field.replaceAll('.','-')}-${index}`,source:{path,pointer},field,timing:'unavailable',targets:[],provenance:{},details:{...row}};
|
|
65
|
+
if(row.frames!==undefined)event.frames=frames(row.frames,where+'.frames');
|
|
66
|
+
if(row.frame!==undefined){const n=frame(row.frame,where+'.frame');if(event.frames&&(event.frames[0]!==n||event.frames[1]!==n))fail(where+'.frame','consistent frame and frames');event.frames=[n,n];}
|
|
67
|
+
// The legacy top-level holds use from/to as frame indices, unlike switches.
|
|
68
|
+
if(field==='holds'&&(row.from!==undefined||row.to!==undefined)){const interval=frames([row.from,row.to],where+'.from/to');if(event.frames&&(event.frames[0]!==interval[0]||event.frames[1]!==interval[1]))fail(where+'.from/to','consistent holds frames and from/to');event.frames=interval;}
|
|
69
|
+
if(event.frames&&report.frameCount!==undefined&&event.frames[1]>=report.frameCount)fail(where+'.frames',`indices below report.frames (${report.frameCount})`);
|
|
70
|
+
const target=(kind:ReportTarget['kind'],name:string)=>{if(!event.targets.some(value=>value.kind===kind&&value.name===name))event.targets.push({kind,name});};
|
|
71
|
+
for(const name of ['bone','slot'] as const)if(row[name]!==undefined)target(name,text(row[name],where+'.'+name));
|
|
72
|
+
if(row.bones!==undefined)for(const name of names(row.bones,where+'.bones'))target('bone',name);
|
|
73
|
+
if(field==='switches'&&row.slots!==undefined){const slots=record(row.slots,where+'.slots');for(const [slot,attachment] of Object.entries(slots)){text(slot,where+'.slots key');text(attachment,where+'.slots.'+slot);if(!event.targets.some(t=>t.kind==='slot'&&t.name===slot))event.targets.push({kind:'slot',name:slot});}}
|
|
74
|
+
for(const key of ['joint','point','chain','side','set','attachment','line'] as const)if(row[key]!==undefined)event.provenance[key]=text(row[key],where+'.'+key);
|
|
75
|
+
for(const key of ['chains','joints'] as const)if(row[key]!==undefined)event.provenance[key]=names(row[key],where+'.'+key);
|
|
76
|
+
if(row.seconds!==undefined)event.durationSeconds=finite(row.seconds,where+'.seconds');
|
|
77
|
+
if(row.spanSeconds!==undefined){const duration=finite(row.spanSeconds,where+'.spanSeconds');if(event.durationSeconds!==undefined&&Math.abs(event.durationSeconds-duration)>1e-6)fail(where+'.spanSeconds','consistent seconds and spanSeconds');event.durationSeconds=duration;}
|
|
78
|
+
if(row.times!==undefined){event.times=numbers(row.times,where+'.times',field==='uncovered');if(field!=='uncovered'&&event.times.length!==2)fail(where+'.times','[first,last] times in seconds');if(event.times.length){event.seek={time:event.times[0]!,source:'event'};if(field!=='uncovered')event.timeInterval={start:event.times[0]!,end:event.times[1]!,endKind:'frame'};}}
|
|
79
|
+
if(row.t!==undefined){const t=finite(row.t,where+'.t');if(event.seek&&Math.abs(event.seek.time-t)>1e-6)fail(where+'.t','consistent event t and times');event.seek={time:t,source:'event'};}
|
|
80
|
+
if(field==='switches'&&(row.from!==undefined||row.to!==undefined)){const start=finite(row.from,where+'.from'),end=finite(row.to,where+'.to');if(end<start)fail(where+'.to','boundary >= from');if(event.seek&&Math.abs(event.seek.time-start)>1e-6)fail(where+'.from','consistent explicit start time');event.seek={time:start,source:'event'};event.timeInterval={start,end,endKind:'boundary'};}
|
|
81
|
+
if(event.frames){const first=mapped(event.frames[0]),last=mapped(event.frames[1]);if(first){if(event.seek&&Math.abs(first.time-event.seek.time)>1e-6)fail(where+'.t','event time matching declared frame time base');if(event.timeInterval?.endKind==='frame'&&last&&Math.abs(event.timeInterval.end-last.time)>1e-6)fail(where+'.times','last time matching declared frame time base');if(!event.seek)event.seek=first;if(!event.timeInterval&&last)event.timeInterval={start:first.time,end:last.time,endKind:'frame'};}}
|
|
82
|
+
if(!event.frames&&!event.times&&!event.seek)fail(where,'frame/frames or explicit t/times event coordinates');
|
|
83
|
+
event.timing=event.seek?'mapped':event.frames?'frames-unmapped':'unavailable';return event;
|
|
84
|
+
});
|
|
85
|
+
report.categories.push({field,state:events.length?'events':'empty',events});
|
|
86
|
+
}
|
|
87
|
+
return report;
|
|
88
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {createControlServer,type ControlIdentity} from './control-server.ts';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { appRoot, buildWeb } from './build.ts';
|
|
4
|
+
import { packagePath, safeFile, type ReviewPackage } from './manifest.ts';
|
|
5
|
+
import { exportReviewStrip, stripJson } from './strip-export.ts';
|
|
6
|
+
import {assertReviewUnchanged,referenceBinding,selectedReference,safeSelectedReference,serveReference} from './reference.ts';
|
|
7
|
+
import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
|
|
8
|
+
import { readSession, saveSession } from './session.ts';
|
|
9
|
+
const headers={ 'X-Content-Type-Options':'nosniff','Cache-Control':'no-store','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
|
|
10
|
+
function json(data:unknown,status=200){return Response.json(data,{status,headers});}
|
|
11
|
+
async function bodyJson(req:Request):Promise<unknown>{
|
|
12
|
+
if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('Expected application/json');
|
|
13
|
+
if(Number(req.headers.get('content-length')??0)>1024*1024)throw new Error('Session payload exceeds 1 MiB');
|
|
14
|
+
if(!req.body)throw new Error('JSON body required');const reader=req.body.getReader();const chunks:Uint8Array[]=[];let bytes=0;
|
|
15
|
+
while(true){const {value,done}=await reader.read();if(done)break;bytes+=value.length;if(bytes>1024*1024){await reader.cancel();throw new Error('Session payload exceeds 1 MiB');}chunks.push(value);}
|
|
16
|
+
const full=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){full.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(full));
|
|
17
|
+
}
|
|
18
|
+
/** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it. */
|
|
19
|
+
export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string}={}){
|
|
20
|
+
const bundle=await buildWeb();
|
|
21
|
+
const unchanged=()=>assertReviewUnchanged(p);
|
|
22
|
+
const control=options.control?createControlServer({identity:options.control,sourceHash:p.sourceHash,mode:'single',unchanged,activity:options.activity}):null;
|
|
23
|
+
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
|
|
24
|
+
const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
|
|
25
|
+
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
26
|
+
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
|
|
27
|
+
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
28
|
+
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
29
|
+
if(req.method==='PUT'&&url.pathname==='/api/session'){
|
|
30
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
31
|
+
const input=await bodyJson(req);await unchanged();return json(await saveSession(p,input,options.stateDir));
|
|
32
|
+
}
|
|
33
|
+
if(req.method==='POST'&&url.pathname==='/api/strip'){
|
|
34
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
35
|
+
const result=await exportReviewStrip(p,await bodyJson(req));
|
|
36
|
+
return new Response(result.png?new Uint8Array(result.png):stripJson(result),{headers:{...headers,'Content-Type':result.png?'image/png':'application/json; charset=utf-8','Content-Disposition':`attachment; filename="review-strip.${result.png?'png':'json'}"`}});
|
|
37
|
+
}
|
|
38
|
+
if(req.method!=='GET'&&req.method!=='HEAD')return json({error:'Method not allowed'},405);
|
|
39
|
+
if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
|
|
40
|
+
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
41
|
+
if(url.pathname==='/review-reference')return await serveReference(req,await safeSelectedReference(p),referenceBinding(p)!.reference.sha256,headers);
|
|
42
|
+
if(url.pathname.startsWith('/assets/')){
|
|
43
|
+
const path=packagePath(decodeURIComponent(url.pathname.slice('/assets/'.length)));if(!p.files.has(path))return json({error:'Asset not declared by package'},404);
|
|
44
|
+
const file=Bun.file(await safeFile(p.root,path));const size=file.size;
|
|
45
|
+
const assetHeaders={...headers,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':file.type||'application/octet-stream','Accept-Ranges':'bytes'};
|
|
46
|
+
const range=req.headers.get('range');
|
|
47
|
+
if(range){
|
|
48
|
+
const match=/^bytes=(\d*)-(\d*)$/.exec(range);let start=0;let end=size-1;
|
|
49
|
+
if(!match||(!match[1]&&!match[2]))return new Response(null,{status:416,headers:{...assetHeaders,'Content-Range':`bytes */${size}`}});
|
|
50
|
+
if(!match[1])start=Math.max(0,size-Number(match[2]));else {start=Number(match[1]);if(match[2])end=Math.min(end,Number(match[2]));}
|
|
51
|
+
if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||start>=size||end<start)return new Response(null,{status:416,headers:{...assetHeaders,'Content-Range':`bytes */${size}`}});
|
|
52
|
+
return new Response(req.method==='HEAD'?null:file.slice(start,end+1),{status:206,headers:{...assetHeaders,'Content-Range':`bytes ${start}-${end}/${size}`,'Content-Length':String(end-start+1)}});
|
|
53
|
+
}
|
|
54
|
+
return new Response(req.method==='HEAD'?null:file,{headers:{...assetHeaders,'Content-Length':String(size)}});
|
|
55
|
+
}
|
|
56
|
+
if(url.pathname==='/main.js')return new Response(req.method==='HEAD'?null:bundle,{headers:{...headers,'Content-Type':'text/javascript'}});
|
|
57
|
+
if(url.pathname==='/'||url.pathname==='/index.html')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/index.html'),{headers:{...headers,'Content-Type':'text/html; charset=utf-8'}});
|
|
58
|
+
if(url.pathname==='/styles.css')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/styles.css'),{headers:{...headers,'Content-Type':'text/css'}});
|
|
59
|
+
return json({error:'Not found'},404);
|
|
60
|
+
}catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}};
|
|
61
|
+
return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,async fetch(req,server){return stampOpen(req,await handle(req,server.port),options.open);}});
|
|
62
|
+
}
|