@rig-c/review 0.1.0 → 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.
@@ -1,5 +1,5 @@
1
1
  /** Versioned local drafts and public numeric view links. No capability or review decision is put in a URL. */
2
- import {parseControlView,type ControlView} from '../src/control.ts';
2
+ import {parseControlPart,parseControlView,type ControlPart,type ControlView} from '../src/control.ts';
3
3
  import type {Json} from '../src/manifest.ts';
4
4
  /** Feedback stores an inert JSON value, independently copied from the typed control observation. */
5
5
  export function serializableView(view:ControlView):Record<string,Json> {
@@ -8,7 +8,7 @@ export function serializableView(view:ControlView):Record<string,Json> {
8
8
  if(view.cameraSync!==undefined)value.cameraSync=view.cameraSync;if(view.activeCamera!==undefined)value.activeCamera=view.activeCamera;return value;
9
9
  }
10
10
  export interface InspectionBinding {mode:'single'|'compare';sourceHash:string;rigs:(string|null)[]}
11
- export interface InspectionSelection {workspace?:'modeling'|'motion';view?:'compare'|'reference'|'rig';checkpoint?:string|null;reference?:string|null;person?:string|null;inspector?:string;partSet?:string|null;axis?:{id:string;value:number}|null}
11
+ export interface InspectionSelection {part?:ControlPart|null;workspace?:'modeling'|'motion';view?:'compare'|'reference'|'rig';checkpoint?:string|null;reference?:string|null;person?:string|null;inspector?:string;partSet?:string|null;axis?:{id:string;value:number}|null}
12
12
  export interface InspectionBookmark {
13
13
  schema:'rig-review-inspection/1';binding:InspectionBinding;animation:string|null;time:number|null;
14
14
  theme:'system'|'dark'|'light';layers:{images:boolean;meshes:boolean;bones:boolean};view:ControlView;selection:InspectionSelection;
@@ -33,12 +33,13 @@ export function parseBookmark(value:unknown,expected?:InspectionBinding):Inspect
33
33
  if(o.time!==null&&(typeof o.time!=='number'||!Number.isFinite(o.time)||o.time<0||o.time>Number.MAX_SAFE_INTEGER))throw new Error('View time requires finite nonnegative seconds');
34
34
  if(!['system','dark','light'].includes(String(o.theme)))throw new Error('View appearance invalid');
35
35
  const layers=record(o.layers,'layers');keys(layers,['images','meshes','bones'],'layers');for(const k of ['images','meshes','bones'])if(typeof layers[k]!=='boolean')throw new Error('View layers must be boolean');
36
- const selection=record(o.selection,'selection');keys(selection,['workspace','view','checkpoint','reference','person','inspector','partSet','axis'],'selection');
36
+ const selection=record(o.selection,'selection');keys(selection,['workspace','view','checkpoint','reference','person','inspector','partSet','axis','part'],'selection');
37
37
  if(selection.workspace!==undefined&&!['modeling','motion'].includes(String(selection.workspace)))throw new Error('View workspace invalid');
38
38
  if(selection.view!==undefined&&!['compare','reference','rig'].includes(String(selection.view)))throw new Error('View selection invalid');
39
39
  for(const k of ['checkpoint','reference','person','inspector','partSet'])if(selection[k]!==undefined&&selection[k]!==null)text(selection[k],k);
40
40
  if(selection.axis!==undefined&&selection.axis!==null){const axis=record(selection.axis,'axis');keys(axis,['id','value'],'axis');text(axis.id,'axis id');if(typeof axis.value!=='number'||!Number.isFinite(axis.value))throw new Error('View axis value invalid');}
41
41
  if(selection.inspector!==undefined&&!['view','rig','playback','feedback'].includes(String(selection.inspector)))throw new Error('Inspector selection invalid');
42
+ if(selection.part!==undefined){const part=parseControlPart(selection.part);if(part&&(binding.mode==='single'?part.side!=='single':part.side==='single'))throw new Error('View part side unavailable in this review mode');selection.part=part;}
42
43
  const view=parseControlView(o.view);
43
44
  if(view.activeCamera&&(binding.mode==='single'&&(view.activeCamera==='a'||view.activeCamera==='b')||binding.mode==='compare'&&view.activeCamera==='rig'))throw new Error('View active camera is unavailable in this review mode');
44
45
  return {schema:o.schema,binding,animation,time:o.time as number|null,theme:o.theme as InspectionBookmark['theme'],layers:{images:layers.images as boolean,meshes:layers.meshes as boolean,bones:layers.bones as boolean},view,selection:{...selection} as InspectionSelection};
@@ -79,7 +80,8 @@ export function restoredZoom(camera:NonNullable<ControlView['cameras']['rig']>,b
79
80
  export function captureInputs(root:ParentNode=document):FormInputs {
80
81
  const result:FormInputs={};for(const node of root.querySelectorAll<HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement>('input,select,textarea')){
81
82
  const key=node.id||(node instanceof HTMLInputElement&&node.type==='radio'&&node.name?`radio:${node.name}:${node.value}`:'');
82
- if(!key||node instanceof HTMLInputElement&&['password','file'].includes(node.type))continue;
83
+ // Browser-wide Settings are not inspection state and never enter a source draft.
84
+ if(!key||node.hasAttribute('data-app-preference')||node instanceof HTMLInputElement&&['password','file'].includes(node.type))continue;
83
85
  result[key]={value:node.value,...(node instanceof HTMLInputElement&&['checkbox','radio'].includes(node.type)?{checked:node.checked}:{})};
84
86
  }return result;
85
87
  }
@@ -87,7 +89,7 @@ export function restoreInputs(inputs:FormInputs,excluded:ReadonlySet<string>=new
87
89
  for(const [key,value]of Object.entries(inputs)){
88
90
  if(excluded.has(key))continue;
89
91
  const node=key.startsWith('radio:')?[...root.querySelectorAll<HTMLInputElement>('input[type=radio]')].find(n=>`radio:${n.name}:${n.value}`===key):root.querySelector<HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement>('#'+CSS.escape(key));
90
- if(!node)continue;if(node instanceof HTMLSelectElement&&![...node.options].some(o=>o.value===value.value))continue;
92
+ if(!node||node.hasAttribute('data-app-preference'))continue;if(node instanceof HTMLSelectElement&&![...node.options].some(o=>o.value===value.value))continue;
91
93
  if(node instanceof HTMLInputElement&&['password','file'].includes(node.type))continue;
92
94
  node.value=value.value;if(node instanceof HTMLInputElement&&value.checked!==undefined)node.checked=value.checked;
93
95
  }
@@ -105,22 +107,39 @@ export function recoverSession<T extends {id:string;revision:string;sourceHash:s
105
107
  if('checkpoints'in baseline){if(!o.checkpoints||typeof o.checkpoints!=='object'||Array.isArray(o.checkpoints))return null;for(const value of Object.values(o.checkpoints)){if(!value||typeof value!=='object'||Array.isArray(value))return null;const item=value as Record<string,unknown>;if(!['pending','pass','fail'].includes(String(item.outcome))||typeof item.note!=='string'||item.note.length>32768)return null;}}
106
108
  return o as T;
107
109
  }
108
- /** Storage is confined to this app/scope. A source change drops that scope's old draft only. */
110
+ /** Drafts kept per origin before least-recently-written eviction; recent drafts are never evicted. */
111
+ export const maximumDrafts=32,protectedDraftMs=24*60*60*1000;
112
+ const draftPrefix='rig-review-draft/1:',draftIndex='rig-review-draft-index/1';
113
+ /** Storage is confined to this app; the scope carries the source identity (`single:<id>:<sourceHash>`), so
114
+ * revisions of one package opened on one origin keep separate drafts. Opening deletes nothing; writing evicts
115
+ * the least-recently-written drafts beyond 32, except any written within the last 24 hours. */
109
116
  export class InspectionDraftStore {
110
117
  private storage:Storage|null=null;
111
118
  private blocked=false;
112
119
  private rawRecovery:string|null=null;
113
120
  recovery():string|null{return this.rawRecovery;}
114
121
  readonly key:string;
115
- constructor(private readonly scope:string,private readonly binding:InspectionBinding,private readonly warn:(message:string)=>void,storage?:Storage){
116
- this.key=`rig-review-draft/1:${scope}:${binding.sourceHash}`;
117
- try{this.storage=storage??localStorage;const active=`rig-review-active/1:${scope}`,previous=this.storage.getItem(active);if(previous&&previous!==this.key&&previous.startsWith(`rig-review-draft/1:${scope}:`)){this.storage.removeItem(previous);warn('Previous input draft cleared for this review scope because its source changed.');}this.storage.setItem(active,this.key);}catch{this.storage=null;warn('Local draft storage unavailable. Keep this page open or export feedback before leaving.');}
122
+ constructor(private readonly scope:string,private readonly binding:InspectionBinding,private readonly warn:(message:string)=>void,storage?:Storage,private readonly now:()=>number=Date.now){
123
+ this.key=`${draftPrefix}${scope}:${binding.sourceHash}`;
124
+ try{this.storage=storage??localStorage;this.storage.getItem(draftIndex);}catch{this.storage=null;warn('Local draft storage unavailable. Keep this page open or export feedback before leaving.');}
125
+ }
126
+ /** Written-at times by key; a draft missing from the index falls back to its own savedAt. */
127
+ private written():Map<string,number>{
128
+ const storage=this.storage!,times=new Map<string,number>();let index:Record<string,unknown>={};try{const raw=storage.getItem(draftIndex);const parsed=raw?JSON.parse(raw):{};if(parsed&&typeof parsed==='object'&&!Array.isArray(parsed))index=parsed;}catch{}
129
+ for(let i=0;i<storage.length;i++){const key=storage.key(i);if(!key?.startsWith(draftPrefix))continue;let time=Number(index[key]);if(!Number.isFinite(time)){try{time=Date.parse(JSON.parse(storage.getItem(key)??'').savedAt);}catch{time=NaN;}}times.set(key,Number.isFinite(time)?time:0);}
130
+ return times;
131
+ }
132
+ private evict():void{
133
+ const storage=this.storage!,now=this.now(),times=this.written();times.set(this.key,now);
134
+ const old=[...times].filter(([key,time])=>key!==this.key&&now-time>protectedDraftMs).sort((a,b)=>a[1]-b[1]);
135
+ for(const [key]of old){if(times.size<=maximumDrafts)break;storage.removeItem(key);times.delete(key);}
136
+ storage.setItem(draftIndex,JSON.stringify(Object.fromEntries(times)));
118
137
  }
119
138
  read():LocalInspectionDraft|null {
120
139
  if(!this.storage)return null;try{const raw=this.storage.getItem(this.key);if(!raw)return null;this.rawRecovery=raw;if(new TextEncoder().encode(raw).length>maximumDraftBytes)throw new Error('Draft exceeds 512 KiB');const o=record(JSON.parse(raw),'draft');keys(o,['schema','sourceHash','baseline','savedAt','reviewDirty','bookmark','inputs','session','ui'],'draft');if(o.schema!=='rig-review-local-draft/1'||o.sourceHash!==this.binding.sourceHash)throw new Error('Draft binding/version differs');if(typeof o.reviewDirty!=='boolean')throw new Error('Draft review state invalid');const bookmark=parseBookmark(o.bookmark,this.binding),baseline=hash(o.baseline);if(typeof o.savedAt!=='string'||!Number.isFinite(Date.parse(o.savedAt)))throw new Error('Draft timestamp invalid');const ui=record(o.ui,'draft UI');for(const [k,v]of Object.entries(ui)){text(k,'draft UI key');if(v!==null&&typeof v!=='string'&&typeof v!=='number'||typeof v==='number'&&!Number.isFinite(v))throw new Error('Draft UI value invalid');}return {schema:o.schema,sourceHash:this.binding.sourceHash,baseline,savedAt:o.savedAt,reviewDirty:o.reviewDirty,bookmark,inputs:parseInputs(o.inputs),session:o.session,ui:ui as LocalInspectionDraft['ui']};}catch(error){this.blocked=true;this.warn(`Saved local draft was not restored: ${error instanceof Error?error.message:String(error)}. It remains in storage for recovery.`);return null;}
121
140
  }
122
141
  write(draft:LocalInspectionDraft):boolean {
123
- if(!this.storage||this.blocked)return false;try{const value=JSON.stringify(draft);if(new TextEncoder().encode(value).length>maximumDraftBytes)throw new Error('Draft exceeds 512 KiB');this.storage.setItem(this.key,value);return true;}catch(error){this.warn(`Local draft could not be saved: ${error instanceof Error?error.message:String(error)}. Export feedback before leaving.`);return false;}
142
+ if(!this.storage||this.blocked)return false;try{const value=JSON.stringify(draft);if(new TextEncoder().encode(value).length>maximumDraftBytes)throw new Error('Draft exceeds 512 KiB');this.storage.setItem(this.key,value);try{this.evict();}catch{}return true;}catch(error){this.warn(`Local draft could not be saved: ${error instanceof Error?error.message:String(error)}. Export feedback before leaving.`);return false;}
124
143
  }
125
144
  clear():void {try{this.storage?.removeItem(this.key);this.blocked=false;this.rawRecovery=null;}catch{this.warn('Local draft could not be cleared.');}}
126
145
  }
@@ -134,7 +153,7 @@ interface PersistenceAdapter<T extends PersistenceSession> {
134
153
  afterInputs:()=>void;onConflict:(conflict:boolean)=>void;onStatus:(message:string)=>void;
135
154
  }
136
155
  export interface PersistenceConnection {restored:boolean;restoring:()=>boolean;save:()=>void;submitted:(session:unknown)=>Promise<void>;dispose:()=>void}
137
- const viewInputIds=new Set(['camera-sync','appearance','layer-images','layer-mesh','layer-bones','animation-select','reference-select','person-select','axis-select','axis-value','scrub','frame-number','frame-scrub','time-input','frame-input','part-set']);
156
+ const viewInputIds=new Set(['part-search','part-solo','camera-sync','appearance','layer-images','layer-mesh','layer-bones','animation-select','reference-select','person-select','axis-select','axis-value','scrub','frame-number','frame-scrub','time-input','frame-input','part-set']);
138
157
  /** Restore once after normal startup; snapshots never perform a server mutation. */
139
158
  export async function connectInspectionPersistence<T extends PersistenceSession>(adapter:PersistenceAdapter<T>):Promise<PersistenceConnection> {
140
159
  let restoring=true,closed=false,conflict=false,timer:ReturnType<typeof setTimeout>|undefined;
@@ -0,0 +1,23 @@
1
+ /** Page-side instance routing and the per-instance write capability. The capability arrives only in the
2
+ * printed `#cap=` handoff fragment (never sent to a server), moves into memory and this tab's
3
+ * sessionStorage under the instance key, and is stripped from the address bar. No endpoint serves it, so a
4
+ * copied link without the fragment opens read-only. Managed pages live under `/instances/<key>/`; direct and
5
+ * foreground pages under `/`. */
6
+ export const capabilityHeader='X-Review-Capability';
7
+ export const readOnlyMessage='Read-only view: reopen with rig-review open to edit.';
8
+ export interface InstanceAccess {readonly base:string;readonly key:string;readonly capability:string|null;route(path:string):string;headers(init?:HeadersInit):Headers}
9
+ export function instanceAccess(href:string,storage:Pick<Storage,'getItem'|'setItem'>|null,replace:(href:string)=>void):InstanceAccess {
10
+ const url=new URL(href),match=/^\/instances\/([a-f0-9]{64})\//.exec(url.pathname);
11
+ if(url.pathname.startsWith('/instances/')&&!match)throw new Error('Instance path invalid');
12
+ const handoff=url.searchParams.get('handoff'),key=match?match[1]!:handoff&&/^[a-f0-9]{64}$/.test(handoff)?handoff:'direct',base=match?`/instances/${key}/`:'/',slot=`rig-review-capability/1:${key}`;
13
+ let capability:string|null=null;
14
+ if(url.hash.startsWith('#cap=')){const value=url.hash.slice(5);if(/^[a-f0-9]{64}$/.test(value))capability=value;url.hash='';replace(url.href);try{if(capability)storage?.setItem(slot,capability);}catch{}}
15
+ if(!capability){try{const saved=storage?.getItem(slot)??null;capability=saved&&/^[a-f0-9]{64}$/.test(saved)?saved:null;}catch{}}
16
+ return {base,key,capability,
17
+ route:path=>{if(!path.startsWith('/')||path.startsWith('//'))throw new Error('Instance route must be app-absolute');return base+path.slice(1);},
18
+ headers:init=>{const headers=new Headers(init);if(capability)headers.set(capabilityHeader,capability);return headers;}};
19
+ }
20
+ /** A server that requires a capability, reached without one, is read-only with this reason. */
21
+ export function readOnlyReason(access:Pick<InstanceAccess,'capability'>,capabilityRequired:boolean):string|null {return capabilityRequired&&!access.capability?readOnlyMessage:null;}
22
+ /** Browser glue: tolerate storage denial; the page then keeps the capability in memory only. */
23
+ export function pageInstanceAccess():InstanceAccess {let storage:Storage|null=null;try{storage=sessionStorage;}catch{}return instanceAccess(location.href,storage,href=>history.replaceState(history.state,'',href));}
package/web/main.ts CHANGED
@@ -1,10 +1,14 @@
1
+ import {PartExplorer,loadPartCatalog} from './part-selection.ts';
1
2
  import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
2
- import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
3
+ import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
3
4
  import {rendererStatus} from './renderer-status.ts';
4
5
  import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,type ViewPoint} from './view-controls.ts';
5
6
  import {RigOverlays} from './rig-overlays.ts';
7
+ import {bindWorkspaceUI,type WorkspaceUI} from './workspace-ui.ts';
6
8
  import {connectControl} from './control.ts';
7
- import type {ControlCommand,ControlInspection,ControlView} from '../src/control.ts';
9
+ import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
10
+ import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
11
+ import {singleEligibility,videoReferenceAt,type InputState,type SingleLayout} from '../src/comparison-eligibility.ts';
8
12
  import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
9
13
  import {initialPlayerAnimation} from '../src/animation-selection.ts';
10
14
  import {initialReviewSelection} from '../src/open-selection.ts';
@@ -32,6 +36,9 @@ const button = (id: string) => el<HTMLButtonElement>(id);
32
36
  const text = (id: string, value: string) => { el(id).textContent = value; };
33
37
  const option = (value: string, label: string) => { const item = document.createElement('option'); item.value = value; item.textContent = label; return item; };
34
38
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
39
+ // First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
40
+ const access = pageInstanceAccess();
41
+ let readOnly: string | null = null;
35
42
  let data: ReviewResponse;
36
43
  let session: ReviewSession;
37
44
  let dirty = false;
@@ -50,14 +57,18 @@ type CameraTarget='rig'|'reference';
50
57
  let cameraSync=true,activeCamera:CameraTarget='rig',cameraRestoring=false,cameraLayout='';
51
58
  const cameraFrames:Partial<Record<CameraTarget,CameraFrame>>={};
52
59
  let view: 'compare' | 'reference' | 'rig' = 'compare';
60
+ // Requested layout persists in the source-bound draft; the effective one follows actual load events only.
61
+ let rigInput:InputState={state:'pending'},referenceInput:InputState={state:'absent'},effectiveView:SingleLayout|null=null;
53
62
  let zoom = 1;
54
63
  let cameraCenter: { x: number; y: number } | null = null;
55
64
  let referenceCenter:ViewPoint|null=null;
56
65
  let rigScale=1,referenceScale=1,referenceZoom=1;
57
66
  let displayedCenter:ViewPoint|null=null;
58
67
  let playbackIntent=0,desiredPlay=false,seekingControl=false;
68
+ let parts:PartExplorer|null=null;
59
69
  let overlays:RigOverlays|null=null;
60
70
  let appearance:ReturnType<typeof bindAppearance>|null=null;
71
+ let workspace:WorkspaceUI|null=null;
61
72
  let idleHeld=false;
62
73
  let suspendedPreview=false;
63
74
  let controlDispose:(()=>void)|null=null;
@@ -85,16 +96,17 @@ function message(value: string, error = false): void {
85
96
  }
86
97
 
87
98
  async function api<T>(path: string, init?: RequestInit): Promise<T> {
88
- const response = await fetch(path, init);
99
+ const method = (init?.method ?? 'GET').toUpperCase();
100
+ const response = await fetch(access.route(path), method === 'GET' || method === 'HEAD' ? init : { ...init, headers: access.headers(init?.headers) });
89
101
  if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
90
102
  return await response.json() as T;
91
103
  }
92
104
 
93
- function referenceUrl(reference:Reference):string {return data.explicitReference?.id===reference.id?data.explicitReference.url:assetUrl(reference.path);}
105
+ function referenceUrl(reference:Reference):string {return data.explicitReference?.id===reference.id?access.route(data.explicitReference.url):assetUrl(reference.path);}
94
106
 
95
107
  function assetUrl(path: string): string {
96
108
  if (!path || path.startsWith('/') || /[\\%?#\x00-\x1f]/.test(path) || /^[a-zA-Z]:/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error(`Unsupported package asset path: ${path}`);
97
- return '/assets/' + path.split('/').map(encodeURIComponent).join('/');
109
+ return access.route('/assets/' + path.split('/').map(encodeURIComponent).join('/'));
98
110
  }
99
111
 
100
112
  function markDirty(): void {
@@ -105,8 +117,8 @@ function markDirty(): void {
105
117
  function updateSavedState(): void {
106
118
  text('dirty', dirty ? 'Unsubmitted changes' : session.updatedAt ? 'Submitted' : 'Not submitted');
107
119
  el('dirty').classList.toggle('dirty', dirty);
108
- button('save').disabled = saving || draftConflict || session.feedbackWarnings.length > 0;
109
- button('save').title = session.feedbackWarnings.length ? 'Delete feedback with validation warnings, then add it again with a valid target.' : '';
120
+ button('save').disabled = saving || draftConflict || !!readOnly || session.feedbackWarnings.length > 0;
121
+ button('save').title = readOnly ? readOnly : session.feedbackWarnings.length ? 'Delete feedback with validation warnings, then add it again with a valid target.' : '';
110
122
  }
111
123
 
112
124
  function renderProvenance(): void {
@@ -164,10 +176,27 @@ function renderCheckpoint(): void {
164
176
  for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="outcome"]')) { radio.checked = radio.value === (entry?.outcome ?? 'pending'); radio.disabled = !checkpoint; }
165
177
  }
166
178
 
167
- function setView(value: typeof view): void {
168
- view = value;
169
- for (const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')) item.setAttribute('aria-pressed', String(item.dataset.view === value));
170
- el('reference-pane').hidden = value === 'rig'; el('rig-pane').hidden = value === 'reference'; fitCamera();
179
+ function setView(value: typeof view): void {view=value;renderLayout();}
180
+ function layoutEligibility(){return singleEligibility({rig:rigInput,reference:referenceInput,requested:view});}
181
+ /** Unavailable layouts stay visible but disabled with their named reason; no placeholder pane stands in for a missing input. */
182
+ function renderLayout():void {
183
+ const state=layoutEligibility();effectiveView=state.effective;
184
+ for(const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')){const layout=item.dataset.view as SingleLayout,reason=state.reasons[layout]??'';item.setAttribute('aria-pressed',String(layout===state.effective));item.disabled=!!reason;item.title=reason;if(reason)item.setAttribute('aria-description',reason);else item.removeAttribute('aria-description');}
185
+ el('reference-pane').hidden=state.effective!=='compare'&&state.effective!=='reference';el('rig-pane').hidden=state.effective!=='compare'&&state.effective!=='rig';
186
+ const shown={compare:'Side by side',reference:'Reference only',rig:'Rig only'};
187
+ text('layout-state',state.effective===null?`No input rendered · ${state.reasons.compare??''}`:state.effective===state.requested?shown[state.effective]:`${shown[state.effective]} · ${shown[state.requested]} unavailable: ${state.reasons[state.requested]??''}`);
188
+ fitCamera();
189
+ }
190
+ function setReferenceInput(value:InputState):void {const leaving=referenceInput.state==='out-of-domain'&&value.state!=='out-of-domain';referenceInput=value;if(value.state==='out-of-domain')message(`REFERENCE_OUT_OF_DOMAIN: ${value.reason}. The reference was not clamped.`,true);else if(leaving)message('Reference back in its domain at the held time.');renderLayout();}
191
+ /** A video counts only after `seeked` at the held time with a decoded frame (readyState >= 2). */
192
+ function holdVideoReference(media:HTMLVideoElement,generation:number):void {
193
+ if(referenceInput.state==='failed')return;
194
+ const next=videoReferenceAt(time,media.duration);
195
+ if(next.state!=='pending'){if(referenceInput.state!==next.state||next.state!=='out-of-domain')setReferenceInput(next);return;}
196
+ if(referenceInput.state==='loaded'){media.currentTime=time;return;}
197
+ const held=time,current=()=>generation===referenceGeneration&&media===video&&referenceInput.state==='pending'&&time===held;
198
+ media.addEventListener('seeked',()=>{if(!current())return;const done=()=>{if(current()&&media.readyState>=2)setReferenceInput({state:'loaded'});};if(media.readyState>=2)done();else for(const event of ['loadeddata','canplay'])media.addEventListener(event,done,{once:true});},{once:true});
199
+ if(referenceInput.state!=='pending')setReferenceInput({state:'pending'});media.currentTime=held;
171
200
  }
172
201
 
173
202
  function svgNode(tag: string, attrs: Record<string, string | number>, parent: SVGElement): SVGElement {
@@ -230,12 +259,12 @@ async function showReference(id: string): Promise<void> {
230
259
  video?.pause(); video = null;controlledVideoSample=null;delete cameraFrames.reference; referenceCenter=null; referenceSize = null; sequence = null; sequenceSvg = null; selectedPerson=''; lastPoseFrame = -1; el('person-control').hidden = true;
231
260
  const content = el('reference-content'); content.replaceChildren();
232
261
  const reference = data.manifest.references.find(item => item.id === id);
233
- el('reference-empty').hidden = false;
262
+ el('reference-empty').hidden = false;setReferenceInput(reference?{state:'pending'}:{state:'absent'});
234
263
  if (!reference) { text('reference-empty', 'No reference supplied.'); text('reference-info', 'Add image, video or input-pose references to the package.'); return; }
235
264
  text('reference-label', reference.label + (reference.type === 'pose' ? ' · Static input pose' : ' · Reference'));
236
265
  text('reference-empty', 'Loading reference…');
237
266
  text('reference-info', `${reference.type} · ${reference.path}`);
238
- const fail = () => { if (generation !== referenceGeneration) return; el('reference-empty').hidden = false; text('reference-empty', 'Could not open the reference. Check the package files.'); };
267
+ const fail = (reason='reference could not render') => { if (generation !== referenceGeneration) return; setReferenceInput({state:'failed',reason}); el('reference-empty').hidden = false; text('reference-empty', 'Could not open the reference. Check the package files.'); };
239
268
  try {
240
269
  if (reference.type === 'pose') {
241
270
  const response = await fetch(referenceUrl(reference)); if (!response.ok) throw new Error(`Pose reference HTTP ${response.status}`);
@@ -248,7 +277,7 @@ async function showReference(id: string): Promise<void> {
248
277
  select('person-select').replaceChildren(); if (people.length !== 1) select('person-select').append(option('', 'Select person')); for (const id of people) select('person-select').append(option(id, id)); selectedPerson = people.length === 1 ? people[0]! : ''; select('person-select').value = selectedPerson; el('person-control').hidden = false;
249
278
  if (!player) duration = canonical.frames[canonical.frames.length - 1]!.t - canonical.frames[0]!.t;
250
279
  text('reference-info', `${reference.path} · picture-px / top-left / y↓ · ${canonical.frames.length} frames${canonical.camera ? ' · Camera metadata preserved (normalization is not applied twice)' : ''}`);
251
- renderSequenceFrame(); fitCamera(); renderTransport(); return;
280
+ renderSequenceFrame(); setReferenceInput({state:'loaded'}); renderTransport(); return;
252
281
  }
253
282
  const pose = readPose(value);
254
283
  referenceSize = { width: pose.width, height: pose.height };
@@ -259,15 +288,16 @@ async function showReference(id: string): Promise<void> {
259
288
  const dot = svgNode('circle', { cx: joint.x, cy: joint.y, r: 4, fill: '#203d31', stroke: '#caead9', 'stroke-width': 1.5 }, svg);
260
289
  const title = document.createElementNS(svg.namespaceURI, 'title'); title.textContent = joint.id; dot.append(title);
261
290
  }
262
- content.append(svg); el('reference-empty').hidden = true; fitCamera();
291
+ content.append(svg); el('reference-empty').hidden = true; setReferenceInput({state:'loaded'});
263
292
  } else if (reference.type === 'image') {
264
293
  const image = new Image(); image.draggable=false; image.alt = reference.label; image.onload = () => { if (generation !== referenceGeneration) return; referenceSize = { width: image.naturalWidth, height: image.naturalHeight }; image.width = image.naturalWidth; image.height = image.naturalHeight; el('reference-empty').hidden = true; fitCamera(); }; await new Promise<void>((resolve,reject)=>{const loaded=image.onload;const timer=setTimeout(()=>reject(new Error('Reference image load timed out')),10000);image.onload=event=>{clearTimeout(timer);if(typeof loaded==='function')loaded.call(image,event);resolve();};image.onerror=()=>{clearTimeout(timer);fail();reject(new Error('Reference image load failed'));};image.src=referenceUrl(reference);content.append(image);});
294
+ await image.decode(); if (generation !== referenceGeneration) return; if (!image.naturalWidth || !image.naturalHeight) throw new Error('Reference image has no renderable pixels'); setReferenceInput({state:'loaded'});
265
295
  } else {
266
296
  const media = document.createElement('video'); media.controls = true; media.muted = true; media.preload = 'metadata'; media.playsInline = true;
267
- media.onloadedmetadata = () => { if (generation !== referenceGeneration) return; referenceSize = { width: media.videoWidth, height: media.videoHeight }; media.width = media.videoWidth; media.height = media.videoHeight; if (Number.isFinite(media.duration) && !player) duration = media.duration; media.currentTime = Math.min(time, Math.max(0, media.duration - .01)); el('reference-empty').hidden = true; fitCamera(); renderTransport(); };
268
- media.onerror = fail; media.src = referenceUrl(reference); video = media; content.append(media);
297
+ media.onloadedmetadata = () => { if (generation !== referenceGeneration) return; referenceSize = { width: media.videoWidth, height: media.videoHeight }; media.width = media.videoWidth; media.height = media.videoHeight; if (Number.isFinite(media.duration) && !player) duration = media.duration; el('reference-empty').hidden = true; holdVideoReference(media, generation); fitCamera(); renderTransport(); };
298
+ media.onerror = () => fail('reference video could not be decoded'); media.src = referenceUrl(reference); video = media; content.append(media);
269
299
  }
270
- } catch (error) { fail(); message(errorText(error), true); }
300
+ } catch (error) { fail(errorText(error)); message(errorText(error), true); }
271
301
  }
272
302
 
273
303
  function referenceFit(): void {
@@ -355,7 +385,7 @@ function seekFrame(frame: number): void {
355
385
  }
356
386
 
357
387
  function drawRig(action:()=>void):void {
358
- try{action();text('renderer-state',rendererStatus(player?.renderer??null,'succeeded').label);redrawOverlay(true);}
388
+ try{action();parts?.present('single');text('renderer-state',rendererStatus(player?.renderer??null,'succeeded').label);redrawOverlay(true);}
359
389
  catch(error){overlays?.clear();text('renderer-state',rendererStatus(null,'failed').label);throw error;}
360
390
  }
361
391
 
@@ -372,7 +402,7 @@ function chooseAnimation(name: string): void {
372
402
  function seek(value: number): void {
373
403
  time = duration > 0 ? Math.min(Math.max(0, value), duration) : Math.max(0, value);
374
404
  if (player && player.current) { try { player.seek(time); drawRig(()=>player!.draw()); } catch (error) {text('renderer-state',rendererStatus(null,'failed').label); message(errorText(error), true); } }
375
- if (video && video.readyState >= 1 && Number.isFinite(video.duration)) video.currentTime = Math.min(time, video.duration);
405
+ if (video && video.readyState >= 1) holdVideoReference(video, referenceGeneration);
376
406
  renderSequenceFrame(); renderTransport();
377
407
  }
378
408
 
@@ -491,7 +521,7 @@ function tick(now: number): void {
491
521
  const dt = lastFrame ? Math.min((now - lastFrame) / 1000, .1) : 0;
492
522
  const previous = time; time = Math.min(duration, time + dt);
493
523
  if (player && player.current) { try { drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
494
- if (video && Math.abs(video.currentTime - time) > .15) video.currentTime = Math.min(time, video.duration || time);
524
+ if (video && video.readyState >= 1 && time > video.duration) { if (referenceInput.state !== 'out-of-domain') holdVideoReference(video, referenceGeneration); } else if (video && referenceInput.state === 'loaded' && Math.abs(video.currentTime - time) > .15) video.currentTime = time;
495
525
  if (input('sync-audio').checked) for (const cue of activeCues()) if ((previous < cue.time && cue.time <= time) || (previous === 0 && cue.time === 0 && dt > 0)) void audition(cue.clip);
496
526
  renderSequenceFrame(); renderTransport(); if(time>=duration){if(canRepeatIdle()&&!idleHeld){stopAudio();seek(0);player?.resume();if(video)void video.play().catch(error=>{pause();message(errorText(error),true);});}else pause();} lastFrame = now;
497
527
  }
@@ -505,9 +535,11 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
505
535
  try {
506
536
  const modelUrl = new URL(assetUrl(data.manifest.model), location.href).href;
507
537
  assets = await loadModel({ modelUrl, setsUrl: null, resolvePage: (name, url) => {
508
- const target = new URL(name, url); if (target.origin !== location.origin || !target.pathname.startsWith('/assets/')) throw new Error(`Model page escapes local package: ${name}`); return target.href;
538
+ const target = new URL(name, url); if (target.origin !== location.origin || !target.pathname.startsWith(access.route('/assets/'))) throw new Error(`Model page escapes local package: ${name}`); return target.href;
509
539
  } });
510
540
  player = new RigPlayer(el('rig-root'), assets);
541
+ // Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
542
+ try{parts?.register('single',await loadPartCatalog(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]),player);}catch(error){parts?.refuseCatalog('single',error);}
511
543
  player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
512
544
  const animations = select('animation-select'); animations.replaceChildren();
513
545
  for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
@@ -578,7 +610,7 @@ function stripStatus(value: string, error = false): void { text('strip-status',
578
610
  function updateStripControls(): void {
579
611
  el('strip-panel').setAttribute('aria-busy', String(stripBusy));
580
612
  for (const id of ['strip-scope', 'strip-reference', 'strip-format']) select(id).disabled = stripBusy;
581
- button('strip-export').disabled = stripBusy; button('strip-export').textContent = stripBusy ? 'Generating strip…' : 'Download strip';
613
+ button('strip-export').disabled = stripBusy || !!readOnly; button('strip-export').title = readOnly ?? ''; button('strip-export').textContent = stripBusy ? 'Generating strip…' : 'Download strip';
582
614
  }
583
615
 
584
616
  async function exportStrip(): Promise<void> {
@@ -605,7 +637,7 @@ async function exportStrip(): Promise<void> {
605
637
  const referenceId = select('strip-reference').value;
606
638
  const request: StripRequest = { format, sourceHash: data.sourceHash, animation: selectedAnimation, ...(referenceId ? { referenceId } : {}), intervals };
607
639
  stripBusy = true; updateStripControls(); stripStatus(`${selectedAnimation} · ${intervals.length} ranges · Generating ${format.toUpperCase()}…`);
608
- const response = await fetch('/api/strip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
640
+ const response = await fetch(access.route('/api/strip'), { method: 'POST', headers: access.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify(request) });
609
641
  if (!response.ok) {
610
642
  const failure: unknown = await response.json().catch(() => null);
611
643
  throw new Error(failure && typeof failure === 'object' && 'error' in failure && typeof failure.error === 'string' ? failure.error : `Strip export failed · HTTP ${response.status}`);
@@ -669,7 +701,7 @@ function updateCamera(target:CameraTarget,value:CameraSignature):void {
669
701
  }
670
702
  function zoomCamera(factor:number,target=availableCamera()):void {if(!target)return;const frame=paneFrame(target);if(frame)updateCamera(target,zoomSignature(cameraSignature(paneCamera(target),frame),factor));}
671
703
  function panCamera(target:CameraTarget,dx:number,dy:number):void {const frame=paneFrame(target);if(frame)updateCamera(target,dragSignature(cameraSignature(paneCamera(target),frame),frame,dx,dy));}
672
- function fitActiveCamera(target=availableCamera()):void {if(target)updateCamera(target,{zoom:1,pan:{x:0,y:0}});}
704
+ function fitActiveCamera(target=availableCamera()):void {if(!target)return;const part=parts?.value();if(target==='rig'&&part){const box=parts?.bounds(),frame=paneFrame(target);if(!box||!frame){message('PART_FIT_UNAVAILABLE: selected slot has no positive drawn bounds.',true);return;}updateCamera(target,boundsFitSignature(box,frame,28,58));message('Selected slot fitted.');}else{if(!paneFrame(target))return;updateCamera(target,{zoom:1,pan:{x:0,y:0}});message('View fitted.');}}
673
705
  function zoomViewport(target:CameraTarget,deltaY:number):void {zoomCamera(deltaY<0?1.1:1/1.1,target);}
674
706
 
675
707
  function bind(): void {
@@ -718,7 +750,7 @@ function bind(): void {
718
750
  try { validateFeedbackSelection(data.feedbackCatalog, { frameStart, frameEnd, target, animation: selectedAnimation }); } catch (error) { message(errorText(error), true); return; }
719
751
  const box = assets?.stage ?? player?.bounds();
720
752
  const center = cameraCenter ?? (box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null);
721
- session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {
753
+ session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {part:parts?.feedback()??null,
722
754
  interval: { frameStart, frameEnd, inclusive: true },
723
755
  checkpoint: checkpoint?.id ?? null, animation: selectedAnimation, time, frame: frameAtTime(activeFrameClock(), time),
724
756
  reference: select('reference-select').value || null, person: selectedPerson || null,
@@ -731,6 +763,7 @@ function bind(): void {
731
763
  input('feedback-summary').value = ''; markDirty(); renderProposals(); message('Added structured feedback. Submit the review to save it.');
732
764
  };
733
765
  button('save').onclick = async () => {
766
+ if(readOnly){message(readOnly,true);return;}
734
767
  if(draftConflict){message('Resolve the saved local draft conflict before submitting.',true);return;}
735
768
  if (session.feedbackWarnings.length) { message('Resolve validation warnings in existing feedback before submitting. Delete the marked entries and add valid replacements.', true); return; }
736
769
  saving = true; updateSavedState(); const savedPayload = JSON.stringify(session);
@@ -742,7 +775,10 @@ function bind(): void {
742
775
  };
743
776
  button('export').onclick = () => { download(`${session.id}-feedback.json`, JSON.stringify({ schema: 'rig-review-feedback/1', id: session.id, revision: session.revision, sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: session.reviewedSource, status: session.status, checkpoints: session.checkpoints, feedback: session.feedback, feedbackWarnings: session.feedbackWarnings, issues: session.feedback.map(issueFields) }, null, 2) + '\n', 'application/json'); message('Exported source provenance, feedback, validation warnings and issue fields as JSON. Source files remain unchanged.'); };
744
777
  button('export-markdown').onclick = () => { const content = [`# ${markdown(session.id)} review feedback`, '', 'Reviewed source:', ...JSON.stringify({ sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: session.reviewedSource }, null, 2).split('\n').map(line => ' ' + line), '', 'Validation warnings:', ...JSON.stringify(session.feedbackWarnings, null, 2).split('\n').map(line => ' ' + line), '', ...session.feedback.flatMap(item => { const issue = issueFields(item); return [`## ${markdown(issue.title)}`, '', issue.body, '', '---', '']; })].join('\n'); download(`${session.id}-issues.md`, content, 'text/markdown'); message('Exported source provenance, validation warnings and structured issue content as Markdown.'); };
745
- appearance=bindAppearance(select('appearance'));
778
+ // Saved browser defaults seed a new inspection; explicit links and source drafts restore over them.
779
+ workspace=bindWorkspaceUI({changed:fitCamera,defaultTheme:theme=>appearance?.set(theme)});
780
+ cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
781
+ appearance=bindAppearance(select('appearance'),workspace.defaults().theme);
746
782
  overlays=new RigOverlays({canvas:el<HTMLCanvasElement>('rig-overlay'),rigRoot:el('rig-root')});
747
783
  viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
748
784
  for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
@@ -753,38 +789,44 @@ function bind(): void {
753
789
  document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
754
790
  const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
755
791
  window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
756
- window.addEventListener('pagehide', () => { persistence?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
792
+ window.addEventListener('pagehide', () => { persistence?.dispose();workspace?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
757
793
  }
758
794
 
759
795
  async function start(): Promise<void> {
760
796
  try {
761
- [data, session] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session')]);
797
+ let writes: { capabilityRequired: boolean };
798
+ [data, session, writes] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
799
+ readOnly = readOnlyReason(access, writes.capabilityRequired);
762
800
  submittedBaseline=structuredClone(session);
763
801
  text('package-title', data.manifest.title); text('revision', data.manifest.revision); document.title = `${data.manifest.title} · rig review`; text('source-hash', `source ${data.sourceHash.slice(0, 12)}`); select('review-status').value = session.status; select('feedback-target-kind').value = 'rig'; input('feedback-target-name').value = data.manifest.id;
764
802
  select('reference-select').replaceChildren(); for (const reference of data.manifest.references) select('reference-select').append(option(reference.id, `${reference.label} · ${reference.type}`));
765
803
  for (const reference of data.manifest.references.filter(item => item.type === 'video')) select('strip-reference').append(option(reference.id, reference.label));
766
804
  if (!data.manifest.references.length) { select('reference-select').append(option('', 'No references supplied')); select('reference-select').disabled = true; }
767
805
  const sourceFiles = el('source-files'); for (const file of data.manifest.authoring) { const a = document.createElement('a'); a.href = assetUrl(file.path); a.textContent = file.label; a.className = 'source-link'; a.target = '_blank'; a.rel = 'noopener'; sourceFiles.append(a); } if (!data.manifest.authoring.length) sourceFiles.textContent = 'No source files supplied';
768
- bind(); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
806
+ bind(); parts=new PartExplorer(el('parts-explorer'),el('selected-part'),previous=>{redrawOverlay(true);const part=parts?.value();if(part){activeCamera='rig';if(view==='reference')setView('rig');showInspector('rig');renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera(part?'rig':availableCamera()));persistence?.save();},part=>{select('feedback-target-kind').value='slot';renderFeedbackTargets(true);input('feedback-target-name').value=part.slot;showInspector('feedback');}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
769
807
  const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
808
+ rigInput=data.manifest.model?{state:'pending'}:{state:'absent'};renderLayout();
770
809
  if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
771
810
  await initPlayer(first);
811
+ // initPlayer keeps `player` only after its first draw succeeded; a declared model path alone never counts.
812
+ rigInput=player?{state:'loaded'}:data.manifest.model?{state:'failed',reason:el('rig-empty').textContent||'compiled player unavailable'}:{state:'absent'};renderLayout();
772
813
  if (first) await chooseCheckpoint(first,initial); else renderCheckpoint();
773
814
  activeCamera=player?'rig':'reference';if(cameraSync)alignCameras();renderTransport();fitCamera();
774
815
  if (requestedId && !first) message(`Checkpoint "${requestedId}" is not in this package. Select one from the list.`, true);
816
+ else if (readOnly) message(readOnly, true);
775
817
  else message(data.manifest.model ? 'Record review status and structured feedback while preserving sources. Motion is read only.' : 'No rig supplied. Review the available references and checkpoints.');
776
818
  requestAnimationFrame(tick);
777
819
  const restored=await setupPersistence();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
778
- controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
820
+ if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
779
821
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
780
822
  }
781
- function showInspector(name:string):void {document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`)?.click();}
782
- function bindInspector():void {bindInspectorTabs(()=>{if(document.querySelector('[data-inspector="feedback"][aria-selected="true"]'))pause();fitCamera();});}
823
+ function showInspector(name:string,reveal=true):void {if(reveal)workspace?.revealInspector();document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`)?.click();}
824
+ function bindInspector():void {bindInspectorTabs(()=>{workspace?.afterTab();if(document.querySelector('[data-inspector="feedback"][aria-selected="true"]'))pause();fitCamera();},()=>workspace?.beforeTab());}
783
825
 
784
826
  function redrawOverlay(fresh:boolean):void {
785
827
  if(!overlays||!player)return;
786
828
  const root=el('rig-root'),project=(x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScale,y:parseFloat(root.style.top||'0')-y*rigScale});
787
- const value=fresh?overlays.refresh({player,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers}):overlays.redraw({project,settings:layers});
829
+ const value=fresh?overlays.refresh({player,frame:parts?.frame('single'),selectedSlot:parts?.value()?.slot,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers}):overlays.redraw({project,settings:layers});
788
830
  text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
789
831
  }
790
832
  function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animations.map(a=>a.name)??[],animation)&&!axisInspection&&input('idle-repeat').checked&&duration>0;}
@@ -792,13 +834,13 @@ function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · re
792
834
  async function playPreview():Promise<void> {
793
835
  if((!player&&!video&&!sequence)||duration<=0)return;
794
836
  const intent=++playbackIntent;desiredPlay=true;idleHeld=false;if(time>=duration)seek(0);
795
- if(video){try{await video.play();}catch(error){pause();message(`Playback is paused: ${errorText(error)}. Use Play to retry.`,true);return;}}
837
+ if(video&&referenceInput.state==='loaded'){try{await video.play();}catch(error){pause();message(`Playback is paused: ${errorText(error)}. Use Play to retry.`,true);return;}}
796
838
  if(intent!==playbackIntent||!desiredPlay){if(!desiredPlay)video?.pause();return;}playing=true;lastFrame=0;player?.resume();button('play').textContent='Ⅱ';button('play').setAttribute('aria-label','Pause');
797
839
  }
798
840
  function localDraft():boolean{return dirty||!!input('feedback-summary').value.trim()||!!input('feedback-start').value.trim()||!!input('feedback-end').value.trim()||!!select('feedback-category').value||!!input('feedback-target-name').value.trim()&&input('feedback-target-name').value!==data.manifest.id;}
799
841
  function controlState():ControlInspection {
800
842
  const box=assets?.stage??player?.bounds(),center=box?{x:box.x+box.width/2,y:box.y+box.height/2}:null;
801
- return {view:numericView(),theme:appearance?.get()??'system',layout:'auto',effectiveLayout:el('stage-layout').dataset.orientation==='vertical'?'column':'row',cameras:{rig:{zoom:player?zoom:1,pan:{x:center&&cameraCenter?(center.x-cameraCenter.x)*rigScale:0,y:center&&cameraCenter?(cameraCenter.y-center.y)*rigScale:0}},reference:{zoom:referenceSize?referenceZoom:1,pan:{x:referenceSize&&referenceCenter?(referenceSize.width/2-referenceCenter.x)*referenceScale:0,y:referenceSize&&referenceCenter?(referenceSize.height/2-referenceCenter.y)*referenceScale:0}}},animation:animation||null,time:player?.time??time,playing,loop:canRepeatIdle()?'idle':'off',layers:{images:!!player&&layers.images,meshes:!!player&&layers.mesh,bones:!!player&&layers.bones},ready:!!data,seeking:seekingControl||!!video?.seeking||!!persistence?.restoring(),dirty:localDraft(),busy:saving||stripBusy||!!persistence?.restoring(),feedbackEligible:!persistence?.restoring(),capabilities:{camera:!!player,referenceCamera:!!referenceSize,animation:!!player,time:duration>0,playback:duration>0,layers:{images:!!player,meshes:!!player,bones:!!player}}};
843
+ return {part:parts?.value()??null,partGeneration:parts?.observation()?.generation??null,view:numericView(),theme:appearance?.get()??'system',layout:'auto',effectiveLayout:el('stage-layout').dataset.orientation==='vertical'?'column':'row',cameras:{rig:{zoom:player?zoom:1,pan:{x:center&&cameraCenter?(center.x-cameraCenter.x)*rigScale:0,y:center&&cameraCenter?(cameraCenter.y-center.y)*rigScale:0}},reference:{zoom:referenceSize?referenceZoom:1,pan:{x:referenceSize&&referenceCenter?(referenceSize.width/2-referenceCenter.x)*referenceScale:0,y:referenceSize&&referenceCenter?(referenceSize.height/2-referenceCenter.y)*referenceScale:0}}},animation:animation||null,time:player?.time??time,playing,loop:canRepeatIdle()?'idle':'off',layers:{images:!!player&&layers.images,meshes:!!player&&layers.mesh,bones:!!player&&layers.bones},ready:!!data,seeking:seekingControl||!!video?.seeking||!!persistence?.restoring(),dirty:localDraft(),busy:saving||stripBusy||!!persistence?.restoring(),feedbackEligible:!persistence?.restoring(),comparison:controlComparison(layoutEligibility()),capabilities:{camera:!!player,referenceCamera:!!referenceSize,animation:!!player,time:duration>0,playback:duration>0,layers:{images:!!player,meshes:!!player,bones:!!player}}};
802
844
  }
803
845
  async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<void> {
804
846
  if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
@@ -861,7 +903,7 @@ function numericView():ControlView {
861
903
  }
862
904
  function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
863
905
  function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
864
- function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:animation||null,time:player?.time??time,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view',partSet:select('part-set').disabled?null:select('part-set').value,axis:axisInspection?{id:select('axis-select').value,value:Number(input('axis-value').value)}:null}};}
906
+ function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:animation||null,time:player?.time??time,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view',partSet:select('part-set').disabled?null:select('part-set').value,axis:axisInspection?{id:select('axis-select').value,value:Number(input('axis-value').value)}:null}};}
865
907
  async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
866
908
  await restoreTransaction(()=>prepareInspection(bookmark,signal),()=>({bookmark:inspectionBookmark(),inputs:captureInputs()}),()=>applyInspection(bookmark,signal),async previous=>{await applyInspection(previous.bookmark,new AbortController().signal);restoreInputs(previous.inputs);renderFeedbackRange();});
867
909
  }
@@ -877,6 +919,7 @@ async function inspectionReferenceInfo(id:string|null,signal:AbortSignal):Promis
877
919
  }
878
920
  async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
879
921
  const selection=bookmark.selection;
922
+ parts?.validate(selection.part);
880
923
  if(player&&!bookmark.animation||bookmark.animation&&!assets?.animations.some(a=>a.name===bookmark.animation))throw new Error('Saved animation is absent from this rig');
881
924
  if(selection.reference&&!data.manifest.references.some(r=>r.id===selection.reference))throw new Error('Saved reference is not declared');
882
925
  if(selection.checkpoint&&!data.manifest.checkpoints.some(c=>c.id===selection.checkpoint))throw new Error('Saved checkpoint is absent');
@@ -898,6 +941,7 @@ async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal)
898
941
  async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
899
942
  cameraRestoring=true;try{
900
943
  const selection=bookmark.selection;
944
+ parts?.select(selection.part??null);
901
945
  pause();
902
946
  if(selection.checkpoint!==undefined){checkpoint=data.manifest.checkpoints.find(c=>c.id===selection.checkpoint);renderCheckpoint();renderCheckpoints();}
903
947
  if(selection.reference!==undefined&&select('reference-select').value!==(selection.reference??'')){select('reference-select').value=selection.reference??'';await showReference(selection.reference??'');if(selection.reference&&!referenceSize&& !video)throw new Error('Saved reference did not load');}
@@ -909,20 +953,20 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
909
953
  if(selection.view)setView(selection.view);
910
954
  appearance?.set(bookmark.theme);
911
955
  Object.assign(layers,{images:bookmark.layers.images,mesh:bookmark.layers.meshes,bones:bookmark.layers.bones});input('layer-images').checked=layers.images;input('layer-mesh').checked=layers.mesh;input('layer-bones').checked=layers.bones;
912
- if(selection.inspector)showInspector(selection.inspector);syncStageLayout(el('stage-layout'));fitCamera();
956
+ if(selection.inspector)showInspector(selection.inspector,false);syncStageLayout(el('stage-layout'));fitCamera();
913
957
  const camera=bookmark.view.cameras.rig,ref=bookmark.view.cameras.reference,rigViewport=el('rig-viewport'),referenceViewport=el('reference-viewport');
914
958
  const nextZoom=camera&&player&&rigViewport.clientWidth&&rigViewport.clientHeight?restoredZoom(camera,rigScale/zoom,rigViewport.clientWidth,rigViewport.clientHeight):camera?.zoom??null;
915
959
  const nextReferenceZoom=ref&&referenceSize&&referenceViewport.clientWidth&&referenceViewport.clientHeight?restoredZoom(ref,referenceScale/referenceZoom,referenceViewport.clientWidth,referenceViewport.clientHeight):ref?.zoom??null;
916
960
  if(nextZoom!==null&&camera){zoom=nextZoom;cameraCenter={...camera.center};}
917
961
  if(nextReferenceZoom!==null&&ref){referenceZoom=nextReferenceZoom;referenceCenter={...ref.center};}
918
962
  cameraSync=bookmark.view.cameraSync??false;activeCamera=bookmark.view.activeCamera==='reference'?'reference':'rig';
919
- fitCamera();redrawOverlay(false);pause();renderTransport();renderCameraControls();
963
+ if(player)drawRig(()=>player!.draw());if(selection.part)parts?.assertIdentity(selection.part);fitCamera();redrawOverlay(false);pause();renderTransport();renderCameraControls();
920
964
  }finally{cameraRestoring=false;}
921
965
  }
922
966
  async function waitReferenceMetadata(media:HTMLVideoElement,signal:AbortSignal):Promise<void> {if(media.readyState>=1)return;await new Promise<void>((resolve,reject)=>{const clean=()=>{clearTimeout(timer);media.removeEventListener('loadedmetadata',ready);media.removeEventListener('error',fail);signal.removeEventListener('abort',fail);};const ready=()=>{clean();resolve();},fail=()=>{clean();reject(new Error('Saved reference metadata unavailable'));};const timer=setTimeout(fail,10000);media.addEventListener('loadedmetadata',ready,{once:true});media.addEventListener('error',fail,{once:true});signal.addEventListener('abort',fail,{once:true});});}
923
967
  async function setupPersistence():Promise<boolean> {
924
- persistence=await connectInspectionPersistence({scope:'single:'+data.manifest.id,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('review-status').value=session.status;renderProposals();renderCheckpoint();renderCheckpoints();updateSavedState();},bookmark:inspectionBookmark,restoreBookmark:restoreInspection,
925
- ui:()=>({reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();},afterInputs:()=>{const target=input('feedback-target-name').value;renderFeedbackTargets(true);if(select('feedback-target-kind').value!=='rig')input('feedback-target-name').value=target;renderFeedbackRange();text('audio-volume-label',`${Math.round(Number(input('audio-volume').value)*100)}%`);},onConflict:value=>{draftConflict=value;updateSavedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;}});
968
+ persistence=await connectInspectionPersistence({scope:'single:'+data.manifest.id+':'+data.sourceHash,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('review-status').value=session.status;renderProposals();renderCheckpoint();renderCheckpoints();updateSavedState();},bookmark:inspectionBookmark,restoreBookmark:restoreInspection,
969
+ ui:()=>({inspectorScroll:workspace?.scroll()??'{}',reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();workspace?.restoreScroll(value.inspectorScroll);},afterInputs:()=>{const target=input('feedback-target-name').value;renderFeedbackTargets(true);if(select('feedback-target-kind').value!=='rig')input('feedback-target-name').value=target;renderFeedbackRange();text('audio-volume-label',`${Math.round(Number(input('audio-volume').value)*100)}%`);},onConflict:value=>{draftConflict=value;updateSavedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;}});
926
970
  return persistence.restored;
927
971
  }
928
972
  void start();