@rig-c/review 0.1.0 → 0.3.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.
- package/LICENSE +21 -0
- package/README.md +126 -7
- package/docs/DESIGN.md +29 -0
- package/docs/REVIEW_PACKAGE.md +24 -5
- package/package.json +5 -3
- package/src/animation-selection.ts +9 -5
- package/src/cli.ts +9 -6
- package/src/compare-server.ts +8 -5
- package/src/comparison-eligibility.ts +54 -0
- package/src/control.ts +32 -3
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/manifest.ts +5 -1
- package/src/open-protocol.ts +17 -0
- package/src/open.ts +67 -34
- package/src/server.ts +8 -7
- package/src/strip-render.ts +1 -1
- package/web/camera-view.ts +10 -0
- package/web/compare.css +44 -0
- package/web/compare.html +3 -3
- package/web/compare.ts +78 -34
- package/web/control.ts +6 -4
- package/web/index.html +3 -3
- package/web/inspection-persistence.ts +30 -11
- package/web/instance-access.ts +23 -0
- package/web/main.ts +85 -41
- package/web/part-selection.ts +136 -0
- package/web/rig-overlays.ts +9 -6
- package/web/styles.css +41 -0
- package/web/view-controls.ts +7 -5
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
117
|
-
try{this.storage=storage??localStorage;
|
|
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
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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();
|
|
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;
|
|
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;
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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();
|