@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.
Files changed (54) hide show
  1. package/README.md +285 -2
  2. package/docs/DESIGN.md +43 -0
  3. package/docs/REVIEW_PACKAGE.md +607 -0
  4. package/examples/demo/SOURCE.md +5 -0
  5. package/examples/demo/chime.wav +0 -0
  6. package/examples/demo/lantern.svg +10 -0
  7. package/examples/demo/pose.json +244 -0
  8. package/examples/demo/review.json +67 -0
  9. package/package.json +47 -4
  10. package/src/animation-selection.ts +29 -0
  11. package/src/build.ts +8 -0
  12. package/src/cli.ts +114 -0
  13. package/src/compare-playhead.ts +55 -0
  14. package/src/compare-presentation.ts +43 -0
  15. package/src/compare-server.ts +60 -0
  16. package/src/compare-session.ts +26 -0
  17. package/src/compare.ts +57 -0
  18. package/src/control-server.ts +83 -0
  19. package/src/control.ts +103 -0
  20. package/src/feedback-export.ts +197 -0
  21. package/src/feedback-validation.ts +38 -0
  22. package/src/frame-clock.ts +36 -0
  23. package/src/manifest.ts +140 -0
  24. package/src/open-protocol.ts +14 -0
  25. package/src/open-selection.ts +11 -0
  26. package/src/open-shell.ts +2 -0
  27. package/src/open-worker.ts +2 -0
  28. package/src/open.ts +126 -0
  29. package/src/pose.ts +27 -0
  30. package/src/probe-reference.ts +16 -0
  31. package/src/reference-frame-index.ts +16 -0
  32. package/src/reference-frames.ts +62 -0
  33. package/src/reference.ts +63 -0
  34. package/src/report.ts +88 -0
  35. package/src/server.ts +61 -0
  36. package/src/session-history.ts +99 -0
  37. package/src/session.ts +58 -0
  38. package/src/strip-export.ts +69 -0
  39. package/src/strip-render.ts +171 -0
  40. package/src/strip.ts +51 -0
  41. package/src/versions.ts +20 -0
  42. package/web/camera-view.ts +34 -0
  43. package/web/compare.css +96 -0
  44. package/web/compare.html +7 -0
  45. package/web/compare.ts +502 -0
  46. package/web/control.ts +65 -0
  47. package/web/index.html +13 -0
  48. package/web/inspection-persistence.ts +200 -0
  49. package/web/main.ts +928 -0
  50. package/web/renderer-status.ts +31 -0
  51. package/web/report-view.ts +51 -0
  52. package/web/rig-overlays.ts +147 -0
  53. package/web/styles.css +98 -0
  54. package/web/view-controls.ts +67 -0
