@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,197 @@
1
+ import type { Json, ReviewedSource } from './manifest.ts';
2
+ import type { Feedback, FeedbackWarning, ReviewSession } from './session.ts';
3
+ import type { CompareBinding } from './compare.ts';
4
+ import type { CompareSession } from './compare-session.ts';
5
+ import type { FeedbackHistory, HistoryRetrieval } from './session-history.ts';
6
+ import type { ReferenceBinding } from './reference.ts';
7
+ type Category = Feedback['category'];
8
+ type Side = 'single' | 'a' | 'b';
9
+ type ParsedFeedbackSession = FeedbackHistory<ReviewSession | CompareSession>;
10
+ interface Counts { defect: number; quality: number; total: number }
11
+ interface BuildCount {
12
+ side: Side;
13
+ sourceHash: string;
14
+ reviewedSource: ReviewedSource;
15
+ counts: Counts;
16
+ }
17
+ interface ExportItem {
18
+ id: string;
19
+ side: Side;
20
+ frameStart: number;
21
+ frameEnd: number;
22
+ target: Feedback['target'];
23
+ category: Category;
24
+ summary: string;
25
+ context?: Json;
26
+ warnings: FeedbackWarning[];
27
+ selectedSource: { sourceHash: string; reviewedSource: ReviewedSource; referenceBinding?: ReferenceBinding };
28
+ }
29
+ interface TargetGroup {
30
+ target: Feedback['target'];
31
+ items: ExportItem[];
32
+ }
33
+ interface CategoryGroup {
34
+ category: Category;
35
+ count: number;
36
+ targets: TargetGroup[];
37
+ }
38
+ export interface FeedbackIssueExport {
39
+ schema: 'rig-review-issue-export/1';
40
+ session: {
41
+ schema: ReviewSession['schema'] | CompareSession['schema'];
42
+ id: string;
43
+ revision: string;
44
+ sourceHash: string;
45
+ status: ReviewSession['status'];
46
+ updatedAt: string | null;
47
+ };
48
+ counts: Counts;
49
+ builds: BuildCount[];
50
+ categories: CategoryGroup[];
51
+ pairedBinding: CompareBinding | null;
52
+ referenceBinding: ReferenceBinding | null;
53
+ feedbackWarnings: FeedbackWarning[];
54
+ retrieval?: HistoryRetrieval;
55
+ historyWarnings: { file: string; error: string }[];
56
+ }
57
+ const categories: readonly Category[] = ['defect', 'quality'];
58
+ const sideOrder: Record<Side, number> = { single: 0, a: 1, b: 2 };
59
+ const compareText = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
60
+ const emptyCounts = (): Counts => ({ defect: 0, quality: 0, total: 0 });
61
+ function cloneSource(source: ReviewedSource): ReviewedSource {
62
+ return { ...source, rig: source.rig ? { ...source.rig } : null, motion: source.motion ? { ...source.motion } : null };
63
+ }
64
+ function cloneJson(value: Json): Json {
65
+ if (Array.isArray(value)) return value.map(cloneJson);
66
+ if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child)]));
67
+ return value;
68
+ }
69
+ function cloneReference(binding: ReferenceBinding): ReferenceBinding {
70
+ return { packageSourceHash: binding.packageSourceHash, reference: { ...binding.reference } };
71
+ }
72
+ function cloneBinding(binding: CompareBinding): CompareBinding {
73
+ return {
74
+ a: { sourceHash: binding.a.sourceHash, reviewedSource: cloneSource(binding.a.reviewedSource) },
75
+ b: { sourceHash: binding.b.sourceHash, reviewedSource: cloneSource(binding.b.reviewedSource) },
76
+ reference: { ...binding.reference }, animation: binding.animation,
77
+ };
78
+ }
79
+
80
+ /** Counts records once; never validates/migrates a submission or expands frames. */
81
+ export function groupFeedbackIssues(input: ParsedFeedbackSession): FeedbackIssueExport {
82
+ const referenceBinding = input.schema === 'rig-review-session/1' && input.referenceBinding ? cloneReference(input.referenceBinding) : null;
83
+ const pairedBinding = input.schema === 'rig-review-compare-session/1' ? cloneBinding(input.binding) : null;
84
+ const builds: BuildCount[] = input.schema === 'rig-review-compare-session/1'
85
+ ? (['a', 'b'] as const).map(side => ({ side, sourceHash: input.binding[side].sourceHash, reviewedSource: cloneSource(input.binding[side].reviewedSource), counts: emptyCounts() }))
86
+ : [{ side: 'single', sourceHash: input.sourceHash, reviewedSource: cloneSource(input.reviewedSource), counts: emptyCounts() }];
87
+ const bySide = new Map(builds.map(build => [build.side, build]));
88
+ const warningMap = new Map<string, FeedbackWarning[]>();
89
+ for (const warning of input.feedbackWarnings) {
90
+ const list = warningMap.get(warning.id) ?? [];
91
+ list.push({ ...warning }); warningMap.set(warning.id, list);
92
+ }
93
+ const grouped = new Map<Category, Map<string, TargetGroup>>(categories.map(category => [category, new Map()]));
94
+ const counts = emptyCounts();
95
+ const seen = new Set<string>();
96
+ for (const feedback of input.feedback) {
97
+ // Parsers already enforce these invariants. Named failures prevent silent
98
+ // omission if a future CLI accidentally bypasses the parsed-input boundary.
99
+ if (seen.has(feedback.id)) throw new Error(`feedback export.id: duplicate ${JSON.stringify(feedback.id)}`);
100
+ seen.add(feedback.id);
101
+ const side: Side = input.schema === 'rig-review-compare-session/1'
102
+ ? (feedback as Feedback & { side: 'a' | 'b' }).side : 'single';
103
+ const build = bySide.get(side);
104
+ if (!build) throw new Error(`feedback export.side: unavailable ${JSON.stringify(side)}`);
105
+ const targetGroups = grouped.get(feedback.category);
106
+ if (!targetGroups) throw new Error(`feedback export.category: unavailable ${JSON.stringify(feedback.category)}`);
107
+ const targetKey = JSON.stringify([feedback.target.kind, feedback.target.name]);
108
+ let target = targetGroups.get(targetKey);
109
+ if (!target) { target = { target: { ...feedback.target }, items: [] }; targetGroups.set(targetKey, target); }
110
+ target.items.push({
111
+ id: feedback.id, side, frameStart: feedback.frameStart, frameEnd: feedback.frameEnd,
112
+ target: { ...feedback.target }, category: feedback.category, summary: feedback.summary,
113
+ ...(feedback.context === undefined ? {} : { context: cloneJson(feedback.context) }),
114
+ warnings: [...(warningMap.get(feedback.id) ?? [])].sort((a, b) => compareText(a.error, b.error)),
115
+ selectedSource: { sourceHash: build.sourceHash, reviewedSource: cloneSource(build.reviewedSource), ...(referenceBinding ? { referenceBinding: cloneReference(referenceBinding) } : {}) },
116
+ });
117
+ counts[feedback.category]++; counts.total++;
118
+ build.counts[feedback.category]++; build.counts.total++;
119
+ }
120
+ const categoryGroups: CategoryGroup[] = categories.map(category => {
121
+ const targets = [...grouped.get(category)!.values()].sort((a, b) => compareText(a.target.kind, b.target.kind) || compareText(a.target.name, b.target.name));
122
+ for (const target of targets) target.items.sort((a, b) => sideOrder[a.side] - sideOrder[b.side] || compareText(a.id, b.id));
123
+ return { category, count: counts[category], targets };
124
+ });
125
+ return {
126
+ schema: 'rig-review-issue-export/1',
127
+ session: { schema: input.schema, id: input.id, revision: input.revision, sourceHash: input.sourceHash, status: input.status, updatedAt: input.updatedAt },
128
+ counts, builds, categories: categoryGroups, pairedBinding, referenceBinding,
129
+ feedbackWarnings: input.feedbackWarnings.map(warning => ({ ...warning })).sort((a, b) => compareText(a.id, b.id) || compareText(a.error, b.error)),
130
+ ...(input.retrieval ? { retrieval: { ...input.retrieval, currentTools: 'a' in input.retrieval.currentTools ? { a: { ...input.retrieval.currentTools.a }, b: { ...input.retrieval.currentTools.b } } : { ...input.retrieval.currentTools } } } : {}),
131
+ historyWarnings: (input.historyWarnings ?? []).map(warning => ({ ...warning })).sort((a, b) => compareText(a.file, b.file) || compareText(a.error, b.error)),
132
+ };
133
+ }
134
+
135
+ /** Entity-escape text in Markdown syntax positions; no raw HTML/line injection. */
136
+ export function feedbackMarkdownText(value: string): string {
137
+ return value.replace(/[&<>\\`*_{}\[\]()#+.!|~\-\r\n\u0000-\u001f\u007f]/g, char => `&#${char.charCodeAt(0)};`);
138
+ }
139
+ function stableJson(value: unknown): string {
140
+ const order = (item: unknown): unknown => {
141
+ if (Array.isArray(item)) return item.map(order);
142
+ if (item !== null && typeof item === 'object') return Object.fromEntries(Object.entries(item).sort(([a], [b]) => compareText(a, b)).map(([key, child]) => [key, order(child)]));
143
+ return item;
144
+ };
145
+ const serialized = JSON.stringify(order(value), null, 2);
146
+ if (serialized === undefined) throw new Error('feedback export: expected serializable parsed evidence');
147
+ return serialized;
148
+ }
149
+ /** A data-owned backtick run can never close the generated fenced JSON block. */
150
+ function jsonBlock(value: unknown): string {
151
+ const json = stableJson(value);
152
+ let longest = 0;
153
+ for (const run of json.matchAll(/`+/g)) longest = Math.max(longest, run[0].length);
154
+ const fence = '`'.repeat(Math.max(3, longest + 1));
155
+ return `${fence}json\n${json}\n${fence}`;
156
+ }
157
+
158
+ /** Local issue-ready text only. No issue creation, clock inference or source IO. */
159
+ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
160
+ const model = groupFeedbackIssues(input), md = feedbackMarkdownText;
161
+ const lines = [
162
+ '# Feedback issue export', '',
163
+ `Session: ${md(model.session.id)} · revision ${md(model.session.revision)}`,
164
+ `Recorded status: ${md(model.session.status)} · saved: ${model.session.updatedAt === null ? 'not submitted' : md(model.session.updatedAt)}`,
165
+ `Source SHA-256: ${md(model.session.sourceHash)}`, '',
166
+ `Items: ${model.counts.total} · defect: ${model.counts.defect} · quality: ${model.counts.quality}`, '',
167
+ '| Build | Revision | Defect | Quality | Total | Source SHA-256 |',
168
+ '| --- | --- | ---: | ---: | ---: | --- |',
169
+ ];
170
+ for (const build of model.builds) lines.push(`| ${md(build.side)} | ${md(build.reviewedSource.packageRevision)} | ${build.counts.defect} | ${build.counts.quality} | ${build.counts.total} | ${md(build.sourceHash)} |`);
171
+ lines.push('', '## Recorded provenance', '', jsonBlock({ builds: model.builds.map(({ counts: _counts, ...build }) => build), pairedBinding: model.pairedBinding, referenceBinding: model.referenceBinding }));
172
+ if (model.retrieval) lines.push('', '## Historical retrieval', '', 'This is a prior-tools record; its recorded approval is not a current-tools approval.', '', jsonBlock(model.retrieval));
173
+ if (model.historyWarnings.length) lines.push('', '## History read warnings', '', jsonBlock(model.historyWarnings));
174
+ // Keep the complete warning list, including unmatched ids, without duplicating
175
+ // issue items or quietly hiding evidence supplied by the parsed reader.
176
+ if (model.feedbackWarnings.length) lines.push('', '## Feedback validation warnings', '', jsonBlock(model.feedbackWarnings));
177
+ for (const category of model.categories) {
178
+ lines.push('', `## ${category.category} (${category.count})`);
179
+ if (!category.targets.length) lines.push('', 'No feedback items.');
180
+ for (const group of category.targets) {
181
+ lines.push('', `### ${md(group.target.kind)}: ${md(group.target.name)}`);
182
+ for (const item of group.items) {
183
+ lines.push('', `#### Feedback ${md(item.id)}`, '',
184
+ `- Category: ${item.category}`,
185
+ `- Target: ${md(item.target.kind)}: ${md(item.target.name)}`,
186
+ `- Inclusive frames: ${item.frameStart}–${item.frameEnd}`,
187
+ `- Side: ${item.side}`,
188
+ `- Summary: ${md(item.summary)}`, '',
189
+ 'Selected build identity:', '', jsonBlock(item.selectedSource));
190
+ if (model.pairedBinding) lines.push('', 'Paired comparison binding:', '', jsonBlock(model.pairedBinding));
191
+ if (item.warnings.length) lines.push('', 'Validation warnings (record retained):', '', jsonBlock(item.warnings));
192
+ if (item.context !== undefined) lines.push('', 'Original inspection context (separate from chosen inclusive frames):', '', jsonBlock(item.context));
193
+ }
194
+ }
195
+ }
196
+ return lines.join('\n') + '\n';
197
+ }
@@ -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,2 @@
1
+ import {runFront} from './front.ts';
2
+ if(import.meta.main)runFront(Bun.argv[2]??'').catch(error=>{console.error(error instanceof Error?error.message:String(error));process.exit(1);});
package/src/front.ts ADDED
@@ -0,0 +1,127 @@
1
+ /** Managed loopback front: one stable origin (127.0.0.1:4318 by default) routing `/instances/<key>/…` to the
2
+ * worker named by that key's private open record. It is a router, not an authority: it never reads packages,
3
+ * never serves or logs a capability, and forwards a mutation (rewriting Origin) only with the page capability. */
4
+ import {randomUUID} from 'node:crypto';
5
+ import {spawn} from 'node:child_process';
6
+ import {lstat,readFile,readdir,realpath,rename,unlink,writeFile,open as openFile} from 'node:fs/promises';
7
+ import {dirname,join} from 'node:path';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {acquireClaim,alive,openFingerprint,readRecord,releaseClaim,running,smallJson,type RecordFile} from './open.ts';
10
+ import {capabilityHeader,openKeyHeader,openNonceHeader,sameSecret} from './open-protocol.ts';
11
+ export const defaultFrontPort=4318; // Foreground `review <dir>` keeps 4317.
12
+ interface FrontRecord {schema:'rig-review-front/1';origin:string;port:number;nonce:string;fingerprint:string;pid:number}
13
+ interface FrontRequest {schema:'rig-review-front-request/1';control:string;request:string;record:string;port:number;nonce:string;fingerprint:string;idleMs:number}
14
+ const appRoot=fileURLToPath(new URL('..',import.meta.url));
15
+ const sleep=(ms:number)=>new Promise(resolveSleep=>setTimeout(resolveSleep,ms));
16
+ const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/,hex64=/^[a-f0-9]{64}$/;
17
+ const frontName=(port:number)=>`front-${port}`;
18
+ const noStore={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'};
19
+ const refuse=(error:string,status:number)=>Response.json({error},{status,headers:noStore});
20
+ export function frontPort(value?:number):number{const raw=value??(process.env.RIG_REVIEW_FRONT_PORT===undefined?defaultFrontPort:Number(process.env.RIG_REVIEW_FRONT_PORT));if(!Number.isSafeInteger(raw)||raw<1||raw>65535)throw new Error('FRONT_PORT: RIG_REVIEW_FRONT_PORT expects an integer 1..65535');return raw;}
21
+ function frontValue(value:unknown,port:number):FrontRecord|null{if(!value||typeof value!=='object'||Array.isArray(value))return null;const v=value as Partial<FrontRecord>;return v.schema==='rig-review-front/1'&&v.port===port&&v.origin===`http://127.0.0.1:${port}`&&uuid.test(v.nonce??'')&&hex64.test(v.fingerprint??'')&&Number.isSafeInteger(v.pid)&&v.pid!>0?v as FrontRecord:null;}
22
+ async function readFront(file:string,port:number):Promise<FrontRecord|null>{try{return frontValue(await smallJson(file),port);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
23
+ async function frontAlive(row:FrontRecord):Promise<boolean>{try{const response=await fetch(row.origin+'/api/front-status',{headers:{authorization:'Bearer '+row.nonce},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await response.json() as {schema?:string;nonce?:string;fingerprint?:string};return v.schema==='rig-review-front-status/1'&&v.nonce===row.nonce&&v.fingerprint===row.fingerprint;}catch{return false;}}
24
+ /** Ensure this state directory's front on `port`; reuse a verified live one. A front from another tool
25
+ * build is ours (verified nonce) and is replaced. Any other listener is FRONT_PORT_BUSY, never a fallback. */
26
+ export async function ensureFront(options:{control:string;port:number;fingerprint:string;idleMs?:number}):Promise<string>{
27
+ const {control,port,fingerprint}=options,name=frontName(port),recordPath=join(control,name+'.json'),nonce=randomUUID(),deadline=performance.now()+20000,idleMs=options.idleMs??30*60*1000;
28
+ const reuse=async()=>{const row=await readFront(recordPath,port);if(!row||!await frontAlive(row))return null;if(row.fingerprint===fingerprint)return row;
29
+ try{process.kill(row.pid,'SIGTERM');}catch{}for(let n=0;n<100&&await frontAlive(row);n++)await sleep(30);return null;};
30
+ const ready=await reuse();if(ready)return ready.origin;
31
+ const claimed=await acquireClaim(control,name,nonce,reuse,deadline,'FRONT_BUSY: front startup claim did not become ready');if('row' in claimed)return claimed.row.origin;
32
+ let request:string|undefined,child:ReturnType<typeof spawn>|undefined,handedOff=false;
33
+ try{
34
+ const row=await reuse();if(row)return row.origin;
35
+ request=join(control,`${name}.${nonce}.request.json`);const log=join(control,`${name}.${nonce}.log`);
36
+ const spec:FrontRequest={schema:'rig-review-front-request/1',control,request,record:recordPath,port,nonce,fingerprint,idleMs};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
37
+ const logHandle=await openFile(log,'a',0o600);try{child=spawn(process.execPath,[join(appRoot,'src/front-worker.ts'),request],{cwd:control,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});child.unref();}finally{await logHandle.close();}
38
+ let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
39
+ while(performance.now()<deadline){const live=await reuse();if(live){handedOff=true;return live.origin;}
40
+ if(exited){if(/FRONT_PORT_BUSY/.test(await readFile(log,'utf8').catch(()=>'')))throw new Error(`FRONT_PORT_BUSY: 127.0.0.1:${port} is held by another listener; use --direct (or set RIG_REVIEW_FRONT_PORT)`);throw new Error(`FRONT_START: ${exited}; front log ${log}`);}
41
+ await sleep(30);}
42
+ throw new Error(`FRONT_TIMEOUT: front did not become ready; log ${log}`);
43
+ }finally{if(child&&!handedOff&&child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');await releaseClaim(join(control,name+'.claim'),nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
44
+ }
45
+ interface Route {mutation:boolean;limit:number;timeoutMs:number;control:boolean}
46
+ /** Closed allowlist per method. Agent control endpoints, open-status and anything unlisted are refused. */
47
+ export function frontRoute(mode:'single'|'compare',method:string,path:string):Route|null{
48
+ const read=method==='GET'||method==='HEAD',get:Route={mutation:false,limit:0,timeoutMs:30000,control:false};
49
+ if(['/','/index.html','/main.js','/styles.css','/api/access'].includes(path))return read?get:null;
50
+ if(path==='/api/control/config')return method==='GET'?{...get,control:true}:null;
51
+ if(['/api/control/register','/api/control/poll','/api/control/ack','/api/control/close'].includes(path))return method==='POST'?{mutation:true,limit:16384,timeoutMs:10000,control:true}:null;
52
+ const session=mode==='single'?'/api/session':'/api/compare/session',strip=mode==='single'?'/api/strip':'/api/compare/strip';
53
+ if(path===session)return read?get:method==='PUT'?{mutation:true,limit:1024*1024,timeoutMs:30000,control:false}:null;
54
+ if(path===strip)return method==='POST'?{mutation:true,limit:1024*1024,timeoutMs:120000,control:false}:null;
55
+ const reads=mode==='single'?['/api/review','/review-reference']:['/api/compare','/api/compare/reference-frames','/compare-reference'];
56
+ if(reads.includes(path))return read?get:null;
57
+ if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
58
+ return null;
59
+ }
60
+ async function boundedBody(req:Request,limit:number,timeoutMs=10000):Promise<ArrayBuffer>{
61
+ if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('FRONT_JSON_REQUIRED');
62
+ const declared=req.headers.get('content-length');if(declared!==null&&(!/^\d+$/.test(declared)||Number(declared)>limit))throw new Error('FRONT_BODY_LIMIT');
63
+ if(!req.body)throw new Error('FRONT_JSON_REQUIRED');const reader=req.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
64
+ 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>limit)throw new Error('FRONT_BODY_LIMIT');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 bytes.buffer;})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('FRONT_BODY_TIMEOUT')),timeoutMs);})]);}
65
+ finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});}
66
+ }
67
+ /** Owner-only regular record for this key, or null. */
68
+ async function instanceRecord(control:string,key:string):Promise<RecordFile|null>{const file=join(control,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)return null;}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw e;}return readRecord(file,key);}
69
+ /** Worker statuses map to named front codes; worker text never reaches the client. */
70
+ const upstreamFailure:Record<number,string>={400:'FRONT_UPSTREAM_REJECTED',403:'FRONT_UPSTREAM_FORBIDDEN',404:'FRONT_UPSTREAM_NOT_FOUND',405:'FRONT_UPSTREAM_METHOD',409:'FRONT_UPSTREAM_CONFLICT'};
71
+ const responseHeaders=['content-type','content-length','content-range','accept-ranges','content-disposition','cache-control','content-security-policy','x-content-type-options','referrer-policy'];
72
+ export const maximumUpstreamStreams=32;
73
+ export function startFront(options:{control:string;port:number;nonce:string;fingerprint:string}){
74
+ const origin=`http://127.0.0.1:${options.port}`;let active=0,last=performance.now();
75
+ const server=Bun.serve({hostname:'127.0.0.1',port:options.port,async fetch(req){
76
+ last=performance.now();
77
+ try{
78
+ const url=new URL(req.url);if(url.origin!==origin)return refuse('FRONT_LOOPBACK_REQUIRED',403);
79
+ if(url.pathname==='/api/front-status'){if(req.method!=='GET'||!sameSecret(req.headers.get('authorization'),'Bearer '+options.nonce))return refuse('FRONT_STATUS_CAPABILITY_REQUIRED',403);return Response.json({schema:'rig-review-front-status/1',nonce:options.nonce,fingerprint:options.fingerprint},{headers:noStore});}
80
+ const match=/^\/instances\/([a-f0-9]{64})(\/.*)$/.exec(url.pathname);if(!match)return refuse('FRONT_ROUTE_REFUSED',404);
81
+ const key=match[1]!,path=match[2]!,sentOrigin=req.headers.get('origin');
82
+ if(req.headers.get('sec-fetch-site')==='cross-site'||sentOrigin!==null&&sentOrigin!==origin)return refuse('FRONT_SAME_ORIGIN_REQUIRED',403);
83
+ const record=await instanceRecord(options.control,key);if(!record)return refuse('FRONT_INSTANCE_UNKNOWN: reopen with rig-review open',404);
84
+ const route=frontRoute(record.mode,req.method,path);if(!route)return refuse('FRONT_ROUTE_REFUSED',404);
85
+ const landing=path==='/'||path==='/index.html';if(!landing&&url.search)return refuse('FRONT_QUERY_REFUSED',400);
86
+ let body:ArrayBuffer|undefined;
87
+ if(route.mutation){
88
+ // Never rewrite Origin without this instance's capability; the worker compares it again itself.
89
+ if(sentOrigin!==origin)return refuse('FRONT_SAME_ORIGIN_REQUIRED',403);
90
+ if(!sameSecret(req.headers.get(capabilityHeader),record.capability))return refuse('FRONT_CAPABILITY_REQUIRED: reopen with rig-review open to edit',403);
91
+ body=await boundedBody(req,route.limit);
92
+ if(!await alive(record))return refuse('FRONT_INSTANCE_EXPIRED: reopen with rig-review open',410);
93
+ }
94
+ if(active>=maximumUpstreamStreams)return refuse('FRONT_BUSY: too many concurrent upstream streams',503);
95
+ const headers=new Headers({[openNonceHeader]:record.nonce});for(const name of ['accept','range','if-range',...(route.mutation?['content-type']:[]),...(route.control?['authorization']:[])]){const value=req.headers.get(name);if(value!==null)headers.set(name,value);}
96
+ if(route.mutation){headers.set('origin',record.origin);headers.set(capabilityHeader,record.capability);}
97
+ active++;let released=false;const abort=new AbortController(),timer=setTimeout(()=>abort.abort(),route.timeoutMs);const release=()=>{if(released)return;released=true;active--;clearTimeout(timer);};
98
+ req.signal.addEventListener('abort',()=>abort.abort(),{once:true});
99
+ let upstream:Response;
100
+ try{upstream=await fetch(record.origin+path+(landing?url.search:''),{method:req.method,headers,...(body?{body}:{}),redirect:'manual',signal:abort.signal});}
101
+ catch{release();return abort.signal.aborted&&!req.signal.aborted?refuse('FRONT_UPSTREAM_TIMEOUT',504):refuse('FRONT_INSTANCE_EXPIRED: reopen with rig-review open',410);}
102
+ if(upstream.headers.get(openKeyHeader)!==key){void upstream.body?.cancel().catch(()=>{});release();return refuse('FRONT_INSTANCE_REPLACED: reopen with rig-review open',410);}
103
+ if(upstream.status>=400&&upstream.status!==416){void upstream.body?.cancel().catch(()=>{});release();const code=upstreamFailure[upstream.status];return refuse(code??'FRONT_UPSTREAM_FAILED',code?upstream.status:502);}
104
+ const out=new Headers();for(const name of responseHeaders){const value=upstream.headers.get(name);if(value!==null)out.set(name,value);}
105
+ if(!upstream.body||req.method==='HEAD'){void upstream.body?.cancel().catch(()=>{});release();return new Response(null,{status:upstream.status,headers:out});}
106
+ clearTimeout(timer);const reader=upstream.body.getReader();
107
+ // Media/model streams stay counted until finished or cancelled by the browser.
108
+ const stream=new ReadableStream<Uint8Array>({async pull(controller){try{const next=await reader.read();if(next.done){controller.close();release();}else controller.enqueue(next.value);}catch(error){release();controller.error(error);}},cancel(reason){release();abort.abort();return reader.cancel(reason).catch(()=>{});}});
109
+ return new Response(stream,{status:upstream.status,headers:out});
110
+ }catch(error){const message=error instanceof Error?error.message:'';return refuse(message.startsWith('FRONT_')?message:'FRONT_REQUEST_FAILED',message==='FRONT_BODY_LIMIT'?413:message==='FRONT_BODY_TIMEOUT'?408:400);}
111
+ }});
112
+ return {origin,stop:()=>server.stop(true),idleMs:()=>performance.now()-last,active:()=>active};
113
+ }
114
+ async function liveInstances(control:string):Promise<boolean>{for(const name of await readdir(control))if(/^[a-f0-9]{64}\.json$/.test(name)){const row=await readRecord(join(control,name),name.slice(0,64)).catch(()=>null);if(row&&running(row.pid))return true;}return false;}
115
+ export async function runFront(requestPath:string):Promise<void>{
116
+ const spec=await smallJson(requestPath) as FrontRequest;if(spec?.schema!=='rig-review-front-request/1'||!uuid.test(spec.nonce??'')||!hex64.test(spec.fingerprint??'')||!Number.isSafeInteger(spec.port)||spec.port<1||spec.port>65535||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('front: invalid request');
117
+ const control=await realpath(spec.control);if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.record!==join(control,frontName(spec.port)+'.json'))throw new Error('front: invalid state locations');
118
+ if(await openFingerprint()!==spec.fingerprint)throw new Error('front: tool changed before startup');
119
+ let front:ReturnType<typeof startFront>;
120
+ try{front=startFront({control,port:spec.port,nonce:spec.nonce,fingerprint:spec.fingerprint});}catch(error){if((error as NodeJS.ErrnoException).code==='EADDRINUSE'||/in use/i.test(error instanceof Error?error.message:''))throw new Error(`FRONT_PORT_BUSY: 127.0.0.1:${spec.port}`);throw error;}
121
+ const record:FrontRecord={schema:'rig-review-front/1',origin:front.origin,port:spec.port,nonce:spec.nonce,fingerprint:spec.fingerprint,pid:process.pid},tmp=spec.record+'.'+spec.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.record);
122
+ await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
123
+ let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;clearInterval(timer);front.stop();const current=await readFront(spec.record,spec.port).catch(()=>null);if(current?.nonce===spec.nonce)await unlink(spec.record).catch(()=>{});process.exit(0);};
124
+ // Idle exit only once no request arrived for idleMs and no live worker record remains.
125
+ const timer=setInterval(()=>{if(front.idleMs()>=spec.idleMs&&front.active()===0)void liveInstances(control).then(live=>{if(!live)void stop();}).catch(()=>{});},Math.min(1000,spec.idleMs));
126
+ process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
127
+ }
@@ -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,31 @@
1
+ /** Fixed-instance handshake and enforced landing binding; no filesystem control API. */
2
+ import {timingSafeEqual} from 'node:crypto';
3
+ export interface OpenIdentity {key:string;contentHash:string;nonce:string;fingerprint:string}
4
+ export function openGate(req:Request,identity:OpenIdentity|undefined):Response|null{
5
+ if(!identity)return null;const url=new URL(req.url);
6
+ if(url.pathname==='/api/open-status'){
7
+ if(req.method!=='GET')return Response.json({error:'Method not allowed'},{status:405});
8
+ if(req.headers.get('authorization')!==`Bearer ${identity.nonce}`)return Response.json({error:'Open instance capability required'},{status:403});
9
+ return Response.json({schema:'rig-review-open-status/1',...identity},{headers:{'Cache-Control':'no-store'}});
10
+ }
11
+ 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});
12
+ return null;
13
+ }
14
+
15
+ 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'}});}
16
+ /** Header names shared by the worker, the managed front and the page. */
17
+ export const capabilityHeader='X-Review-Capability',openNonceHeader='X-Review-Open-Nonce',openKeyHeader='X-Review-Open-Key';
18
+ export const capabilityPattern=/^[a-f0-9]{64}$/;
19
+ export function sameSecret(actual:string|null,expected:string):boolean{const a=Buffer.from(actual??''),b=Buffer.from(expected);return a.length===b.length&&timingSafeEqual(a,b);}
20
+ /** Every non-GET request needs this instance's page capability, compared in constant time before any
21
+ * Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. */
22
+ export function accessGate(req:Request,capability:string|undefined):Response|null{
23
+ const url=new URL(req.url),headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'};
24
+ if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
25
+ if(!capability||req.method==='GET'||req.method==='HEAD')return null;
26
+ return sameSecret(req.headers.get(capabilityHeader),capability)?null:Response.json({error:'REVIEW_CAPABILITY_REQUIRED: reopen with rig-review open to edit'},{status:403,headers});
27
+ }
28
+ /** The front forwards the open record's nonce on every request; a replaced worker refuses it. */
29
+ export function openNonceGate(req:Request,identity:OpenIdentity|undefined):Response|null{const sent=req.headers.get(openNonceHeader);if(sent===null||!identity)return null;return sameSecret(sent,identity.nonce)?null:Response.json({error:'OPEN_INSTANCE_REPLACED'},{status:409,headers:{'Cache-Control':'no-store'}});}
30
+ /** Proof for the front that this exact worker answered, not whatever now holds the port. */
31
+ export function stampOpen(req:Request,response:Response,identity:OpenIdentity|undefined):Response{if(identity&&sameSecret(req.headers.get(openNonceHeader),identity.nonce))response.headers.set(openKeyHeader,identity.key);return response;}
@@ -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
+ }