@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +376 -2
  3. package/docs/DESIGN.md +72 -0
  4. package/docs/REVIEW_PACKAGE.md +627 -0
  5. package/examples/demo/SOURCE.md +5 -0
  6. package/examples/demo/chime.wav +0 -0
  7. package/examples/demo/lantern.svg +10 -0
  8. package/examples/demo/pose.json +244 -0
  9. package/examples/demo/review.json +67 -0
  10. package/package.json +49 -4
  11. package/src/animation-selection.ts +29 -0
  12. package/src/build.ts +8 -0
  13. package/src/cli.ts +116 -0
  14. package/src/compare-playhead.ts +55 -0
  15. package/src/compare-presentation.ts +43 -0
  16. package/src/compare-server.ts +63 -0
  17. package/src/compare-session.ts +26 -0
  18. package/src/compare.ts +57 -0
  19. package/src/comparison-eligibility.ts +54 -0
  20. package/src/control-server.ts +83 -0
  21. package/src/control.ts +132 -0
  22. package/src/feedback-export.ts +197 -0
  23. package/src/feedback-validation.ts +38 -0
  24. package/src/frame-clock.ts +36 -0
  25. package/src/front-worker.ts +2 -0
  26. package/src/front.ts +127 -0
  27. package/src/manifest.ts +140 -0
  28. package/src/open-protocol.ts +31 -0
  29. package/src/open-selection.ts +11 -0
  30. package/src/open-shell.ts +2 -0
  31. package/src/open-worker.ts +2 -0
  32. package/src/open.ts +142 -0
  33. package/src/pose.ts +27 -0
  34. package/src/probe-reference.ts +16 -0
  35. package/src/reference-frame-index.ts +16 -0
  36. package/src/reference-frames.ts +62 -0
  37. package/src/reference.ts +63 -0
  38. package/src/report.ts +88 -0
  39. package/src/server.ts +62 -0
  40. package/src/session-history.ts +99 -0
  41. package/src/session.ts +58 -0
  42. package/src/strip-export.ts +69 -0
  43. package/src/strip-render.ts +171 -0
  44. package/src/strip.ts +51 -0
  45. package/src/versions.ts +20 -0
  46. package/web/camera-view.ts +44 -0
  47. package/web/compare.css +140 -0
  48. package/web/compare.html +7 -0
  49. package/web/compare.ts +546 -0
  50. package/web/control.ts +67 -0
  51. package/web/index.html +13 -0
  52. package/web/inspection-persistence.ts +219 -0
  53. package/web/instance-access.ts +23 -0
  54. package/web/main.ts +972 -0
  55. package/web/part-selection.ts +136 -0
  56. package/web/renderer-status.ts +31 -0
  57. package/web/report-view.ts +51 -0
  58. package/web/rig-overlays.ts +150 -0
  59. package/web/styles.css +139 -0
  60. package/web/view-controls.ts +69 -0
  61. package/web/workspace-layout.ts +84 -0
  62. package/web/workspace-preferences.ts +95 -0
  63. package/web/workspace-ui.ts +91 -0