@@ -0,0 +1,38 @@
1
+ import { lastFrame, timeAtFrame, type FrameClock } from './frame-clock.ts';
2
+
3
+ /** Null names mean unavailable metadata; an empty list means known empty. */
4
+ export interface FeedbackCatalog {
5
+ rig:string;bones:string[]|null;slots:string[]|null;
6
+ animations:{name:string;duration:number}[]|null;
7
+ clock:FrameClock|null;clockAnimation:string|null;
8
+ }
9
+ export type FeedbackTargetKind='bone'|'slot'|'rig';
10
+ export interface FeedbackSelection {frameStart:number;frameEnd:number;target:{kind:FeedbackTargetKind;name:string};animation:string|null}
11
+ export function feedbackTargetNames(catalog:FeedbackCatalog,kind:FeedbackTargetKind):string[]|null {
12
+ return kind==='rig'?[catalog.rig]:kind==='bone'?catalog.bones:catalog.slots;
13
+ }
14
+ /** Clip end restricts declared samples; it never creates a rate or a frame. */
15
+ export function feedbackLastFrame(catalog:FeedbackCatalog,animation:string|null):number|null {
16
+ const last=lastFrame(catalog.clock);if(last===null)return null;
17
+ if(catalog.animations===null)return catalog.clockAnimation===null||animation===null||animation===catalog.clockAnimation?last:null;
18
+ if(!animation||catalog.clockAnimation!==animation)return null;
19
+ const clip=catalog.animations.find(value=>value.name===animation);if(!clip)return null;
20
+ let low=0,high=last;
21
+ while(low<=high){const middle=low+Math.floor((high-low)/2),time=timeAtFrame(catalog.clock,middle);if(time!==null&&time<=clip.duration)low=middle+1;else high=middle-1;}
22
+ // -1 is a known empty clip/sample intersection, not an unknown clock.
23
+ return high;
24
+ }
25
+ export function validateFeedbackSelection(catalog:FeedbackCatalog,value:FeedbackSelection):void {
26
+ if(!value.target||!['bone','slot','rig'].includes(value.target.kind))throw new Error('feedback.target.kind: expected bone | slot | rig');
27
+ if(typeof value.target.name!=='string'||!value.target.name.trim())throw new Error('feedback.target.name: expected nonempty model or package name');
28
+ if(value.animation!==null&&typeof value.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
29
+ if(!Number.isSafeInteger(value.frameStart)||!Number.isSafeInteger(value.frameEnd)||value.frameStart<0||value.frameEnd<value.frameStart)throw new Error('feedback frames: expected inclusive integer 0 <= frameStart <= frameEnd');
30
+ const names=feedbackTargetNames(catalog,value.target.kind);
31
+ if(names===null)throw new Error(`feedback.target.name: ${value.target.kind} validation unavailable without model ${value.target.kind} names`);
32
+ if(!names.includes(value.target.name))throw new Error(`feedback.target.name: ${value.target.kind} ${JSON.stringify(value.target.name)} not in ${value.target.kind==='rig'?'package':'model'}`);
33
+ if(value.animation!==null&&catalog.animations!==null&&!catalog.animations.some(clip=>clip.name===value.animation))throw new Error(`feedback.context.animation: ${JSON.stringify(value.animation)} not in model`);
34
+ if(catalog.animations===null&&lastFrame(catalog.clock)!==null&&catalog.clockAnimation!==null&&value.animation!==null&&value.animation!==catalog.clockAnimation)throw new Error('feedback.context.animation: does not match source report frame clock');
35
+ if(catalog.animations!==null&&lastFrame(catalog.clock)!==null&&catalog.clockAnimation!==null&&value.animation===null)throw new Error('feedback.context.animation: required to bind declared report frame clock');
36
+ const last=feedbackLastFrame(catalog,value.animation);
37
+ if(last!==null&&value.frameEnd>last)throw new Error(`feedback.frameEnd: ${value.frameEnd} beyond clip last frame ${last}`);
38
+ }
@@ -0,0 +1,36 @@
1
+ /** Explicit producer clock only. No duration, event spacing or video-rate fallback. */
2
+ export type FrameClock =
3
+ | {kind:'uniform';fps:number;frame0:number;frameCount?:number}
4
+ | {kind:'samples';frameTimes:number[]};
5
+
6
+ /** Source-only time remains usable; a player must identify the matching output. */
7
+ export function clockForAnimation(clock:FrameClock|null|undefined,animation:string|undefined,activeAnimation:string,hasPlayer=true):FrameClock|null {
8
+ if(!clock)return null;
9
+ return !hasPlayer||(animation&&animation===activeAnimation)?clock:null;
10
+ }
11
+
12
+ export function lastFrame(clock:FrameClock|null|undefined):number|null {
13
+ if(!clock)return null;
14
+ const count=clock.kind==='samples'?clock.frameTimes.length:clock.frameCount;
15
+ return count!==undefined&&Number.isSafeInteger(count)&&count>0?count-1:null;
16
+ }
17
+
18
+ /** Return exactly the declared sample time; invalid/noninteger frames stay unknown. */
19
+ export function timeAtFrame(clock:FrameClock|null|undefined,frame:number):number|null {
20
+ const last=lastFrame(clock);
21
+ if(!clock||last===null||!Number.isSafeInteger(frame)||frame<0||frame>last)return null;
22
+ const time=clock.kind==='samples'?clock.frameTimes[frame]!:clock.frame0+frame/clock.fps;
23
+ return Number.isFinite(time)?time:null;
24
+ }
25
+
26
+ /** The latest sample at or before time, only inside the closed declared domain. */
27
+ export function frameAtTime(clock:FrameClock|null|undefined,time:number):number|null {
28
+ const last=lastFrame(clock);if(last===null||!Number.isFinite(time))return null;
29
+ const firstTime=timeAtFrame(clock,0),lastTime=timeAtFrame(clock,last);
30
+ if(firstTime===null||lastTime===null||time<firstTime||time>lastTime)return null;
31
+ // Search the declared times directly: avoids both interpolation and floating
32
+ // multiplication rounding a mathematically exact uniform sample down a frame.
33
+ let low=0,high=last;
34
+ while(low<=high){const middle=low+Math.floor((high-low)/2);const sample=timeAtFrame(clock,middle);if(sample===null)return null;if(sample<=time)low=middle+1;else high=middle-1;}
35
+ return high>=0?high:null;
36
+ }
@@ -0,0 +1,140 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { toolVersions } from './versions.ts';
3
+ import type { FeedbackCatalog } from './feedback-validation.ts';
4
+ import { validatePoseSequence } from './pose.ts';
5
+ import { parseReport, type ReportSeeds } from './report.ts';
6
+ import { readFile, realpath, stat, lstat, readdir } from 'node:fs/promises';
7
+ import { createReadStream } from 'node:fs';
8
+ import { basename, dirname, isAbsolute, relative, resolve, posix } from 'node:path';
9
+
10
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
11
+ export interface Reference { id: string; label: string; type: 'image' | 'video' | 'pose'; path: string; width?: number; height?: number }
12
+ export interface Checkpoint { id: string; title: string; time?: number; animation?: string; reference?: string; note?: string; workspace?: 'modeling' | 'motion'; camera?: { zoom: number; x: number; y: number }; axis?: {id:string;value:number} }
13
+ export interface ReviewManifest {
14
+ schema: 'rig-review/1'; id: string; title: string; revision: string; model?: string; motion?: string;
15
+ references: Reference[]; animations: { id: string; label?: string; duration?: number }[];
16
+ checkpoints: Checkpoint[]; authoring: { label: string; path: string }[];
17
+ axes: { id: string; label: string; animation: string; min: number; max: number }[];
18
+ audio: { clips: { id: string; label?: string; path: string }[]; cues: { id: string; clip: string; time: number; animation?: string }[] };
19
+ }
20
+ export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null }
21
+ export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog }
22
+ function fail(field: string, expected: string): never { throw new Error(`${field}: expected ${expected}`); }
23
+ export function object(value: unknown, field: string): Record<string, unknown> { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(field, 'object'); return value as Record<string, unknown>; }
24
+ export function string(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim() || value.length > 10000) fail(field, 'nonempty string (at most 10000 characters)'); return value; }
25
+ function id(value: unknown, field: string): string { const s = string(value, field); if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(s)) fail(field, 'identifier [A-Za-z0-9][A-Za-z0-9._-], at most 128 characters'); return s; }
26
+ export function choice<T extends string>(value: unknown, choices: readonly T[], field: string): T { if (!choices.includes(value as T)) fail(field, choices.join(' | ')); return value as T; }
27
+ function number(value: unknown, field: string, min = 0): number { if (typeof value !== 'number' || !Number.isFinite(value) || value < min) fail(field, `finite number >= ${min}`); return value; }
28
+ function optionalString(value: unknown, field: string): string | undefined { return value === undefined ? undefined : string(value, field); }
29
+ function optionalNumber(value: unknown, field: string): number | undefined { return value === undefined ? undefined : number(value, field); }
30
+ function list<T>(value: unknown, field: string, parse: (v: Record<string, unknown>, field: string) => T): T[] {
31
+ if (value === undefined) return []; if (!Array.isArray(value) || value.length > 10000) fail(field, 'array of at most 10000 entries');
32
+ const result = value.map((v, i) => parse(object(v, `${field}[${i}]`), `${field}[${i}]`));
33
+ const ids = result.filter((v): v is T & { id: string } => !!v && typeof v === 'object' && 'id' in v);
34
+ if (new Set(ids.map(v => v.id)).size !== ids.length) fail(field, 'unique ids'); return result;
35
+ }
36
+ /** Manifest paths are literal portable relative paths, never encoded URLs. */
37
+ export function packagePath(value: unknown, field = 'path'): string {
38
+ const p = string(value, field);
39
+ if (isAbsolute(p) || /^[a-zA-Z]:/.test(p) || /[:\\%?#\x00-\x1f]/.test(p) || p.split('/').some(s => !s || s === '.' || s === '..') || posix.normalize(p) !== p) fail(field, 'literal relative path inside package; no traversal, encoding, backslash, query or fragment');
40
+ return p;
41
+ }
42
+ export async function safeFile(root: string, path: string): Promise<string> {
43
+ packagePath(path); const full = await realpath(resolve(root, path)); const rel = relative(root, full);
44
+ if (!rel || rel.startsWith('..' + '/') || rel === '..' || isAbsolute(rel)) fail(path, 'file inside package (symlinks may not escape)');
45
+ if (!(await stat(full)).isFile()) fail(path, 'regular file'); return full;
46
+ }
47
+ export function parseManifest(input: unknown): ReviewManifest {
48
+ const m = object(input, 'review'); if (m.schema !== 'rig-review/1') fail('schema', 'rig-review/1');
49
+ const audio = m.audio === undefined ? {} : object(m.audio, 'audio');
50
+ const manifest: ReviewManifest = {
51
+ schema: 'rig-review/1', id: id(m.id, 'id'), title: string(m.title, 'title'), revision: string(m.revision, 'revision'),
52
+ model: m.model === undefined ? undefined : packagePath(m.model, 'model'),
53
+ motion:m.motion===undefined?undefined:packagePath(m.motion,'motion'),
54
+ references: list(m.references, 'references', (v,f) => ({ id:id(v.id,f+'.id'), label:string(v.label,f+'.label'), type:choice(v.type,['image','video','pose'],f+'.type'), path:packagePath(v.path,f+'.path'), width:v.width===undefined?undefined:number(v.width,f+'.width',Number.EPSILON), height:v.height===undefined?undefined:number(v.height,f+'.height',Number.EPSILON) })),
55
+ animations: list(m.animations,'animations',(v,f) => ({id:id(v.id,f+'.id'),label:optionalString(v.label,f+'.label'),duration:optionalNumber(v.duration,f+'.duration')})),
56
+ checkpoints: list(m.checkpoints,'checkpoints',(v,f) => ({id:id(v.id,f+'.id'),title:string(v.title,f+'.title'),time:optionalNumber(v.time,f+'.time'),animation:optionalString(v.animation,f+'.animation'),reference:optionalString(v.reference,f+'.reference'),note:optionalString(v.note,f+'.note'),workspace:v.workspace === undefined ? undefined : choice(v.workspace,['modeling','motion'] as const,f+'.workspace'),axis:v.axis===undefined?undefined:(()=>{const a=object(v.axis,f+'.axis');return {id:id(a.id,f+'.axis.id'),value:number(a.value,f+'.axis.value',-Number.MAX_VALUE)};})(),camera:v.camera === undefined ? undefined : (() => {const c=object(v.camera,f+'.camera'); const zoom=number(c.zoom,f+'.camera.zoom',Number.EPSILON); return {zoom,x:number(c.x,f+'.camera.x',-Number.MAX_VALUE),y:number(c.y,f+'.camera.y',-Number.MAX_VALUE)};})()})),
57
+ authoring:list(m.authoring,'authoring',(v,f)=>({label:string(v.label,f+'.label'),path:packagePath(v.path,f+'.path')})),
58
+ axes:list(m.axes,'axes',(v,f)=>({id:id(v.id,f+'.id'),label:string(v.label,f+'.label'),animation:string(v.animation,f+'.animation'),min:number(v.min,f+'.min',-Number.MAX_VALUE),max:number(v.max,f+'.max',-Number.MAX_VALUE)})),
59
+ audio:{clips:list(audio.clips,'audio.clips',(v,f)=>({id:id(v.id,f+'.id'),label:optionalString(v.label,f+'.label'),path:packagePath(v.path,f+'.path')})),cues:list(audio.cues,'audio.cues',(v,f)=>({id:id(v.id,f+'.id'),clip:string(v.clip,f+'.clip'),time:number(v.time,f+'.time'),animation:optionalString(v.animation,f+'.animation')}))}
60
+ };
61
+ if(manifest.model&&!manifest.model.endsWith('.model.json'))fail('model','compiled model path ending .model.json (the play runtime input)');
62
+ for (const c of manifest.checkpoints) { if(c.reference && !manifest.references.some(r=>r.id===c.reference)) fail(`checkpoint ${c.id}.reference`,'declared reference id'); if(c.animation && !manifest.animations.some(a=>a.id===c.animation)) fail(`checkpoint ${c.id}.animation`,'declared animation id'); }
63
+ for (const c of manifest.audio.cues) { if(!manifest.audio.clips.some(a=>a.id===c.clip)) fail(`cue ${c.id}.clip`,'declared audio clip id'); if(c.animation && !manifest.animations.some(a=>a.id===c.animation)) fail(`cue ${c.id}.animation`,'declared animation id'); }
64
+ for (const a of manifest.axes) { if(a.min >= a.max) fail(`axis ${a.id}`,'min < max'); if(!manifest.animations.some(v=>v.id===a.animation)) fail(`axis ${a.id}.animation`,'declared animation id'); }
65
+ for(const c of manifest.checkpoints) if(c.axis){const a=manifest.axes.find(a=>a.id===c.axis!.id);if(!a)fail(`checkpoint ${c.id}.axis`,'declared axis id');if(c.axis.value<a.min||c.axis.value>a.max)fail(`checkpoint ${c.id}.axis.value`,`number in [${a.min}, ${a.max}]`);}
66
+ return manifest;
67
+ }
68
+ export async function loadReview(input: string): Promise<ReviewPackage> {
69
+ const inputPath=await realpath(resolve(input));const directory=(await stat(inputPath)).isDirectory();
70
+ if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'));}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}}
71
+ const manifestPath=inputPath;const root=directory?inputPath:await realpath(dirname(inputPath));
72
+ const manifest=directory?await folderManifest(root):parseManifest(JSON.parse(await readFile(manifestPath,'utf8')));
73
+ const files = new Set([...(directory?[]:[relative(root,manifestPath)]),...manifest.references.map(v=>v.path),...manifest.authoring.map(v=>v.path),...manifest.audio.clips.map(v=>v.path)]);
74
+ const rootFile=async(name:string):Promise<string|undefined>=>{try{await safeFile(root,name);return name;}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw e;}};
75
+ const authoringPath=(name:string):string|undefined=>{const paths=manifest.authoring.filter(v=>posix.basename(v.path)===name);return paths.length===1?paths[0].path:undefined;};
76
+ const rigPath=manifest.model??await rootFile('skeleton.json')??authoringPath('skeleton.json')??await rootFile('skeleton.skel')??authoringPath('skeleton.skel');
77
+ const motionPath=manifest.motion??await rootFile('motion.json')??authoringPath('motion.json');
78
+ if(rigPath)files.add(rigPath);if(motionPath)files.add(motionPath);
79
+ let modelBytesHash:string|null=null;
80
+ if(manifest.model) {
81
+ files.add(manifest.model); const bytes=await readFile(await safeFile(root,manifest.model));modelBytesHash=createHash('sha256').update(bytes).digest('hex');const model = object(JSON.parse(bytes.toString('utf8')),'model');
82
+ if(model.spec !== 'rigc-compiled/3') fail('model.spec','rigc-compiled/3, produced by a gated rigc build');
83
+ if(!Array.isArray(model.pages)) fail('model.pages','array');
84
+ for(const page of model.pages) { const p = object(page,'model.pages[]'); const name = string(p.name,'model.pages[].name'); if(isAbsolute(name)||/[:\\%?#\x00-\x1f]/.test(name)||name.split('/').some(v=>!v))fail('model.pages[].name','literal relative model page path'); files.add(packagePath(posix.join(posix.dirname(manifest.model),name),'model page resolved inside package')); }
85
+ }
86
+ for(const r of manifest.references.filter(r=>r.type==='pose')) {
87
+ const pose=object(JSON.parse(await readFile(await safeFile(root,r.path),'utf8')),`pose ${r.id}`);
88
+ if(pose.spec==='rig-pose-sequence/1'){validatePoseSequence(pose,`pose ${r.id}`);continue;}
89
+ if(pose.schema!=='rig-review-pose/1'||pose.coordinateSystem!=='image-pixels-y-down') fail(`pose ${r.id}`,'rig-review-pose/1 with image-pixels-y-down coordinates');
90
+ number(pose.width,`pose ${r.id}.width`,Number.EPSILON); number(pose.height,`pose ${r.id}.height`,Number.EPSILON);
91
+ const joints=list(pose.joints,`pose ${r.id}.joints`,(v,f)=>({id:id(v.id,f+'.id'),x:number(v.x,f+'.x',-Number.MAX_VALUE),y:number(v.y,f+'.y',-Number.MAX_VALUE),parent:optionalString(v.parent,f+'.parent')}));
92
+ for(const j of joints) if(j.parent&&!joints.some(v=>v.id===j.parent&&v.id!==j.id)) fail(`pose ${r.id} joint ${j.id}.parent`,'another declared joint id');
93
+ }
94
+ const reportPath=files.has('report.json')?'report.json':authoringPath('report.json');let report:ReportSeeds|null=null;let modelMetadata:Record<string,unknown>|null=null;
95
+ const hash = createHash('sha256');hash.update(JSON.stringify(manifest));hash.update('\0');const fileHashes=new Map<string,string>();
96
+ for(const p of [...files].sort()) { const full=await safeFile(root,p); const before=await stat(full); hash.update(p);hash.update('\0');hash.update(String(before.size));hash.update('\0'); const fileHash=createHash('sha256');const metadataChunks:Buffer[]=[];for await(const chunk of createReadStream(full)){hash.update(chunk);fileHash.update(chunk);if(p===reportPath||p===manifest.model)metadataChunks.push(chunk);}fileHashes.set(p,fileHash.digest('hex')); const after=await stat(full); if(before.size!==after.size||before.mtimeMs!==after.mtimeMs) throw new Error(`Asset ${p} changed while hashing; retry with stable package inputs`);
97
+ // The parser and package hash consume the same bytes, never two file reads.
98
+ if(p===manifest.model){if(fileHashes.get(p)!==modelBytesHash)throw new Error(`Model ${p} changed while hashing; retry with stable package inputs`);modelMetadata=object(JSON.parse(Buffer.concat(metadataChunks).toString('utf8')),'model');}
99
+ if(p===reportPath){let value:unknown;try{value=JSON.parse(Buffer.concat(metadataChunks).toString('utf8'));}catch(e){throw new Error(`${p}: ${e instanceof Error?e.message:String(e)}`);}report=parseReport(value,p);}
100
+ }
101
+ const reviewedSource:ReviewedSource={packageRevision:manifest.revision,...await toolVersions(),rig:rigPath?{path:rigPath,sha256:fileHashes.get(rigPath)!}:null,motion:motionPath?{path:motionPath,sha256:fileHashes.get(motionPath)!}:null};
102
+ const feedbackCatalog:FeedbackCatalog={rig:manifest.id,bones:modelNames(modelMetadata,'bones'),slots:modelNames(modelMetadata,'slots'),animations:modelAnimations(modelMetadata),clock:report?.clock??null,clockAnimation:report?.animation??null};
103
+ return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog};
104
+ }
105
+
106
+ /** An adapter over an existing output directory; never writes a manifest there. */
107
+ async function folderManifest(root:string):Promise<ReviewManifest>{
108
+ const entries=(await readdir(root,{withFileTypes:true})).filter(v=>v.isFile()||v.isSymbolicLink()).map(v=>v.name).sort();
109
+ const has=(name:string)=>entries.includes(name);const references:Reference[]=[];
110
+ let model:string|undefined;
111
+ // These are named layouts, not a recursive search. Check existence separately
112
+ // so an existing broken symlink is refused rather than treated as absent.
113
+ for(const candidate of ['skeleton.model.json','build/skeleton.model.json']){
114
+ try{await lstat(resolve(root,candidate));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;}
115
+ await safeFile(root,candidate);model=candidate;break;
116
+ }
117
+ for(const name of entries){
118
+ if(/\.(mp4|webm|mov)$/i.test(name))references.push({id:`video-${references.length+1}`,label:name,type:'video',path:name});
119
+ if(/(strip|contact|sheet|띠|filmstrip)/i.test(name)&&/\.(png|jpe?g|webp|svg)$/i.test(name))references.push({id:`strip-${references.length+1}`,label:name,type:'image',path:name});
120
+ if(/pose/i.test(name)&&/\.json$/i.test(name)){const value=JSON.parse(await readFile(await safeFile(root,name),'utf8'));if(value?.spec==='rig-pose-sequence/1'||value?.schema==='rig-review-pose/1')references.push({id:`pose-${references.length+1}`,label:name,type:'pose',path:name});}
121
+ }
122
+ const name=basename(root);const packageId=name.replace(/[^a-zA-Z0-9._-]+/g,'-').replace(/^[^a-zA-Z0-9]+/,'').slice(0,128)||'output-bundle';
123
+ const authoring=entries.filter(n=>n==='motion.json'||n==='report.json'||(/^skeleton\./i.test(n)&&n!=='skeleton.model.json')).map(path=>({label:path+' · read only',path}));
124
+ if(!references.length&&!authoring.length&&!model)throw new Error('Output folder: no recognized skeleton.*, build/skeleton.model.json, motion.json, report.json, source video, pose sequence or contact strip');
125
+ return parseManifest({schema:'rig-review/1',id:packageId,title:name,revision:'folder-adapter/1',model,motion:has('motion.json')?'motion.json':undefined,references,authoring,checkpoints:references.map(r=>({id:r.id,title:'Inspect '+r.label,reference:r.id,workspace:'motion'}))});
126
+ }
127
+
128
+ /** Exact hashed model bytes supply names; absent metadata stays unavailable. */
129
+ function modelNames(model:Record<string,unknown>|null,field:'bones'|'slots'):string[]|null {
130
+ if(model===null||model[field]===undefined)return null;
131
+ const entries=model[field];if(!Array.isArray(entries))fail(`model.${field}`,'array');
132
+ const names=entries.map((value,i)=>string(object(value,`model.${field}[${i}]`).name,`model.${field}[${i}].name`));
133
+ if(new Set(names).size!==names.length)fail(`model.${field}`,'unique names');return names;
134
+ }
135
+ function modelAnimations(model:Record<string,unknown>|null):FeedbackCatalog['animations'] {
136
+ if(model===null||model.animations===undefined)return null;
137
+ if(!Array.isArray(model.animations))fail('model.animations','array');
138
+ const animations=model.animations.map((value,i)=>{const v=object(value,`model.animations[${i}]`);return {name:string(v.name,`model.animations[${i}].name`),duration:number(v.duration,`model.animations[${i}].duration`)};});
139
+ if(new Set(animations.map(value=>value.name)).size!==animations.length)fail('model.animations','unique names');return animations;
140
+ }
@@ -0,0 +1,14 @@
1
+ /** Fixed-instance handshake and enforced landing binding; no filesystem control API. */
2
+ export interface OpenIdentity {key:string;contentHash:string;nonce:string;fingerprint:string}
3
+ export function openGate(req:Request,identity:OpenIdentity|undefined):Response|null{
4
+ if(!identity)return null;const url=new URL(req.url);
5
+ if(url.pathname==='/api/open-status'){
6
+ if(req.method!=='GET')return Response.json({error:'Method not allowed'},{status:405});
7
+ if(req.headers.get('authorization')!==`Bearer ${identity.nonce}`)return Response.json({error:'Open instance capability required'},{status:403});
8
+ return Response.json({schema:'rig-review-open-status/1',...identity},{headers:{'Cache-Control':'no-store'}});
9
+ }
10
+ if((url.pathname==='/'||url.pathname==='/index.html')&&(url.searchParams.get('input')!==identity.contentHash||url.searchParams.get('handoff')!==identity.key))return Response.json({error:'Open URL binding does not match this review instance'},{status:409});
11
+ return null;
12
+ }
13
+
14
+ export function staleOpenStatus(identity:OpenIdentity):Response{return Response.json({schema:'rig-review-open-status/1',...identity,ready:false,error:'Inputs changed; open a new review binding'},{status:409,headers:{'Cache-Control':'no-store'}});}
@@ -0,0 +1,11 @@
1
+ import type {ReviewManifest} from './manifest.ts';
2
+ /** An explicit CLI video wins automatic startup, never a human/deep-link choice. */
3
+ export function initialReviewSelection(manifest:Pick<ReviewManifest,'checkpoints'|'references'>&Partial<Pick<ReviewManifest,'axes'>>,requestedId:string|null,explicitId?:string,selectedAnimation?:string){
4
+ const automatic=manifest.checkpoints[0];
5
+ // Both the checkpoint and its optional axis must stay on the selected clip.
6
+ const axisAnimation=automatic?.axis?manifest.axes?.find(axis=>axis.id===automatic.axis!.id)?.animation:undefined;
7
+ const compatible=!selectedAnimation||(automatic?.animation===selectedAnimation&&(!automatic.axis||axisAnimation===selectedAnimation));
8
+ const checkpoint=requestedId?manifest.checkpoints.find(point=>point.id===requestedId):compatible?automatic:undefined;
9
+ const referenceId=requestedId&&checkpoint?.reference?checkpoint.reference:explicitId??checkpoint?.reference??manifest.references[0]?.id;
10
+ return {checkpoint,referenceId,updateUrl:!!requestedId||(!explicitId&&!selectedAnimation)};
11
+ }
@@ -0,0 +1,2 @@
1
+ /** POSIX copy/paste command words; execution itself always uses argument arrays. */
2
+ export function shellWord(value:string):string{return "'"+value.replace(/'/g,"'\\''")+"'";}
@@ -0,0 +1,2 @@
1
+ import {runOpenWorker} from './open.ts';
2
+ if(import.meta.main)runOpenWorker(Bun.argv[2]??'').catch(error=>{console.error(error instanceof Error?error.message:String(error));process.exit(1);});
package/src/open.ts ADDED
@@ -0,0 +1,126 @@
1
+ import {parseControlCommand,type ControlCommand,type ControlReceipt} from './control.ts';
2
+ /** One fixed-package worker per exact binding. There is no general daemon API. */
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+ import {spawn} from 'node:child_process';
5
+ import {homedir} from 'node:os';
6
+ import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,open as openFile} from 'node:fs/promises';
7
+ import {constants} from 'node:fs';
8
+ import {basename,dirname,join,relative,resolve,isAbsolute} from 'node:path';
9
+ import {fileURLToPath} from 'node:url';
10
+ import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
11
+ import {loadReview,type ReviewPackage} from './manifest.ts';
12
+ import {loadCompare,assertCompareUnchanged,type ComparePackage} from './compare.ts';
13
+ import {startServer} from './server.ts';
14
+ import {startCompareServer} from './compare-server.ts';
15
+ import {selectAnimation,type AnimationSelection} from './animation-selection.ts';
16
+ import type {OpenIdentity} from './open-protocol.ts';
17
+ import {playPackagePath} from './versions.ts';
18
+ export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean}
19
+ export type OpenPackage=({mode:'single';p:ReviewPackage;inputs:OpenInputs;contentHash:string}|{mode:'compare';p:ComparePackage;inputs:OpenInputs;contentHash:string})&{selection:AnimationSelection|null};
20
+ interface RecordFile extends OpenIdentity {schema:'rig-review-open-instance/1';pid:number;origin:string;controlToken?:string}
21
+ interface WorkerRequest {schema:'rig-review-open-request/1';inputs:OpenInputs;identity:OpenIdentity;stateDir:string;registry:string;request:string;idleMs:number;controlToken?:string}
22
+ const appRoot=fileURLToPath(new URL('..',import.meta.url));
23
+ const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
24
+ const sleep=(ms:number)=>new Promise(resolveSleep=>setTimeout(resolveSleep,ms));
25
+ export const defaultStateDir=()=>process.env.RIG_REVIEW_STATE_DIR??join(homedir(),'.local','state','rig-c-review');
26
+ export async function loadOpenPackage(inputs:OpenInputs):Promise<OpenPackage>{
27
+ if(!inputs.compare){const p=await loadReferencedReview(inputs.input,inputs.reference),selection=selectAnimation([{label:'single',catalog:p.feedbackCatalog,report:p.report}],inputs.animation,true);return {mode:'single',p,selection,contentHash:p.sourceHash,inputs:{input:p.manifestPath,...(selectedReference(p)?{reference:selectedReference(p)!.path}:{}),...(selection?{animation:selection.name}:{}),...(inputs.control?{control:true}:{})}};}
28
+ if(!inputs.reference)throw new Error('open compare: explicit --reference required');
29
+ const [a,b]=await Promise.all([loadReview(inputs.input),loadReview(inputs.compare)]),selection=selectAnimation([{label:'a',catalog:a.feedbackCatalog,report:a.report},{label:'b',catalog:b.feedbackCatalog,report:b.report}],inputs.animation)!;
30
+ const p=await loadCompare({a:inputs.input,b:inputs.compare,reference:inputs.reference,animation:selection.name});
31
+ if(p.a.sourceHash!==a.sourceHash||p.b.sourceHash!==b.sourceHash)throw new Error('open inputs changed while selecting animation');
32
+ return {mode:'compare',p,selection,contentHash:p.response.sourceHash,inputs:{input:p.inputs.a,compare:p.inputs.b,reference:p.inputs.reference,animation:selection.name,...(inputs.control?{control:true}:{})}};
33
+ }
34
+ /** Flat tool source/static files are small; no package or producer recursive scan. */
35
+ export async function openFingerprint():Promise<string>{
36
+ const hash=createHash('sha256');for(const directory of ['src','web'])for(const entry of (await readdir(join(appRoot,directory),{withFileTypes:true})).sort((a,b)=>a.name<b.name?-1:1))if(entry.isFile()&&/\.(ts|css|html)$/.test(entry.name)){hash.update(directory+'/'+entry.name+'\0');hash.update(await readFile(join(appRoot,directory,entry.name)));}
37
+ // An installed copy ships no lockfile; the resolved runtime manifest still binds its dependency.
38
+ hash.update('package.json\0');hash.update(await readFile(join(appRoot,'package.json')));
39
+ hash.update('bun.lock\0');try{hash.update(await readFile(join(appRoot,'bun.lock')));}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;hash.update('(absent)');}
40
+ hash.update('@rig-c/play/package.json\0');hash.update(await readFile(playPackagePath()));return hash.digest('hex');
41
+ }
42
+ export function openKey(p:OpenPackage,fingerprint:string):string {return digest({fingerprint,installation:appRoot,mode:p.mode,inputs:p.inputs,content:p.contentHash,identity:p.mode==='single'?p.p.reviewedSource:p.p.response.binding});}
43
+ export function openUrl(record:Pick<RecordFile,'origin'|'key'|'contentHash'>):string {const url=new URL(record.origin);url.searchParams.set('input',record.contentHash);url.searchParams.set('handoff',record.key);return url.href;}
44
+ async function smallJson(file:string):Promise<unknown>{const handle=await openFile(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const before=await handle.stat();if(!before.isFile()||before.size>65536)throw new Error('open state: bounded regular file required');const buffer=Buffer.alloc(before.size+1);let size=0;while(size<buffer.length){const next=await handle.read(buffer,size,buffer.length-size,size);if(!next.bytesRead)break;size+=next.bytesRead;}const after=await handle.stat();if(size!==before.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)throw new Error('open state: changed while reading');return JSON.parse(buffer.subarray(0,size).toString('utf8'));}finally{await handle.close();}}
45
+ function recordValue(value:unknown,key:string):RecordFile|null {if(!value||typeof value!=='object')return null;const v=value as Partial<RecordFile>;return v.schema==='rig-review-open-instance/1'&&v.key===key&&/^[a-f0-9]{64}$/.test(v.contentHash??'')&&/^[a-f0-9]{64}$/.test(v.fingerprint??'')&&typeof v.nonce==='string'&&/^[a-f0-9-]{36}$/.test(v.nonce)&&typeof v.origin==='string'&&/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(v.origin)&&Number(v.origin.slice(v.origin.lastIndexOf(':')+1))<=65535&&Number.isSafeInteger(v.pid)&&v.pid!>0?v as RecordFile:null;}
46
+ async function readRecord(file:string,key:string):Promise<RecordFile|null>{try{return recordValue(await smallJson(file),key);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
47
+ /** Hard byte ceiling and body deadline, including stalled or chunked responses. */
48
+ export async function readOpenStatus(response:Response,timeoutMs=1500):Promise<unknown>{
49
+ if(!response.body)throw new Error('open status: body required');const reader=response.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
50
+ try{return await Promise.race([(async()=>{const chunks:Uint8Array[]=[];let size=0;while(true){const next=await reader.read();if(next.done)break;size+=next.value.length;if(size>65536)throw new Error('open status: exceeds 64 KiB');chunks.push(next.value);}const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(bytes));})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('open status: body timeout')),timeoutMs);})]);}
51
+ finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});try{reader.releaseLock();}catch{}}
52
+ }
53
+ async function alive(record:RecordFile):Promise<boolean>{try{const response=await fetch(record.origin+'/api/open-status',{headers:{authorization:`Bearer ${record.nonce}`},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await readOpenStatus(response) as OpenIdentity&{schema:string};return v.schema==='rig-review-open-status/1'&&v.key===record.key&&v.contentHash===record.contentHash&&v.nonce===record.nonce&&v.fingerprint===record.fingerprint;}catch{return false;}}
54
+
55
+ function running(pid:number):boolean{try{process.kill(pid,0);return true;}catch(e){return (e as NodeJS.ErrnoException).code==='EPERM';}}
56
+ async function claimOwner(claim:string):Promise<{file:string;pid:number;nonce:string}|null>{
57
+ const info=await lstat(claim);if(info.isSymbolicLink()||!info.isDirectory())throw new Error('open.state: claim must be an owned directory');const names=await readdir(claim);if(!names.length)return null;if(names.length!==1||!/^owner(?:\.[a-f0-9-]{36})?\.json$/.test(names[0]!))throw new Error('open.state: invalid startup claim');const file=join(claim,names[0]!),owner=await smallJson(file) as {pid?:number;nonce?:string};if(!Number.isSafeInteger(owner.pid)||owner.pid!<=0||typeof owner.nonce!=='string'||!/^([a-f0-9-]{36})$/.test(owner.nonce)||names[0]!=='owner.json'&&names[0]!==`owner.${owner.nonce}.json`)throw new Error('open.state: invalid startup claimant');return {file,pid:owner.pid!,nonce:owner.nonce};
58
+ }
59
+ async function removeClaimFile(claim:string,owner:{file:string;nonce:string}){try{const current=await smallJson(owner.file) as {nonce?:string};if(current.nonce!==owner.nonce)return;await unlink(owner.file);await rmdir(claim);}catch(e){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((e as NodeJS.ErrnoException).code??''))throw e;}}
60
+ async function releaseClaim(claim:string,nonce:string){await removeClaimFile(claim,{file:join(claim,`owner.${nonce}.json`),nonce});}
61
+ async function canonicalDestination(path:string):Promise<string>{try{return await realpath(path);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;const parent=dirname(path);if(parent===path)throw e;return join(await canonicalDestination(parent),basename(path));}}
62
+ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null}>{
63
+ const p=await loadOpenPackage(inputs),fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
64
+ const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?[p.p.root]:[p.p.a.root,p.p.b.root];
65
+ for(const root of roots){const rel=relative(root,stateDir);if(!rel||rel!=='..'&&!rel.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&!isAbsolute(rel))throw new Error('open.state: must be outside input packages');}
66
+ const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');
67
+ const registry=join(canonicalControl,key+'.json'),claim=join(canonicalControl,key+'.claim'),nonce=randomUUID(),deadline=performance.now()+20000;
68
+ const reuse=async()=>{const row=await readRecord(registry,key);return row&&row.fingerprint===fingerprint&&row.contentHash===p.contentHash&&await alive(row)?row:null;};
69
+ const ready=await reuse();if(ready)return {url:openUrl(ready),reused:true,nonce:ready.nonce,inputs:p.inputs,selection:p.selection};
70
+ // Publish a populated directory atomically: no live owner ever exposes an
71
+ // empty claim. Unique owner filenames make stale cleanup safe after replacement.
72
+ const candidate=join(canonicalControl,`${key}.${nonce}.claiming`);await mkdir(candidate,{mode:0o700});await writeFile(join(candidate,`owner.${nonce}.json`),JSON.stringify({pid:process.pid,nonce}),{mode:0o600,flag:'wx'});
73
+ let claimed=false;
74
+ try{while(!claimed){try{try{await lstat(claim);const occupied=new Error('claim exists') as NodeJS.ErrnoException;occupied.code='EEXIST';throw occupied;}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}await rename(candidate,claim);claimed=true;}
75
+ catch(e){if(!['EEXIST','ENOTEMPTY'].includes((e as NodeJS.ErrnoException).code??''))throw e;const row=await reuse();if(row){await rm(candidate,{recursive:true});return {url:openUrl(row),reused:true,nonce:row.nonce,inputs:p.inputs,selection:p.selection};}
76
+ try{const before=await lstat(claim),owner=await claimOwner(claim);if(owner&&!running(owner.pid)){const again=await claimOwner(claim);if(again?.nonce===owner.nonce&&(await lstat(claim)).ino===before.ino)await removeClaimFile(claim,owner);}else if(!owner&&Date.now()-before.mtimeMs>=250&&(await lstat(claim)).ino===before.ino){try{await rmdir(claim);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((error as NodeJS.ErrnoException).code??''))throw error;}}}
77
+ catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error('OPEN_BUSY: instance startup claim did not become ready');await sleep(30);}}}
78
+ catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
79
+ let request:string|undefined,spawned:ReturnType<typeof spawn>|undefined,handedOff=false;
80
+ try{
81
+ const row=await reuse();if(row)return {url:openUrl(row),reused:true,nonce:row.nonce,inputs:p.inputs,selection:p.selection};
82
+ const identity:OpenIdentity={key,contentHash:p.contentHash,nonce,fingerprint};request=join(canonicalControl,`${key}.${nonce}.request.json`);const log=join(canonicalControl,`${key}.${nonce}.log`);
83
+ const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs:options.idleMs??30*60*1000,...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
84
+ const logHandle=await openFile(log,'a',0o600);let child;try{child=spawn(process.execPath,[join(appRoot,'src/open-worker.ts'),request],{cwd:canonicalControl,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});spawned=child;child.unref();}finally{await logHandle.close();}
85
+ let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
86
+ while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {url:openUrl(record),reused:false,nonce:record.nonce,inputs:p.inputs,selection:p.selection};}if(exited)throw new Error(`OPEN_START: ${exited}; worker log ${log}`);await sleep(30);}throw new Error(`OPEN_TIMEOUT: worker did not become ready; log ${log}`);
87
+ }finally{if(spawned&&!handedOff&&spawned.exitCode===null&&spawned.signalCode===null)spawned.kill('SIGTERM');await releaseClaim(claim,nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
88
+ }
89
+ export async function runOpenWorker(requestPath:string):Promise<void>{
90
+ const raw=await smallJson(requestPath) as WorkerRequest;if(raw.schema!=='rig-review-open-request/1'||!raw.identity||!recordValue({schema:'rig-review-open-instance/1',...raw.identity,pid:process.pid,origin:'http://127.0.0.1:1'},raw.identity.key))throw new Error('open worker: invalid request');
91
+ const spec=raw,control=await realpath(join(spec.stateDir,'open'));if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.registry!==join(control,spec.identity.key+'.json')||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('open worker: invalid state locations or idle limit');
92
+ const p=await loadOpenPackage(spec.inputs),fingerprint=await openFingerprint();if(fingerprint!==spec.identity.fingerprint||openKey(p,fingerprint)!==spec.identity.key||p.contentHash!==spec.identity.contentHash)throw new Error('open worker: inputs changed before startup');
93
+ if(spec.inputs.control&&(!spec.controlToken||!/^[a-f0-9-]{36}$/.test(spec.controlToken)))throw new Error('open control: invalid private capability');
94
+ let last=performance.now();const options={port:0,stateDir:spec.stateDir,open:spec.identity,activity:()=>{last=performance.now();},...(spec.inputs.control?{control:{token:spec.controlToken!,instanceKey:spec.identity.key}}:{})};
95
+ const server=p.mode==='single'?await startServer(p.p,{...options,initialAnimation:p.selection?.name}):await startCompareServer(p.p,options);
96
+ const check=()=>p.mode==='single'?assertReviewUnchanged(p.p):assertCompareUnchanged(p.p);
97
+ await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}`,...(spec.inputs.control?{controlToken:spec.controlToken}:{})};const tmp=spec.registry+'.'+spec.identity.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.registry);
98
+ await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
99
+ let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;server.stop(true);clearInterval(timer);const current=await readRecord(spec.registry,spec.identity.key);if(current?.nonce===spec.identity.nonce)await unlink(spec.registry).catch(()=>{});};
100
+ const timer=setInterval(()=>{if(performance.now()-last>=spec.idleMs)void stop();},Math.min(1000,spec.idleMs));process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
101
+ }
102
+
103
+ /** Read/control an already running, explicitly opted-in exact binding. Never starts a worker. */
104
+ export async function controlReview(inputs:OpenInputs,commandOptions:{pageId?:string;expectedRevision?:number;requestId?:string;command?:ControlCommand;waitMs?:number;stateDir?:string}={}):Promise<unknown>{
105
+ const wait=commandOptions.waitMs??10000;if(!Number.isSafeInteger(wait)||wait<0||wait>30000)throw new Error('CONTROL_WAIT: expected integer 0..30000');
106
+ const p=await loadOpenPackage({...inputs,control:true}),fingerprint=await openFingerprint(),key=openKey(p,fingerprint),stateDir=await canonicalDestination(resolve(commandOptions.stateDir??defaultStateDir()));
107
+ const controlDir=join(stateDir,'open');
108
+ try{if((await lstat(controlDir)).isSymbolicLink())throw new Error('CONTROL_STATE: control directory cannot be a symlink');}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');throw error;}
109
+ const file=join(controlDir,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)throw new Error('CONTROL_STATE: owner-only regular capability record required');}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');throw error;}
110
+ const record=await readRecord(file,key);
111
+ if(!record||!record.controlToken||!await alive(record))throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');
112
+ const headers={Authorization:'Bearer '+record.controlToken,Origin:record.origin,'Content-Type':'application/json'};
113
+ const get=async(path:string,body?:unknown)=>{const response=await fetch(record.origin+path,{method:body===undefined?'GET':'POST',headers,...(body===undefined?{}:{body:JSON.stringify(body)}),redirect:'error',signal:AbortSignal.timeout(5000)});const value=await readOpenStatus(response,5000);if(!response.ok)throw new Error((value as {error?:string}).error??'CONTROL_REQUEST_FAILED');return value;};
114
+ const state=await get('/api/control') as {sourceHash:string;instanceKey:string;pages:{pageId:string;revision:number}[]};
115
+ if(state.sourceHash!==p.contentHash||state.instanceKey!==key)throw new Error('CONTROL_STALE_BINDING');
116
+ if(!commandOptions.command)return state;
117
+ const page=commandOptions.pageId?state.pages.find(row=>row.pageId===commandOptions.pageId):state.pages.length===1?state.pages[0]:undefined;
118
+ if(!page)throw new Error(state.pages.length>1?'CONTROL_PAGE_REQUIRED: select exact --page from current state':'CONTROL_NO_PAGE');
119
+ if(!Number.isSafeInteger(commandOptions.expectedRevision)||commandOptions.expectedRevision!<0)throw new Error('CONTROL_REVISION_REQUIRED: use the observed --expected-revision');
120
+ const command=parseControlCommand(commandOptions.command),request={schema:'rig-review-control-command/1',requestId:commandOptions.requestId??randomUUID(),pageId:page.pageId,instanceKey:key,sourceHash:p.contentHash,expectedRevision:commandOptions.expectedRevision,command};
121
+ const receipt=await get('/api/control/commands',request) as ControlReceipt;
122
+ if(wait===0)return receipt;
123
+ const deadline=performance.now()+wait;let current=receipt;
124
+ while(['queued','dispatched'].includes(current.status)&&performance.now()<deadline){await sleep(Math.min(100,Math.max(1,deadline-performance.now())));current=await get('/api/control/receipts/'+encodeURIComponent(current.commandId)) as ControlReceipt;}
125
+ return {...current,...(['queued','dispatched'].includes(current.status)?{timedOut:true}:{} )};
126
+ }
package/src/pose.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** The canonical source-pose boundary; no retargeting or invented observations. */
2
+ export function validatePoseSequence(input:unknown,label:string):void {
3
+ const fail=(field:string,expectation:string):never=>{throw new Error(`${label} ${field}: expected ${expectation}`);};
4
+ const obj=(v:unknown,f:string):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))return fail(f,'object');return v as Record<string,unknown>;};
5
+ const finite=(v:unknown,f:string):number=>{if(typeof v!=='number'||!Number.isFinite(v))return fail(f,'finite number');return v;};
6
+ const seq=obj(input,'sequence');if(seq.spec!=='rig-pose-sequence/1')fail('spec','rig-pose-sequence/1');
7
+ const space=obj(seq.space,'space');if(space.units!=='picture-px'||space.origin!=='top-left'||space.y!=='down')fail('space','picture-px, top-left, y down');
8
+ if(finite(seq.width,'width')<=0||finite(seq.height,'height')<=0)fail('dimensions','positive width and height');
9
+ if(!Array.isArray(seq.frames)||!seq.frames.length)fail('frames','nonempty frame array');
10
+ let previous=-Infinity;
11
+ for(const [index,raw] of (seq.frames as unknown[]).entries()){
12
+ const frame=obj(raw,`frames[${index}]`);const t=finite(frame.t,`frames[${index}].t`);if(t<=previous)fail(`frames[${index}].t`,'strictly increasing time in seconds');previous=t;
13
+ if(!Array.isArray(frame.people))fail(`frames[${index}].people`,'array');const ids=new Set<string>();
14
+ for(const personRaw of frame.people as unknown[]){const person=obj(personRaw,'person');const personId=typeof person.id==='string'&&person.id?person.id:fail('person.id','nonempty string');if(ids.has(personId))fail('person.id','unique string per frame');ids.add(personId);const joints=obj(person.joints,'joints');
15
+ for(const [name,jointRaw] of Object.entries(joints)){const joint=obj(jointRaw,`joint ${name}`);if(!['observed','occluded','missing','inferred'].includes(String(joint.state)))fail(`joint ${name}.state`,'observed | occluded | missing | inferred');
16
+ if(joint.at!==undefined){const at=Array.isArray(joint.at)?joint.at:fail(`joint ${name}.at`,'[x,y]');if(at.length!==2)fail(`joint ${name}.at`,'[x,y]');finite(at[0],`joint ${name}.at[0]`);finite(at[1],`joint ${name}.at[1]`);}else if(joint.state==='inferred')fail(`joint ${name}.at`,'[x,y] for inferred joint');
17
+ if(joint.score!==undefined)finite(joint.score,`joint ${name}.score`);
18
+ }
19
+ }
20
+ }
21
+ if(seq.camera!==undefined){
22
+ if(!Array.isArray(seq.camera)||seq.camera.length!==(seq.frames as unknown[]).length)fail('camera','one camera entry per frame');
23
+ for(const [index,item] of (seq.camera as unknown[]).entries()){const c=obj(item,`camera[${index}]`);if(finite(c.s,'camera.s')<=0)fail('camera.s','positive scale');finite(c.tx,'camera.tx');finite(c.ty,'camera.ty');const inliers=finite(c.inliers,'camera.inliers');if(!Number.isInteger(inliers)||inliers<0)fail('camera.inliers','integer >= 0');}
24
+ }
25
+ if(seq.travel!==undefined&&seq.travel!=='in-place')fail('travel','in-place');
26
+ if(seq.cameraSource!==undefined&&(typeof seq.cameraSource!=='string'||!seq.cameraSource.trim()||seq.camera===undefined))fail('cameraSource','nonempty string with camera array');
27
+ }
@@ -0,0 +1,16 @@
1
+ import {execFile} from 'node:child_process';
2
+ import type {VideoInfo} from './reference-frames.ts';
3
+ function fail(code:string,text:string):never {throw new Error(`${code}: ${text}`);}
4
+ function command(binary:string,args:string[],kind:string):Promise<Buffer>{
5
+ return new Promise((resolve,reject)=>execFile(binary,args,{encoding:'buffer',timeout:60000,maxBuffer:32*1024*1024,windowsHide:true},(error,stdout,stderr)=>{if(error){reject(new Error(`STRIP_${kind}: ${binary}: ${error.message}; ${stderr.toString('utf8').slice(0,1200)}`));return;}resolve(stdout);}));
6
+ }
7
+ export async function probeReference(referencePath:string,binary:string):Promise<VideoInfo>{
8
+ const bytes=await command(binary,['-v','error','-threads','1','-select_streams','v:0','-show_frames','-show_streams','-show_format','-show_entries','frame=best_effort_timestamp_time:stream=duration:format=duration','-of','json',referencePath],'FFPROBE');
9
+ const value=JSON.parse(bytes.toString('utf8')) as {frames?:{best_effort_timestamp_time?:string}[];streams?:{duration?:string}[];format?:{duration?:string}};
10
+ if(!Array.isArray(value.frames)||!value.frames.length)fail('STRIP_REFERENCE','no decoded video frames');
11
+ const frames=value.frames.map((frame,index)=>({index,time:Number(frame.best_effort_timestamp_time)}));
12
+ for(let i=0;i<frames.length;i++)if(!Number.isFinite(frames[i]!.time)||frames[i]!.time<0||(i>0&&frames[i]!.time<frames[i-1]!.time))fail('STRIP_REFERENCE','expected finite ordered nonnegative decoded PTS');
13
+ const duration=Number(value.streams?.[0]?.duration??value.format?.duration);
14
+ if(!Number.isFinite(duration)||duration<frames.at(-1)!.time)fail('STRIP_REFERENCE','finite video duration covering decoded frames required');
15
+ return {frames,duration};
16
+ }
@@ -0,0 +1,16 @@
1
+ /** One fixed server-selected video capability. Cache only after byte verification. */
2
+ import {hashLocalVideo} from './reference.ts';
3
+ import {probeReference} from './probe-reference.ts';
4
+ import {requireUniqueReferencePts,type ReferenceFrameIndex,type VideoInfo} from './reference-frames.ts';
5
+ export function referenceFrameIndex(path:string,sha256:string,sourceHash:string,binary=process.env.RIG_REVIEW_FFPROBE??'ffprobe'):()=>Promise<ReferenceFrameIndex>{
6
+ let cached:VideoInfo|null=null,pending:Promise<VideoInfo>|null=null;
7
+ const unchanged=async()=>{try{const current=await hashLocalVideo(path);if(current.path!==path||current.sha256!==sha256)throw new Error('hash differs');}catch{throw new Error('Reference inputs changed. Restart review to bind current video bytes.');}};
8
+ return async()=>{
9
+ await unchanged();
10
+ if(!cached){
11
+ if(!pending)pending=(async()=>{const video=await probeReference(path,binary);requireUniqueReferencePts(video);await unchanged();return video;})();
12
+ try{cached=await pending;}catch(error){await unchanged();return {available:false,reason:error instanceof Error&&error.message.includes('REFERENCE_FRAME_PTS_AMBIGUOUS')?'REFERENCE_FRAME_PTS_AMBIGUOUS: exact frame selection unavailable for duplicate PTS':'REFERENCE_FRAME_INDEX_UNAVAILABLE: ffprobe must decode an unambiguous timestamp index; basic seconds playback remains available'};}finally{pending=null;}
13
+ }
14
+ await unchanged();return {available:true,sourceHash,referenceSha256:sha256,video:cached};
15
+ };
16
+ }
@@ -0,0 +1,62 @@
1
+ /** Actual reference PTS and explicit producer-sample associations. No guessed rate. */
2
+ import { lastFrame,timeAtFrame,type FrameClock } from './frame-clock.ts';
3
+ import {requireCommonTime,type CompareDomain} from './compare-playhead.ts';
4
+ export interface VideoFrame {index:number;time:number}
5
+ export interface VideoInfo {frames:VideoFrame[];duration:number}
6
+ export type ReferenceFrameIndex = {available:true;sourceHash:string;referenceSha256:string;video:VideoInfo}|{available:false;reason:string};
7
+ /** Keep the strip's decoded selection contract, including its earlier-PTS tie. */
8
+ export function nearestReferenceFrame(frames:readonly VideoFrame[],time:number):VideoFrame {
9
+ if(!Number.isFinite(time)||!frames.length)throw new Error('STRIP_REFERENCE_TIME: finite time and decoded frames required');
10
+ let lo=0,hi=frames.length;while(lo<hi){const mid=lo+Math.floor((hi-lo)/2);if(frames[mid]!.time<time)lo=mid+1;else hi=mid;}
11
+ const after=frames[lo],before=frames[lo-1];if(!before)return after!;if(!after)return before;
12
+ return time-before.time<=after.time-time?before:after;
13
+ }
14
+ export function requireUniqueReferencePts(video:VideoInfo):void {
15
+ if(!video.frames.length||!Number.isFinite(video.duration))throw new Error('REFERENCE_FRAME_INDEX_INVALID: finite duration and frames required');
16
+ for(let i=0;i<video.frames.length;i++){const frame=video.frames[i]!;if(frame.index!==i||!Number.isFinite(frame.time)||frame.time<0||frame.time>=video.duration)throw new Error('REFERENCE_FRAME_INDEX_INVALID: ordered finite frames inside video duration required');if(i&&frame.time<=video.frames[i-1]!.time)throw new Error('REFERENCE_FRAME_PTS_AMBIGUOUS: duplicate or unordered PTS cannot identify an exact decoded frame');}
17
+ }
18
+ /** Seek inside the selected real frame's interval, not onto rounded PTS boundary. */
19
+ export function referenceSeekPosition(video:VideoInfo,frame:VideoFrame):number {
20
+ const next=video.frames[frame.index+1]?.time??video.duration;
21
+ if(video.frames[frame.index]!==frame||!Number.isFinite(next)||next<=frame.time)throw new Error('REFERENCE_FRAME_SEEK_UNAVAILABLE: no real frame interval');
22
+ const middle=frame.time+(next-frame.time)/2;
23
+ if(!(middle>frame.time&&middle<next))throw new Error('REFERENCE_FRAME_SEEK_UNAVAILABLE: frame interval has no representable interior');
24
+ return middle;
25
+ }
26
+ /** Browser PTS can retain more precision than ffprobe decimal output. Match the
27
+ * nearest indexed timestamp inside the actual video domain; no epsilon or rate. */
28
+ export function referenceFrameAtTime(video:VideoInfo,time:number):VideoFrame|null {
29
+ if(!Number.isFinite(time)||!Number.isFinite(video.duration)||!video.frames.length||time<0||time>=video.duration)return null;
30
+ return nearestReferenceFrame(video.frames,time);
31
+ }
32
+ /** CPU submission budget estimate from adjacent decoded timestamps. The browser
33
+ * reports the current display time only; this is not a compositor acknowledgement. */
34
+ export function nextReferenceInterval(video:VideoInfo|null,time:number,playbackRate:number):number|null {
35
+ if(!video||!Number.isFinite(playbackRate)||playbackRate<=0)return null;
36
+ const current=referenceFrameAtTime(video,time);if(!current)return null;
37
+ const next=video.frames[current.index+1];if(!next)return null;
38
+ const interval=(next.time-current.time)/playbackRate;
39
+ return Number.isFinite(interval)&&interval>0?interval:null;
40
+ }
41
+ /** Invert explicit producer sample→nearest decoded index only when one-to-one.
42
+ * This is a review sample association, not frameAtTime's floor interpretation. */
43
+ export function producerFrameAssociations(clock:FrameClock|null|undefined,video:VideoInfo):Map<number,number|null> {
44
+ const result=new Map<number,number|null>(),last=lastFrame(clock);if(last===null)return result;if(last>=100000)throw new Error('REFERENCE_SAMPLE_LIMIT: exact review association supports at most 100000 explicit samples');
45
+ for(let frame=0;frame<=last;frame++){const time=timeAtFrame(clock,frame);if(time===null||time<0||time>video.duration)continue;const index=nearestReferenceFrame(video.frames,time).index;result.set(index,result.has(index)?null:frame);}
46
+ return result;
47
+ }
48
+
49
+ export class ReferenceSelectionUnavailable extends Error {constructor(detail:string){super(`REFERENCE_SELECTION_UNAVAILABLE: ${detail}`);this.name='ReferenceSelectionUnavailable';}}
50
+ /** Refuse an unsupported selection before the caller changes video or inspection. */
51
+ export function inspectionReferenceFrame(video:VideoInfo,time:number,domain:CompareDomain):VideoFrame {
52
+ requireCommonTime(time,domain);
53
+ if(time<0||time>video.duration)throw new ReferenceSelectionUnavailable('requested time is outside video duration');
54
+ const selected=nearestReferenceFrame(video.frames,time);
55
+ if(selected.time<domain.start||selected.time>domain.end)throw new ReferenceSelectionUnavailable(`nearest decoded frame at ${selected.time} s is outside the shared clip domain; choose an earlier supported reference frame`);
56
+ return selected;
57
+ }
58
+ /** Explicit playback stop selection, distinct from nearest-request selection. */
59
+ export function lastSupportedReferenceFrame(video:VideoInfo,domain:CompareDomain):VideoFrame|null {
60
+ let lo=0,hi=video.frames.length;while(lo<hi){const mid=lo+Math.floor((hi-lo)/2);if(video.frames[mid]!.time<=domain.end)lo=mid+1;else hi=mid;}
61
+ const frame=video.frames[lo-1];return frame&&frame.time>=domain.start?frame:null;
62
+ }