@rig-c/review 0.0.0-stage → 0.1.0
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/README.md +285 -2
- package/docs/DESIGN.md +43 -0
- package/docs/REVIEW_PACKAGE.md +607 -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 +47 -4
- package/src/animation-selection.ts +29 -0
- package/src/build.ts +8 -0
- package/src/cli.ts +114 -0
- package/src/compare-playhead.ts +55 -0
- package/src/compare-presentation.ts +43 -0
- package/src/compare-server.ts +60 -0
- package/src/compare-session.ts +26 -0
- package/src/compare.ts +57 -0
- package/src/control-server.ts +83 -0
- package/src/control.ts +103 -0
- package/src/feedback-export.ts +197 -0
- package/src/feedback-validation.ts +38 -0
- package/src/frame-clock.ts +36 -0
- package/src/manifest.ts +140 -0
- package/src/open-protocol.ts +14 -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 +126 -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 +61 -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 +34 -0
- package/web/compare.css +96 -0
- package/web/compare.html +7 -0
- package/web/compare.ts +502 -0
- package/web/control.ts +65 -0
- package/web/index.html +13 -0
- package/web/inspection-persistence.ts +200 -0
- package/web/main.ts +928 -0
- package/web/renderer-status.ts +31 -0
- package/web/report-view.ts +51 -0
- package/web/rig-overlays.ts +147 -0
- package/web/styles.css +98 -0
- package/web/view-controls.ts +67 -0
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,61 @@
|
|
|
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,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
|
+
export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity}={}){
|
|
19
|
+
const bundle=await buildWeb();
|
|
20
|
+
const unchanged=()=>assertReviewUnchanged(p);
|
|
21
|
+
const control=options.control?createControlServer({identity:options.control,sourceHash:p.sourceHash,mode:'single',unchanged,activity:options.activity}):null;
|
|
22
|
+
return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,
|
|
23
|
+
async fetch(req,server){try {
|
|
24
|
+
const url=new URL(req.url);const origin=`http://127.0.0.1:${server.port}`;
|
|
25
|
+
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
26
|
+
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
27
|
+
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
28
|
+
if(req.method==='PUT'&&url.pathname==='/api/session'){
|
|
29
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
30
|
+
const input=await bodyJson(req);await unchanged();return json(await saveSession(p,input,options.stateDir));
|
|
31
|
+
}
|
|
32
|
+
if(req.method==='POST'&&url.pathname==='/api/strip'){
|
|
33
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
34
|
+
const result=await exportReviewStrip(p,await bodyJson(req));
|
|
35
|
+
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'}"`}});
|
|
36
|
+
}
|
|
37
|
+
if(req.method!=='GET'&&req.method!=='HEAD')return json({error:'Method not allowed'},405);
|
|
38
|
+
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}});}
|
|
39
|
+
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
40
|
+
if(url.pathname==='/review-reference')return await serveReference(req,await safeSelectedReference(p),referenceBinding(p)!.reference.sha256,headers);
|
|
41
|
+
if(url.pathname.startsWith('/assets/')){
|
|
42
|
+
const path=packagePath(decodeURIComponent(url.pathname.slice('/assets/'.length)));if(!p.files.has(path))return json({error:'Asset not declared by package'},404);
|
|
43
|
+
const file=Bun.file(await safeFile(p.root,path));const size=file.size;
|
|
44
|
+
const assetHeaders={...headers,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':file.type||'application/octet-stream','Accept-Ranges':'bytes'};
|
|
45
|
+
const range=req.headers.get('range');
|
|
46
|
+
if(range){
|
|
47
|
+
const match=/^bytes=(\d*)-(\d*)$/.exec(range);let start=0;let end=size-1;
|
|
48
|
+
if(!match||(!match[1]&&!match[2]))return new Response(null,{status:416,headers:{...assetHeaders,'Content-Range':`bytes */${size}`}});
|
|
49
|
+
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]));}
|
|
50
|
+
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}`}});
|
|
51
|
+
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)}});
|
|
52
|
+
}
|
|
53
|
+
return new Response(req.method==='HEAD'?null:file,{headers:{...assetHeaders,'Content-Length':String(size)}});
|
|
54
|
+
}
|
|
55
|
+
if(url.pathname==='/main.js')return new Response(req.method==='HEAD'?null:bundle,{headers:{...headers,'Content-Type':'text/javascript'}});
|
|
56
|
+
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'}});
|
|
57
|
+
if(url.pathname==='/styles.css')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/styles.css'),{headers:{...headers,'Content-Type':'text/css'}});
|
|
58
|
+
return json({error:'Not found'},404);
|
|
59
|
+
}catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** CLI-only historical retrieval. Current UI/save identities remain strict. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { open, opendir } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { object, string, type ReviewedSource, type ReviewPackage } from './manifest.ts';
|
|
8
|
+
import { freshSession, parseSession, sessionFile, type ReviewSession } from './session.ts';
|
|
9
|
+
import { freshCompareSession, parseCompareSession, compareSessionFile, type CompareSession } from './compare-session.ts';
|
|
10
|
+
import { assertCompareUnchanged, type CompareBinding, type ComparePackage } from './compare.ts';
|
|
11
|
+
|
|
12
|
+
type Tools=Pick<ReviewedSource,'viewerVersion'|'runtimeVersion'>;
|
|
13
|
+
export interface HistoryRetrieval {kind:'prior-tools';toolsChangedSince:true;matchingSubmissions:number;currentTools:Tools|{a:Tools;b:Tools}}
|
|
14
|
+
export type FeedbackHistory<T> = T & {retrieval?:HistoryRetrieval;historyWarnings?:{file:string;error:string}[]};
|
|
15
|
+
const MAX_FILE_BYTES=32*1024*1024,MAX_SCAN_BYTES=64*1024*1024;
|
|
16
|
+
const tools=(source:ReviewedSource):Tools=>({viewerVersion:source.viewerVersion,runtimeVersion:source.runtimeVersion});
|
|
17
|
+
function content(source:unknown):unknown {if(!source||typeof source!=='object'||Array.isArray(source))return null;return {...source,viewerVersion:null,runtimeVersion:null};}
|
|
18
|
+
function recordedSource(value:unknown,current:ReviewedSource):ReviewedSource|null {
|
|
19
|
+
if(!isDeepStrictEqual(content(value),content(current)))return null;
|
|
20
|
+
const source=object(value,'history.reviewedSource');
|
|
21
|
+
return {...current,viewerVersion:string(source.viewerVersion,'history.viewerVersion'),runtimeVersion:string(source.runtimeVersion,'history.runtimeVersion')};
|
|
22
|
+
}
|
|
23
|
+
/** Read a regular file through a no-follow descriptor with a fixed byte ceiling. */
|
|
24
|
+
async function readRecord(file:string,budget:{bytes:number}):Promise<unknown>{
|
|
25
|
+
const handle=await open(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
26
|
+
try{
|
|
27
|
+
const before=await handle.stat();if(!before.isFile())throw new Error('not a regular file');
|
|
28
|
+
if(before.size>MAX_FILE_BYTES)throw new Error('exceeds 32 MiB history file limit');
|
|
29
|
+
budget.bytes+=before.size;if(budget.bytes>MAX_SCAN_BYTES)throw new Error('feedback history: exceeds 64 MiB search limit');
|
|
30
|
+
const buffer=Buffer.alloc(before.size+1);let size=0;
|
|
31
|
+
while(size<buffer.length){const read=await handle.read(buffer,size,buffer.length-size,size);if(!read.bytesRead)break;size+=read.bytesRead;}
|
|
32
|
+
const after=await handle.stat();if(size!==before.size||after.size!==before.size||after.mtimeMs!==before.mtimeMs)throw new Error('changed while reading');
|
|
33
|
+
try{return JSON.parse(buffer.subarray(0,size).toString('utf8'));}catch{throw new Error('invalid JSON');}
|
|
34
|
+
}finally{await handle.close();}
|
|
35
|
+
}
|
|
36
|
+
function submittedAt(value:unknown):number {
|
|
37
|
+
if(typeof value!=='string'||!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value))throw new Error('feedback history.updatedAt: expected saved ISO timestamp');
|
|
38
|
+
const time=Date.parse(value);if(!Number.isFinite(time)||new Date(time).toISOString()!==value)throw new Error('feedback history.updatedAt: expected saved ISO timestamp');return time;
|
|
39
|
+
}
|
|
40
|
+
/** Recheck at the return boundary: a concurrent current save beats history. */
|
|
41
|
+
export async function preferCurrentSession<T>(readCurrent:()=>Promise<T|null>,readHistory:()=>Promise<T>):Promise<T>{
|
|
42
|
+
const current=await readCurrent();if(current!==null)return current;
|
|
43
|
+
try{const historical=await readHistory();return (await readCurrent())??historical;}
|
|
44
|
+
catch(error){const concurrent=await readCurrent();if(concurrent!==null)return concurrent;throw error;}
|
|
45
|
+
}
|
|
46
|
+
async function currentRecord<T>(file:string,parse:(value:unknown)=>T):Promise<T|null>{
|
|
47
|
+
try{return parse(await readRecord(file,{bytes:0}));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw e;}
|
|
48
|
+
}
|
|
49
|
+
async function discover<T extends {updatedAt:string|null}>(file:string,readCurrent:()=>Promise<T|null>,fresh:T,candidate:(value:Record<string,unknown>,name:string)=>T|null,currentTools:HistoryRetrieval['currentTools']):Promise<FeedbackHistory<T>>{
|
|
50
|
+
return preferCurrentSession<FeedbackHistory<T>>(readCurrent,async()=>{
|
|
51
|
+
let directory:Awaited<ReturnType<typeof opendir>>;
|
|
52
|
+
try{directory=await opendir(dirname(file));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return fresh;throw e;}
|
|
53
|
+
let best:{session:T;time:number;name:string}|undefined,count=0;const warnings:{file:string;error:string}[]=[],budget={bytes:0};
|
|
54
|
+
for await(const entry of directory){
|
|
55
|
+
if(entry.name===basename(file)||!/^[a-f0-9]{64}\.json$/.test(entry.name))continue;
|
|
56
|
+
if(!entry.isFile()){warnings.push({file:entry.name,error:'not a regular file'});if(warnings.length>100)throw new Error('feedback history: more than 100 unreadable state files');continue;}
|
|
57
|
+
let raw:unknown;
|
|
58
|
+
try{raw=await readRecord(join(dirname(file),entry.name),budget);}catch(e){
|
|
59
|
+
const error=e instanceof Error?e.message:String(e);if(error.includes('64 MiB'))throw e;
|
|
60
|
+
warnings.push({file:entry.name,error});if(warnings.length>100)throw new Error('feedback history: more than 100 unreadable state files');continue;
|
|
61
|
+
}
|
|
62
|
+
if(!raw||typeof raw!=='object'||Array.isArray(raw))continue;
|
|
63
|
+
// Matching records with corrupt provenance/timestamps fail by name. Unrelated
|
|
64
|
+
// records never become a substitute for the current package's review.
|
|
65
|
+
const session=candidate(raw as Record<string,unknown>,entry.name);if(!session)continue;
|
|
66
|
+
const time=submittedAt(session.updatedAt);count++;
|
|
67
|
+
if(!best||time>best.time||(time===best.time&&entry.name<best.name))best={session,time,name:entry.name};
|
|
68
|
+
}
|
|
69
|
+
return {...(best?{...best.session,retrieval:{kind:'prior-tools' as const,toolsChangedSince:true as const,matchingSubmissions:count,currentTools}}:fresh),...(warnings.length?{historyWarnings:warnings}:{})};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readFeedbackSession(p:ReviewPackage,stateDir?:string):Promise<FeedbackHistory<ReviewSession>>{
|
|
74
|
+
const file=sessionFile(p,stateDir);
|
|
75
|
+
return discover(file,()=>currentRecord(file,value=>({...parseSession(value,p,false),updatedAt:object(value,'session').updatedAt as string|null})),freshSession(p),(value,name)=>{
|
|
76
|
+
if(value.schema!=='rig-review-session/1'||value.id!==p.manifest.id||value.revision!==p.manifest.revision||value.sourceHash!==p.sourceHash)return null;
|
|
77
|
+
const reviewedSource=recordedSource(value.reviewedSource,p.reviewedSource);if(!reviewedSource)return null;
|
|
78
|
+
const historical={...p,reviewedSource};if(basename(sessionFile(historical,stateDir))!==name)throw new Error('feedback history: session filename does not match recorded binding');
|
|
79
|
+
if(isDeepStrictEqual(tools(reviewedSource),tools(p.reviewedSource)))throw new Error('feedback history: unexpected current-tools record');
|
|
80
|
+
return {...parseSession(value,historical,false),updatedAt:value.updatedAt as string|null};
|
|
81
|
+
},tools(p.reviewedSource));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function readFeedbackCompareSession(p:ComparePackage,stateDir?:string):Promise<FeedbackHistory<CompareSession>>{
|
|
85
|
+
const file=compareSessionFile(p,stateDir);
|
|
86
|
+
return discover(file,async()=>{await assertCompareUnchanged(p);return currentRecord(file,value=>({...parseCompareSession(value,p,false),updatedAt:object(value,'compare session').updatedAt as string|null}));},freshCompareSession(p),(value,name)=>{
|
|
87
|
+
if(value.schema!=='rig-review-compare-session/1')return null;
|
|
88
|
+
const binding=value.binding;if(!binding||typeof binding!=='object'||Array.isArray(binding))return null;
|
|
89
|
+
const old=binding as Record<string,unknown>;if(!isDeepStrictEqual(old.reference,p.response.binding.reference)||old.animation!==p.response.animation)return null;
|
|
90
|
+
const sides:ReviewPackage[]=[];
|
|
91
|
+
for(const side of ['a','b'] as const){const raw=old[side];if(!raw||typeof raw!=='object'||Array.isArray(raw))return null;const row=raw as Record<string,unknown>;if(row.sourceHash!==p[side].sourceHash)return null;const reviewedSource=recordedSource(row.reviewedSource,p[side].reviewedSource);if(!reviewedSource)return null;sides.push({...p[side],reviewedSource});}
|
|
92
|
+
const a=sides[0]!,b=sides[1]!,historicalBinding:CompareBinding={a:{sourceHash:a.sourceHash,reviewedSource:a.reviewedSource},b:{sourceHash:b.sourceHash,reviewedSource:b.reviewedSource},reference:p.response.binding.reference,animation:p.response.animation};
|
|
93
|
+
const sourceHash=createHash('sha256').update(JSON.stringify(historicalBinding)).digest('hex');
|
|
94
|
+
const historical:ComparePackage={...p,a,b,response:{...p.response,id:'compare-'+sourceHash.slice(0,16),sourceHash,binding:historicalBinding}};
|
|
95
|
+
if(basename(compareSessionFile(historical,stateDir))!==name)throw new Error('feedback history: comparison filename does not match recorded binding');
|
|
96
|
+
if(isDeepStrictEqual(historicalBinding,p.response.binding))throw new Error('feedback history: unexpected current-tools comparison');
|
|
97
|
+
return {...parseCompareSession(value,historical,false),updatedAt:value.updatedAt as string|null};
|
|
98
|
+
},{a:tools(p.a.reviewedSource),b:tools(p.b.reviewedSource)});
|
|
99
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import {referenceBinding,type ReferenceBinding} from './reference.ts';
|
|
7
|
+
import { validateFeedbackSelection } from './feedback-validation.ts';
|
|
8
|
+
import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
|
|
9
|
+
export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
10
|
+
export function parseFeedback(item:unknown,strict=true):Feedback {
|
|
11
|
+
const f=object(item,'feedback');const target=object(f.target,'feedback.target');
|
|
12
|
+
// Older releases admitted integer values beyond the safe range. Retain those
|
|
13
|
+
// as readable evidence; strict saves and validatePackageFeedback refuse them.
|
|
14
|
+
const integer=strict?Number.isSafeInteger:Number.isInteger;
|
|
15
|
+
if(!integer(f.frameStart)||!integer(f.frameEnd)||(f.frameStart as number)<0||(f.frameEnd as number)<(f.frameStart as number))throw new Error('feedback frames: expected inclusive integer 0 <= frameStart <= frameEnd');
|
|
16
|
+
const summary=string(f.summary,'feedback.summary');if(summary.length>1000||/[\r\n]/.test(summary))throw new Error('feedback.summary: expected one line <= 1000 characters');
|
|
17
|
+
return {id:string(f.id,'feedback.id'),frameStart:f.frameStart as number,frameEnd:f.frameEnd as number,target:{kind:choice(target.kind,['bone','slot','rig'] as const,'feedback.target.kind'),name:string(target.name,'feedback.target.name')},category:choice(f.category,['defect','quality'] as const,'feedback.category'),summary,...(f.context===undefined?{}:{context:f.context as Json})};
|
|
18
|
+
}
|
|
19
|
+
export interface FeedbackWarning {id:string;error:string}
|
|
20
|
+
|
|
21
|
+
/** Derive the annotated interval, retaining inspection context as separate data. */
|
|
22
|
+
export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string):Feedback {
|
|
23
|
+
const context=feedback.context===undefined?{}:object(feedback.context,'feedback.context');
|
|
24
|
+
const interval={frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true};
|
|
25
|
+
if(context.interval!==undefined&&!isDeepStrictEqual(context.interval,interval))throw new Error('feedback.context.interval: must match chosen inclusive frameStart/frameEnd');
|
|
26
|
+
if(context.animation!==undefined&&context.animation!==null&&typeof context.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
|
|
27
|
+
if(animation!==undefined&&context.animation!==undefined&&context.animation!==null&&context.animation!==animation)throw new Error('feedback.context.animation: does not match selected comparison animation');
|
|
28
|
+
const selected=animation??(typeof context.animation==='string'?context.animation:null);
|
|
29
|
+
validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
|
|
30
|
+
return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
|
|
31
|
+
}
|
|
32
|
+
export function readPackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string):{feedback:Feedback;warning?:FeedbackWarning} {
|
|
33
|
+
try{return {feedback:validatePackageFeedback(feedback,p,animation)};}catch(e){
|
|
34
|
+
// Old submitted evidence remains readable. Only add a missing derived
|
|
35
|
+
// interval; contradictory supplied evidence is preserved with its warning.
|
|
36
|
+
const context=feedback.context;const normalized=context===undefined?{interval:{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true}}:context&&typeof context==='object'&&!Array.isArray(context)&&!('interval' in context)?{...context,interval:{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true}}:context;
|
|
37
|
+
return {feedback:{...feedback,...(normalized===undefined?{}:{context:normalized})},warning:{id:feedback.id,error:e instanceof Error?e.message:String(e)}};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export interface ReviewSession { schema:'rig-review-session/1'; id:string; revision:string; sourceHash:string; reviewedSource:ReviewedSource; referenceBinding?:ReferenceBinding; status:'pending'|'approved'|'changes-requested'; checkpoints:Record<string,{outcome:'pending'|'pass'|'fail';note:string}>; feedback:Feedback[]; feedbackWarnings:FeedbackWarning[]; updatedAt:string|null }
|
|
41
|
+
export function freshSession(p:ReviewPackage):ReviewSession { return {schema:'rig-review-session/1',id:p.manifest.id,revision:p.manifest.revision,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p)}:{}),status:'pending',checkpoints:{},feedback:[],feedbackWarnings:[],updatedAt:null}; }
|
|
42
|
+
export function parseSession(input:unknown,p:ReviewPackage,strict=true):ReviewSession {
|
|
43
|
+
const v=object(input,'session'); const fresh=freshSession(p);
|
|
44
|
+
for(const k of ['schema','id','revision','sourceHash'] as const) if(v[k]!==fresh[k]) throw new Error(`session.${k}: does not match loaded package`);
|
|
45
|
+
if(!isDeepStrictEqual(v.reviewedSource,p.reviewedSource))throw new Error('session.reviewedSource: does not match exact rig/motion hashes and tool versions of loaded package');
|
|
46
|
+
if(!isDeepStrictEqual(v.referenceBinding,fresh.referenceBinding))throw new Error('session.referenceBinding: does not match selected reference and package hash');
|
|
47
|
+
const status=choice(v.status,['pending','approved','changes-requested'],'session.status');
|
|
48
|
+
const entries=object(v.checkpoints,'session.checkpoints'); const checkpoints:ReviewSession['checkpoints']={};
|
|
49
|
+
for(const [key,value] of Object.entries(entries)) { if(!p.manifest.checkpoints.some(c=>c.id===key)) throw new Error(`session.checkpoints: unknown checkpoint ${key}`);const c=object(value,'checkpoint');if(typeof c.note!=='string'||c.note.length>10000)throw new Error('checkpoint.note: expected string <= 10000 characters');checkpoints[key]={outcome:choice(c.outcome,['pending','pass','fail'],'checkpoint.outcome'),note:c.note}; }
|
|
50
|
+
if(v.corrections!==undefined)throw new Error('session.corrections: authoring proposals are not accepted; use read-only structured feedback');
|
|
51
|
+
if(!Array.isArray(v.feedback)||v.feedback.length>1000)throw new Error('session.feedback: expected array <= 1000');
|
|
52
|
+
const feedbackWarnings:FeedbackWarning[]=[];const feedback:Feedback[]=v.feedback.map((item:unknown)=>{const parsed=parseFeedback(item,strict);if(strict)return validatePackageFeedback(parsed,p);const read=readPackageFeedback(parsed,p);if(read.warning)feedbackWarnings.push(read.warning);return read.feedback;});
|
|
53
|
+
if(new Set(feedback.map(f=>f.id)).size!==feedback.length)throw new Error('session.feedback: duplicate ids');
|
|
54
|
+
return {...fresh,status,checkpoints,feedback,feedbackWarnings,updatedAt:new Date().toISOString()};
|
|
55
|
+
}
|
|
56
|
+
export function sessionFile(p:ReviewPackage,stateDir=join(homedir(),'.local','state','rig-c-review')):string { const key=createHash('sha256').update(JSON.stringify([p.manifest.id,p.manifest.revision,p.sourceHash,p.reviewedSource])).digest('hex'); return join(stateDir,key+'.json'); }
|
|
57
|
+
export async function readSession(p:ReviewPackage,stateDir?:string):Promise<ReviewSession> {try {const v=JSON.parse(await readFile(sessionFile(p,stateDir),'utf8'));const parsed=parseSession(v,p,false);return {...parsed,updatedAt:v.updatedAt};}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return freshSession(p);throw e;} }
|
|
58
|
+
export async function saveSession(p:ReviewPackage,input:unknown,stateDir?:string):Promise<ReviewSession> {const value=parseSession(input,p);const file=sessionFile(p,stateDir);await mkdir(join(file,'..'),{recursive:true,mode:0o700});const tmp=file+'.'+crypto.randomUUID()+'.tmp';await writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600});await rename(tmp,file);return value;}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { realpath, stat, lstat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve, parse, sep, type posix } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { safeFile, type ReviewPackage } from './manifest.ts';
|
|
7
|
+
import {assertReviewUnchanged,selectedReference,safeSelectedReference,packageSourceHash} from './reference.ts';
|
|
8
|
+
import { assertCompareUnchanged, safeCompareReference, type ComparePackage } from './compare.ts';
|
|
9
|
+
import { parseStripRequest, planStrip, type StripBinding, type StripPlan, type StripRenderInput, type StripRenderResult } from './strip.ts';
|
|
10
|
+
export interface StripExportOptions {render?:(input:StripRenderInput)=>Promise<StripRenderResult>}
|
|
11
|
+
export interface StripExport {plan:StripPlan;rendering:(Omit<StripRenderResult,'png'>&{pngSha256:string})|null;png:Uint8Array|null}
|
|
12
|
+
const sha=(bytes:Uint8Array)=>createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
async function unchanged(p:ReviewPackage){try{await assertReviewUnchanged(p);}catch(e){throw new Error(`Strip inputs changed. Restart review before export. ${e instanceof Error?e.message:String(e)}`);}}
|
|
14
|
+
/** Streaming identity remains bounded even for long source videos and JSON plans. */
|
|
15
|
+
export async function hashStripReference(path:string,onChunk?:(size:number)=>void){
|
|
16
|
+
const canonical=await realpath(path),before=await stat(canonical);if(!before.isFile())throw new Error('strip.reference: expected regular video');
|
|
17
|
+
const hash=createHash('sha256');for await(const chunk of createReadStream(canonical,{highWaterMark:65536})){hash.update(chunk);onChunk?.(chunk.length);}
|
|
18
|
+
const after=await stat(canonical);if(await realpath(path)!==canonical||before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new Error('Strip inputs changed: reference changed while hashing');
|
|
19
|
+
return {path:canonical,name:basename(canonical),sha256:hash.digest('hex')};
|
|
20
|
+
}
|
|
21
|
+
const referenceFile=hashStripReference;
|
|
22
|
+
async function verifyReference(original:{path:string;sha256:string}){const current=await referenceFile(original.path);if(current.path!==original.path||current.sha256!==original.sha256)throw new Error('Strip inputs changed: reference video changed during export');}
|
|
23
|
+
function requireAnimation(p:ReviewPackage,animation:string,png:boolean){const clips=p.feedbackCatalog.animations;if(clips&&!clips.some(c=>c.name===animation))throw new Error(`strip.animation: ${JSON.stringify(animation)} not in model`);if(png&&(!p.manifest.model||!clips))throw new Error('strip.model: compiled model required for PNG');}
|
|
24
|
+
async function finish(plan:StripPlan,referencePath:string,packages:{id:'single'|'a'|'b';p:ReviewPackage}[],png:boolean,verify:()=>Promise<void>,options:StripExportOptions):Promise<StripExport>{
|
|
25
|
+
let rendered:StripRenderResult|null=null;
|
|
26
|
+
if(png){const rigs:StripRenderInput['rigs']=[];for(const {id,p} of packages){requireAnimation(p,plan.animation,true);rigs.push({id,root:p.root,modelPath:await safeFile(p.root,p.manifest.model!),animation:plan.animation,clock:p.report?.animation===plan.animation?p.report.clock:null});}const render=options.render??(await import('./strip-render.ts')).renderStrip;rendered=await render({plan,referencePath,rigs});}
|
|
27
|
+
await verify();
|
|
28
|
+
if(!rendered)return {plan,rendering:null,png:null};
|
|
29
|
+
const {png:bytes,...details}=rendered;return {plan,rendering:{...details,pngSha256:sha(bytes)},png:bytes};
|
|
30
|
+
}
|
|
31
|
+
export async function exportReviewStrip(p:ReviewPackage,input:unknown,options:StripExportOptions={}):Promise<StripExport>{
|
|
32
|
+
const request=parseStripRequest(input);await unchanged(p);requireAnimation(p,request.animation,false);
|
|
33
|
+
if(!request.referenceId)throw new Error('strip.referenceId: explicitly select a declared reference video');
|
|
34
|
+
const selected=p.manifest.references.find(r=>r.id===request.referenceId&&r.type==='video');if(!selected)throw new Error('strip.referenceId: expected declared reference video id');
|
|
35
|
+
const explicit=selectedReference(p),external=explicit?.binding.reference.id===selected.id;
|
|
36
|
+
const reference=await referenceFile(external?await safeSelectedReference(p):await safeFile(p.root,selected.path));
|
|
37
|
+
if(external&&reference.sha256!==explicit!.binding.reference.sha256)throw new Error('Strip inputs changed: selected reference hash differs');
|
|
38
|
+
const binding:StripBinding={sourceHash:p.sourceHash,packages:[{id:'single',label:p.manifest.title,sourceHash:packageSourceHash(p),reviewedSource:p.reviewedSource}],reference:{name:reference.name,sha256:reference.sha256},compare:null};
|
|
39
|
+
const plan=planStrip(request,{mode:'single',binding,reports:{single:p.report}});
|
|
40
|
+
return finish(plan,reference.path,[{id:'single',p}],request.format==='png',async()=>{await unchanged(p);const current=external?await safeSelectedReference(p):await safeFile(p.root,selected.path);if(current!==reference.path)throw new Error('Strip inputs changed: reference path changed');await verifyReference(reference);},options);
|
|
41
|
+
}
|
|
42
|
+
export async function exportCompareStrip(p:ComparePackage,input:unknown,options:StripExportOptions={}):Promise<StripExport>{
|
|
43
|
+
const request=parseStripRequest(input);await assertCompareUnchanged(p);if(request.animation!==p.response.animation)throw new Error('strip.animation: expected fixed comparison animation');
|
|
44
|
+
const reference=await referenceFile(await safeCompareReference(p));if(reference.sha256!==p.response.binding.reference.sha256)throw new Error('Strip inputs changed: reference hash differs');
|
|
45
|
+
const binding:StripBinding={sourceHash:p.response.sourceHash,packages:(['a','b'] as const).map(id=>({id,label:`${id.toUpperCase()}: ${p[id].manifest.title}`,sourceHash:p[id].sourceHash,reviewedSource:p[id].reviewedSource})),reference:p.response.binding.reference,compare:p.response.binding};
|
|
46
|
+
const plan=planStrip(request,{mode:'compare',binding,reports:{a:p.a.report,b:p.b.report}});
|
|
47
|
+
return finish(plan,reference.path,[{id:'a',p:p.a},{id:'b',p:p.b}],request.format==='png',async()=>{await assertCompareUnchanged(p);await safeCompareReference(p);await verifyReference(reference);},options);
|
|
48
|
+
}
|
|
49
|
+
export function stripJson(result:StripExport):string{return JSON.stringify({plan:result.plan,rendering:result.rendering},null,2)+'\n';}
|
|
50
|
+
/** Pure boundary rules also exercise Windows paths on a non-Windows verifier. */
|
|
51
|
+
export function stripPathIsInside(root:string,target:string,paths:Pick<typeof posix,'relative'|'isAbsolute'|'sep'>={relative,isAbsolute,sep}):boolean{
|
|
52
|
+
const rel=paths.relative(root,target);return rel===''||(!rel.startsWith('..'+paths.sep)&&rel!=='..'&&!paths.isAbsolute(rel));
|
|
53
|
+
}
|
|
54
|
+
export function stripParentPaths(target:string,paths:Pick<typeof posix,'relative'|'parse'|'dirname'|'resolve'|'sep'>={relative,parse,dirname,resolve,sep}):string[]{
|
|
55
|
+
const base=paths.parse(target).root,parts=paths.relative(base,paths.dirname(target)).split(paths.sep).filter(Boolean),parents:string[]=[];let cursor=base;
|
|
56
|
+
for(const part of parts){cursor=paths.resolve(cursor,part);parents.push(cursor);}return parents;
|
|
57
|
+
}
|
|
58
|
+
/** No symlink parent or package-contained path can become a derived output target. */
|
|
59
|
+
export async function validateStripOutput(output:string,roots:string[]):Promise<string>{
|
|
60
|
+
const target=resolve(output);
|
|
61
|
+
for(const parent of stripParentPaths(target)){const info=await lstat(parent);if(info.isSymbolicLink()||!info.isDirectory())throw new Error('strip.output: parent must be an existing directory without symlinks');}
|
|
62
|
+
for(const root of roots)if(stripPathIsInside(await realpath(root),target))throw new Error('strip.output: derived files must be outside every source package');
|
|
63
|
+
try{await lstat(target);throw new Error('strip.output: destination already exists');}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
|
|
64
|
+
return target;
|
|
65
|
+
}
|
|
66
|
+
export async function writeStripOutput(result:StripExport,output:string,roots:string[]):Promise<void>{
|
|
67
|
+
const destination=await validateStripOutput(output,roots);const bytes=result.png??stripJson(result);
|
|
68
|
+
if(result.png){const sidecar=await validateStripOutput(destination+'.json',roots);await writeFile(destination,bytes,{flag:'wx'});await writeFile(sidecar,stripJson(result),{flag:'wx'});}else await writeFile(destination,bytes,{flag:'wx'});
|
|
69
|
+
}
|