@@ -0,0 +1,43 @@
1
+ /** Consumer presentation bookkeeping. Preparing a pose never claims it was drawn. */
2
+ import {drawSynchronizedPair,prepareSynchronizedPair,type CompareTrace,type ReferenceSample,type InspectionPlayer,type CompareDomain} from './compare-playhead.ts';
3
+ export interface PresentationState {
4
+ status:'unknown'|'drawn'|'held'|'failed';target:ReferenceSample|null;prepared:CompareTrace|null;lastDrawn:CompareTrace|null;inspection:CompareTrace|null;
5
+ callbacks:number;dropped:number;deadlineMisses:number;presentationGaps:number;lastPoseMs:number;lastDrawMs:number;lastLatenessMs:number;drawBudgetEstimateMs:number;nextReferenceEstimateMs:number|null;currentDisplayLatenessMs:number|null;deadlineKnownCallbacks:number;deadlineUnknownCallbacks:number;deadlineKind:'paused'|'next-reference-estimate'|'unknown';
6
+ }
7
+ export class ComparePresentation {
8
+ private value:PresentationState={status:'unknown',target:null,prepared:null,lastDrawn:null,inspection:null,callbacks:0,dropped:0,deadlineMisses:0,presentationGaps:0,lastPoseMs:0,lastDrawMs:0,lastLatenessMs:0,drawBudgetEstimateMs:0,nextReferenceEstimateMs:null,currentDisplayLatenessMs:null,deadlineKnownCallbacks:0,deadlineUnknownCallbacks:0,deadlineKind:'unknown'};
9
+ private recentDraw:number[]=[];private previousPresented:number|null=null;
10
+ snapshot():PresentationState{return {...this.value,target:this.value.target?{...this.value.target}:null,prepared:this.value.prepared?{...this.value.prepared}:null,lastDrawn:this.value.lastDrawn?{...this.value.lastDrawn}:null,inspection:this.value.inspection?{...this.value.inspection}:null};}
11
+ private finish(now:number,deadline:number|null):void {this.value.lastLatenessMs=deadline===null?0:Math.max(0,now-deadline);if(this.value.lastLatenessMs>0)this.value.deadlineMisses++;}
12
+ private draw(a:InspectionPlayer,b:InspectionPlayer,trace:CompareTrace,now:()=>number):void {
13
+ const start=now();try{drawSynchronizedPair(a,b);}catch(error){this.value.status='failed';this.value.lastDrawn=null;this.value.inspection=null;throw error;}
14
+ this.value.lastDrawMs=now()-start;this.recentDraw.push(this.value.lastDrawMs);if(this.recentDraw.length>20)this.recentDraw.shift();
15
+ const sorted=[...this.recentDraw].sort((x,y)=>x-y);this.value.drawBudgetEstimateMs=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]!;
16
+ this.value.lastDrawn={...trace};
17
+ }
18
+ /** Paused inspection has no presentation deadline and keeps deterministic replay. */
19
+ inspect(sample:ReferenceSample,a:InspectionPlayer,b:InspectionPlayer,domain:CompareDomain,now:()=>number):PresentationState {
20
+ this.previousPresented=null;this.value.deadlineKind='paused';this.value.nextReferenceEstimateMs=null;this.value.currentDisplayLatenessMs=null;this.value.target={...sample};this.value.inspection=null;const start=now();
21
+ try{const trace=prepareSynchronizedPair(sample,a,b,domain);this.value.prepared=trace;this.value.lastPoseMs=now()-start;this.draw(a,b,trace,now);this.value.inspection={...trace};this.value.status='drawn';this.value.lastLatenessMs=0;return this.snapshot();}
22
+ catch(error){this.value.status='failed';this.value.inspection=null;throw error;}
23
+ }
24
+ /** Always submit the latest observed pair immediately. Synchronous seek/draw
25
+ * cannot receive a newer JavaScript callback midway. Lateness is classified
26
+ * afterward; the estimated next reference boundary never suppresses drawing. */
27
+ present(sample:ReferenceSample,expectedDisplayTime:number|null,presentedFrames:number|null,a:InspectionPlayer,b:InspectionPlayer,domain:CompareDomain,now:()=>number,nextIntervalSeconds:number|null=null):PresentationState {
28
+ this.value.callbacks++;this.value.target={...sample};this.value.inspection=null;this.value.prepared=null;this.value.lastPoseMs=0;this.value.lastDrawMs=0;
29
+ const estimate=expectedDisplayTime!==null&&Number.isFinite(expectedDisplayTime)&&nextIntervalSeconds!==null&&Number.isFinite(nextIntervalSeconds)&&nextIntervalSeconds>0?expectedDisplayTime+nextIntervalSeconds*1000:null;
30
+ this.value.nextReferenceEstimateMs=estimate!==null&&Number.isFinite(estimate)?estimate:null;
31
+ this.value.deadlineKind=this.value.nextReferenceEstimateMs===null?'unknown':'next-reference-estimate';
32
+ if(this.value.nextReferenceEstimateMs===null)this.value.deadlineUnknownCallbacks++;else this.value.deadlineKnownCallbacks++;
33
+ if(presentedFrames!==null){if(this.previousPresented!==null)this.value.presentationGaps+=Math.max(0,presentedFrames-this.previousPresented-1);this.previousPresented=presentedFrames;}
34
+ const started=now();
35
+ try{
36
+ const trace=prepareSynchronizedPair(sample,a,b,domain);this.value.prepared=trace;this.value.lastPoseMs=now()-started;
37
+ this.draw(a,b,trace,now);const finished=now();this.finish(finished,this.value.nextReferenceEstimateMs);
38
+ this.value.currentDisplayLatenessMs=expectedDisplayTime===null?null:Math.max(0,finished-expectedDisplayTime);
39
+ this.value.status='drawn';if(this.value.deadlineKind==='next-reference-estimate'&&this.value.lastLatenessMs===0)this.value.inspection={...trace};
40
+ return this.snapshot();
41
+ }catch(error){this.value.status='failed';this.value.inspection=null;throw error;}
42
+ }
43
+ }
@@ -0,0 +1,63 @@
1
+ import {createControlServer,type ControlIdentity} from './control-server.ts';
2
+ import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { appRoot, buildWeb } from './build.ts';
5
+ import { packagePath, safeFile } from './manifest.ts';
6
+ import { assertCompareUnchanged, safeCompareReference, type ComparePackage } from './compare.ts';
7
+ import { serveReference } from './reference.ts';
8
+ import {referenceFrameIndex} from './reference-frame-index.ts';
9
+ import { exportCompareStrip, stripJson } from './strip-export.ts';
10
+ import { readCompareSession, saveCompareSession } from './compare-session.ts';
11
+
12
+ 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'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
13
+ const json=(value:unknown,status=200)=>Response.json(value,{status,headers});
14
+ async function bodyJson(req:Request):Promise<unknown>{
15
+ if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('Expected application/json');
16
+ if(Number(req.headers.get('content-length')??0)>1024*1024)throw new Error('Session payload exceeds 1 MiB');
17
+ if(!req.body)throw new Error('JSON body required');const reader=req.body.getReader(),chunks:Uint8Array[]=[];let size=0;
18
+ while(true){const {value,done}=await reader.read();if(done)break;size+=value.length;if(size>1024*1024){await reader.cancel();throw new Error('Session payload exceeds 1 MiB');}chunks.push(value);}
19
+ 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));
20
+ }
21
+ function serveAsset(req:Request,path:string):Response{
22
+ const file=Bun.file(path),size=file.size;const assetHeaders={...headers,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':file.type||'application/octet-stream','Accept-Ranges':'bytes'};
23
+ const range=req.headers.get('range');if(range){const match=/^bytes=(\d*)-(\d*)$/.exec(range);let start=0,end=size-1;
24
+ if(!match||(!match[1]&&!match[2]))return new Response(null,{status:416,headers:{...assetHeaders,'Content-Range':`bytes */${size}`}});
25
+ 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]));}
26
+ 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}`}});
27
+ 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)}});
28
+ }
29
+ return new Response(req.method==='HEAD'?null:file,{headers:{...assetHeaders,'Content-Length':String(size)}});
30
+ }
31
+ /** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it. */
32
+ export async function startCompareServer(p:ComparePackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;activity?:()=>void;control?:ControlIdentity;capability?:string}={}){
33
+ const bundle=await buildWeb('compare');
34
+ const frames=referenceFrameIndex(p.referencePath,p.response.binding.reference.sha256,p.response.sourceHash);
35
+ const control=options.control?createControlServer({identity:options.control,sourceHash:p.response.sourceHash,mode:'compare',unchanged:()=>assertCompareUnchanged(p),activity:options.activity}):null;
36
+ const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try{
37
+ const url=new URL(req.url),origin=`http://127.0.0.1:${port}`;
38
+ if(url.origin!==origin)return json({error:'Loopback host required'},403);
39
+ const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
40
+ const controlled=await control?.(req,origin);if(controlled)return controlled;
41
+ options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await assertCompareUnchanged(p);}catch{return staleOpenStatus(options.open!);}}return gate;}
42
+ if(req.method==='PUT'&&url.pathname==='/api/compare/session'){
43
+ if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
44
+ return json(await saveCompareSession(p,await bodyJson(req),options.stateDir));
45
+ }
46
+ if(req.method==='POST'&&url.pathname==='/api/compare/strip'){
47
+ if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
48
+ const result=await exportCompareStrip(p,await bodyJson(req));
49
+ 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'}"`}});
50
+ }
51
+ if(req.method!=='GET'&&req.method!=='HEAD')return json({error:'Method not allowed'},405);
52
+ if(url.pathname==='/api/compare/reference-frames'){if(url.search)return json({error:'Reference frame index accepts no parameters'},400);await assertCompareUnchanged(p);await safeCompareReference(p);return json(await frames());}
53
+ if(url.pathname==='/api/compare'){await assertCompareUnchanged(p);return json(p.response);}
54
+ if(url.pathname==='/api/compare/session')return json(await readCompareSession(p,options.stateDir));
55
+ if(url.pathname==='/compare-reference')return await serveReference(req,await safeCompareReference(p),p.response.binding.reference.sha256,headers);
56
+ if(url.pathname.startsWith('/compare-assets/')){const match=/^\/compare-assets\/(a|b)\/(.+)$/.exec(url.pathname);if(!match)return json({error:'Unknown comparison side'},404);const side=match[1] as 'a'|'b',path=packagePath(decodeURIComponent(match[2]!));if(!p[side].files.has(path))return json({error:'Asset not declared by package'},404);return serveAsset(req,await safeFile(p[side].root,path));}
57
+ if(url.pathname==='/main.js')return new Response(req.method==='HEAD'?null:bundle,{headers:{...headers,'Content-Type':'text/javascript'}});
58
+ if(url.pathname==='/'||url.pathname==='/index.html')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/compare.html'),{headers:{...headers,'Content-Type':'text/html; charset=utf-8'}});
59
+ if(url.pathname==='/styles.css')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/compare.css'),{headers:{...headers,'Content-Type':'text/css'}});
60
+ return json({error:'Not found'},404);
61
+ }catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}};
62
+ 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);}});
63
+ }
@@ -0,0 +1,26 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { choice, object } from './manifest.ts';
7
+ import { parseFeedback, readPackageFeedback, validatePackageFeedback, type FeedbackWarning, type Feedback } from './session.ts';
8
+ import { assertCompareUnchanged, type CompareBinding, type ComparePackage } from './compare.ts';
9
+
10
+ export interface CompareFeedback extends Feedback {side:'a'|'b'}
11
+ export interface CompareSession {schema:'rig-review-compare-session/1';id:string;revision:'compare/1';sourceHash:string;binding:CompareBinding;status:'pending'|'approved'|'changes-requested';feedback:CompareFeedback[];feedbackWarnings:FeedbackWarning[];updatedAt:string|null}
12
+ export function freshCompareSession(p:ComparePackage):CompareSession {const d=p.response;return {schema:'rig-review-compare-session/1',id:d.id,revision:d.revision,sourceHash:d.sourceHash,binding:d.binding,status:'pending',feedback:[],feedbackWarnings:[],updatedAt:null};}
13
+ export function parseCompareSession(input:unknown,p:ComparePackage,strict=true):CompareSession {
14
+ const value=object(input,'compare session'),fresh=freshCompareSession(p);
15
+ for(const key of ['schema','id','revision','sourceHash'] as const)if(value[key]!==fresh[key])throw new Error(`compare session.${key}: does not match loaded comparison`);
16
+ if(!isDeepStrictEqual(value.binding,fresh.binding))throw new Error('compare session.binding: does not match both source hashes, rig/motion identities, reference hash and animation');
17
+ if(value.corrections!==undefined)throw new Error('compare session.corrections: motion authoring proposals are not accepted');
18
+ if(!Array.isArray(value.feedback)||value.feedback.length>1000)throw new Error('compare session.feedback: expected array <= 1000');
19
+ const feedbackWarnings:FeedbackWarning[]=[];
20
+ const feedback:CompareFeedback[]=value.feedback.map((item:unknown)=>{const side=choice(object(item,'feedback').side,['a','b'] as const,'feedback.side'),parsed=parseFeedback(item,strict);if(strict)return {...validatePackageFeedback(parsed,p[side],p.response.animation),side};const read=readPackageFeedback(parsed,p[side],p.response.animation);if(read.warning)feedbackWarnings.push(read.warning);return {...read.feedback,side};});
21
+ if(new Set(feedback.map(f=>f.id)).size!==feedback.length)throw new Error('compare session.feedback: duplicate ids');
22
+ return {...fresh,status:choice(value.status,['pending','approved','changes-requested'] as const,'compare session.status'),feedback,feedbackWarnings,updatedAt:new Date().toISOString()};
23
+ }
24
+ export function compareSessionFile(p:ComparePackage,stateDir=join(homedir(),'.local','state','rig-c-review')):string {const key=createHash('sha256').update(JSON.stringify(['compare',p.response.sourceHash,p.response.binding])).digest('hex');return join(stateDir,key+'.json');}
25
+ export async function readCompareSession(p:ComparePackage,stateDir?:string):Promise<CompareSession>{await assertCompareUnchanged(p);try{const value=JSON.parse(await readFile(compareSessionFile(p,stateDir),'utf8'));return {...parseCompareSession(value,p,false),updatedAt:value.updatedAt};}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return freshCompareSession(p);throw e;}}
26
+ export async function saveCompareSession(p:ComparePackage,input:unknown,stateDir?:string):Promise<CompareSession>{const value=parseCompareSession(input,p);await assertCompareUnchanged(p);const file=compareSessionFile(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;}
package/src/compare.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { readFile, realpath, stat } from 'node:fs/promises';
4
+ import { basename, resolve } from 'node:path';
5
+ import { loadReview, object, safeFile, string, type ReviewManifest, type ReviewedSource, type ReviewPackage } from './manifest.ts';
6
+ import type { FeedbackCatalog } from './feedback-validation.ts';
7
+ import type { ReportSeeds } from './report.ts';
8
+
9
+ export interface CompareInputs {a:string;b:string;reference:string;animation:string}
10
+ export interface CompareBinding {
11
+ a:{sourceHash:string;reviewedSource:ReviewedSource};
12
+ b:{sourceHash:string;reviewedSource:ReviewedSource};
13
+ reference:{name:string;sha256:string};animation:string;
14
+ }
15
+ export interface CompareSide {manifest:ReviewManifest;report:ReportSeeds|null;feedbackCatalog:FeedbackCatalog;sourceHash:string;reviewedSource:ReviewedSource;assetsBase:'/compare-assets/a/'|'/compare-assets/b/'}
16
+ export interface CompareResponse {mode:'compare';id:string;title:string;revision:'compare/1';sourceHash:string;binding:CompareBinding;animation:string;a:CompareSide;b:CompareSide;reference:{name:string;sha256:string;url:'/compare-reference'}}
17
+ export interface ComparePackage {inputs:CompareInputs;a:ReviewPackage;b:ReviewPackage;referencePath:string;response:CompareResponse}
18
+
19
+ async function hashFile(path:string):Promise<string>{
20
+ const before=await stat(path);if(!before.isFile())throw new Error('compare.reference: expected regular local video file');
21
+ const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);
22
+ const after=await stat(path);if(before.size!==after.size||before.mtimeMs!==after.mtimeMs)throw new Error('compare.reference changed while hashing');return hash.digest('hex');
23
+ }
24
+ async function requireAnimation(p:ReviewPackage,animation:string,side:'a'|'b'):Promise<void>{
25
+ if(!p.manifest.model||!p.reviewedSource.rig)throw new Error(`compare.${side}.model: expected compiled model for playback`);
26
+ const bytes=await readFile(await safeFile(p.root,p.manifest.model));
27
+ if(createHash('sha256').update(bytes).digest('hex')!==p.reviewedSource.rig.sha256)throw new Error(`compare.${side} inputs changed while reading animation`);
28
+ const model=object(JSON.parse(bytes.toString('utf8')),`compare.${side}.model`);
29
+ if(!Array.isArray(model.animations))throw new Error(`compare.${side}.model.animations: expected array`);
30
+ const found=model.animations.map((value,i)=>object(value,`compare.${side}.animations[${i}]`)).find(value=>value.name===animation);
31
+ if(!found)throw new Error(`compare.${side}.animation: model has no animation ${JSON.stringify(animation)}`);
32
+ if(typeof found.duration!=='number'||!Number.isFinite(found.duration)||found.duration<0)throw new Error(`compare.${side}.animation.duration: expected finite number >= 0`);
33
+ }
34
+
35
+ /** Three explicit local inputs; a package manifest cannot select the external reference. */
36
+ export async function loadCompare(inputs:CompareInputs):Promise<ComparePackage>{
37
+ const animation=string(inputs.animation,'compare.animation');
38
+ const [a,b,referencePath]=await Promise.all([loadReview(inputs.a),loadReview(inputs.b),realpath(resolve(inputs.reference))]);
39
+ if(!/\.(mp4|webm|mov)$/i.test(referencePath))throw new Error('compare.reference: expected local mp4, webm or mov file');
40
+ await Promise.all([requireAnimation(a,animation,'a'),requireAnimation(b,animation,'b')]);
41
+ const reference={name:basename(referencePath),sha256:await hashFile(referencePath)};
42
+ const binding:CompareBinding={a:{sourceHash:a.sourceHash,reviewedSource:a.reviewedSource},b:{sourceHash:b.sourceHash,reviewedSource:b.reviewedSource},reference,animation};
43
+ const sourceHash=createHash('sha256').update(JSON.stringify(binding)).digest('hex');
44
+ return {inputs:{a:a.manifestPath,b:b.manifestPath,reference:referencePath,animation},a,b,referencePath,response:{mode:'compare',id:'compare-'+sourceHash.slice(0,16),title:`${a.manifest.title} / ${b.manifest.title}`,revision:'compare/1',sourceHash,binding,animation,a:{manifest:a.manifest,report:a.report,feedbackCatalog:a.feedbackCatalog,sourceHash:a.sourceHash,reviewedSource:a.reviewedSource,assetsBase:'/compare-assets/a/'},b:{manifest:b.manifest,report:b.report,feedbackCatalog:b.feedbackCatalog,sourceHash:b.sourceHash,reviewedSource:b.reviewedSource,assetsBase:'/compare-assets/b/'},reference:{...reference,url:'/compare-reference'}}};
45
+ }
46
+
47
+ export async function assertCompareUnchanged(p:ComparePackage):Promise<void>{
48
+ try{const current=await loadCompare(p.inputs);if(current.response.sourceHash!==p.response.sourceHash)throw new Error('source hash differs');}
49
+ catch(e){throw new Error(`Comparison inputs changed. Restart compare to bind feedback to current inputs. ${e instanceof Error?e.message:String(e)}`);}
50
+ }
51
+
52
+ /** Re-resolve the one CLI-selected reference before each byte-range read. */
53
+ export async function safeCompareReference(p:ComparePackage):Promise<string>{
54
+ const current=await realpath(p.referencePath);
55
+ if(current!==p.referencePath||!(await stat(current)).isFile())throw new Error('compare.reference: expected the originally selected canonical regular file (no symlink replacement)');
56
+ return current;
57
+ }
@@ -0,0 +1,54 @@
1
+ /** Layout offers follow actual load events. A declared path, a stored request or a placeholder never establishes an input. */
2
+ export type InputState={state:'loaded'}|{state:'pending'}|{state:'absent'}|{state:'failed';reason:string}|{state:'out-of-domain';reason:string};
3
+ export const singleLayouts=['compare','reference','rig'] as const;
4
+ export const pairLayouts=['compare','a','b','reference'] as const;
5
+ export type SingleLayout=typeof singleLayouts[number];
6
+ export type PairLayout=typeof pairLayouts[number];
7
+ export type ComparisonLayout=SingleLayout|PairLayout;
8
+ /** Review has no producer candidate handoff yet; this is stated, not drawn. */
9
+ export const candidateUnavailable={state:'unavailable',reason:'no producer handoff'} as const;
10
+ export interface Eligibility<L extends ComparisonLayout> {
11
+ mode:'single'|'compare';requested:L;effective:L|null;usable:L[];reasons:Partial<Record<L,string>>;candidate:typeof candidateUnavailable;
12
+ }
13
+ export interface PairEligibility extends Eligibility<PairLayout> {feedback:boolean;playback:boolean;static:boolean}
14
+ export interface SingleInputs {rig:InputState;reference:InputState;requested:SingleLayout}
15
+ export type PairDomain={end:number}|{refused:string}|null;
16
+ export interface PairInputs {a:InputState;b:InputState;reference:InputState;domain:PairDomain;requested?:PairLayout}
17
+
18
+ const prose:Record<InputState['state'],string>={loaded:'',pending:'not rendered yet',absent:'not supplied',failed:'',['out-of-domain']:''};
19
+ /** Named reason for an input that cannot be drawn. Codes are stable; detail is display text. */
20
+ export function inputReason(name:'RIG'|'REFERENCE'|'A'|'B',input:InputState):string|null {
21
+ if(input.state==='loaded')return null;
22
+ if(input.state==='failed')return `${name}_FAILED: ${input.reason}`;
23
+ if(input.state==='out-of-domain')return `${name}_OUT_OF_DOMAIN: ${input.reason}`;
24
+ return `${name}_${input.state.toUpperCase()}: ${name==='REFERENCE'&&input.state==='pending'?'held time not decoded yet':prose[input.state]}`;
25
+ }
26
+ function settle<L extends ComparisonLayout>(mode:'single'|'compare',requested:L,order:readonly L[],reasons:Partial<Record<L,string>>,fallback:readonly L[]):Eligibility<L> {
27
+ const usable=order.filter(layout=>reasons[layout]===undefined);
28
+ const effective=usable.includes(requested)?requested:fallback.find(layout=>usable.includes(layout))??null;
29
+ return {mode,requested,effective,usable,reasons,candidate:candidateUnavailable};
30
+ }
31
+ /** Single review: side by side needs an actually drawn rig AND a decoded reference. */
32
+ export function singleEligibility(value:SingleInputs):Eligibility<SingleLayout> {
33
+ const rig=inputReason('RIG',value.rig),reference=inputReason('REFERENCE',value.reference),reasons:Partial<Record<SingleLayout,string>>={};
34
+ if(rig)reasons.rig=rig;if(reference)reasons.reference=reference;
35
+ if(rig||reference)reasons.compare=[rig,reference].filter(Boolean).join(' · ');
36
+ return settle('single',value.requested,singleLayouts,reasons,value.requested==='reference'?['reference','rig']:['rig','reference']);
37
+ }
38
+ /** A held time outside a video's real domain is refused, never clamped. */
39
+ export function videoReferenceAt(held:number,duration:number):InputState {
40
+ if(!Number.isFinite(duration)||duration<0)return {state:'failed',reason:'video duration is not finite'};
41
+ if(!Number.isFinite(held)||held<0||held>duration)return {state:'out-of-domain',reason:`held time ${held} s is outside the video domain 0–${duration} s`};
42
+ return {state:'pending'};
43
+ }
44
+ /** Two builds: the pair needs both sides, the shared video and a valid common domain. A failed side leaves the survivor usable alone. */
45
+ export function pairEligibility(value:PairInputs):PairEligibility {
46
+ const a=inputReason('A',value.a),b=inputReason('B',value.b),reference=inputReason('REFERENCE',value.reference),reasons:Partial<Record<PairLayout,string>>={};
47
+ if(a)reasons.a=a;if(b)reasons.b=b;if(reference)reasons.reference=reference;
48
+ const domain=value.domain,end=domain&&'end' in domain&&Number.isFinite(domain.end)&&domain.end>=0?domain.end:null;
49
+ const domainReason=a||b||reference?null:domain===null?'DOMAIN_PENDING: shared domain not established':'refused' in domain?`DOMAIN_REFUSED: ${domain.refused}`:end===null?'DOMAIN_REFUSED: shared end is not finite':null;
50
+ if(a||b||reference||domainReason)reasons.compare=[a,b,reference,domainReason].filter(Boolean).join(' · ');
51
+ const result=settle('compare',value.requested??'compare',pairLayouts,reasons,['a','b','reference']);
52
+ const pair=result.usable.includes('compare');
53
+ return {...result,feedback:pair,playback:pair&&end!==null&&end>0,static:pair&&end===0};
54
+ }
@@ -0,0 +1,83 @@
1
+ import {randomUUID,timingSafeEqual} from 'node:crypto';
2
+ import {controlInteger,controlObject,controlString,parseControlInspection,parseControlRequest,type ControlDelivery,type ControlInspection,type ControlMode,type ControlReceipt,type ControlRequest} from './control.ts';
3
+ export interface ControlIdentity {token:string;instanceKey:string}
4
+ interface Page {id:string;token:string;revision:number;inspection:ControlInspection;seen:number;queue:string[];inflight:string|null}
5
+ interface Entry {request:ControlRequest;receipt:ControlReceipt;created:number;dispatched?:number}
6
+ const response=(v:unknown,status=200)=>Response.json(v,{status,headers:{'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}});
7
+ function bearer(req:Request,token:string):boolean{const actual=Buffer.from(req.headers.get('authorization')??''),expected=Buffer.from('Bearer '+token);return actual.length===expected.length&&timingSafeEqual(actual,expected);}
8
+ export async function readControlBody(req:Request,timeoutMs=2000):Promise<unknown>{
9
+ if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('CONTROL_JSON_REQUIRED');
10
+ if(!req.body)throw new Error('CONTROL_JSON_REQUIRED');const reader=req.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
11
+ 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>16384)throw new Error('CONTROL_BODY_LIMIT: maximum 16 KiB');chunks.push(next.value);}const bytes=new Uint8Array(size);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}return JSON.parse(new TextDecoder().decode(bytes));})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('CONTROL_BODY_TIMEOUT')),timeoutMs);})]);}
12
+ finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});}
13
+ }
14
+ /** Per-worker bounded ephemeral inspection mailbox. No file, package or feedback mutation. */
15
+ export function createControlServer(options:{identity:ControlIdentity;sourceHash:string;mode:ControlMode;unchanged:()=>Promise<void>;activity?:()=>void;now?:()=>number}){
16
+ const pages=new Map<string,Page>(),entries=new Map<string,Entry>(),requests=new Map<string,string>();const now=options.now??Date.now;
17
+ function expire(){
18
+ const time=now();for(const [id,page]of pages)if(time-page.seen>30000||page.inflight&&time-(entries.get(page.inflight)?.dispatched??time)>30000){for(const entry of entries.values())if(entry.request.pageId===id&&['queued','dispatched'].includes(entry.receipt.status)){entry.receipt.status=entry.receipt.status==='queued'?'rejected':'outcome-unknown';entry.receipt.error='CONTROL_PAGE_EXPIRED';}pages.delete(id);}
19
+ for(const entry of entries.values())if(entry.receipt.status==='queued'&&time-entry.created>10000){entry.receipt.status='rejected';entry.receipt.error='CONTROL_COMMAND_EXPIRED';}
20
+ }
21
+ function snapshot(){return {schema:'rig-review-control-state/1',instanceKey:options.identity.instanceKey,sourceHash:options.sourceHash,mode:options.mode,pages:[...pages.values()].map(p=>({pageId:p.id,revision:p.revision,inspection:p.inspection}))};}
22
+ function pageFor(req:Request,id:unknown):Page{const page=pages.get(controlString(id,'control.pageId'));if(!page||!bearer(req,page.token))throw new Error('CONTROL_PAGE_CAPABILITY_REQUIRED');return page;}
23
+ function errorStatus(message:string){return /CAPABILITY_REQUIRED/.test(message)?403:/STALE|INPUTS_CHANGED|BUSY|EXPIRED|NO_PAGE/.test(message)?409:400;}
24
+ return async function handle(req:Request,origin:string):Promise<Response|null>{
25
+ const url=new URL(req.url);if(!url.pathname.startsWith('/api/control'))return null;
26
+ try{
27
+ if(url.origin!==origin||url.search)return response({error:'CONTROL_LOOPBACK_PATH_REQUIRED'},403);
28
+ expire();
29
+ if(url.pathname==='/api/control/config'&&req.method==='GET')return response({schema:'rig-review-control-config/1',enabled:true,sourceHash:options.sourceHash,instanceKey:options.identity.instanceKey,mode:options.mode});
30
+ const agent=bearer(req,options.identity.token);
31
+ if(url.pathname==='/api/control'&&req.method==='GET'){if(!agent)return response({error:'CONTROL_CAPABILITY_REQUIRED'},403);await options.unchanged();return response(snapshot());}
32
+ if(url.pathname.startsWith('/api/control/receipts/')&&req.method==='GET'){
33
+ if(!agent)return response({error:'CONTROL_CAPABILITY_REQUIRED'},403);const entry=entries.get(url.pathname.slice('/api/control/receipts/'.length));return entry?response(entry.receipt):response({error:'CONTROL_RECEIPT_NOT_FOUND'},404);
34
+ }
35
+ if(req.method!=='POST')return response({error:'CONTROL_METHOD_NOT_ALLOWED'},405);
36
+ if(req.headers.get('origin')!==origin||req.headers.get('sec-fetch-site')==='cross-site')return response({error:'CONTROL_SAME_ORIGIN_REQUIRED'},403);
37
+ if(url.pathname==='/api/control/commands'){
38
+ if(!agent)return response({error:'CONTROL_CAPABILITY_REQUIRED'},403);
39
+ const input=parseControlRequest(await readControlBody(req));if(input.sourceHash!==options.sourceHash||input.instanceKey!==options.identity.instanceKey)return response({error:'CONTROL_STALE_BINDING'},409);
40
+ const existing=requests.get(input.requestId);if(existing){const entry=entries.get(existing)!;if(JSON.stringify(entry.request)!==JSON.stringify(input))return response({error:'CONTROL_REQUEST_ID_CONFLICT'},409);return response(entry.receipt);}
41
+ const page=pages.get(input.pageId);if(!page)return response({error:'CONTROL_NO_PAGE'},409);
42
+ if(input.expectedRevision!==page.revision)return response({error:'CONTROL_STALE_STATE'},409);
43
+ if(page.queue.filter(id=>entries.get(id)?.receipt.status==='queued').length>=8)return response({error:'CONTROL_QUEUE_BUSY'},409);
44
+ await options.unchanged();
45
+ // Recheck after asynchronous hashing: a concurrent poll/command may have won.
46
+ const concurrent=requests.get(input.requestId);if(concurrent){const entry=entries.get(concurrent)!;return JSON.stringify(entry.request)===JSON.stringify(input)?response(entry.receipt):response({error:'CONTROL_REQUEST_ID_CONFLICT'},409);}
47
+ if(pages.get(page.id)!==page||input.expectedRevision!==page.revision)return response({error:'CONTROL_STALE_STATE'},409);
48
+ page.queue=page.queue.filter(id=>entries.get(id)?.receipt.status==='queued');if(page.queue.length>=8)return response({error:'CONTROL_QUEUE_BUSY'},409);
49
+ while(entries.size>=64){const old=[...entries].find(([,v])=>!['queued','dispatched'].includes(v.receipt.status));if(!old)return response({error:'CONTROL_RECEIPTS_BUSY'},409);requests.delete(old[1].request.requestId);entries.delete(old[0]);}
50
+ const commandId=randomUUID(),receipt:ControlReceipt={commandId,requestId:input.requestId,pageId:page.id,status:'queued',revision:page.revision,inspection:null};entries.set(commandId,{request:input,receipt,created:now()});requests.set(input.requestId,commandId);page.queue.push(commandId);options.activity?.();return response(receipt,202);
51
+ }
52
+ if(url.pathname==='/api/control/register'){
53
+ const input=controlObject(await readControlBody(req),['sourceHash','inspection'],'control.register');if(input.sourceHash!==options.sourceHash)return response({error:'CONTROL_STALE_BINDING'},409);
54
+ const inspection=parseControlInspection(input.inspection);if(pages.size>=4)return response({error:'CONTROL_PAGES_BUSY'},409);await options.unchanged();if(pages.size>=4)return response({error:'CONTROL_PAGES_BUSY'},409);
55
+ const page:Page={id:randomUUID(),token:randomUUID(),revision:0,inspection,seen:now(),queue:[],inflight:null};pages.set(page.id,page);options.activity?.();return response({pageId:page.id,pageToken:page.token,revision:0},201);
56
+ }
57
+ if(url.pathname!=='/api/control/poll'&&url.pathname!=='/api/control/ack'&&url.pathname!=='/api/control/close')return response({error:'CONTROL_NOT_FOUND'},404);
58
+ // Read the bounded body only after identifying a live page capability.
59
+ const tokenPage=[...pages.values()].find(p=>bearer(req,p.token));if(!tokenPage)return response({error:'CONTROL_PAGE_CAPABILITY_REQUIRED'},403);
60
+ const input=controlObject(await readControlBody(req),url.pathname.endsWith('/ack')?['pageId','revision','inspection','commandId','status','error']:url.pathname.endsWith('/close')?['pageId']:['pageId','revision','inspection','active'],'control.page');
61
+ const page=pageFor(req,input.pageId);if(url.pathname.endsWith('/close')){for(const entry of entries.values())if(entry.request.pageId===page.id&&['queued','dispatched'].includes(entry.receipt.status)){entry.receipt.status=entry.receipt.status==='dispatched'?'outcome-unknown':'rejected';entry.receipt.error='CONTROL_PAGE_CLOSED';}pages.delete(page.id);return response({closed:true});}
62
+ const revision=controlInteger(input.revision,'control.revision'),inspection=parseControlInspection(input.inspection);if(revision<page.revision)return response({error:'CONTROL_STALE_STATE'},409);page.seen=now();
63
+ if(url.pathname.endsWith('/ack')){
64
+ const id=controlString(input.commandId,'control.commandId'),entry=entries.get(id);if(!entry||entry.request.pageId!==page.id||page.inflight!==id)return response({error:'CONTROL_STALE_ACK'},409);
65
+ if(!['applied','rejected','failed','superseded'].includes(String(input.status)))throw new Error('control.status: invalid acknowledgment');
66
+ if(input.status==='applied'&&revision!==entry.request.expectedRevision+1)throw new Error('control.revision: applied acknowledgment must advance exactly once');
67
+ await options.unchanged();if(pages.get(page.id)!==page||page.inflight!==id||page.revision>revision)return response({error:'CONTROL_STALE_ACK'},409);entry.receipt={...entry.receipt,status:input.status as ControlReceipt['status'],revision,inspection,...(input.error===undefined?{}:{error:controlString(input.error,'control.error',1024)})};page.inflight=null;
68
+ }
69
+ page.revision=revision;page.inspection=inspection;
70
+ if(input.active===true)options.activity?.();
71
+ if(url.pathname.endsWith('/ack'))return response({acknowledged:true});
72
+ if(input.active!==true&&input.active!==false)throw new Error('control.active: expected boolean');
73
+ if(page.inflight)return response({command:null});
74
+ while(page.queue.length){const id=page.queue.shift()!,entry=entries.get(id);if(!entry||entry.receipt.status!=='queued')continue;
75
+ if(entry.request.expectedRevision!==revision){entry.receipt.status='superseded';entry.receipt.error='CONTROL_STALE_STATE';continue;}
76
+ try{await options.unchanged();}catch(error){entry.receipt.status='rejected';entry.receipt.error='CONTROL_INPUTS_CHANGED';throw error;}
77
+ if(page.inflight||pages.get(page.id)!==page||page.revision!==revision){entry.receipt.status='superseded';entry.receipt.error='CONTROL_STALE_STATE';return response({command:null});}
78
+ page.inflight=id;entry.dispatched=now();entry.receipt.status='dispatched';const command:ControlDelivery={commandId:id,request:entry.request};return response({command});
79
+ }
80
+ return response({command:null});
81
+ }catch(error){const message=error instanceof Error?error.message:String(error);return response({error:message},/inputs changed/i.test(message)?409:errorStatus(message));}
82
+ };
83
+ }
package/src/control.ts ADDED
@@ -0,0 +1,132 @@
1
+ /** Inspection-only protocol. Source identity and human review decisions are not commands. */
2
+ import {singleLayouts,pairLayouts,type ComparisonLayout,type Eligibility} from './comparison-eligibility.ts';
3
+ export type ControlMode='single'|'compare';
4
+ export type ControlCameraTarget='rig'|'a'|'b'|'reference';
5
+ export interface ControlCamera {zoom:number;pan:{x:number;y:number}}
6
+ export interface ControlLayers {images:boolean;meshes:boolean;bones:boolean}
7
+ /** Observed camera coordinates, independent of viewport-specific CSS pan. */
8
+ export interface ControlNumericCamera {
9
+ space:'world'|'image';center:{x:number;y:number};zoom:number;
10
+ viewport:{width:number;height:number};visible:{x:number;y:number;width:number;height:number};
11
+ }
12
+ export interface ControlView {
13
+ schema:'rig-review-view/1';
14
+ cameraSync?:boolean;activeCamera?:ControlCameraTarget;
15
+ cameras:{rig:ControlNumericCamera|null;a:ControlNumericCamera|null;b:ControlNumericCamera|null;reference:ControlNumericCamera|null};
16
+ }
17
+ export type PartIdentityReason='hidden'|'no-attachment'|'timeline-null'|'set-hide';
18
+ /** Persistable exact target at the bookmarked pose. Generation is observation only. */
19
+ export interface ControlPartIdentity {attachment:string|null;skin:string|null;reason:PartIdentityReason|null;set:string|null;activeSkin:string|null}
20
+ export function parsePartIdentity(value:unknown):ControlPartIdentity {
21
+ const o=controlObject(value,['attachment','skin','reason','set','activeSkin'],'control.part.identity');
22
+ const nullable=(value:unknown,name:string)=>value===null?null:controlString(value,name,512);
23
+ const result={attachment:nullable(o.attachment,'part.attachment'),skin:nullable(o.skin,'part.skin'),reason:o.reason===null?null:choice(o.reason,['hidden','no-attachment','timeline-null','set-hide'] as const,'part.reason'),set:nullable(o.set,'part.set'),activeSkin:nullable(o.activeSkin,'part.activeSkin')};
24
+ if(result.reason===null?(result.attachment===null||result.skin===null):(result.attachment!==null||result.skin!==null))throw new Error('PART_IDENTITY_SHAPE: resolved key/owning skin or explicit null reason required');return result;
25
+ }
26
+ export interface ControlPart {side:'single'|'a'|'b';slot:string;mode:'context'|'solo';identity?:ControlPartIdentity}
27
+ export function parseControlPart(value:unknown):ControlPart|null {if(value===null)return null;const o=controlObject(value,['side','slot','mode','identity'],'control.part');return {side:choice(o.side,['single','a','b'] as const,'control.part.side'),slot:controlString(o.slot,'control.part.slot',512),mode:choice(o.mode,['context','solo'] as const,'control.part.mode'),...(o.identity===undefined?{}:{identity:parsePartIdentity(o.identity)})};}
28
+ /** Observed layout offer: requested (source-bound draft), effective (actually visible) and a named reason per unavailable layout. */
29
+ export interface ControlComparison {mode:'single'|'compare';requestedLayout:ComparisonLayout;effectiveLayout:ComparisonLayout|null;usableLayouts:ComparisonLayout[];reasons:Partial<Record<ComparisonLayout,string>>;candidate:{state:'unavailable';reason:'no producer handoff'}}
30
+ export function controlComparison(value:Eligibility<ComparisonLayout>):ControlComparison {return {mode:value.mode,requestedLayout:value.requested,effectiveLayout:value.effective,usableLayouts:[...value.usable],reasons:{...value.reasons},candidate:{...value.candidate}};}
31
+ export function parseControlComparison(value:unknown):ControlComparison {
32
+ const o=controlObject(value,['mode','requestedLayout','effectiveLayout','usableLayouts','reasons','candidate'],'control.comparison');
33
+ const mode=choice(o.mode,['single','compare'] as const,'control.comparison.mode'),layouts:readonly ComparisonLayout[]=mode==='single'?singleLayouts:pairLayouts;
34
+ const requested=choice(o.requestedLayout,layouts,'control.comparison.requestedLayout');
35
+ if(!Array.isArray(o.usableLayouts)||o.usableLayouts.length>layouts.length)throw new Error('control.comparison.usableLayouts: expected layout list');
36
+ const usable=o.usableLayouts.map(item=>choice(item,layouts,'control.comparison.usableLayouts'));if(new Set(usable).size!==usable.length)throw new Error('control.comparison.usableLayouts: duplicate layout');
37
+ const effective=o.effectiveLayout===null?null:choice(o.effectiveLayout,layouts,'control.comparison.effectiveLayout');
38
+ if(effective===null?usable.length>0:!usable.includes(effective))throw new Error('control.comparison.effectiveLayout: must be a usable layout, null only when none is usable');
39
+ if(usable.includes(requested)&&effective!==requested)throw new Error('control.comparison.effectiveLayout: a usable request is effective');
40
+ const r=controlObject(o.reasons,layouts,'control.comparison.reasons'),reasons:Partial<Record<ComparisonLayout,string>>={};
41
+ for(const layout of layouts){if(usable.includes(layout)){if(r[layout]!==undefined)throw new Error(`control.comparison.reasons.${layout}: usable layout has no refusal`);}else reasons[layout]=controlString(r[layout],`control.comparison.reasons.${layout}`,1024);}
42
+ const c=controlObject(o.candidate,['state','reason'],'control.comparison.candidate');if(c.state!=='unavailable'||c.reason!=='no producer handoff')throw new Error('control.comparison.candidate: no candidate source exists');
43
+ return {mode,requestedLayout:requested,effectiveLayout:effective,usableLayouts:usable,reasons,candidate:{state:'unavailable',reason:'no producer handoff'}};
44
+ }
45
+ export interface ControlInspection {
46
+ theme:'system'|'dark'|'light';layout:'auto';effectiveLayout:'row'|'column'|null;
47
+ cameras:{rig:ControlCamera;reference:ControlCamera;a?:ControlCamera;b?:ControlCamera};animation:string|null;time:number|null;
48
+ playing:boolean;loop:'idle'|'off';layers:ControlLayers;ready:boolean;seeking:boolean;
49
+ dirty:boolean;busy:boolean;feedbackEligible:boolean;view?:ControlView;comparison?:ControlComparison;part?:ControlPart|null;partGeneration?:number|null;
50
+ capabilities:{camera:boolean;referenceCamera:boolean;animation:boolean;time:boolean;playback:boolean;layers:ControlLayers};
51
+ }
52
+ export type ControlCommand=
53
+ |{type:'theme';value:'system'|'dark'|'light'}|{type:'layout';value:'auto'}
54
+ |{type:'camera';target:ControlCameraTarget;zoom:number;pan:{x:number;y:number}}
55
+ |{type:'fit';target:ControlCameraTarget}|{type:'camera-sync';value:boolean}|{type:'playback';value:'play'|'pause'}
56
+ |{type:'animation';name:string}|{type:'time';seconds:number}|({type:'layers'}&ControlLayers);
57
+ export interface ControlRequest {schema:'rig-review-control-command/1';requestId:string;pageId:string;instanceKey:string;sourceHash:string;expectedRevision:number;command:ControlCommand}
58
+ export interface ControlDelivery {commandId:string;request:ControlRequest}
59
+ export type ControlOutcome='queued'|'dispatched'|'applied'|'rejected'|'failed'|'superseded'|'outcome-unknown';
60
+ export interface ControlReceipt {commandId:string;requestId:string;pageId:string;status:ControlOutcome;error?:string;revision:number;inspection:ControlInspection|null}
61
+ export function controlObject(v:unknown,keys:readonly string[],name:string):Record<string,unknown>{
62
+ if(!v||typeof v!=='object'||Array.isArray(v)||Object.getPrototypeOf(v)!==Object.prototype)throw new Error(`${name}: expected object`);
63
+ const o=v as Record<string,unknown>;for(const key of Object.keys(o))if(!keys.includes(key))throw new Error(`${name}.${key}: unknown field`);return o;
64
+ }
65
+ export function controlString(v:unknown,name:string,max=128):string{if(typeof v!=='string'||!v||v.length>max||/[\x00-\x1f]/.test(v))throw new Error(`${name}: expected nonempty text <= ${max}`);return v;}
66
+ export function controlInteger(v:unknown,name:string):number{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw new Error(`${name}: expected safe integer >= 0`);return v;}
67
+ function num(v:unknown,name:string,min:number,max:number):number{if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)throw new Error(`${name}: expected finite ${min}..${max}`);return v;}
68
+ function bool(v:unknown,name:string):boolean{if(typeof v!=='boolean')throw new Error(`${name}: expected boolean`);return v;}
69
+ function choice<T extends string>(v:unknown,values:readonly T[],name:string):T{if(typeof v!=='string'||!values.includes(v as T))throw new Error(`${name}: expected ${values.join(' | ')}`);return v as T;}
70
+ function pan(v:unknown,limit=65536){const o=controlObject(v,['x','y'],'control.pan');return {x:num(o.x,'control.pan.x',-limit,limit),y:num(o.y,'control.pan.y',-limit,limit)};}
71
+ /** Sync scales CSS displacement by pane size; observed pan is not a command input. */
72
+ function camera(v:unknown):ControlCamera{const o=controlObject(v,['zoom','pan'],'control.camera');return {zoom:num(o.zoom,'control.camera.zoom',.25,8),pan:pan(o.pan,Number.MAX_SAFE_INTEGER)};}
73
+ function layers(v:unknown):ControlLayers{const o=controlObject(v,['images','meshes','bones'],'control.layers');return {images:bool(o.images,'control.layers.images'),meshes:bool(o.meshes,'control.layers.meshes'),bones:bool(o.bones,'control.layers.bones')};}
74
+ export function parseControlView(v:unknown):ControlView{
75
+ const o=controlObject(v,['schema','cameras','cameraSync','activeCamera'],'control.view');
76
+ if(o.schema!=='rig-review-view/1')throw new Error('control.view.schema: expected rig-review-view/1');
77
+ const c=controlObject(o.cameras,['rig','a','b','reference'],'control.view.cameras');
78
+ const numeric=(value:unknown,id:'rig'|'a'|'b'|'reference'):ControlNumericCamera|null=>{
79
+ if(value===null)return null;
80
+ const name=`control.view.cameras.${id}`,camera=controlObject(value,['space','center','zoom','viewport','visible'],name);
81
+ const space=id==='reference'?'image':'world';
82
+ if(camera.space!==space)throw new Error(`${name}.space: expected ${space}`);
83
+ const center=controlObject(camera.center,['x','y'],`${name}.center`),viewport=controlObject(camera.viewport,['width','height'],`${name}.viewport`),visible=controlObject(camera.visible,['x','y','width','height'],`${name}.visible`);
84
+ const coordinate=(value:unknown,field:string)=>num(value,`${name}.${field}`,-Number.MAX_SAFE_INTEGER,Number.MAX_SAFE_INTEGER);
85
+ const positive=(value:unknown,field:string,max:number)=>{const n=num(value,`${name}.${field}`,0,max);if(n===0)throw new Error(`${name}.${field}: expected positive dimension`);return n;};
86
+ return {space,center:{x:coordinate(center.x,'center.x'),y:coordinate(center.y,'center.y')},zoom:num(camera.zoom,`${name}.zoom`,.25,8),viewport:{width:positive(viewport.width,'viewport.width',65536),height:positive(viewport.height,'viewport.height',65536)},visible:{x:coordinate(visible.x,'visible.x'),y:coordinate(visible.y,'visible.y'),width:positive(visible.width,'visible.width',Number.MAX_SAFE_INTEGER),height:positive(visible.height,'visible.height',Number.MAX_SAFE_INTEGER)}};
87
+ };
88
+ return {schema:'rig-review-view/1',...(o.cameraSync===undefined?{}:{cameraSync:bool(o.cameraSync,'control.view.cameraSync')}),...(o.activeCamera===undefined?{}:{activeCamera:choice(o.activeCamera,['rig','a','b','reference'] as const,'control.view.activeCamera')}),cameras:{rig:numeric(c.rig,'rig'),a:numeric(c.a,'a'),b:numeric(c.b,'b'),reference:numeric(c.reference,'reference')}};
89
+ }
90
+ export function parseControlCommand(v:unknown):ControlCommand{
91
+ const base=controlObject(v,['type','value','target','zoom','pan','name','seconds','images','meshes','bones'],'control.command');
92
+ const type=choice(base.type,['theme','layout','camera','fit','camera-sync','playback','animation','time','layers'] as const,'control.command.type');
93
+ const keys={theme:['type','value'],layout:['type','value'],camera:['type','target','zoom','pan'],fit:['type','target'],'camera-sync':['type','value'],playback:['type','value'],animation:['type','name'],time:['type','seconds'],layers:['type','images','meshes','bones']}[type];controlObject(v,keys,'control.command');
94
+ if(type==='theme')return {type,value:choice(base.value,['system','dark','light'] as const,'control.theme')};
95
+ if(type==='layout')return {type,value:choice(base.value,['auto'] as const,'control.layout')};
96
+ if(type==='camera')return {type,target:choice(base.target,['rig','a','b','reference'] as const,'control.target'),zoom:num(base.zoom,'control.zoom',.25,8),pan:pan(base.pan)};
97
+ if(type==='fit')return {type,target:choice(base.target,['rig','a','b','reference'] as const,'control.target')};
98
+ if(type==='camera-sync')return {type,value:bool(base.value,'control.cameraSync')};
99
+ if(type==='playback')return {type,value:choice(base.value,['play','pause'] as const,'control.playback')};
100
+ if(type==='animation')return {type,name:controlString(base.name,'control.animation')};
101
+ if(type==='time')return {type,seconds:num(base.seconds,'control.seconds',0,Number.MAX_SAFE_INTEGER)};
102
+ return {type,images:bool(base.images,'control.layers.images'),meshes:bool(base.meshes,'control.layers.meshes'),bones:bool(base.bones,'control.layers.bones')};
103
+ }
104
+ export function parseControlInspection(v:unknown):ControlInspection{
105
+ const o=controlObject(v,['theme','layout','effectiveLayout','cameras','animation','time','playing','loop','layers','ready','seeking','dirty','busy','feedbackEligible','capabilities','view','part','partGeneration','comparison'],'control.inspection');
106
+ const c=controlObject(o.cameras,['rig','reference','a','b'],'control.cameras'),a=controlObject(o.capabilities,['camera','referenceCamera','animation','time','playback','layers'],'control.capabilities');
107
+ return {...(o.view===undefined?{}:{view:parseControlView(o.view)}),...(o.comparison===undefined?{}:{comparison:parseControlComparison(o.comparison)}),...(o.part===undefined?{}:{part:parseControlPart(o.part)}),...(o.partGeneration===undefined?{}:{partGeneration:o.partGeneration===null?null:controlInteger(o.partGeneration,'control.partGeneration')}),theme:choice(o.theme,['system','dark','light'] as const,'control.theme'),layout:choice(o.layout,['auto'] as const,'control.layout'),effectiveLayout:o.effectiveLayout===null?null:choice(o.effectiveLayout,['row','column'] as const,'control.effectiveLayout'),cameras:{rig:camera(c.rig),reference:camera(c.reference),...(c.a===undefined?{}:{a:camera(c.a)}),...(c.b===undefined?{}:{b:camera(c.b)})},animation:o.animation===null?null:controlString(o.animation,'control.animation'),time:o.time===null?null:num(o.time,'control.time',0,Number.MAX_SAFE_INTEGER),playing:bool(o.playing,'control.playing'),loop:choice(o.loop,['idle','off'] as const,'control.loop'),layers:layers(o.layers),ready:bool(o.ready,'control.ready'),seeking:bool(o.seeking,'control.seeking'),dirty:bool(o.dirty,'control.dirty'),busy:bool(o.busy,'control.busy'),feedbackEligible:bool(o.feedbackEligible,'control.feedbackEligible'),capabilities:{camera:bool(a.camera,'control.capabilities.camera'),referenceCamera:bool(a.referenceCamera,'control.capabilities.referenceCamera'),animation:bool(a.animation,'control.capabilities.animation'),time:bool(a.time,'control.capabilities.time'),playback:bool(a.playback,'control.capabilities.playback'),layers:layers(a.layers)}};
108
+ }
109
+ export function parseControlRequest(v:unknown):ControlRequest{
110
+ const o=controlObject(v,['schema','requestId','pageId','instanceKey','sourceHash','expectedRevision','command'],'control.request');
111
+ if(o.schema!=='rig-review-control-command/1')throw new Error('control.schema: expected rig-review-control-command/1');
112
+ for(const field of ['instanceKey','sourceHash'])if(typeof o[field]!=='string'||!/^[a-f0-9]{64}$/.test(o[field] as string))throw new Error(`control.${field}: expected SHA-256`);
113
+ return {schema:o.schema,requestId:controlString(o.requestId,'control.requestId'),pageId:controlString(o.pageId,'control.pageId'),instanceKey:o.instanceKey as string,sourceHash:o.sourceHash as string,expectedRevision:controlInteger(o.expectedRevision,'control.expectedRevision'),command:parseControlCommand(o.command)};
114
+ }
115
+ /** Shared preflight; adapters additionally validate actual names/domains before mutation. */
116
+ export function preflightControl(command:ControlCommand,state:ControlInspection,mode:ControlMode):void{
117
+ if(state.busy)throw new Error('CONTROL_BUSY: inspection update in progress');
118
+ if(['animation','time','playback'].includes(command.type)&&state.dirty)throw new Error('CONTROL_UNSUBMITTED_CHANGES: preserve the current feedback draft');
119
+ if(['animation','time','playback'].includes(command.type)&&(!state.ready||state.seeking))throw new Error('CONTROL_NOT_READY: inspection is not settled');
120
+ const cap=state.capabilities;
121
+ if(command.type==='animation'&&mode==='compare'&&command.name!==state.animation)throw new Error('CONTROL_REBIND_REQUIRED: comparison animation is source-bound');
122
+ if(command.type==='animation'&&!cap.animation)throw new Error('CONTROL_UNAVAILABLE: animation');
123
+ if(command.type==='time'&&!cap.time)throw new Error('CONTROL_UNAVAILABLE: time');
124
+ if(command.type==='playback'&&!cap.playback)throw new Error('CONTROL_UNAVAILABLE: playback');
125
+ if(command.type==='camera-sync'&&state.view?.cameraSync===undefined)throw new Error('CONTROL_UNAVAILABLE: camera sync');
126
+ if(command.type==='camera'||command.type==='fit'){
127
+ const target=command.target;
128
+ if((target==='a'||target==='b')&&(mode!=='compare'||!cap.camera||!state.cameras[target]&&!state.view?.cameras[target]))throw new Error(`CONTROL_UNAVAILABLE: ${target} camera`);
129
+ if((target==='rig'&&!cap.camera)||(target==='reference'&&!cap.referenceCamera))throw new Error(`CONTROL_UNAVAILABLE: ${target} camera`);
130
+ }
131
+ if(command.type==='layers')for(const name of ['images','meshes','bones'] as const)if(command[name]!==state.layers[name]&&!cap.layers[name])throw new Error(`CONTROL_UNAVAILABLE: ${name}`);
132
+ }