@rig-c/review 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +376 -2
- package/docs/DESIGN.md +72 -0
- package/docs/REVIEW_PACKAGE.md +627 -0
- package/examples/demo/SOURCE.md +5 -0
- package/examples/demo/chime.wav +0 -0
- package/examples/demo/lantern.svg +10 -0
- package/examples/demo/pose.json +244 -0
- package/examples/demo/review.json +67 -0
- package/package.json +49 -4
- package/src/animation-selection.ts +29 -0
- package/src/build.ts +8 -0
- package/src/cli.ts +116 -0
- package/src/compare-playhead.ts +55 -0
- package/src/compare-presentation.ts +43 -0
- package/src/compare-server.ts +63 -0
- package/src/compare-session.ts +26 -0
- package/src/compare.ts +57 -0
- package/src/comparison-eligibility.ts +54 -0
- package/src/control-server.ts +83 -0
- package/src/control.ts +132 -0
- package/src/feedback-export.ts +197 -0
- package/src/feedback-validation.ts +38 -0
- package/src/frame-clock.ts +36 -0
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/manifest.ts +140 -0
- package/src/open-protocol.ts +31 -0
- package/src/open-selection.ts +11 -0
- package/src/open-shell.ts +2 -0
- package/src/open-worker.ts +2 -0
- package/src/open.ts +142 -0
- package/src/pose.ts +27 -0
- package/src/probe-reference.ts +16 -0
- package/src/reference-frame-index.ts +16 -0
- package/src/reference-frames.ts +62 -0
- package/src/reference.ts +63 -0
- package/src/report.ts +88 -0
- package/src/server.ts +62 -0
- package/src/session-history.ts +99 -0
- package/src/session.ts +58 -0
- package/src/strip-export.ts +69 -0
- package/src/strip-render.ts +171 -0
- package/src/strip.ts +51 -0
- package/src/versions.ts +20 -0
- package/web/camera-view.ts +44 -0
- package/web/compare.css +140 -0
- package/web/compare.html +7 -0
- package/web/compare.ts +546 -0
- package/web/control.ts +67 -0
- package/web/index.html +13 -0
- package/web/inspection-persistence.ts +219 -0
- package/web/instance-access.ts +23 -0
- package/web/main.ts +972 -0
- package/web/part-selection.ts +136 -0
- package/web/renderer-status.ts +31 -0
- package/web/report-view.ts +51 -0
- package/web/rig-overlays.ts +150 -0
- package/web/styles.css +139 -0
- package/web/view-controls.ts +69 -0
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
package/web/compare.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import {PartExplorer,loadPartCatalog} from './part-selection.ts';
|
|
2
|
+
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature,type PaneCamera} from './camera-view.ts';
|
|
3
|
+
import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
4
|
+
import type { CompareResponse, CompareSide } from '../src/compare.ts';
|
|
5
|
+
import type { CompareFeedback, CompareSession } from '../src/compare-session.ts';
|
|
6
|
+
import { clockForAnimation, lastFrame, timeAtFrame } from '../src/frame-clock.ts';
|
|
7
|
+
import { commonDomain, drawSynchronizedPair, compareSeekStep, feedbackTrace, inspectionFrame, requireCommonTime, type InspectionPlayer, type CompareDomain, type CompareTrace, type ReferenceSample } from '../src/compare-playhead.ts';
|
|
8
|
+
import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection } from '../src/feedback-validation.ts';
|
|
9
|
+
import type { StripInterval, StripRequest } from '../src/strip.ts';
|
|
10
|
+
|
|
11
|
+
import {bindAppearance,bindPan,bindStageLayout,bindInspectorTabs,syncStageLayout,idleAvailable} from './view-controls.ts';
|
|
12
|
+
import {RigOverlays} from './rig-overlays.ts';
|
|
13
|
+
import {bindWorkspaceUI,type WorkspaceUI} from './workspace-ui.ts';
|
|
14
|
+
import {connectControl} from './control.ts';
|
|
15
|
+
import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
|
|
16
|
+
import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
|
|
17
|
+
import {pairEligibility,type InputState,type PairDomain} from '../src/comparison-eligibility.ts';
|
|
18
|
+
import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
19
|
+
import {RendererBadge} from './renderer-status.ts';
|
|
20
|
+
import {ComparePresentation,type PresentationState} from '../src/compare-presentation.ts';
|
|
21
|
+
import {nextReferenceInterval,ReferenceSelectionUnavailable,inspectionReferenceFrame,lastSupportedReferenceFrame,producerFrameAssociations,referenceFrameAtTime,referenceSeekPosition,type ReferenceFrameIndex,type VideoInfo,type VideoFrame} from '../src/reference-frames.ts';
|
|
22
|
+
type Side = 'a' | 'b';
|
|
23
|
+
const el = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
24
|
+
const node = document.getElementById(id); if (!node) throw new Error(`Missing compare UI element: ${id}`); return node as T;
|
|
25
|
+
};
|
|
26
|
+
const input = (id: string) => el<HTMLInputElement>(id), select = (id: string) => el<HTMLSelectElement>(id), button = (id: string) => el<HTMLButtonElement>(id);
|
|
27
|
+
const text = (id: string, value: string) => { el(id).textContent = value; };
|
|
28
|
+
const describe = (value: unknown) => value instanceof Error ? value.message : String(value);
|
|
29
|
+
const video = el<HTMLVideoElement>('reference-video');
|
|
30
|
+
let data: CompareResponse, session: CompareSession;
|
|
31
|
+
const assets: Partial<Record<Side, ModelAssets>> = {}, players: Partial<Record<Side, RigPlayer>> = {};
|
|
32
|
+
let domain: CompareDomain | null = null, trace: CompareTrace | null = null, lastPresented: ReferenceSample | null = null;
|
|
33
|
+
let dirty = false, saving = false, ready = false, seeking = false, disposed = false;
|
|
34
|
+
let seekAbort: AbortController | null = null, videoCallback: number | null = null, fallbackCallback: number | null = null;
|
|
35
|
+
let latestSeek = 0;
|
|
36
|
+
type CameraTarget=Side|'reference';
|
|
37
|
+
const cameras:Record<CameraTarget,PaneCamera>={a:{zoom:1,center:null},b:{zoom:1,center:null},reference:{zoom:1,center:null}};
|
|
38
|
+
const cameraFrames:Partial<Record<CameraTarget,CameraFrame>>={};
|
|
39
|
+
const rigScales:Record<Side,number>={a:1,b:1};
|
|
40
|
+
let cameraSync=true,activeCamera:CameraTarget='a',cameraRestoring=false,cameraLayout='';
|
|
41
|
+
let idleHeld=true,loopRestarting=false,suspendedPreview=false;
|
|
42
|
+
let appearance:ReturnType<typeof bindAppearance>|null=null,controlDispose:(()=>void)|null=null,workspace:WorkspaceUI|null=null;
|
|
43
|
+
const viewDisposers:(()=>void)[]=[];
|
|
44
|
+
let parts:PartExplorer|null=null;
|
|
45
|
+
const overlays:Partial<Record<Side,RigOverlays>>={};
|
|
46
|
+
function inspectionPlayer(side:Side):InspectionPlayer {const player=players[side];if(!player)throw new Error('Part player unavailable');return {get time(){return player.time;},pause:()=>player.pause(),seek:time=>player.seek(time),draw:()=>{player.draw();parts?.present(side);}};}
|
|
47
|
+
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
48
|
+
let stripBusy = false;
|
|
49
|
+
let persistence:PersistenceConnection|null=null,submittedBaseline:CompareSession|null=null,draftConflict=false;
|
|
50
|
+
// First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
|
|
51
|
+
const access=pageInstanceAccess();let readOnly:string|null=null;
|
|
52
|
+
let referenceFrames:VideoInfo|null=null;
|
|
53
|
+
const presentation=new ComparePresentation();
|
|
54
|
+
const rendererBadges={a:new RendererBadge(),b:new RendererBadge()};
|
|
55
|
+
const associations:Record<Side,Map<number,number|null>>={a:new Map(),b:new Map()};
|
|
56
|
+
let requestedProducer:{side:Side;frame:number;time:number}|null=null;
|
|
57
|
+
let endStopReason:string|null=null;
|
|
58
|
+
// Actual load outcomes per input; the shared domain exists only after all three loaded.
|
|
59
|
+
let pairInputs:{a:InputState;b:InputState;reference:InputState;domain:PairDomain}={a:{state:'pending'},b:{state:'pending'},reference:{state:'pending'},domain:null};
|
|
60
|
+
const traceRows: CompareTrace[] = [];
|
|
61
|
+
|
|
62
|
+
function message(value: string, error = false): void { text('message', value); el('message').classList.toggle('error', error); }
|
|
63
|
+
function savedState(): void { text('save-state', dirty ? 'Unsubmitted changes' : session.updatedAt ? 'Submitted' : 'Not submitted'); el('save-state').classList.toggle('dirty', dirty); button('submit').disabled = saving || draftConflict || !!readOnly || session.feedbackWarnings.length > 0; button('submit').title = readOnly ? readOnly : session.feedbackWarnings.length ? 'Delete feedback with validation warnings, then add it again with a valid target.' : ''; }
|
|
64
|
+
function markDirty(): void { dirty = true; savedState(); }
|
|
65
|
+
async function api<T>(path: string, init?: RequestInit): Promise<T> { const method = (init?.method ?? 'GET').toUpperCase(); const response = await fetch(access.route(path), method === 'GET' || method === 'HEAD' ? init : { ...init, headers: access.headers(init?.headers) }); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); return await response.json() as T; }
|
|
66
|
+
|
|
67
|
+
function sideAsset(side: Side, path: string): string {
|
|
68
|
+
if (!path || path.startsWith('/') || /[\\%?#\x00-\x1f:]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error(`Invalid ${side} asset path: ${path}`);
|
|
69
|
+
const base = `/compare-assets/${side}/`;
|
|
70
|
+
if (data[side].assetsBase !== base) throw new Error(`Unexpected ${side} assets base`);
|
|
71
|
+
return access.route(base + path.split('/').map(encodeURIComponent).join('/'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function currentFrame(side: Side): number | null {
|
|
75
|
+
const player = players[side];
|
|
76
|
+
const clock = clockForAnimation(data[side].report?.clock, data[side].report?.animation, player?.current ?? '', !!player);
|
|
77
|
+
return inspectionFrame(clock, feedbackTrace(ready, seeking || video.seeking, trace));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function currentReviewFrame(side:Side):number|null {
|
|
81
|
+
if(!referenceFrames||!trace)return null;
|
|
82
|
+
const frame=referenceFrameAtTime(referenceFrames,trace.reference);return frame?associations[side].get(frame.index)??null:null;
|
|
83
|
+
}
|
|
84
|
+
function frameLabel(side: Side): string {
|
|
85
|
+
const sideData=data[side],player=players[side],clock=clockForAnimation(sideData.report?.clock,sideData.report?.animation,player?.current??'',!!player);
|
|
86
|
+
if(!clock)return 'Review frame unknown';
|
|
87
|
+
const frame=currentReviewFrame(side),decoded=referenceFrames&&trace?referenceFrameAtTime(referenceFrames,trace.reference):null;
|
|
88
|
+
return `Review F${frame??'—'} / ${lastFrame(clock)??'—'}${decoded?` · video #${decoded.index}`:''}`;
|
|
89
|
+
}
|
|
90
|
+
function frameControls():void {
|
|
91
|
+
const exact=!!referenceFrames&&typeof video.requestVideoFrameCallback==='function';
|
|
92
|
+
for(const side of ['a','b'] as const){const option=select('frame-basis').querySelector<HTMLOptionElement>(`option[value="${side}"]`);if(option)option.disabled=!associations[side].size;}
|
|
93
|
+
const side=select('frame-basis').value as Side,clock=data[side]?.report?.clock;
|
|
94
|
+
input('frame-input').max=String(lastFrame(clock)??0);input('frame-input').disabled=!exact||!clock||!ready||seeking;button('frame-seek').disabled=!exact||!clock||!ready||seeking;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderTrace(value: CompareTrace): void {
|
|
98
|
+
trace = value; traceRows.push({ ...value }); if (traceRows.length > 12) traceRows.shift();
|
|
99
|
+
text('reference-time', `${value.reference.toFixed(6)} s`);
|
|
100
|
+
for (const side of ['a', 'b'] as const) { text(`${side}-time`, `${value[side].toFixed(6)} s`); text(`${side}-frame`, frameLabel(side)); }
|
|
101
|
+
text('time', `${value.reference.toFixed(4)} / ${domain!.end.toFixed(4)} s`);
|
|
102
|
+
if (document.activeElement !== input('scrub')) input('scrub').value = String(value.reference);
|
|
103
|
+
if (document.activeElement !== input('time-input')) input('time-input').value = value.reference.toFixed(6);
|
|
104
|
+
text('clock-source', value.source === 'video-frame' ? 'requestVideoFrameCallback · Actual mediaTime' : 'currentTime fallback');
|
|
105
|
+
text('trace', JSON.stringify({ seekStep: compareSeekStep, referenceClock: value.source, frames: { a: currentFrame('a'), b: currentFrame('b') }, reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')}, requestedProducer,endStopReason, decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,value.reference)?.index??null:null, recent: traceRows }, null, 2));
|
|
106
|
+
updateFeedbackAvailability();frameControls();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function updateFeedbackAvailability(): void { button('add-feedback').disabled = !feedbackTrace(ready, seeking || video.seeking, trace); }
|
|
110
|
+
function invalidateTrace(reason: string): void {
|
|
111
|
+
for(const side of ['a','b'] as const)text(`${side}-renderer`,rendererBadges[side].pending().label);
|
|
112
|
+
trace = null; updateFeedbackAvailability();
|
|
113
|
+
for (const id of ['reference-time', 'a-time', 'b-time']) text(id, '— s');
|
|
114
|
+
for (const side of ['a', 'b'] as const) text(`${side}-frame`, 'Frame unknown · not synchronized');
|
|
115
|
+
text('time', '— / — s'); text('clock-source', reason);
|
|
116
|
+
text('trace', JSON.stringify({ status: reason, current: null, previous: traceRows }, null, 2));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stop(): void { video.pause(); players.a?.pause(); players.b?.pause(); button('play').textContent = loopRestarting&&!idleHeld?'Ⅱ':'▶'; button('play').setAttribute('aria-label',loopRestarting&&!idleHeld?'Cancel idle restart and pause comparison':'Play reference video and both rigs'); }
|
|
120
|
+
function failPair(error: unknown): void { stop(); ready = false; invalidateTrace('Comparison stopped · no valid synchronized time');for(const side of ['a','b'] as const){overlays[side]?.clear();text(`${side}-renderer`,rendererBadges[side].failed().label);} button('play').disabled = true; input('scrub').disabled = true; button('seek').disabled = true;button('frame-seek').disabled=true; message(`Comparison stopped: ${describe(error)}`, true); }
|
|
121
|
+
|
|
122
|
+
function renderPresentation(state:PresentationState):void {
|
|
123
|
+
for(const side of ['a','b'] as const){const value=rendererBadges[side].observe(state.status,players[side]?.renderer??null);text(`${side}-renderer`,value.label);}
|
|
124
|
+
if(state.status==='drawn')redrawOverlays(true);else if(state.status==='failed')for(const overlay of Object.values(overlays))overlay.clear();
|
|
125
|
+
const target=state.target?.time,last=state.lastDrawn;
|
|
126
|
+
if(target!==undefined){text('reference-time',`${target.toFixed(6)} s`);text('time',`${target.toFixed(4)} / ${domain!.end.toFixed(4)} s`);input('scrub').value=String(target);}
|
|
127
|
+
const delta=target===undefined||!last?null:Math.max(Math.abs(last.a-target),Math.abs(last.b-target))*24;
|
|
128
|
+
const visible=last?`${last.reference.toFixed(6)} s`:'unknown';
|
|
129
|
+
text('sync-status',`${state.status==='held'?'Held':state.status==='drawn'?(state.lastLatenessMs>0?'Drawn late':'Drawn'):'Unknown'} pose ${visible} · Δ ${delta===null?'—':delta.toFixed(2)} (1/24 s units) · dropped ${state.dropped} · deadline misses ${state.deadlineMisses}/${state.deadlineKnownCallbacks} known next-reference estimates (${state.deadlineUnknownCallbacks} unknown) · replay ${state.lastPoseMs.toFixed(1)} ms · lateness ${state.deadlineKind==='unknown'?'unknown':state.lastLatenessMs.toFixed(1)+' ms'} (next-reference estimate; CPU submission)`);
|
|
130
|
+
el('sync-status').classList.toggle('error',state.status==='held'||state.lastLatenessMs>0||state.status==='failed');
|
|
131
|
+
if(!state.inspection){
|
|
132
|
+
trace=null;updateFeedbackAvailability();
|
|
133
|
+
for(const side of ['a','b'] as const){text(`${side}-time`,last?`${last[side].toFixed(6)} s · ${state.status==='held'?'held':'drawn; feedback unavailable'}`:'unknown');const decoded=last&&referenceFrames?referenceFrameAtTime(referenceFrames,last.reference):null,frame=decoded?associations[side].get(decoded.index)??null:null;text(`${side}-frame`,last?`${state.status==='held'?'Held':'Drawn'} review F${frame??'—'}`:'Review frame unknown');}
|
|
134
|
+
text('clock-source','Feedback requires a pair submitted before the known next-reference estimate or paused inspection.');
|
|
135
|
+
}
|
|
136
|
+
text('sync-warning',state.deadlineKind==='unknown'?'Next-reference timing unknown: an adjacent decoded timestamp and positive playback rate are required. Playback continues; pause for exact inspection.':state.lastLatenessMs>0?'The pair finished CPU draw submission after the estimated next reference display boundary. Pause for exact inspection.':'');
|
|
137
|
+
text('trace',JSON.stringify({presentation:state,playerTimes:{a:players.a?.time??null,b:players.b?.time??null},rawFloorFrames:{a:currentFrame('a'),b:currentFrame('b')},reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')},requestedProducer,endStopReason,renderers:{a:rendererBadges.a.snapshot(),b:rendererBadges.b.snapshot()},decodedIndex:referenceFrames&&target!==undefined?referenceFrameAtTime(referenceFrames,target)?.index??null:null},null,2));
|
|
138
|
+
frameControls();
|
|
139
|
+
}
|
|
140
|
+
function acceptReference(sample: ReferenceSample,deadline:number|null=null,presentedFrames:number|null=null): void {
|
|
141
|
+
if(!ready||seeking||video.seeking||!domain||!players.a||!players.b)return;
|
|
142
|
+
if(sample.time>domain.end){stopAtSupportedEnd();return;}
|
|
143
|
+
if(trace?.reference===sample.time&&trace.source===sample.source)return;
|
|
144
|
+
try{const state=presentation.present(sample,deadline,presentedFrames,inspectionPlayer('a'),inspectionPlayer('b'),domain,()=>performance.now(),nextReferenceInterval(referenceFrames,sample.time,video.playbackRate));if(state.inspection)renderTrace(state.inspection);renderPresentation(state);}catch(error){failPair(error);renderPresentation(presentation.snapshot());}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stopAtSupportedEnd():void {
|
|
148
|
+
if(canRepeatIdle()&&!idleHeld&&!document.hidden){void restartIdle();return;}
|
|
149
|
+
stop();requestedProducer=null;
|
|
150
|
+
if(!domain)return;
|
|
151
|
+
const selected=referenceFrames&&typeof video.requestVideoFrameCallback==='function'?lastSupportedReferenceFrame(referenceFrames,domain):null;
|
|
152
|
+
if(selected){const reason=`Playback stopped at the last supported decoded reference frame: ${selected.time.toFixed(6)} s (shared clip end ${domain.end.toFixed(6)} s).`;void seekTo(selected.time,null,reason);}
|
|
153
|
+
else{endStopReason='Playback stopped outside the shared clip domain. Exact reference selection is unavailable; choose a supported time to inspect again.';invalidateTrace(endStopReason);text('sync-warning',endStopReason);message(endStopReason);frameControls();}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function observeVideoFrames(): void {
|
|
157
|
+
if (typeof video.requestVideoFrameCallback === 'function') {
|
|
158
|
+
const next = () => { videoCallback = video.requestVideoFrameCallback((_now, metadata) => {
|
|
159
|
+
if (disposed) return;
|
|
160
|
+
lastPresented = { time: metadata.mediaTime, source: 'video-frame' };
|
|
161
|
+
acceptReference(lastPresented,metadata.expectedDisplayTime,metadata.presentedFrames); next();
|
|
162
|
+
}); }; next();
|
|
163
|
+
} else {
|
|
164
|
+
// This is a media-clock sampler, not an elapsed-time animation loop. The
|
|
165
|
+
// only time sent to either player is the reference element's currentTime.
|
|
166
|
+
const next = () => { if (disposed) return; acceptReference({ time: video.currentTime, source: 'current-time' }); fallbackCallback = requestAnimationFrame(next); }; next();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** On supported browsers a seek waits for BOTH seeked and the decoded frame. */
|
|
171
|
+
function seekReferenceFrame(target: number, signal: AbortSignal,selected:VideoFrame|null): Promise<ReferenceSample> {
|
|
172
|
+
video.pause();
|
|
173
|
+
const seekPosition=selected?referenceSeekPosition(referenceFrames!,selected):target;
|
|
174
|
+
if(!video.seeking&&lastPresented&&(selected?referenceFrameAtTime(referenceFrames!,lastPresented.time)?.index===selected.index:video.currentTime===target))return Promise.resolve({...lastPresented});
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
let seeked = false, presented: ReferenceSample | null = null, handle: number | null = null;
|
|
177
|
+
const hasFrames = typeof video.requestVideoFrameCallback === 'function';
|
|
178
|
+
const timer = setTimeout(() => finish(new Error('Timed out waiting for reference video seek / decoded-frame response.')), 10000);
|
|
179
|
+
const cleanup = () => { clearTimeout(timer); video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onError); signal.removeEventListener('abort', onAbort); if (handle !== null) video.cancelVideoFrameCallback(handle); };
|
|
180
|
+
const finish = (error?: Error) => { if (error) { cleanup(); reject(error); } else if (seeked && presented) { cleanup(); resolve(presented); } };
|
|
181
|
+
const onSeeked = () => { seeked = true; if (!hasFrames) presented = { time: video.currentTime, source: 'current-time' }; finish(); };
|
|
182
|
+
const onError = () => finish(new Error('Reference video seek failed'));
|
|
183
|
+
const onAbort = () => finish(new DOMException('Newer reference seek requested', 'AbortError'));
|
|
184
|
+
if (signal.aborted) { finish(new DOMException('Reference seek aborted', 'AbortError')); return; }
|
|
185
|
+
signal.addEventListener('abort', onAbort, { once: true }); video.addEventListener('seeked', onSeeked); video.addEventListener('error', onError, { once: true });
|
|
186
|
+
try {
|
|
187
|
+
// Registered while paused before changing currentTime, so a fast seek
|
|
188
|
+
// cannot present its new frame before this callback is installed.
|
|
189
|
+
if (hasFrames) handle = video.requestVideoFrameCallback((_now, metadata) => { handle = null; const actual=selected?referenceFrameAtTime(referenceFrames!,metadata.mediaTime):null;if(selected&&actual?.index!==selected.index){finish(new ReferenceSelectionUnavailable('REFERENCE_FRAME_MISMATCH: decoded frame does not match selected reference PTS; choose another supported time to inspect'));return;}presented = { time: metadata.mediaTime, source: 'video-frame' }; finish(); });
|
|
190
|
+
video.currentTime = seekPosition;
|
|
191
|
+
if (!video.seeking) { seeked = true; if (!hasFrames) presented = { time: video.currentTime, source: 'current-time' }; finish(); }
|
|
192
|
+
} catch (error) { finish(new Error(describe(error))); }
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function seekTo(target: number,producer:{side:Side;frame:number;time:number}|null=null,endReason:string|null=null,continuing=false): Promise<void> {
|
|
197
|
+
if (!domain || !players.a || !players.b || !ready) return;
|
|
198
|
+
if(!continuing)idleHeld=true;
|
|
199
|
+
let selected:VideoFrame|null=null;
|
|
200
|
+
try { requireCommonTime(target, domain);if(referenceFrames&&typeof video.requestVideoFrameCallback==='function')selected=inspectionReferenceFrame(referenceFrames,target,domain); } catch (error) { message(describe(error), true); return; }
|
|
201
|
+
stop(); seekAbort?.abort(); seekAbort = new AbortController(); const signal = seekAbort.signal, generation = ++latestSeek; seeking = true; invalidateTrace('Synchronizing reference video and both rigs');
|
|
202
|
+
try {
|
|
203
|
+
const sample=await seekReferenceFrame(target,signal,selected);
|
|
204
|
+
if(signal.aborted||generation!==latestSeek)throw new DOMException('Reference seek superseded','AbortError');
|
|
205
|
+
if(sample.time<domain.start||sample.time>domain.end)throw new ReferenceSelectionUnavailable('the presented reference timestamp is outside the shared clip domain; choose a supported time');
|
|
206
|
+
const inspected=presentation.inspect(sample,inspectionPlayer('a'),inspectionPlayer('b'),domain,()=>performance.now());
|
|
207
|
+
const value=inspected.inspection!;
|
|
208
|
+
if (generation !== latestSeek || signal.aborted) return;
|
|
209
|
+
requestedProducer=producer;endStopReason=endReason;seeking = false; lastPresented = { time: value.reference, source: value.source }; renderTrace(value);renderPresentation(inspected);
|
|
210
|
+
message(endReason??'Inspecting both rigs at the actual reference video time.');
|
|
211
|
+
} catch (error) { if (!signal.aborted && generation === latestSeek){if(error instanceof ReferenceSelectionUnavailable){stop();invalidateTrace(describe(error));message(describe(error),true);}else failPair(error);} }
|
|
212
|
+
finally { if (generation === latestSeek) { seeking = false; updateFeedbackAvailability();frameControls(); } }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function unionBounds(a: PoseBounds, b: PoseBounds): PoseBounds { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y }; }
|
|
216
|
+
function fit(): void {
|
|
217
|
+
const layout=['a','b','reference'].map(target=>{const v=el(target+'-viewport');return `${v.clientWidth}:${v.clientHeight}`;}).join('|')+!!players.a+!!players.b+video.videoWidth+':'+video.videoHeight;
|
|
218
|
+
if(cameraSync&&!cameraRestoring&&layout!==cameraLayout)alignCameras();cameraLayout=layout;
|
|
219
|
+
for(const side of ['a','b'] as const){
|
|
220
|
+
const frame=paneFrame(side);if(!frame)continue;const camera=cameras[side],center=camera.center??frame.center,scale=rigScales[side]=frame.baseScale*camera.zoom;
|
|
221
|
+
const viewport=el(side+'-viewport'),root=el(side+'-root');root.style.left=`${viewport.clientWidth/2-center.x*scale}px`;root.style.top=`${viewport.clientHeight/2+center.y*scale}px`;root.style.transform=`scale(${scale})`;players[side]?.renderer.syncPixelRatio();
|
|
222
|
+
}
|
|
223
|
+
redrawOverlays(false);referenceFit();renderCameraControls();renderNumericView();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function loadSide(side: Side, value: CompareSide): Promise<number> {
|
|
227
|
+
text(`${side}-title`, value.manifest.title);
|
|
228
|
+
if (!value.manifest.model) throw new Error(`${side.toUpperCase()}: compiled model Not supplied`);
|
|
229
|
+
const modelUrl = new URL(sideAsset(side, value.manifest.model), location.href).href;
|
|
230
|
+
const loaded = await loadModel({ modelUrl, setsUrl: null, resolvePage: (name, base) => { const url = new URL(name, base); if (url.origin !== location.origin || !url.pathname.startsWith(access.route(`/compare-assets/${side}/`))) throw new Error(`${side}: model page escapes its package`); return url.href; } });
|
|
231
|
+
assets[side] = loaded; const clip = loaded.animations.find(item => item.name === data.animation); if (!clip) throw new Error(`${side.toUpperCase()}: animation "${data.animation}" Not supplied`);
|
|
232
|
+
const player = new RigPlayer(el(`${side}-root`), loaded, { seekStep: compareSeekStep });
|
|
233
|
+
// A side counts as loaded only after its first successful draw; a parts catalog refusal never fails the side.
|
|
234
|
+
try { players[side] = player;try{parts?.register(side,await loadPartCatalog(modelUrl,value.reviewedSource.rig!.sha256,value.feedbackCatalog.slots??[]),player);}catch(error){parts?.refuseCatalog(side,error);} player.environment(null); player.play(data.animation, { loop: false }); player.pause(); player.draw(); }
|
|
235
|
+
catch (error) { delete players[side]; player.dispose(); throw error; }
|
|
236
|
+
el(`${side}-empty`).hidden = true; return clip.duration;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function loadFrameIndex():Promise<void>{
|
|
240
|
+
try{
|
|
241
|
+
const value=await api<ReferenceFrameIndex>('/api/compare/reference-frames');
|
|
242
|
+
if(!value.available){text('frame-index-status',value.reason);return;}
|
|
243
|
+
if(value.sourceHash!==data.sourceHash||value.referenceSha256!==data.reference.sha256)throw new Error('Reference frame index identity differs');
|
|
244
|
+
referenceFrames=value.video;
|
|
245
|
+
for(const side of ['a','b'] as const){const clock=clockForAnimation(data[side].report?.clock,data[side].report?.animation,data.animation,true);associations[side]=producerFrameAssociations(clock,referenceFrames);}
|
|
246
|
+
select('frame-basis').value=associations.b.size?'b':'a';
|
|
247
|
+
text('frame-index-status',typeof video.requestVideoFrameCallback==='function'?'Exact selection: nearest real reference PTS; review frame = unique explicit sample association.':'Exact frame selection unavailable: requestVideoFrameCallback required; seconds playback remains available.');
|
|
248
|
+
}catch(error){referenceFrames=null;text('frame-index-status',`Exact frame selection unavailable: ${describe(error)}`);}
|
|
249
|
+
frameControls();
|
|
250
|
+
}
|
|
251
|
+
function loadReference(): Promise<number> {
|
|
252
|
+
if (data.reference.url !== '/compare-reference') throw new Error('Reference must be the server-bound local compare video'); const url = new URL(access.route(data.reference.url), location.href); if (url.origin !== location.origin || url.pathname !== access.route('/compare-reference') || url.search || url.hash) throw new Error('Reference must be the server-bound local compare video');
|
|
253
|
+
text('reference-name', data.reference.name); observeVideoFrames();
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const timer = setTimeout(() => reject(new Error('Could not read reference video metadata / first frame.')), 15000);
|
|
256
|
+
video.addEventListener('loadeddata', () => { clearTimeout(timer); if (!Number.isFinite(video.duration) || video.duration < 0) reject(new Error('Reference video duration must be finite.')); else { el('reference-empty').hidden = true; resolve(video.duration); } }, { once: true });
|
|
257
|
+
video.addEventListener('error', () => { clearTimeout(timer); reject(new Error('Reference video load failed')); }, { once: true }); video.src = url.href; video.load();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function provenance(): void {
|
|
262
|
+
const brief = el('provenance-brief'); brief.replaceChildren();
|
|
263
|
+
for (const side of ['a', 'b'] as const) { const value = data[side].reviewedSource; const p = document.createElement('p'); p.textContent = `${side.toUpperCase()} · ${value.packageRevision} / rig ${value.rig?.sha256.slice(0, 12) ?? 'Not supplied'} / motion ${value.motion?.sha256.slice(0, 12) ?? 'Not supplied'}`; brief.append(p); }
|
|
264
|
+
const p = document.createElement('p'); p.textContent = `REF · ${data.reference.sha256.slice(0, 12)}`; brief.append(p); text('provenance-full', JSON.stringify(session.binding, null, 2));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderFeedbackSelection(): void {
|
|
268
|
+
const side = select('side').value, kind = select('target-kind').value;
|
|
269
|
+
const catalog = side === 'a' || side === 'b' ? data[side].feedbackCatalog : null;
|
|
270
|
+
const names = catalog && ['bone', 'slot', 'rig'].includes(kind) ? feedbackTargetNames(catalog, kind as CompareFeedback['target']['kind']) : null;
|
|
271
|
+
const list = el<HTMLDataListElement>('target-names'); list.replaceChildren();
|
|
272
|
+
for (const name of names ?? []) { const option = document.createElement('option'); option.value = name; list.append(option); }
|
|
273
|
+
const field = input('target-name'); field.value = kind === 'rig' && catalog ? catalog.rig : ''; field.readOnly = kind === 'rig'; field.disabled = !names?.length;
|
|
274
|
+
text('target-hint', !catalog || !kind ? 'Select a target build and type first.' : names === null ? 'Actual target catalog unavailable · bone and slot feedback cannot be added.' : !names.length ? 'No targets of this type.' : kind === 'rig' ? 'Actual rig identifier for the selected package.' : `Choose or enter an exact name from ${side.toUpperCase()}: ${names.length} rig targets.`);
|
|
275
|
+
const last = catalog ? feedbackLastFrame(catalog, data.animation) : null;
|
|
276
|
+
for (const id of ['frame-start', 'frame-end']) { if (last === null) input(id).removeAttribute('max'); else input(id).max = String(Math.max(0, last)); }
|
|
277
|
+
text('feedback-range-hint', !catalog ? 'Select A or B to check frame bounds.' : last === null ? 'Bounds unknown · enter an inclusive integer frame range. FPS is not inferred.' : last < 0 ? 'No frames overlap the explicit clock and selected clip.' : `${side.toUpperCase()}: F0–F${last} for the explicit clock and current clip · inclusive`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderFeedback(): void {
|
|
281
|
+
const list = el('feedback-list'); list.replaceChildren();
|
|
282
|
+
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
283
|
+
warnings.textContent = session.feedbackWarnings.length ? `${session.feedbackWarnings.length} validation warnings in existing feedback · original records are preserved. Review the marked entries, delete them and add valid replacements before submitting. JSON and Markdown exports preserve both the original records and warnings.` : '';
|
|
284
|
+
for (const feedback of session.feedback) {
|
|
285
|
+
const row = document.createElement('div'); row.className = 'feedback-item'; const title = document.createElement('strong'); title.textContent = `${feedback.side.toUpperCase()} · ${feedback.category} · ${feedback.target.kind}:${feedback.target.name} · F${feedback.frameStart}–${feedback.frameEnd}`; const summary = document.createElement('span'); summary.textContent = feedback.summary;
|
|
286
|
+
row.append(title, summary);
|
|
287
|
+
for (const warning of session.feedbackWarnings.filter(value => value.id === feedback.id)) { const note = document.createElement('p'); note.className = 'hint'; note.textContent = `Validation warning · ${warning.error}`; row.append(note); }
|
|
288
|
+
const details = document.createElement('details'), heading = document.createElement('summary'), source = document.createElement('pre'); heading.textContent = 'Saved feedback and inspection context'; source.textContent = JSON.stringify(feedback, null, 2); details.append(heading, source);
|
|
289
|
+
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete'; remove.onclick = () => { session.feedback = session.feedback.filter(value => value.id !== feedback.id); session.feedbackWarnings = session.feedbackWarnings.filter(warning => warning.id !== feedback.id); renderFeedback(); markDirty(); };
|
|
290
|
+
row.append(details, remove); list.append(row);
|
|
291
|
+
}
|
|
292
|
+
savedState();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function download(filename: string, content: string, type: string): void { const url = URL.createObjectURL(new Blob([content], { type })), link = document.createElement('a'); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
|
|
296
|
+
function downloadStrip(filename: string, content: Blob): void { const url = URL.createObjectURL(content), link = document.createElement('a'); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }
|
|
297
|
+
function stripStatus(value: string, error = false): void { text('strip-status', value); el('strip-status').setAttribute('role', error ? 'alert' : 'status'); }
|
|
298
|
+
function updateStripControls(): void {
|
|
299
|
+
el('strip-panel').setAttribute('aria-busy', String(stripBusy));
|
|
300
|
+
select('strip-scope').disabled = stripBusy; select('strip-format').disabled = stripBusy; select('strip-basis').disabled = stripBusy || select('strip-scope').value === 'feedback';
|
|
301
|
+
button('strip-export').disabled = stripBusy || !!readOnly; button('strip-export').title = readOnly ?? ''; button('strip-export').textContent = stripBusy ? 'Generating strip…' : 'Download strip';
|
|
302
|
+
}
|
|
303
|
+
async function exportStrip(): Promise<void> {
|
|
304
|
+
if (stripBusy) return;
|
|
305
|
+
stripStatus('Checking the selected range…');
|
|
306
|
+
try {
|
|
307
|
+
const format = select('strip-format').value;
|
|
308
|
+
if (format !== 'png' && format !== 'json') throw new Error('Select PNG or JSON.');
|
|
309
|
+
const intervals: StripInterval[] = [];
|
|
310
|
+
if (select('strip-scope').value === 'feedback') {
|
|
311
|
+
if (!session.feedback.length) throw new Error('No feedback ranges to export.');
|
|
312
|
+
for (const feedback of session.feedback) {
|
|
313
|
+
const context = feedback.context;
|
|
314
|
+
if (!context || typeof context !== 'object' || Array.isArray(context) || context.animation !== data.animation) throw new Error(`Feedback ${feedback.id}: Its animation is missing or differs from the comparison animation. The range was not reassigned to another animation.`);
|
|
315
|
+
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: feedback.side, frameStart: feedback.frameStart, frameEnd: feedback.frameEnd });
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
const basis = select('strip-basis').value, start = input('frame-start').value.trim(), end = input('frame-end').value.trim();
|
|
319
|
+
if (basis !== 'a' && basis !== 'b') throw new Error('Explicitly select A or B as the report defining the frame range.');
|
|
320
|
+
if (!start || !end) throw new Error('Enter the strip start and end frames in the feedback form.');
|
|
321
|
+
intervals.push({ id: 'selection', basis, frameStart: Number(start), frameEnd: Number(end) });
|
|
322
|
+
}
|
|
323
|
+
for (const interval of intervals) if (!Number.isSafeInteger(interval.frameStart) || !Number.isSafeInteger(interval.frameEnd) || interval.frameStart < 0 || interval.frameEnd < interval.frameStart) throw new Error(`${interval.id}: Specify an inclusive range of nonnegative integer frames.`);
|
|
324
|
+
const request: StripRequest = { format, sourceHash: data.sourceHash, animation: data.animation, intervals };
|
|
325
|
+
stripBusy = true; updateStripControls(); stripStatus(`${data.animation} · ${intervals.length} ranges · Generating ${format.toUpperCase()}…`);
|
|
326
|
+
const response = await fetch(access.route('/api/compare/strip'), { method: 'POST', headers: access.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify(request) });
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
const failure: unknown = await response.json().catch(() => null);
|
|
329
|
+
throw new Error(failure && typeof failure === 'object' && 'error' in failure && typeof failure.error === 'string' ? failure.error : `Strip export failed · HTTP ${response.status}`);
|
|
330
|
+
}
|
|
331
|
+
const mime = format === 'png' ? 'image/png' : 'application/json';
|
|
332
|
+
if (!(response.headers.get('content-type') ?? '').toLowerCase().startsWith(mime)) throw new Error('The strip response format does not match the request.');
|
|
333
|
+
downloadStrip(`review-strip.${format}`, await response.blob()); stripStatus(`${data.animation} · ${intervals.length} ranges · Requested ${format.toUpperCase()} download. Review status was not changed.`);
|
|
334
|
+
} catch (error) { stripStatus(describe(error), true); }
|
|
335
|
+
finally { stripBusy = false; updateStripControls(); }
|
|
336
|
+
}
|
|
337
|
+
const md = (value: string) => value.replace(/[\\`*_{}\[\]<>]/g, '\\$&');
|
|
338
|
+
function issue(feedback: CompareFeedback) {
|
|
339
|
+
const title = `[${feedback.category}][${feedback.side.toUpperCase()}] ${feedback.target.kind}:${feedback.target.name} F${feedback.frameStart}–${feedback.frameEnd} · ${feedback.summary}`;
|
|
340
|
+
const body = [`Side: ${feedback.side.toUpperCase()}`, `Frames: ${feedback.frameStart}–${feedback.frameEnd} (inclusive; explicit producer frame numbers)`, `Target: ${feedback.target.kind} / ${md(feedback.target.name)}`, '', md(feedback.summary), '', 'Ordered compare provenance:', ...JSON.stringify(session.binding, null, 2).split('\n').map(line => ' ' + line), '', 'Inspection context:', ...JSON.stringify(feedback.context ?? null, null, 2).split('\n').map(line => ' ' + line), '', 'Validation warnings (must be corrected before submission):', ...JSON.stringify(session.feedbackWarnings.filter(warning => warning.id === feedback.id), null, 2).split('\n').map(line => ' ' + line)].join('\n');
|
|
341
|
+
return { title, body, labels: [feedback.category] };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function bind(): void {
|
|
345
|
+
select('frame-basis').onchange=frameControls;
|
|
346
|
+
button('frame-seek').onclick=()=>{const side=select('frame-basis').value as Side,raw=input('frame-input').value.trim(),frame=Number(raw),clock=clockForAnimation(data[side].report?.clock,data[side].report?.animation,players[side]?.current??'',true),time=timeAtFrame(clock,frame);if(!raw||time===null){message('Choose an integer frame inside the explicit producer clock.',true);return;}void seekTo(time,{side,frame,time});};
|
|
347
|
+
button('strip-export').onclick = () => { void exportStrip(); };
|
|
348
|
+
for (const id of ['strip-scope', 'strip-basis', 'strip-format']) select(id).onchange = () => { updateStripControls(); stripStatus('Selection changed. Check the ranges and frame-basis report before downloading.'); };
|
|
349
|
+
button('play').onclick=()=>{if(loopRestarting||seeking&&!idleHeld){void cancelPendingPlayback();return;}if(!video.paused){idleHeld=true;const current=lastPresented?.time;stop();if(current!==undefined)void seekTo(current);return;}void playComparison().catch(error=>message(describe(error),true));};
|
|
350
|
+
input('scrub').oninput = () => { void seekTo(Number(input('scrub').value)); };
|
|
351
|
+
button('seek').onclick = () => { const value = input('time-input').value.trim(); if (!value) { message('Enter a time in seconds.', true); return; } void seekTo(Number(value)); };
|
|
352
|
+
video.addEventListener('ended', () => { stopAtSupportedEnd(); });
|
|
353
|
+
input('reference-audio').onchange = () => { video.muted = !input('reference-audio').checked; };
|
|
354
|
+
button('zoom-in').onclick=()=>cameraAction(()=>zoomCamera(1.25));button('zoom-out').onclick=()=>cameraAction(()=>zoomCamera(1/1.25));button('fit').onclick=()=>cameraAction(()=>fitActiveCamera());
|
|
355
|
+
input('camera-sync').onchange=()=>cameraAction(()=>setCameraSync(input('camera-sync').checked));
|
|
356
|
+
for(const target of ['a','b','reference'] as const){
|
|
357
|
+
el(target+'-viewport').addEventListener('wheel',event=>{event.preventDefault();cameraAction(()=>zoomCamera(event.deltaY<0?1.1:1/1.1,target));},{passive:false});
|
|
358
|
+
for(const event of ['pointerdown','focusin'] as const)el(target+'-viewport').addEventListener(event,()=>activateCamera(target));
|
|
359
|
+
}
|
|
360
|
+
select('status').onchange = () => { session.status = select('status').value as CompareSession['status']; markDirty(); };
|
|
361
|
+
select('side').onchange = () => { renderFeedbackSelection(); select('category').value = ''; input('summary').value = ''; }; select('target-kind').onchange = renderFeedbackSelection;
|
|
362
|
+
button('add-feedback').onclick = () => {
|
|
363
|
+
const inspected = feedbackTrace(ready, seeking || video.seeking, trace);
|
|
364
|
+
if (!inspected) { message('Wait for the reference video and both rigs to synchronize successfully before adding feedback.', true); return; }
|
|
365
|
+
const side = select('side').value, start = input('frame-start').value.trim(), end = input('frame-end').value.trim(), frameStart = Number(start), frameEnd = Number(end), kind = select('target-kind').value, category = select('category').value, name = input('target-name').value.trim(), summary = input('summary').value.trim();
|
|
366
|
+
if (side !== 'a' && side !== 'b') { message('Select A or B as the feedback target.', true); return; }
|
|
367
|
+
if (!start || !end || !Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd) || frameStart < 0 || frameEnd < frameStart) { message('Enter an inclusive range of nonnegative integer frames.', true); return; }
|
|
368
|
+
if (!['bone', 'slot', 'rig'].includes(kind) || !['defect', 'quality'].includes(category) || !name || !summary || /[\r\n]/.test(summary) || summary.length > 1000) { message('Enter a target type, actual name, category and one-line summary of up to 1,000 characters.', true); return; }
|
|
369
|
+
const target: CompareFeedback['target'] = { kind: kind as CompareFeedback['target']['kind'], name };
|
|
370
|
+
try { validateFeedbackSelection(data[side].feedbackCatalog, { frameStart, frameEnd, target, animation: data.animation }); } catch (error) { message(describe(error), true); return; }
|
|
371
|
+
session.feedback.push({ id: crypto.randomUUID(), side, frameStart, frameEnd, target, category: category as CompareFeedback['category'], summary, context: { interval: { frameStart, frameEnd, inclusive: true }, animation: data.animation, mediaTime: inspected.reference, clockSource: inspected.source, frame: currentFrame(side), frames: { a: currentFrame('a'), b: currentFrame('b') }, playheads: { reference: inspected.reference, a: inspected.a, b: inspected.b },reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')},requestedProducer,part:parts?.feedback()??null,view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,inspected.reference)?.index??null:null } });
|
|
372
|
+
input('summary').value = ''; markDirty(); renderFeedback(); message('Added feedback bound to the target build and both reviewed sources.');
|
|
373
|
+
};
|
|
374
|
+
button('submit').onclick = async () => { if(readOnly){message(readOnly,true);return;}if(draftConflict){message('Resolve the saved local draft conflict before submitting.',true);return;}if (session.feedbackWarnings.length) { message('Resolve validation warnings in existing feedback before submitting. Delete the marked entries and add valid replacements.', true); return; } saving = true; savedState(); const payload = JSON.stringify(session); try { const saved = await api<CompareSession>('/api/compare/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload }); if (JSON.stringify(session) === payload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;await persistence?.submitted(saved); message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status and A/B/reference-video provenance.'); } catch (error) { message(`Submission failed: ${describe(error)}`, true); } finally { saving = false; savedState(); } };
|
|
375
|
+
button('export-json').onclick = () => { download(`${session.id}-feedback.json`, JSON.stringify({ ...session, schema: 'rig-review-compare-feedback/1', issues: session.feedback.map(issue) }, null, 2) + '\n', 'application/json'); };
|
|
376
|
+
button('export-markdown').onclick = () => { download(`${session.id}-issues.md`, [`# ${md(data.title)}`, '', 'Ordered A/B and reference provenance:', ...JSON.stringify(session.binding, null, 2).split('\n').map(line => ' ' + line), '', 'Validation warnings:', ...JSON.stringify(session.feedbackWarnings, null, 2).split('\n').map(line => ' ' + line), '', ...session.feedback.flatMap(feedback => { const entry = issue(feedback); return [`## ${md(entry.title)}`, '', entry.body, '', '---', '']; })].join('\n'), 'text/markdown'); };
|
|
377
|
+
// Saved browser defaults seed a new inspection; explicit links and source drafts restore over them.
|
|
378
|
+
workspace=bindWorkspaceUI({changed:fit,defaultTheme:theme=>appearance?.set(theme)});
|
|
379
|
+
cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
|
|
380
|
+
appearance=bindAppearance(select('appearance'),workspace.defaults().theme);
|
|
381
|
+
for(const side of ['a','b'] as const)overlays[side]=new RigOverlays({canvas:el<HTMLCanvasElement>(side+'-overlay'),rigRoot:el(side+'-root')});
|
|
382
|
+
for(const target of ['a','b','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
383
|
+
viewDisposers.push(bindStageLayout(el('stage-layout'),fit));
|
|
384
|
+
for(const [id,key] of [['layer-images','images'],['layer-mesh','mesh'],['layer-bones','bones']] as const)input(id).onchange=()=>{layers[key]=input(id).checked;redrawOverlays(false);};
|
|
385
|
+
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));};
|
|
386
|
+
el('inspector-feedback').addEventListener('focusin',()=>{idleHeld=true;const current=lastPresented?.time;stop();if(current!==undefined&&!seeking)void seekTo(current);});
|
|
387
|
+
bindInspector();
|
|
388
|
+
document.addEventListener('visibilitychange',()=>{if(document.hidden){suspendedPreview=!video.paused;stop();}else if(suspendedPreview&&!idleHeld){suspendedPreview=false;void playComparison().catch(error=>message(describe(error),true));}});
|
|
389
|
+
const observer = new ResizeObserver(fit); observer.observe(el('a-viewport')); observer.observe(el('b-viewport'));observer.observe(el('reference-viewport'));
|
|
390
|
+
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
391
|
+
window.addEventListener('pagehide', () => { persistence?.dispose();workspace?.dispose();disposed = true; seekAbort?.abort();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());for(const overlay of Object.values(overlays))overlay.dispose();stop(); if (videoCallback !== null) video.cancelVideoFrameCallback(videoCallback); if (fallbackCallback !== null) cancelAnimationFrame(fallbackCallback); for (const side of ['a', 'b'] as const) { players[side]?.dispose(); assets[side]?.dispose(); } observer.disconnect(); });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function start(): Promise<void> {
|
|
395
|
+
try {
|
|
396
|
+
let writes: { capabilityRequired: boolean };
|
|
397
|
+
[data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
|
|
398
|
+
readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true);
|
|
399
|
+
submittedBaseline=structuredClone(session);
|
|
400
|
+
if (data.id !== session.id || data.sourceHash !== session.sourceHash || JSON.stringify(data.binding) !== JSON.stringify(session.binding)) throw new Error('Comparison and submission session refer to different source identities. Reopen the review.');
|
|
401
|
+
text('title', data.title); text('animation', `${data.animation} · A/B order and reference video hash are fixed`); document.title = data.title + ' · rig review compare'; select('status').value = session.status; provenance(); bind(); parts=new PartExplorer(el('parts-explorer'),el('selected-part'),previous=>{redrawOverlays(true);const part=parts?.value();if(part){activeCamera=part.side==='b'?'b':'a';workspace?.revealInspector();document.querySelector<HTMLButtonElement>('[data-inspector=rig]')?.click();renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera());persistence?.save();},part=>{select('side').value=part.side;select('target-kind').value='slot';renderFeedbackSelection();input('target-name').value=part.slot;document.querySelector<HTMLButtonElement>('[data-inspector=feedback]')?.click();}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackSelection(); renderFeedback(); savedState();
|
|
402
|
+
renderPairLayout();
|
|
403
|
+
const settled = await Promise.allSettled([loadReference(), loadSide('a', data.a), loadSide('b', data.b)]);
|
|
404
|
+
const outcome = (value: PromiseSettledResult<number>): InputState => value.status === 'fulfilled' ? { state: 'loaded' } : { state: 'failed', reason: describe(value.reason) };
|
|
405
|
+
pairInputs = { reference: outcome(settled[0]), a: outcome(settled[1]), b: outcome(settled[2]), domain: null };
|
|
406
|
+
if (settled[0].status === 'fulfilled' && settled[1].status === 'fulfilled' && settled[2].status === 'fulfilled') { try { domain = commonDomain(settled[0].value, settled[1].value, settled[2].value); pairInputs.domain = { end: domain.end }; } catch (error) { pairInputs.domain = { refused: describe(error) }; } }
|
|
407
|
+
renderPairLayout();
|
|
408
|
+
if (!domain) { showSurvivors(); return; }
|
|
409
|
+
void loadFrameIndex();
|
|
410
|
+
input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
|
|
411
|
+
input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
|
|
412
|
+
await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
413
|
+
if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
414
|
+
} catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
|
|
415
|
+
}
|
|
416
|
+
function sharedBox():PoseBounds|null {const a=assets.a?.stage??players.a?.bounds(),b=assets.b?.stage??players.b?.bounds();return a&&b?unionBounds(a,b):a??b??null;}
|
|
417
|
+
/** The pair is offered only when both sides and the shared video actually loaded; a failed side leaves the survivor as a single pane, named. */
|
|
418
|
+
function renderPairLayout():PairEligibilityState {
|
|
419
|
+
const state=pairEligibility(pairInputs),shown={reference:state.effective==='compare'||state.effective==='reference',a:state.effective==='compare'||state.effective==='a',b:state.effective==='compare'||state.effective==='b'};
|
|
420
|
+
for(const target of ['reference','a','b'] as const){const pane=el(target+'-viewport').closest('section');if(pane)pane.hidden=!shown[target];}
|
|
421
|
+
el('stage-layout').dataset.panes=String(Object.values(shown).filter(Boolean).length);
|
|
422
|
+
if(state.effective!=='compare')text('domain',state.effective===null?`No input rendered · ${state.reasons.compare??''}`:`${state.effective==='reference'?'Reference only':state.effective.toUpperCase()+' only'} · comparison unavailable: ${state.reasons.compare??''}`);
|
|
423
|
+
return state;
|
|
424
|
+
}
|
|
425
|
+
type PairEligibilityState=ReturnType<typeof pairEligibility>;
|
|
426
|
+
/** Static single-pane inspection of whatever actually loaded. No synchronized pair, playback or feedback. */
|
|
427
|
+
function showSurvivors():void {
|
|
428
|
+
invalidateTrace('Comparison unavailable · no synchronized pair');
|
|
429
|
+
for(const side of ['a','b'] as const){if(pairInputs[side].state==='loaded'&&players[side])text(`${side}-renderer`,rendererBadges[side].succeeded(players[side]!.renderer).label);else{assets[side]?.dispose();delete assets[side];text(`${side}-empty`,`${side.toUpperCase()} unavailable · ${pairInputs[side].state==='failed'?(pairInputs[side] as {reason:string}).reason:'not loaded'}`);text(`${side}-renderer`,rendererBadges[side].failed().label);}}
|
|
430
|
+
if(pairInputs.reference.state!=='loaded')text('reference-empty','Reference video unavailable.');
|
|
431
|
+
updateFeedbackAvailability();
|
|
432
|
+
const state=renderPairLayout();activeCamera=state.effective==='b'?'b':state.effective==='reference'?'reference':'a';fit();
|
|
433
|
+
message(`Comparison unavailable · ${state.reasons.compare??''}${state.effective&&state.effective!=='compare'?` · showing ${state.effective==='reference'?'the reference':state.effective.toUpperCase()} alone`:''}`,true);
|
|
434
|
+
if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
435
|
+
}
|
|
436
|
+
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
437
|
+
const viewport=el(target+'-viewport');if(viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;
|
|
438
|
+
const box=target==='reference'?video.videoWidth>0&&video.videoHeight>0?{x:0,y:0,width:video.videoWidth,height:video.videoHeight}:null:players[target]?sharedBox():null;
|
|
439
|
+
if(!box)return null;const frame=cameraFrame(target==='reference'?'image':'world',box,viewport.clientWidth,viewport.clientHeight,target==='reference'?0:22,target==='reference'?0:38);if(frame)cameraFrames[target]=frame;return frame;
|
|
440
|
+
}
|
|
441
|
+
function availableCamera():CameraTarget|null {return paneFrame(activeCamera)?activeCamera:paneFrame('a')?'a':paneFrame('b')?'b':paneFrame('reference')?'reference':null;}
|
|
442
|
+
function activateCamera(target:CameraTarget):void {if(paneFrame(target)){activeCamera=target;renderCameraControls();}}
|
|
443
|
+
function alignCameras():void {
|
|
444
|
+
const target=availableCamera();if(!target)return;const source=paneFrame(target)!;assertCameraView(cameras[target],source);const signature=cameraSignature(cameras[target],source);
|
|
445
|
+
const updates:Partial<Record<CameraTarget,PaneCamera>>={};
|
|
446
|
+
for(const other of ['a','b','reference'] as const){if(other===target)continue;const frame=paneFrame(other);if(frame)updates[other]=cameraFromSignature(signature,frame);}
|
|
447
|
+
Object.assign(cameras,updates);activeCamera=target;
|
|
448
|
+
}
|
|
449
|
+
function renderCameraControls():void {input('camera-sync').checked=cameraSync;const target=availableCamera();text('zoom-label',`${Math.round((target?cameras[target].zoom:1)*100)}% · ${target==='reference'?'Reference':target?.toUpperCase()??'A'}`);button('fit').textContent=cameraSync?'Fit synchronized views':'Fit active view';}
|
|
450
|
+
function setCameraSync(value:boolean):void {if(value)alignCameras();cameraSync=value;fit();}
|
|
451
|
+
function cameraAction(action:()=>void):void {try{action();}catch(error){renderCameraControls();message(describe(error),true);}}
|
|
452
|
+
function updateCamera(target:CameraTarget,value:CameraSignature,rigGroup=false):void {
|
|
453
|
+
const frame=paneFrame(target);if(!frame)return;
|
|
454
|
+
const updates:Partial<Record<CameraTarget,PaneCamera>>={[target]:cameraFromSignature(value,frame)};
|
|
455
|
+
for(const other of ['a','b','reference'] as const){if(other===target||!cameraSync&&!(rigGroup&&other!=='reference'))continue;const next=paneFrame(other);if(next)updates[other]=cameraFromSignature(value,next);}
|
|
456
|
+
Object.assign(cameras,updates);activeCamera=target;fit();
|
|
457
|
+
}
|
|
458
|
+
function zoomCamera(factor:number,target=availableCamera()):void {if(!target)return;const frame=paneFrame(target);if(frame)updateCamera(target,zoomSignature(cameraSignature(cameras[target],frame),factor));}
|
|
459
|
+
function panCamera(target:CameraTarget,dx:number,dy:number):void {const frame=paneFrame(target);if(frame)updateCamera(target,dragSignature(cameraSignature(cameras[target],frame),frame,dx,dy));}
|
|
460
|
+
function fitActiveCamera(target=availableCamera()):void {if(!target)return;const part=parts?.value();if(part?.side===target){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,22,38));message('Selected slot fitted.');}else{if(!paneFrame(target))return;updateCamera(target,{zoom:1,pan:{x:0,y:0}});message('View fitted.');}}
|
|
461
|
+
function referenceFit():void {const frame=paneFrame('reference');if(!frame)return;const signature=cameraSignature(cameras.reference,frame);video.style.transform=`translate(${signature.pan.x*frame.width}px,${signature.pan.y*frame.height}px) scale(${signature.zoom})`;}
|
|
462
|
+
function redrawOverlays(fresh:boolean):void {
|
|
463
|
+
const warnings:string[]=[];let needsCapture=false;
|
|
464
|
+
for(const side of ['a','b'] as const){const overlay=overlays[side],player=players[side];if(!overlay||!player)continue;const root=el(`${side}-root`),project=(x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScales[side],y:parseFloat(root.style.top||'0')-y*rigScales[side]});const snapshot=fresh?overlay.refresh({player,frame:parts?.frame(side),selectedSlot:parts?.value()?.side===side?parts.value()!.slot:null,bones:(data[side].feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers}):overlay.redraw({project,settings:layers});warnings.push(...snapshot.warnings);needsCapture ||= snapshot.needsCapture;}
|
|
465
|
+
text('layer-state',warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');
|
|
466
|
+
if(!fresh&&needsCapture&&trace&&!seeking&&!video.seeking&&players.a&&players.b){try{drawSynchronizedPair(inspectionPlayer('a'),inspectionPlayer('b'));redrawOverlays(true);for(const side of ['a','b'] as const)text(`${side}-renderer`,rendererBadges[side].observe('drawn',players[side]!.renderer).label);}catch(error){failPair(error);}}
|
|
467
|
+
}
|
|
468
|
+
function bindInspector():void {bindInspectorTabs(()=>{workspace?.afterTab();if(document.querySelector('[data-inspector="feedback"][aria-selected="true"]')){idleHeld=true;const current=lastPresented?.time;stop();if(current!==undefined&&!seeking)void seekTo(current);}fit();},()=>workspace?.beforeTab());}
|
|
469
|
+
|
|
470
|
+
function canRepeatIdle():boolean{return data?.animation==='idle'&&idleAvailable(assets.a?.animations.map(a=>a.name)??[],data.animation)&&idleAvailable(assets.b?.animations.map(a=>a.name)??[],data.animation)&&input('idle-repeat').checked&&!!domain&&domain.end>0;}
|
|
471
|
+
function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · repeat enabled':'Bound non-idle inspection · no automatic playback');}
|
|
472
|
+
async function playComparison(signal?:AbortSignal):Promise<void> {
|
|
473
|
+
if(!ready||seeking||!domain||domain.end<=0)throw new Error('Comparison playback is unavailable');
|
|
474
|
+
idleHeld=false;if(trace&&trace.reference>=domain.end){await seekTo(0,null,null,true);if(!trace||signal?.aborted)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');}
|
|
475
|
+
const generation=latestSeek;requestedProducer=null;endStopReason=null;
|
|
476
|
+
try{await video.play();if(signal?.aborted||generation!==latestSeek||idleHeld){stop();throw new Error('CONTROL_SUPERSEDED');}button('play').textContent='Ⅱ';button('play').setAttribute('aria-label','Pause comparison');}catch(error){stop();idleHeld=true;throw new Error(`Playback is paused: ${describe(error)}. Use Play to retry.`);}
|
|
477
|
+
}
|
|
478
|
+
async function cancelPendingPlayback():Promise<void> {
|
|
479
|
+
const previous=presentation.snapshot().lastDrawn?.reference??trace?.reference??null;
|
|
480
|
+
idleHeld=true;++latestSeek;seekAbort?.abort();seeking=false;requestedProducer=null;stop();invalidateTrace('Idle restart cancelled · restoring paused inspection');frameControls();
|
|
481
|
+
// Recover the last actual drawn inspection via a NEW generation. The aborted
|
|
482
|
+
// restart can no longer commit or resume after this explicit manual hold.
|
|
483
|
+
if(previous!==null&&ready&&!disposed)await seekTo(previous);
|
|
484
|
+
}
|
|
485
|
+
async function restartIdle():Promise<void> {
|
|
486
|
+
if(loopRestarting)return;loopRestarting=true;stop();
|
|
487
|
+
try{await seekTo(0,null,null,true);if(!idleHeld&&canRepeatIdle()&&!disposed&&!document.hidden&&trace)await playComparison();}
|
|
488
|
+
catch(error){idleHeld=true;message(describe(error),true);}finally{loopRestarting=false;}
|
|
489
|
+
}
|
|
490
|
+
function localDraft():boolean{return dirty||!!input('summary').value.trim()||!!input('frame-start').value.trim()||!!input('frame-end').value.trim()||!!input('target-name').value.trim()||!!select('category').value;}
|
|
491
|
+
function controlState():ControlInspection {
|
|
492
|
+
const css=(target:CameraTarget)=>{const frame=paneFrame(target,true);if(!frame)return {zoom:1,pan:{x:0,y:0}};const value=cameraSignature(cameras[target],frame);return {zoom:value.zoom,pan:{x:value.pan.x*frame.width,y:value.pan.y*frame.height}};};
|
|
493
|
+
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:css('a'),a:css('a'),b:css('b'),reference:css('reference')},animation:data.animation,time:trace?.reference??null,playing:!video.paused,loop:canRepeatIdle()?'idle':'off',layers:{images:!!players.a&&layers.images,meshes:!!players.a&&layers.mesh,bones:!!players.a&&layers.bones},ready,seeking:seeking||video.seeking||!!persistence?.restoring(),dirty:localDraft(),busy:saving||stripBusy||!!persistence?.restoring(),feedbackEligible:!persistence?.restoring()&&pairEligibility(pairInputs).feedback&&!!feedbackTrace(ready,seeking||video.seeking,trace),comparison:controlComparison(pairEligibility(pairInputs)),capabilities:{camera:!!players.a&&!!players.b,referenceCamera:video.readyState>=1,animation:false,time:ready,playback:ready&&pairEligibility(pairInputs).playback,layers:{images:!!players.a&&!!players.b,meshes:!!players.a&&!!players.b,bones:!!players.a&&!!players.b}}};
|
|
494
|
+
}
|
|
495
|
+
async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<void> {
|
|
496
|
+
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
497
|
+
if(['animation','time','playback'].includes(command.type)&&(localDraft()||saving||stripBusy))throw new Error('CONTROL_DRAFT_BUSY: preserve review draft');
|
|
498
|
+
if(command.type==='theme'){appearance?.set(command.value);return;}
|
|
499
|
+
if(command.type==='layout'){fit();return;}
|
|
500
|
+
if(command.type==='camera-sync'){setCameraSync(command.value);return;}
|
|
501
|
+
if(command.type==='animation'){if(command.name!==data.animation)throw new Error('CONTROL_REBIND_REQUIRED');return;}
|
|
502
|
+
if(command.type==='layers'){if(!players.a||!players.b)throw new Error('CONTROL_LAYERS_UNAVAILABLE');Object.assign(layers,{images:command.images,mesh:command.meshes,bones:command.bones});input('layer-images').checked=layers.images;input('layer-mesh').checked=layers.mesh;input('layer-bones').checked=layers.bones;redrawOverlays(false);return;}
|
|
503
|
+
if(command.type==='camera'||command.type==='fit'){
|
|
504
|
+
const target=command.target==='rig'?'a':command.target,frame=paneFrame(target);if(!frame)throw new Error('CONTROL_CAMERA_UNAVAILABLE');
|
|
505
|
+
const value=command.type==='fit'?{zoom:1,pan:{x:0,y:0}}:{zoom:command.zoom,pan:{x:command.pan.x/frame.width,y:command.pan.y/frame.height}};
|
|
506
|
+
updateCamera(target,value,command.target==='rig');return;
|
|
507
|
+
}
|
|
508
|
+
if(command.type==='playback'){if(command.value==='play')await playComparison(signal);else{idleHeld=true;const current=lastPresented?.time;stop();if(current!==undefined){await seekTo(current);if(!trace)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');}}return;}
|
|
509
|
+
if(command.type==='time'){if(!domain)throw new Error('CONTROL_TIME_UNAVAILABLE');requireCommonTime(command.seconds,domain);await seekTo(command.seconds);if(signal.aborted||!trace)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');}
|
|
510
|
+
}
|
|
511
|
+
function numericView():ControlView {
|
|
512
|
+
const numeric=(target:CameraTarget)=>{const frame=paneFrame(target,true);if(!frame)return null;const camera=cameras[target];return numericCamera(frame.space,camera.center??frame.center,camera.zoom,frame.baseScale*camera.zoom,frame.width,frame.height);};
|
|
513
|
+
return {schema:'rig-review-view/1',cameraSync,activeCamera,cameras:{rig:null,a:numeric('a'),b:numeric('b'),reference:numeric('reference')}};
|
|
514
|
+
}
|
|
515
|
+
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
516
|
+
function inspectionBinding():InspectionBinding{return {mode:'compare',sourceHash:data.sourceHash,rigs:[data.a.reviewedSource.rig?.sha256??null,data.b.reviewedSource.rig?.sha256??null]};}
|
|
517
|
+
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:data.animation,time:trace?.reference??presentation.snapshot().lastDrawn?.reference??null,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,reference:data.reference.sha256,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view'}};}
|
|
518
|
+
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
519
|
+
await restoreTransaction(async()=>{
|
|
520
|
+
|
|
521
|
+
parts?.validate(bookmark.selection.part);
|
|
522
|
+
if(bookmark.animation!==data.animation||bookmark.selection.reference!==data.reference.sha256)throw new Error('Saved comparison animation/reference requires a different source binding');
|
|
523
|
+
if(!domain)throw new Error('Comparison is not ready');if(bookmark.time!==null)requireCommonTime(bookmark.time,domain);
|
|
524
|
+
for(const target of ['a','b','reference'] as const)if(bookmark.view.cameras[target]&&!paneFrame(target,true))throw new Error('Saved '+target+' camera is unavailable');
|
|
525
|
+
if(bookmark.view.cameras.rig)throw new Error('Saved single-rig camera is unavailable in comparison');
|
|
526
|
+
if(signal.aborted)throw new Error('Inspection restore superseded');
|
|
527
|
+
},()=>({bookmark:inspectionBookmark(),inputs:captureInputs()}),()=>applyInspection(bookmark,signal),async previous=>{await applyInspection(previous.bookmark,new AbortController().signal);restoreInputs(previous.inputs);video.muted=!input('reference-audio').checked;});
|
|
528
|
+
}
|
|
529
|
+
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
530
|
+
cameraRestoring=true;try{
|
|
531
|
+
parts?.select(bookmark.selection.part??null);
|
|
532
|
+
idleHeld=true;stop();if(bookmark.time!==null){await seekTo(bookmark.time);if(signal.aborted||!trace)throw new Error('Saved reference inspection could not settle');}
|
|
533
|
+
appearance?.set(bookmark.theme);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;
|
|
534
|
+
if(bookmark.selection.inspector){workspace?.beforeTab();for(const tab of document.querySelectorAll<HTMLButtonElement>('[data-inspector]')){const selected=tab.dataset.inspector===bookmark.selection.inspector;tab.setAttribute('aria-selected',String(selected));tab.tabIndex=selected?0:-1;}for(const panel of document.querySelectorAll<HTMLElement>('[data-inspector-panel]'))panel.hidden=panel.dataset.inspectorPanel!==bookmark.selection.inspector;workspace?.afterTab();}
|
|
535
|
+
syncStageLayout(el('stage-layout'));fit();
|
|
536
|
+
for(const target of ['a','b','reference'] as const){const camera=bookmark.view.cameras[target],frame=paneFrame(target,true);if(camera&&frame)cameras[target]={zoom:restoredZoom(camera,frame.baseScale,frame.width,frame.height),center:{...camera.center}};}
|
|
537
|
+
cameraSync=bookmark.view.cameraSync??false;activeCamera=bookmark.view.activeCamera==='b'||bookmark.view.activeCamera==='reference'?bookmark.view.activeCamera:'a';
|
|
538
|
+
if(bookmark.selection.part)parts?.assertIdentity(bookmark.selection.part);fit();redrawOverlays(false);idleHeld=true;stop();
|
|
539
|
+
}finally{cameraRestoring=false;}
|
|
540
|
+
}
|
|
541
|
+
async function setupPersistence():Promise<boolean> {
|
|
542
|
+
persistence=await connectInspectionPersistence({scope:'compare:'+data.a.manifest.id+':'+data.a.sourceHash+':'+data.b.manifest.id+':'+data.b.sourceHash,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('status').value=session.status;renderFeedback();savedState();},bookmark:inspectionBookmark,restoreBookmark:restoreInspection,
|
|
543
|
+
ui:()=>({inspectorScroll:workspace?.scroll()??'{}',frameBasis:select('frame-basis').value}),restoreUI:value=>{if(value.frameBasis==='a'||value.frameBasis==='b')select('frame-basis').value=value.frameBasis;frameControls();workspace?.restoreScroll(value.inspectorScroll);},afterInputs:()=>{video.muted=!input('reference-audio').checked;const name=input('target-name').value;renderFeedbackSelection();input('target-name').value=name;},onConflict:value=>{draftConflict=value;savedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;}});
|
|
544
|
+
return persistence.restored;
|
|
545
|
+
}
|
|
546
|
+
void start();
|