@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,171 @@
1
+ /** Browser-free host for the published player. No posing or raster math lives here. */
2
+ import { execFile } from 'node:child_process';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, relative, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { CanvasRenderer, loadModel, RigPlayer, type ModelAssets } from '@rig-c/play';
7
+ import type { Canvas, Image, SKRSContext2D } from '@napi-rs/canvas';
8
+ import { frameAtTime } from './frame-clock.ts';
9
+ import { packagePath, safeFile } from './manifest.ts';
10
+ import { compareSeekStep } from './compare-playhead.ts';
11
+ import { stripNoClock, type StripRenderInput, type StripRenderResult, type StripRenderSample, type StripSide } from './strip.ts';
12
+ import { toolVersions } from './versions.ts';
13
+
14
+ export interface StripTools { ffmpeg?:string; ffprobe?:string }
15
+ import {nearestReferenceFrame} from './reference-frames.ts';
16
+ import {probeReference} from './probe-reference.ts';
17
+ export {nearestReferenceFrame} from './reference-frames.ts';
18
+ type NativeCanvas=typeof import('@napi-rs/canvas');
19
+ interface OpenRig {id:StripSide;assets:ModelAssets;player:RigPlayer;renderer:CanvasRenderer;clock:StripRenderInput['rigs'][number]['clock']}
20
+ function fail(code:string,text:string):never {throw new Error(`${code}: ${text}`);}
21
+ let hostQueue=Promise.resolve();
22
+
23
+ function command(binary:string,args:string[],kind:string):Promise<Buffer>{
24
+ return new Promise((resolveCommand,reject)=>{
25
+ execFile(binary,args,{encoding:'buffer',timeout:60000,maxBuffer:32*1024*1024,windowsHide:true},(error,stdout,stderr)=>{
26
+ if(error){reject(new Error(`STRIP_${kind}: ${binary}: ${error.message}; ${stderr.toString('utf8').slice(0,1200)}`));return;}
27
+ resolveCommand(stdout);
28
+ });
29
+ });
30
+ }
31
+ export async function referenceFramePng(path:string,index:number,binary:string,width:number,height:number):Promise<Buffer>{
32
+ // Downsize before native image decode; output cells must not retain hundreds
33
+ // of full-resolution source rasters merely because the video was 4K.
34
+ return command(binary,['-v','error','-threads','1','-filter_threads','1','-i',path,'-map','0:v:0','-vf',`select=eq(n\\,${index}),scale=${width}:${height}:force_original_aspect_ratio=decrease`,'-vsync','0','-frames:v','1','-f','image2pipe','-c:v','png','-threads','1','pipe:1'],'FFMPEG');
35
+ }
36
+
37
+ /** Only DOM ownership and canvas/image IO are adapted; all drawing remains native/player code. */
38
+ function installHost(native:NativeCanvas,images:Map<string,Buffer>):()=>void {
39
+ const globals=globalThis as unknown as Record<string,unknown>;
40
+ const saved=new Map(['document','Image'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
41
+ function canvas(){const c=native.createCanvas(1,1);Object.assign(c,{style:{},remove(){}});return c;}
42
+ const imageSrc=Object.getOwnPropertyDescriptor(native.Image.prototype,'src');
43
+ if(!imageSrc?.get||!imageSrc.set)fail('STRIP_CANVAS','native Image src accessor unavailable');
44
+ class LocalImage extends native.Image {}
45
+ Object.defineProperty(LocalImage.prototype,'src',{get(){return imageSrc.get!.call(this);},set(value:unknown){
46
+ if(typeof value!=='string')fail('STRIP_IMAGE','only declared page URLs may be loaded');
47
+ const bytes=images.get(value);if(!bytes)fail('STRIP_IMAGE',`undeclared image ${value}`);
48
+ imageSrc.set!.call(this,bytes);
49
+ }});
50
+ globals.document={baseURI:'https://strip.invalid/',createElement(tag:string){if(tag!=='canvas')fail('STRIP_HOST',`unexpected element ${tag}`);return canvas();}};
51
+ globals.Image=LocalImage;
52
+ return ()=>{for(const [key,descriptor] of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globals[key];};
53
+ }
54
+ async function openRig(input:StripRenderInput['rigs'][number],images:Map<string,Buffer>):Promise<OpenRig>{
55
+ const model=await safeFile(input.root,packagePath(relative(input.root,input.modelPath).split('\\').join('/')));
56
+ if(!model.endsWith('.model.json'))fail('STRIP_MODEL','compiled .model.json required');
57
+ const bytes=await readFile(model);const metadata=JSON.parse(bytes.toString('utf8')) as {pages?:{name:string}[]};
58
+ if(!Array.isArray(metadata.pages))fail('STRIP_MODEL','declared model pages required');
59
+ const urls=new Map<string,string>();
60
+ for(const [index,page] of metadata.pages.entries()){
61
+ if(typeof page.name!=='string'||/[\\%?#:\x00-\x1f]/.test(page.name))fail('STRIP_MODEL','literal relative page path required');
62
+ const name=packagePath(relative(input.root,resolve(dirname(model),page.name)).split('\\').join('/'));
63
+ const path=await safeFile(input.root,name),url=`https://strip.invalid/${input.id}/page-${index}`;
64
+ urls.set(page.name,url);images.set(url,await readFile(path));
65
+ }
66
+ const modelUrl=`https://strip.invalid/${input.id}/skeleton.model.json`;
67
+ const fileFetch=new Proxy(fetch,{apply:(_target,_this,args:Parameters<typeof fetch>)=>{
68
+ if(String(args[0])!==modelUrl)return Promise.reject(new Error('STRIP_FETCH: undeclared model URL'));
69
+ return Promise.resolve(new Response(new Uint8Array(bytes)));
70
+ }});
71
+ const assets=await loadModel({modelUrl,fetch:fileFetch,setsUrl:null,regions:'none',resolvePage:name=>{const url=urls.get(name);if(!url)fail('STRIP_MODEL',`undeclared page ${name}`);return url;}});
72
+ try {
73
+ if(!assets.animations.some(animation=>animation.name===input.animation))fail('STRIP_ANIMATION',`missing ${input.animation}`);
74
+ const root={style:{clipPath:''},appendChild(node:unknown){return node;}} as unknown as HTMLElement;
75
+ const player=new RigPlayer(root,assets,{mode:'canvas',seekStep:compareSeekStep});
76
+ const renderer=player.renderer;if(!(renderer instanceof CanvasRenderer))fail('STRIP_RENDERER','published canvas renderer required');
77
+ renderer.backend='canvas2d';renderer.pixelRatio=1;player.environment(null);player.play(input.animation,{loop:false});player.pause();
78
+ return {id:input.id,assets,player,renderer,clock:input.clock};
79
+ }catch(error){assets.dispose();throw error;}
80
+ }
81
+ function seek(rigs:OpenRig[],time:number):void {
82
+ for(const rig of rigs){
83
+ rig.player.seek(time);
84
+ if(!Number.isFinite(rig.player.time)||Math.abs(rig.player.time-time)>1e-9)fail('STRIP_PLAYHEAD',`actual ${rig.id} time ${rig.player.time} differs from ${time}`);
85
+ for(const slot of rig.player.posedFrame().order)if(slot.tint.slice(0,3).some(channel=>channel!==1)||slot.dark?.some(channel=>channel!==0))fail('STRIP_UNSUPPORTED_TINT',`${rig.id} slot ${slot.slot}: canvas2d cannot reproduce RGB/dark tint`);
86
+ }
87
+ if(rigs.some(rig=>rig.player.time!==rigs[0]!.player.time))fail('STRIP_PLAYHEAD','players did not reach the same actual PTS');
88
+ }
89
+ function contain(ctx:SKRSContext2D,image:Canvas|Image,x:number,y:number,width:number,height:number):void {
90
+ const scale=Math.min(width/image.width,height/image.height);if(!Number.isFinite(scale)||scale<=0)fail('STRIP_IMAGE','decoded image has no extent');
91
+ ctx.drawImage(image,x+(width-image.width*scale)/2,y+(height-image.height*scale)/2,image.width*scale,image.height*scale);
92
+ }
93
+ function footer(plan:StripRenderInput['plan'],renderer:StripRenderResult['renderer']):string[]{
94
+ const lines=[`${renderer.name} ${renderer.version} | ${renderer.backend}`,`animation ${plan.animation} | nearest decoded PTS; ties earlier | seekStep ${compareSeekStep}`,`source SHA256 ${plan.binding.sourceHash}`,`reference ${plan.binding.reference.name}`,`reference SHA256 ${plan.binding.reference.sha256}`];
95
+ for(const item of plan.binding.packages){const p=item.reviewedSource;lines.push(`${item.id.toUpperCase()} ${item.label} | revision ${p.packageRevision}`,`viewer ${p.viewerVersion} | runtime ${p.runtimeVersion}`,`package SHA256 ${item.sourceHash}`,`rig ${p.rig?.path??'unavailable'}`,`rig SHA256 ${p.rig?.sha256??'unavailable'}`,`motion ${p.motion?.path??'unavailable'}`,`motion SHA256 ${p.motion?.sha256??'unavailable'}`);}
96
+ return lines;
97
+ }
98
+ function wrapped(ctx:SKRSContext2D,lines:string[],maxWidth:number):string[]{
99
+ return lines.flatMap(line=>{const chunks:string[]=[];let current='';for(const char of line){if(current&&ctx.measureText(current+char).width>maxWidth){chunks.push(current);current='';}current+=char;}chunks.push(current);return chunks;});
100
+ }
101
+
102
+ export async function renderStrip(input:StripRenderInput,tools:StripTools={}):Promise<StripRenderResult>{
103
+ const {plan}=input;
104
+ if(plan.groups.some(group=>group.samples.some(sample=>sample.time===null)))throw new Error(stripNoClock);
105
+ const previous=hostQueue;let release=()=>{};hostQueue=new Promise<void>(done=>{release=done;});await previous;
106
+ const rigs:OpenRig[]=[];let restore=()=>{};
107
+ try {
108
+ let native:NativeCanvas;
109
+ try{native=await import('@napi-rs/canvas');}catch(error){fail('STRIP_CANVAS',`@napi-rs/canvas unavailable: ${error instanceof Error?error.message:String(error)}`);}
110
+ const images=new Map<string,Buffer>();restore=installHost(native,images);
111
+ const ffmpeg=tools.ffmpeg??process.env.RIG_REVIEW_FFMPEG??'ffmpeg',ffprobe=tools.ffprobe??process.env.RIG_REVIEW_FFPROBE??'ffprobe';
112
+ const video=await probeReference(input.referencePath,ffprobe);
113
+ for(const source of input.rigs)rigs.push(await openRig(source,images));
114
+ if(!rigs.length)fail('STRIP_MODEL','at least one compiled rig required');
115
+ const end=Math.min(video.duration,...rigs.map(rig=>rig.assets.animations.find(a=>a.name===input.plan.animation)!.duration));
116
+ const selected=plan.groups.flatMap(group=>group.samples.map(sample=>{
117
+ const time=sample.time!;if(!Number.isFinite(time)||time<video.frames[0]!.time||time>end)fail('STRIP_DOMAIN',`requested ${time} outside common domain ${video.frames[0]!.time}..${end}`);
118
+ const presented=nearestReferenceFrame(video.frames,time);
119
+ if(presented.time<video.frames[0]!.time||presented.time>end)fail('STRIP_DOMAIN',`decoded PTS ${presented.time} outside common domain; no clamp`);
120
+ return {group,sample,presented};
121
+ }));
122
+ let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
123
+ for(const item of selected){seek(rigs,item.presented.time);for(const rig of rigs){const box=rig.player.bounds();if(box){minX=Math.min(minX,box.x);minY=Math.min(minY,box.y);maxX=Math.max(maxX,box.x+box.width);maxY=Math.max(maxY,box.y+box.height);}}}
124
+ if(!(maxX>minX&&maxY>minY))fail('STRIP_VIEWPORT','selected poses have no drawable extent');
125
+ const padding=Math.max(maxX-minX,maxY-minY)*.04;
126
+ const x=Math.floor(minX-padding),y=Math.floor(minY-padding),worldWidth=Math.ceil(maxX+padding)-x,worldHeight=Math.ceil(maxY+padding)-y;
127
+ const layout=plan.layout,width=layout.rowLabelWidth+Math.max(...plan.groups.map(group=>group.samples.length))*layout.cellWidth;
128
+ const versions=await toolVersions();const packageJson=JSON.parse(await readFile(resolve(dirname(fileURLToPath(import.meta.resolve('@napi-rs/canvas'))),'package.json'),'utf8')) as {version:string};
129
+ const renderer={name:'@rig-c/play',version:versions.runtimeVersion,backend:`canvas2d / @napi-rs/canvas ${packageJson.version}`};
130
+ // The plan reserves a maximum footer budget. Measure the complete wrapped
131
+ // provenance with the drawing font before allocating the final raster.
132
+ const measure=native.createCanvas(1,1).getContext('2d');measure.font='12px monospace';
133
+ const lines=wrapped(measure,footer(plan,renderer),width-20),footerHeight=lines.length*16+16;
134
+ if(footerHeight>layout.footerHeight)fail('STRIP_LAYOUT_FOOTER',`need ${footerHeight}px for complete provenance, have ${layout.footerHeight}`);
135
+ const groupHeight=layout.labelHeight*2+plan.rows.length*layout.cellHeight+layout.groupGap,height=plan.groups.length*groupHeight+footerHeight;
136
+ if(!Number.isSafeInteger(width)||!Number.isSafeInteger(height)||width<=0||height<=0||width>16384||height>16384||width*height>64000000)fail('STRIP_LAYOUT','invalid or excessive canvas size');
137
+ for(const rig of rigs){rig.renderer.viewport={x,y,width:worldWidth,height:worldHeight};rig.renderer.pixelRatio=Math.min(layout.cellWidth/worldWidth,layout.cellHeight/worldHeight);}
138
+ const out=native.createCanvas(width,height),ctx=out.getContext('2d');ctx.fillStyle='#f1f1f1';ctx.fillRect(0,0,width,height);ctx.font='14px sans-serif';ctx.textBaseline='top';
139
+ const samples:StripRenderSample[]=[];const decoded=new Map<number,Image>();
140
+ for(const [groupIndex,group] of plan.groups.entries()){
141
+ const top=groupIndex*groupHeight;ctx.fillStyle='#202b24';ctx.fillText(`${group.id} | ${group.basis} F${group.frameStart}–${group.frameEnd}`,8,top+8);
142
+ for(const [column,sample] of group.samples.entries()){
143
+ const left=layout.rowLabelWidth+column*layout.cellWidth,item=selected.find(value=>value.group===group&&value.sample===sample)!;
144
+ const actual=item.presented.time;seek(rigs,actual);
145
+ const record:StripRenderSample={groupId:group.id,requestedFrame:sample.requestedFrame,requestedTime:sample.time!,presentedTime:actual,delta:actual-sample.time!,decodedFrame:item.presented.index,sampling:'video-frame',playerTimes:{},frames:{}};
146
+ ctx.fillStyle='#202b24';ctx.fillText(`F${sample.requestedFrame} | ${sample.time!.toFixed(5)}s`,left+6,top+layout.labelHeight+7);
147
+ let reference=decoded.get(item.presented.index);if(!reference){reference=await native.loadImage(await referenceFramePng(input.referencePath,item.presented.index,ffmpeg,layout.cellWidth,layout.cellHeight));decoded.set(item.presented.index,reference);if(decoded.size>2)decoded.delete(decoded.keys().next().value!);}
148
+ for(const [rowIndex,row] of plan.rows.entries()){
149
+ const cellTop=top+layout.labelHeight*2+rowIndex*layout.cellHeight;
150
+ ctx.fillStyle='#202b24';if(column===0)ctx.fillText(row.label.slice(0,100),8,cellTop+10,layout.rowLabelWidth-16);
151
+ if(row.id==='reference'){contain(ctx,reference,left,cellTop,layout.cellWidth,layout.cellHeight);ctx.fillStyle='#172a22';ctx.fillText(`PTS ${actual.toFixed(6)}`,left+5,cellTop+5);}
152
+ else {
153
+ const rig=rigs.find(value=>value.id===row.id);if(!rig)fail('STRIP_MODEL',`missing row ${row.id}`);
154
+ rig.player.draw();if(rig.renderer.backendActive!=='canvas2d')fail('STRIP_RENDERER','unexpected raster backend');
155
+ contain(ctx,rig.renderer.canvas as unknown as Canvas,left,cellTop,layout.cellWidth,layout.cellHeight);
156
+ record.playerTimes[row.id]=rig.player.time;record.frames[row.id]=frameAtTime(rig.clock,actual);
157
+ // The selected basis labels the requested report sample. The pose
158
+ // still uses decoded PTS, preserved separately in the JSON record.
159
+ const label=row.id===group.basis?`F${sample.requestedFrame} | ${sample.time!.toFixed(6)}s`:`F${record.frames[row.id]??'—'} | ${rig.player.time.toFixed(6)}s`;
160
+ ctx.fillStyle='#172a22';ctx.fillText(label,left+5,cellTop+5);
161
+ }
162
+ ctx.strokeStyle='#b7c2b9';ctx.strokeRect(left,cellTop,layout.cellWidth,layout.cellHeight);
163
+ }
164
+ samples.push(record);
165
+ }
166
+ }
167
+ ctx.font='12px monospace';
168
+ ctx.fillStyle='#172a22';lines.forEach((line,index)=>ctx.fillText(line,10,height-footerHeight+8+index*16));
169
+ return {png:await out.encode('png'),width,height,renderer,samples};
170
+ } finally {try{for(const rig of rigs){try{rig.player.dispose();}finally{rig.assets.dispose();}}}finally{try{restore();}finally{release();}}}
171
+ }
package/src/strip.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { ReviewedSource } from './manifest.ts';
2
+ import type { CompareBinding } from './compare.ts';
3
+ import type { ReportSeeds } from './report.ts';
4
+ import type { FrameClock } from './frame-clock.ts';
5
+
6
+ export type StripSide='single'|'a'|'b';
7
+ export interface StripInterval {id:string;feedbackId?:string;basis:StripSide;frameStart:number;frameEnd:number}
8
+ export interface StripRequest {format:'png'|'json';sourceHash:string;animation:string;referenceId?:string;intervals:StripInterval[]}
9
+ export interface StripBinding {sourceHash:string;packages:{id:StripSide;label:string;sourceHash:string;reviewedSource:ReviewedSource}[];reference:{name:string;sha256:string};compare:CompareBinding|null}
10
+ export interface StripRow {id:'reference'|StripSide;label:string}
11
+ export interface StripGroup {id:string;feedbackId:string|null;basis:StripSide;frameStart:number;frameEnd:number;samples:{requestedFrame:number;time:number|null}[]}
12
+ /** layout.footerHeight is a maximum provenance budget; rendering.height records the measured PNG. */
13
+ export interface StripPlan {schema:'rig-review-strip/1';mode:'single'|'compare';animation:string;basis:StripSide|'mixed';rows:StripRow[];groups:StripGroup[];binding:StripBinding;layout:{cellWidth:number;cellHeight:number;labelHeight:number;rowLabelWidth:number;groupGap:number;footerHeight:number}}
14
+ export interface StripRenderInput {plan:StripPlan;referencePath:string;rigs:{id:StripSide;root:string;modelPath:string;animation:string;clock:FrameClock|null}[]}
15
+ export interface StripRenderSample {groupId:string;requestedFrame:number;requestedTime:number;presentedTime:number;delta:number;decodedFrame:number;sampling:'video-frame';playerTimes:Partial<Record<StripSide,number>>;frames:Partial<Record<StripSide,number|null>>}
16
+ export interface StripRenderResult {png:Uint8Array;width:number;height:number;renderer:{name:string;version:string;backend:string};samples:StripRenderSample[]}
17
+ export const stripNoClock='Report has no frameTimes — regenerate with pose 0.30.0 or later';
18
+ function obj(value:unknown,field:string):Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value))throw new Error(`${field}: expected object`);return value as Record<string,unknown>;}
19
+ function text(value:unknown,field:string):string{if(typeof value!=='string'||!value.trim()||value.length>256)throw new Error(`${field}: expected nonempty string <= 256 characters`);return value;}
20
+ function keys(value:Record<string,unknown>,allowed:string[],field:string){for(const key of Object.keys(value))if(!allowed.includes(key))throw new Error(`${field}.${key}: unsupported field`);}
21
+ export function parseStripRequest(input:unknown):StripRequest{
22
+ const v=obj(input,'strip');keys(v,['format','sourceHash','animation','referenceId','intervals'],'strip');
23
+ if(v.format!=='png'&&v.format!=='json')throw new Error('strip.format: expected png | json');
24
+ if(typeof v.sourceHash!=='string'||!/^([a-f0-9]{64})$/.test(v.sourceHash))throw new Error('strip.sourceHash: expected bound SHA-256');
25
+ if(!Array.isArray(v.intervals)||v.intervals.length<1||v.intervals.length>64)throw new Error('strip.intervals: expected 1…64 inclusive intervals');
26
+ const intervals=v.intervals.map((value,index)=>{const f=`strip.intervals[${index}]`,r=obj(value,f);keys(r,['id','feedbackId','basis','frameStart','frameEnd'],f);if(r.basis!=='single'&&r.basis!=='a'&&r.basis!=='b')throw new Error(`${f}.basis: expected single | a | b`);if(!Number.isSafeInteger(r.frameStart)||!Number.isSafeInteger(r.frameEnd)||(r.frameStart as number)<0||(r.frameEnd as number)<(r.frameStart as number))throw new Error(`${f}: expected inclusive safe integers 0 <= frameStart <= frameEnd`);return {id:text(r.id,f+'.id'),...(r.feedbackId===undefined?{}:{feedbackId:text(r.feedbackId,f+'.feedbackId')}),basis:r.basis,frameStart:r.frameStart as number,frameEnd:r.frameEnd as number} as StripInterval;});
27
+ if(new Set(intervals.map(v=>v.id)).size!==intervals.length)throw new Error('strip.intervals.id: expected unique ids');
28
+ if(intervals.reduce((n,v)=>n+v.frameEnd-v.frameStart+1,0)>256)throw new Error('strip.intervals: export exceeds 256 total frames; split the selection');
29
+ return {format:v.format,sourceHash:v.sourceHash,animation:text(v.animation,'strip.animation'),...(v.referenceId===undefined?{}:{referenceId:text(v.referenceId,'strip.referenceId')}),intervals};
30
+ }
31
+ /** Only explicit report frameTimes select export times, including in uniform-clock packages. */
32
+ export function planStrip(request:StripRequest,context:{mode:'single'|'compare';binding:StripBinding;reports:Partial<Record<StripSide,ReportSeeds|null>>}):StripPlan{
33
+ if(request.sourceHash!==context.binding.sourceHash)throw new Error('Strip inputs changed: sourceHash does not match loaded package');
34
+ if(context.mode==='compare'&&request.referenceId!==undefined)throw new Error('strip.referenceId: compare uses its fixed reference');
35
+ const groups=request.intervals.map(interval=>{
36
+ if(context.mode==='single'?interval.basis!=='single':interval.basis==='single')throw new Error('strip.basis: does not match review mode');
37
+ const report=context.reports[interval.basis],clock=report?.clock;
38
+ if(report?.animation&&report.animation!==request.animation)throw new Error('strip.animation: differs from selected report animation');
39
+ const times=clock?.kind==='samples'&&clock.frameTimes.length?clock.frameTimes:null;
40
+ if(times&&report?.animation!==request.animation)throw new Error('strip.animation: explicit report animation required to bind frameTimes');
41
+ if(!times&&request.format==='png')throw new Error(stripNoClock);
42
+ if(times&&interval.frameEnd>=times.length)throw new Error(`strip.frameEnd: ${interval.frameEnd} beyond report last frame ${times.length-1}`);
43
+ return {...interval,feedbackId:interval.feedbackId??null,samples:Array.from({length:interval.frameEnd-interval.frameStart+1},(_,i)=>({requestedFrame:interval.frameStart+i,time:times?.[interval.frameStart+i]??null}))};
44
+ });
45
+ const rows:StripRow[]=[{id:'reference',label:'Reference'},...context.binding.packages.map(p=>({id:p.id,label:p.label}))];
46
+ const layout={cellWidth:220,cellHeight:260,labelHeight:32,rowLabelWidth:120,groupGap:24,footerHeight:180+context.binding.packages.length*320};
47
+ const width=layout.rowLabelWidth+Math.max(...groups.map(g=>g.samples.length))*layout.cellWidth;
48
+ const height=groups.length*(layout.labelHeight*2+rows.length*layout.cellHeight+layout.groupGap)+layout.footerHeight;
49
+ if(width>16384||height>16384||width*height>64000000)throw new Error('strip.layout: export exceeds 64 megapixels or 16384 pixels; split the selection');
50
+ return {schema:'rig-review-strip/1',mode:context.mode,animation:request.animation,basis:new Set(groups.map(group=>group.basis)).size===1?groups[0]!.basis:'mixed',rows,groups,binding:structuredClone(context.binding),layout};
51
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ function version(text:string,label:string):string {
6
+ const value=JSON.parse(text) as {version?:unknown};
7
+ if(typeof value.version!=='string'||!value.version)throw new Error(`${label}: package version missing`);
8
+ return value.version;
9
+ }
10
+ /** The runtime's manifest wherever the installer put it: nested in a checkout, hoisted beside this package in an install. */
11
+ export function playPackagePath():string {
12
+ let dir=dirname(Bun.resolveSync('@rig-c/play',fileURLToPath(new URL('.',import.meta.url))));
13
+ for(;;){const candidate=join(dir,'package.json');if(existsSync(candidate)&&dirname(candidate).endsWith(join('@rig-c','play')))return candidate;const parent=dirname(dir);if(parent===dir)throw new Error('@rig-c/play: package.json not found from resolved entry');dir=parent;}
14
+ }
15
+ /** Read installed tool versions; never treat a producer report's generic version as one. */
16
+ export async function toolVersions():Promise<{viewerVersion:string;runtimeVersion:string}>{
17
+ const viewerVersion=version(await readFile(new URL('../package.json',import.meta.url),'utf8'),'review');
18
+ const runtimeVersion=version(await readFile(playPackagePath(),'utf8'),'@rig-c/play');
19
+ return {viewerVersion,runtimeVersion};
20
+ }
@@ -0,0 +1,34 @@
1
+ /** Fit-relative inspection cameras. Images and world geometry retain their own units. */
2
+ export interface CameraPoint {x:number;y:number}
3
+ export interface PaneCamera {zoom:number;center:CameraPoint|null}
4
+ export interface CameraFrame {space:'image'|'world';center:CameraPoint;baseScale:number;width:number;height:number}
5
+ export interface CameraSignature {zoom:number;pan:CameraPoint}
6
+ export const clampZoom=(zoom:number):number=>Math.max(.25,Math.min(8,zoom));
7
+ export function cameraFrame(space:CameraFrame['space'],box:{x:number;y:number;width:number;height:number},width:number,height:number,marginX=0,marginY=0):CameraFrame|null {
8
+ if(![box.x,box.y,box.width,box.height,width,height].every(Number.isFinite)||box.width<=0||box.height<=0||width<=0||height<=0)return null;
9
+ return {space,center:{x:box.x+box.width/2,y:box.y+box.height/2},baseScale:Math.max(.001,Math.min((width-marginX)/box.width,(height-marginY)/box.height)),width,height};
10
+ }
11
+ export function cameraSignature(camera:PaneCamera,frame:CameraFrame):CameraSignature {
12
+ const center=camera.center??frame.center,scale=frame.baseScale*camera.zoom;
13
+ return {zoom:camera.zoom,pan:{x:(frame.center.x-center.x)*scale/frame.width,y:(frame.space==='world'?center.y-frame.center.y:frame.center.y-center.y)*scale/frame.height}};
14
+ }
15
+ /** Match the closed numeric observation before a candidate can replace a live camera. */
16
+ export function assertCameraView(camera:PaneCamera,frame:CameraFrame):void {
17
+ const center=camera.center??frame.center,scale=frame.baseScale*camera.zoom,width=frame.width/scale,height=frame.height/scale;
18
+ const coordinate=(value:number)=>Number.isFinite(value)&&Math.abs(value)<=Number.MAX_SAFE_INTEGER;
19
+ if(![center.x,center.y,center.x-width/2,center.y-height/2,(frame.center.x-center.x)*scale,(frame.center.y-center.y)*scale].every(coordinate)||![width,height].every(value=>Number.isFinite(value)&&value>0&&value<=Number.MAX_SAFE_INTEGER)||!Number.isFinite(scale)||scale<=0||!Number.isFinite(camera.zoom)||camera.zoom<.25||camera.zoom>8||![frame.width,frame.height].every(value=>Number.isFinite(value)&&value>0&&value<=65536))throw new Error('VIEW_CAMERA_RANGE: derived camera exceeds numeric source-coordinate limits');
20
+ }
21
+ export function cameraFromSignature(value:CameraSignature,frame:CameraFrame):PaneCamera {
22
+ if(![value.zoom,value.pan.x,value.pan.y].every(Number.isFinite)||value.zoom<.25||value.zoom>8)throw new Error('Camera signature requires finite pan and zoom 0.25..8');
23
+ const scale=frame.baseScale*value.zoom;
24
+ const camera={zoom:value.zoom,center:{x:frame.center.x-value.pan.x*frame.width/scale,y:frame.center.y+(frame.space==='world'?1:-1)*value.pan.y*frame.height/scale}};
25
+ assertCameraView(camera,frame);return camera;
26
+ }
27
+ /** Zoom preserves the normalized location already being inspected. */
28
+ export function zoomSignature(value:CameraSignature,factor:number):CameraSignature {
29
+ const zoom=clampZoom(value.zoom*factor),ratio=zoom/value.zoom;
30
+ return {zoom,pan:{x:value.pan.x*ratio,y:value.pan.y*ratio}};
31
+ }
32
+ export function dragSignature(value:CameraSignature,frame:CameraFrame,dx:number,dy:number):CameraSignature {
33
+ return {zoom:value.zoom,pan:{x:value.pan.x+dx/frame.width,y:value.pan.y+dy/frame.height}};
34
+ }
@@ -0,0 +1,96 @@
1
+ :root{font:13px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e0eeec;background:#10212a;color-scheme:dark}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button{padding:7px 10px;border:1px solid #486574;border-radius:6px;color:inherit;background:#28414c;cursor:pointer}button:hover{background:#385763}button:disabled{opacity:.4;cursor:default}.primary{background:#b7dfcb;color:#183c2c;border-color:#b7dfcb}.primary:hover{background:#d0eddf}input,select{border:1px solid #4b6c7c;background:#102833;border-radius:6px;color:inherit;padding:8px;max-width:100%;width:100%}input[type=range],input[type=checkbox]{accent-color:#b6dec9;padding:0}input[type=checkbox]{width:auto}input[type=range]{min-width:60px}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #d0eada;outline-offset:3px}header{padding:15px 21px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #355563;background:#152c36;flex-wrap:wrap}.brand{font-size:19px;letter-spacing:-.5px;white-space:nowrap}.brand span{font-size:10px;letter-spacing:.08em;display:block;color:#b5dcc9}h1{font-size:14px;font-weight:500;margin:0}h2{font-size:13px;font-weight:500;margin:0 0 13px}.hint{font-size:11px;color:#a6c2cf}.actions{display:flex;align-items:center;gap:7px;margin-left:auto;flex-wrap:wrap}.actions button{font-size:11px}.layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;min-height:calc(100vh - 118px)}main{min-width:0;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #34515e;font-size:11px;color:#b6cfd9}.row{display:flex;gap:5px;align-items:center;flex-wrap:wrap}.row button{font-size:11px;padding:4px 8px}.row output{font:11px ui-monospace,SFMono-Regular,monospace;padding:0 4px}.stages{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:12px;flex:1;min-height:500px}.pane{display:flex;flex-direction:column;min-width:0;border:1px solid #3b5d6c;border-radius:8px;overflow:hidden;background:#152d39}.pane-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid #385762;font-size:11px;color:#b4cfd9;min-height:45px;flex-wrap:wrap}.pane-head strong{font-weight:500}.side-label{display:inline-grid;place-items:center;width:23px;height:23px;border:1px solid #7aab95;border-radius:5px;color:#ccead9;flex:none}.rig-viewport,.reference-viewport{position:relative;min-height:420px;overflow:hidden;flex:1;background:radial-gradient(ellipse at 50% 40%,#294754,#102631 75%)}.rig-viewport{background-image:radial-gradient(#8eb7c637 .7px,transparent .7px);background-size:22px 22px}.rig-root{position:absolute;transform-origin:0 0}video{width:100%;height:100%;position:absolute;inset:0;object-fit:contain;background:#07171f}.empty{position:absolute;inset:50px 16px 30px;display:grid;place-items:center;text-align:center;font-size:12px;color:#a9c7d4;line-height:1.7;overflow-wrap:anywhere}.pane-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 11px;border-top:1px solid #385762;color:#b5ccd7;font:11px ui-monospace,SFMono-Regular,monospace;min-height:42px}.pane-foot label{display:flex;align-items:center;gap:4px;font:11px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.transport{display:flex;gap:11px;align-items:center;flex-wrap:wrap;padding:13px 17px;border-top:1px solid #3b5b69;background:#17313d}.transport input[type=range]{flex:1}.transport input[type=number]{width:100px}.transport output{font:11px ui-monospace,SFMono-Regular,monospace;white-space:nowrap;color:#c2dae2}.compare-info{display:flex;justify-content:space-between;gap:9px;flex-wrap:wrap;padding:9px 17px;color:#a4c0ce;font-size:11px}.trace{font-size:11px;color:#abc6d1;padding:4px 17px}.trace summary,.provenance summary{cursor:pointer}.trace pre{font:11px/1.6 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.boundary{font-size:11px;color:#91b2c1;padding:7px 17px;margin:0}aside{background:#192f3b;padding:21px 17px;border-left:1px solid #3b5b69}.eyebrow{font-size:11px;letter-spacing:.08em;color:#b3ccd4;margin-bottom:15px}aside label{display:block;font-size:11px;color:#b1cbd5;margin:12px 0 6px}.provenance{padding:13px 11px;margin-top:22px;background:#223c47;border:1px solid #4b6a77;border-radius:7px;font-size:11px}.provenance p{margin:7px 0;color:#b8d1db;overflow-wrap:anywhere}.provenance details{margin-top:12px}.provenance pre{font:11px/1.6 ui-monospace,SFMono-Regular,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.feedback-form{border-top:1px solid #405f6c;margin-top:24px;padding-top:21px}.frame-range{display:grid;grid-template-columns:1fr 1fr;gap:9px}.block{width:100%;margin-top:14px}.feedback-item{font-size:11px;overflow-wrap:anywhere;padding:12px 0;border-top:1px solid #426372;margin-top:13px}.feedback-item strong{display:block;font-weight:500;color:#d2e7e2}.feedback-item button{padding:3px 7px;font-size:11px;margin-top:7px}.dirty{color:#e1c697!important}.error{color:#f2baab!important}footer{display:flex;justify-content:space-between;gap:13px;flex-wrap:wrap;padding:11px 18px;background:#142a35;border-top:1px solid #365966;font-size:11px;color:#b5cfd9}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
2
+ @media(min-width:1600px){.rig-viewport,.reference-viewport{min-height:620px}.layout{grid-template-columns:minmax(0,1fr) 320px}}
3
+ @media(max-width:1100px){.layout{grid-template-columns:minmax(0,1fr) 260px}aside{padding:17px 12px}.stages{gap:7px;padding:9px;min-height:430px}.rig-viewport,.reference-viewport{min-height:380px}.pane-head{padding:9px}.pane-foot{padding:9px 7px}}
4
+ @media(max-width:850px){.layout{grid-template-columns:1fr}aside{border-left:0;border-top:1px solid #3b5b69;display:grid;grid-template-columns:1fr 1fr;gap:12px 24px}aside>.eyebrow,aside>label,aside>select{grid-column:1/-1}.feedback-form,.provenance{margin-top:0}.feedback-form{padding-top:0;border-top:0}.actions{margin-left:0}.rig-viewport,.reference-viewport{min-height:420px}}
5
+ @media(max-width:550px){header{gap:12px;padding:13px}.stages{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.pane:first-child{grid-column:1/-1}.reference-viewport{min-height:240px}.rig-viewport{min-height:365px}.pane-head{padding:7px}.pane-foot{font-size:11px}aside{display:block}.feedback-form{margin-top:23px;padding-top:20px;border-top:1px solid #405f6c}.toolbar{padding:11px 13px}.transport{padding:12px;gap:8px}.transport input[type=range]{min-width:130px}.transport input[type=number]{width:84px}.provenance{margin-top:18px}}
6
+ @media(pointer:coarse){button{min-height:42px}input,select{font-size:16px}.row button{min-width:38px}}
7
+
8
+ /* Inspection layout is bounded independently of pane content and review forms. */
9
+ #stage-layout{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:minmax(0,1fr);height:clamp(480px,calc(100dvh - 300px),900px);min-height:0;flex:none;align-items:stretch}
10
+ #stage-layout[data-orientation=vertical]{grid-template-columns:minmax(0,1fr);grid-template-rows:repeat(2,minmax(0,1fr))}
11
+ #stage-layout>.stage-pane,#stage-layout>.pane{grid-column:auto;min-height:0;min-width:0;display:flex;flex-direction:column}
12
+ #stage-layout .viewport,#stage-layout .rig-viewport,#stage-layout .reference-viewport{min-height:0;min-width:0;flex:1;touch-action:none;cursor:grab;user-select:none}
13
+ #stage-layout [data-panning=true]{cursor:grabbing!important}
14
+ #stage-layout img{-webkit-user-drag:none;user-select:none}
15
+ .rig-overlay{position:absolute;inset:0;pointer-events:none;width:100%;height:100%;z-index:1}
16
+ .inspection-controls{display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap;padding:9px 14px;border-bottom:1px solid #35515e;font-size:11px;background:#172f3b}
17
+ .inspection-controls label{display:inline-flex;align-items:center;gap:5px;margin:0;white-space:nowrap}
18
+ .inspection-controls select{width:auto;padding:4px;font-size:11px}
19
+ .inspection-controls input[type=checkbox]{width:auto;margin:0}
20
+ .inspection-controls fieldset{display:flex;gap:10px;align-items:center;border:0;padding:0;margin:0;min-width:0;flex-wrap:wrap}
21
+ .inspection-controls legend{float:left;margin-right:9px;font-size:11px}
22
+ #control-state:empty{display:none}
23
+ :root[data-theme=light]{color-scheme:light;color:#233c47;background:#f4f7f9}
24
+ :root[data-theme=dark]{color-scheme:dark}
25
+ [data-theme=light] body,[data-theme=light] main{background:#f4f7f9;color:#233c47}
26
+ [data-theme=light] header,[data-theme=light] .topbar,[data-theme=light] .workspaces,[data-theme=light] footer{background:#edf3f5;border-color:#bacbd3;color:#294552}
27
+ [data-theme=light] aside,[data-theme=light] .transport,[data-theme=light] .frame-controls,[data-theme=light] .inspection-controls,[data-theme=light] .sound-panel,[data-theme=light] .angle-panel,[data-theme=light] .report-panel{background:#e9f0f3;border-color:#b7c9d1;color:#294552}
28
+ [data-theme=light] button{background:#e2ebef;border-color:#a6bdc8;color:#264652}
29
+ [data-theme=light] button:hover{background:#d0e2e8}
30
+ [data-theme=light] button.primary,[data-theme=light] .primary{background:#24694d;border-color:#24694d;color:#fff}
31
+ [data-theme=light] input,[data-theme=light] select,[data-theme=light] textarea{background:#fff;border-color:#8faab7;color:#243e4a;accent-color:#276c50}
32
+ [data-theme=light] input::placeholder,[data-theme=light] textarea::placeholder{color:#607986}
33
+ [data-theme=light] .hint,[data-theme=light] .subtle,[data-theme=light] .label,[data-theme=light] .eyebrow,[data-theme=light] aside label,[data-theme=light] .tool small,[data-theme=light] .stage-footer,[data-theme=light] .compare-info,[data-theme=light] .boundary,[data-theme=light] .trace,[data-theme=light] .transport output,[data-theme=light] .zoom-controls output,[data-theme=light] .frame-range label,[data-theme=light] .frame-controls label{color:#496774}
34
+ [data-theme=light] .workspaces button,[data-theme=light] .segmented button{color:#3e5c69}
35
+ [data-theme=light] .workspaces button[aria-pressed=true]{color:#185738;border-bottom-color:#276c50}
36
+ [data-theme=light] .workspaces button span{color:#58727e}
37
+ [data-theme=light] .segmented,[data-theme=light] .provenance,[data-theme=light] .report-selection pre{background:#e1ebef;border-color:#9cb5c0;color:#294552}
38
+ [data-theme=light] .segmented button[aria-pressed=true],[data-theme=light] .checkpoint[aria-current=true],[data-theme=light] .report-event-list button[aria-current=true]{background:#cde4d8;border-color:#56866e;color:#1f4937}
39
+ [data-theme=light] .checkpoint small{color:#4b6c59}
40
+ [data-theme=light] .provenance p,[data-theme=light] .provenance-row span,[data-theme=light] .provenance details,[data-theme=light] .report-selection details,[data-theme=light] .report-event-list small{color:#486470}
41
+ [data-theme=light] .report-event-list button,[data-theme=light] .report-empty-categories span{background:#e5eef2;border-color:#acbfca;color:#294552}
42
+ [data-theme=light] .feedback-item strong{color:#1e4e3b}
43
+ [data-theme=light] a{color:#175f48}
44
+ [data-theme=light] .error{color:#a03225!important}
45
+ [data-theme=light] .dirty,[data-theme=light] .report-event-list .no-time{color:#875800!important}
46
+ [data-theme=light] :focus-visible{outline-color:#24694d}
47
+ /* Source pixels retain their original color; the neutral stage stays dark in both themes. */
48
+
49
+ #stage-layout{grid-template-columns:repeat(3,minmax(0,1fr))}#stage-layout[data-orientation=vertical]{grid-template-columns:minmax(0,1fr);grid-template-rows:repeat(3,minmax(0,1fr))}
50
+ #reference-video{transform-origin:center;user-select:none}
51
+
52
+ /* The application shell never scrolls; each Inspector tab and the source list owns overflow. */
53
+ html,body{height:100%;overflow:hidden}body{height:100dvh}#app{height:100dvh;display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden}
54
+ .layout{min-height:0!important;overflow:hidden;grid-template-columns:170px minmax(0,1fr)!important}
55
+ main{min-height:0;overflow:hidden;display:flex;flex-direction:column}
56
+ .left-panel{min-height:0;overflow:auto;padding:13px 10px}
57
+ #stage-layout{height:auto;min-height:0;flex:1;overflow:hidden;padding:8px;gap:7px}
58
+ .inspector{height:clamp(205px,32dvh,340px);min-height:180px;flex:none;display:flex;flex-direction:column;border-top:1px solid #49616d;background:#182d38;overflow:hidden}
59
+ .inspector-heading{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:7px 12px;border-bottom:1px solid #3e5c69;flex:none;font-size:11px}
60
+ .inspector-heading strong{font-size:10px;letter-spacing:.08em;font-weight:500}
61
+ .inspector-heading [role=tablist]{display:flex;gap:4px}
62
+ .inspector-heading button{padding:4px 9px;font-size:11px}
63
+ .inspector-heading button[aria-selected=true]{background:#3d5d4d;color:#e0f3e7;border-color:#7fa98e}
64
+ .inspector-body{flex:1;min-height:0;overflow:hidden}
65
+ .inspector-body>[role=tabpanel]{height:100%;overflow:auto;padding:12px 15px;min-height:0}
66
+ .inspector .inspection-controls{padding:0;border:0;background:transparent}
67
+ .inspector .provenance{margin-top:10px}
68
+ .inspector .transport{padding:0 0 10px;border:0;background:transparent;flex-wrap:wrap}
69
+ .inspector .report-panel{padding:10px 0}
70
+ .inspector .feedback-form{border:0;padding:0;margin:0}
71
+ #inspector-feedback{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(270px,100%),1fr));gap:20px;align-items:start}
72
+ #inspector-feedback>section,#inspector-feedback>#checkpoint-detail,#inspector-feedback>#proposal-panel{break-inside:avoid}
73
+ #inspector-feedback .divider{display:none}
74
+ #inspector-feedback>label,#inspector-feedback>select{display:block;max-width:300px}
75
+ #inspector-rig #modeling-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0}
76
+ #inspector-rig .tool{width:auto}
77
+ #inspector-rig #part-set{max-width:190px}
78
+ #inspector-playback #motion-tools{display:flex;gap:8px;flex-wrap:wrap}
79
+ #inspector-playback .tool{width:auto}
80
+ [data-theme=light] .inspector{background:#edf3f5;border-color:#a6bec8;color:#294552}
81
+ [data-theme=light] .inspector-heading{border-color:#b3c6d0}
82
+ [data-theme=light] .inspector-heading button[aria-selected=true]{background:#cde4d8;color:#1e4d36;border-color:#59836b}
83
+ @media(max-width:780px){.layout{grid-template-columns:130px minmax(0,1fr)!important}.inspector{height:36dvh}.stage-meta{padding:6px 10px 0}.stage-footer{padding:5px 10px}.inspector-heading>.hint{display:none}}
84
+ @media(max-width:540px){.layout{grid-template-columns:minmax(0,1fr)!important}.left-panel{display:block;padding:8px;font-size:11px}.layout{grid-template-columns:105px minmax(0,1fr)!important}.topbar,header{padding:7px 10px;gap:8px}.topbar .brand,header .brand{font-size:14px}.top-actions,.actions{gap:4px}.top-actions button,.actions button{padding:4px 6px;font-size:10px}.workspaces button{padding:6px}.inspector{height:40dvh;min-height:150px}.inspector-heading{gap:8px;padding:5px 8px}.inspector-heading button{padding:4px 7px}.stage-footer{display:none}}
85
+
86
+ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-template-columns:minmax(0,1fr)!important}.toolbar{padding:7px 12px}.pane-head{padding:5px 9px;min-height:32px}.pane-foot{padding:5px 8px;min-height:30px}.inspector{height:clamp(205px,32dvh,340px)}
87
+
88
+ @media(max-width:540px){.layout{grid-template-columns:minmax(0,1fr)!important}}
89
+
90
+ .inspector .feedback-form>label,.inspector .review-summary>label{display:block;font-size:11px;margin:10px 0 5px}.inspector .frame-range input{width:100%}.inspector [role=tabpanel]{overscroll-behavior:contain}.left-panel{overscroll-behavior:contain}#stage-layout .pane-label{max-width:calc(100% - 15px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
91
+
92
+ .rig-layer-controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:12px;font-size:11px}.rig-layer-controls fieldset{display:flex;gap:12px;align-items:center;flex-wrap:wrap;border:0;margin:0;padding:0}.rig-layer-controls legend{float:left;margin-right:10px}.rig-layer-controls label{display:inline-flex;gap:5px;align-items:center}.rig-layer-controls input[type=checkbox]{width:auto}
93
+
94
+ .numeric-view{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;padding:8px;background:#14232c;color:#c9dfe7;border-radius:5px}.view-link-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}.draft-notice{flex:none;font-size:11px;padding:0 12px;color:#e0c58e}.draft-notice:has(#draft-state:empty){display:none}.draft-notice #draft-recovery{display:inline-flex;gap:8px;margin-left:8px;flex-wrap:wrap}.draft-notice button{font-size:11px;padding:2px 6px}[data-theme=light] .numeric-view{background:#e1eaee;color:#264653}[data-theme=light] .draft-notice{color:#80521a}[data-theme=light] .outcome legend,[data-theme=light] .brand span,[data-theme=light] .toolbar{color:#496774}
95
+
96
+ .camera-sync{display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.camera-sync input[type=checkbox]{width:auto;flex:none;margin:0}
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>rig review · compare</title><link rel="stylesheet" href="/styles.css"></head><body>
3
+ <header><div class="brand">rig review <span>COMPARE</span></div><div><h1 id="title">A / B comparison</h1><span id="animation" class="hint"></span></div><div class="actions"><span id="save-state" class="hint">Loading…</span><button id="export-json">Feedback JSON</button><button id="export-markdown">Issue Markdown</button><button id="submit" class="primary" disabled>Submit review</button></div></header>
4
+ <div class="layout"><main><div class="toolbar"><span>Inspect A and B at the reference video time</span><div class="row"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom active view out">−</button><output id="zoom-label">100%</output><button id="zoom-in" aria-label="Zoom active view in">+</button><button id="fit">Fit synchronized views</button></div></div>
5
+ <div id="stage-layout" class="stages"><section class="pane"><div class="pane-head"><strong>REFERENCE</strong><span id="reference-name"></span></div><div id="reference-viewport" class="reference-viewport"><video id="reference-video" preload="auto" playsinline muted></video><p id="reference-empty" class="empty">Loading reference video…</p></div><div class="pane-foot"><span id="reference-time">— s</span><label><input id="reference-audio" type="checkbox">Video audio</label></div></section><section class="pane"><div class="pane-head"><strong class="side-label">A</strong><span id="a-title"></span></div><div id="a-viewport" class="rig-viewport"><div id="a-root" class="rig-root"></div><canvas id="a-overlay" class="rig-overlay" aria-label="A actual rig mesh and bone overlay"></canvas><p id="a-empty" class="empty">Loading rig…</p></div><div class="pane-foot"><span id="a-time">— s</span><span id="a-frame">rate unknown</span><span id="a-renderer" aria-label="A last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div></section><section class="pane"><div class="pane-head"><strong class="side-label">B</strong><span id="b-title"></span></div><div id="b-viewport" class="rig-viewport"><div id="b-root" class="rig-root"></div><canvas id="b-overlay" class="rig-overlay" aria-label="B actual rig mesh and bone overlay"></canvas><p id="b-empty" class="empty">Loading rig…</p></div><div class="pane-foot"><span id="b-time">— s</span><span id="b-frame">rate unknown</span><span id="b-renderer" aria-label="B last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div></section></div>
6
+ <section class="inspector" aria-label="Inspection controls"><div class="inspector-heading"><strong>INSPECTOR</strong><div role="tablist" aria-label="Inspector sections"><button role="tab" id="tab-view" data-inspector="view" aria-controls="inspector-view" aria-selected="true" tabindex="0">View</button><button role="tab" id="tab-rig" data-inspector="rig" aria-controls="inspector-rig" aria-selected="false" tabindex="-1">Rig</button><button role="tab" id="tab-playback" data-inspector="playback" aria-controls="inspector-playback" aria-selected="false" tabindex="-1">Playback</button><button role="tab" id="tab-feedback" data-inspector="feedback" aria-controls="inspector-feedback" aria-selected="false" tabindex="-1">Feedback</button></div><span class="hint">Local view controls · source files stay unchanged</span></div><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div><div class="inspector-body"><section id="inspector-view" role="tabpanel" aria-labelledby="tab-view" data-inspector-panel="view"><div class="view-link-controls"><button id="copy-view" type="button">Copy view link</button><span class="hint">Numeric source region · opening this link restores a paused inspection</span></div><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><div class="inspection-controls" aria-label="Inspection display controls"><label>Appearance <select id="appearance"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span></div><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off for independent Reference, A and B views. Toolbar zoom and Fit use the last active pane.</p></section><section id="inspector-rig" role="tabpanel" aria-labelledby="tab-rig" data-inspector-panel="rig" hidden><div class="rig-layer-controls"><fieldset id="layer-controls"><legend>Rig layers</legend><label><input id="layer-images" type="checkbox" checked disabled>Images</label><label><input id="layer-mesh" type="checkbox" disabled>Mesh edges</label><label><input id="layer-bones" type="checkbox" disabled>Bone points</label></fieldset><span id="layer-state" class="hint">No rig supplied</span></div><p class="hint">These controls show actual images, last-drawn mesh edges and named bone points. Both builds use the fixed animation named above. This inspector does not author geometry or motion.</p></section><section id="inspector-playback" role="tabpanel" aria-labelledby="tab-playback" data-inspector-panel="playback" hidden><section class="transport"><button id="play" aria-label="Play reference video and both rigs" disabled>▶</button><input id="scrub" type="range" min="0" max="1" step="0.0001" value="0" aria-label="Shared comparison time" disabled><output id="time">— / — s</output><label for="time-input" class="sr-only">Reference video time to inspect</label><input id="time-input" type="number" min="0" step="0.0001" placeholder="Seconds" disabled><button id="seek" disabled>Go to time</button></section><div class="compare-info"><output id="sync-status">Presentation pending</output><span id="sync-warning"></span></div><div class="compare-info"><label for="frame-basis">Review frame</label><select id="frame-basis"><option value="a">A explicit samples</option><option value="b">B explicit samples</option></select><input id="frame-input" type="number" min="0" step="1" aria-label="Explicit producer frame" disabled><button id="frame-seek" type="button" disabled>Inspect frame</button><span id="frame-index-status">Loading reference timestamp index…</span></div><div class="compare-info"><span id="domain">Checking shared playback range…</span><span id="clock-source">Waiting for video time</span></div><details class="trace"><summary>Synchronization trace</summary><pre id="trace">No frame presented yet.</pre></details><p class="boundary">Motion is read only. The reference video FPS is not used to infer the pose frame clock.</p></section><section id="inspector-feedback" role="tabpanel" aria-labelledby="tab-feedback" data-inspector-panel="feedback" hidden><div class="review-summary"><div class="eyebrow">COMPARE REVIEW</div><label for="status">Review status</label><select id="status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance"><h2>Source provenance</h2><div id="provenance-brief"></div><details><summary>Both builds, versions and reference video hash</summary><pre id="provenance-full"></pre></details></section></div><section class="feedback-form"><h2>Frame-range feedback</h2><label for="side">Target build</label><select id="side"><option value="">Select A or B</option><option value="a">A</option><option value="b">B</option></select><div class="frame-range"><label>Start frame<input id="frame-start" type="number" min="0" step="1" inputmode="numeric"></label><label>End frame<input id="frame-end" type="number" min="0" step="1" inputmode="numeric"></label></div><p id="feedback-range-hint" class="hint">Select A or B to check frame bounds.</p><label for="target-kind">Target type</label><select id="target-kind"><option value="">Select</option><option value="bone">Bone</option><option value="slot">Slot</option><option value="rig">Entire rig</option></select><label for="target-name">Actual target name</label><input id="target-name" type="text" list="target-names" autocomplete="off"><datalist id="target-names"></datalist><p id="target-hint" class="hint">Select a target build and type.</p><label for="category">Category</label><select id="category"><option value="">Select</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="summary">One-line summary</label><input id="summary" type="text" maxlength="1000" placeholder="What looks wrong, and over which frames?"><button id="add-feedback" class="block" disabled>Add feedback</button><p id="feedback-warnings" class="hint" role="status" hidden></p><div id="feedback-list"></div></section><details id="strip-panel" class="trace"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Exports Reference / A / B rows without changing inspection time.</p><label for="strip-scope">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-basis">Report defining the range frames</label><select id="strip-basis"><option value="">Select A or B as frame basis</option><option value="a">A report frameTimes</option><option value="b">B report frameTimes</option></select><label for="strip-format">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">The same frame number in A and B is not assumed to mean the same time. Batch export uses each feedback side as its frame basis. Without frameTimes, PNG is refused and JSON preserves time: null.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading comparison provenance…</p></details></section></div></section></main>
7
+ </div><footer><span id="message" role="status" aria-live="polite">Loading comparison provenance from the server…</span><span>Source files remain unchanged.</span></footer><script type="module" src="/main.js"></script></body></html>