@rig-c/review 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -7
- package/docs/DESIGN.md +29 -0
- package/docs/REVIEW_PACKAGE.md +22 -2
- package/package.json +5 -3
- package/src/cli.ts +7 -5
- 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/open-protocol.ts +17 -0
- package/src/open.ts +46 -30
- 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
package/web/compare.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
2
3
|
import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
3
4
|
import type { CompareResponse, CompareSide } from '../src/compare.ts';
|
|
4
5
|
import type { CompareFeedback, CompareSession } from '../src/compare-session.ts';
|
|
5
6
|
import { clockForAnimation, lastFrame, timeAtFrame } from '../src/frame-clock.ts';
|
|
6
|
-
import { commonDomain, drawSynchronizedPair, compareSeekStep, feedbackTrace, inspectionFrame, requireCommonTime, type CompareDomain, type CompareTrace, type ReferenceSample } from '../src/compare-playhead.ts';
|
|
7
|
+
import { commonDomain, drawSynchronizedPair, compareSeekStep, feedbackTrace, inspectionFrame, requireCommonTime, type InspectionPlayer, type CompareDomain, type CompareTrace, type ReferenceSample } from '../src/compare-playhead.ts';
|
|
7
8
|
import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection } from '../src/feedback-validation.ts';
|
|
8
9
|
import type { StripInterval, StripRequest } from '../src/strip.ts';
|
|
9
10
|
|
|
10
11
|
import {bindAppearance,bindPan,bindStageLayout,bindInspectorTabs,syncStageLayout,idleAvailable} from './view-controls.ts';
|
|
11
12
|
import {RigOverlays} from './rig-overlays.ts';
|
|
13
|
+
import {bindWorkspaceUI,type WorkspaceUI} from './workspace-ui.ts';
|
|
12
14
|
import {connectControl} from './control.ts';
|
|
13
|
-
import
|
|
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';
|
|
14
18
|
import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
15
19
|
import {RendererBadge} from './renderer-status.ts';
|
|
16
20
|
import {ComparePresentation,type PresentationState} from '../src/compare-presentation.ts';
|
|
@@ -35,30 +39,36 @@ const cameraFrames:Partial<Record<CameraTarget,CameraFrame>>={};
|
|
|
35
39
|
const rigScales:Record<Side,number>={a:1,b:1};
|
|
36
40
|
let cameraSync=true,activeCamera:CameraTarget='a',cameraRestoring=false,cameraLayout='';
|
|
37
41
|
let idleHeld=true,loopRestarting=false,suspendedPreview=false;
|
|
38
|
-
let appearance:ReturnType<typeof bindAppearance>|null=null,controlDispose:(()=>void)|null=null;
|
|
42
|
+
let appearance:ReturnType<typeof bindAppearance>|null=null,controlDispose:(()=>void)|null=null,workspace:WorkspaceUI|null=null;
|
|
39
43
|
const viewDisposers:(()=>void)[]=[];
|
|
44
|
+
let parts:PartExplorer|null=null;
|
|
40
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);}};}
|
|
41
47
|
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
42
48
|
let stripBusy = false;
|
|
43
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;
|
|
44
52
|
let referenceFrames:VideoInfo|null=null;
|
|
45
53
|
const presentation=new ComparePresentation();
|
|
46
54
|
const rendererBadges={a:new RendererBadge(),b:new RendererBadge()};
|
|
47
55
|
const associations:Record<Side,Map<number,number|null>>={a:new Map(),b:new Map()};
|
|
48
56
|
let requestedProducer:{side:Side;frame:number;time:number}|null=null;
|
|
49
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};
|
|
50
60
|
const traceRows: CompareTrace[] = [];
|
|
51
61
|
|
|
52
62
|
function message(value: string, error = false): void { text('message', value); el('message').classList.toggle('error', error); }
|
|
53
|
-
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 || session.feedbackWarnings.length > 0; button('submit').title = session.feedbackWarnings.length ? 'Delete feedback with validation warnings, then add it again with a valid target.' : ''; }
|
|
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.' : ''; }
|
|
54
64
|
function markDirty(): void { dirty = true; savedState(); }
|
|
55
|
-
async function api<T>(path: string, init?: RequestInit): Promise<T> { const response = await fetch(path, init); if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`); return await response.json() as T; }
|
|
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; }
|
|
56
66
|
|
|
57
67
|
function sideAsset(side: Side, path: string): string {
|
|
58
68
|
if (!path || path.startsWith('/') || /[\\%?#\x00-\x1f:]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error(`Invalid ${side} asset path: ${path}`);
|
|
59
69
|
const base = `/compare-assets/${side}/`;
|
|
60
70
|
if (data[side].assetsBase !== base) throw new Error(`Unexpected ${side} assets base`);
|
|
61
|
-
return base + path.split('/').map(encodeURIComponent).join('/');
|
|
71
|
+
return access.route(base + path.split('/').map(encodeURIComponent).join('/'));
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
function currentFrame(side: Side): number | null {
|
|
@@ -131,7 +141,7 @@ function acceptReference(sample: ReferenceSample,deadline:number|null=null,prese
|
|
|
131
141
|
if(!ready||seeking||video.seeking||!domain||!players.a||!players.b)return;
|
|
132
142
|
if(sample.time>domain.end){stopAtSupportedEnd();return;}
|
|
133
143
|
if(trace?.reference===sample.time&&trace.source===sample.source)return;
|
|
134
|
-
try{const state=presentation.present(sample,deadline,presentedFrames,
|
|
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());}
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
function stopAtSupportedEnd():void {
|
|
@@ -193,7 +203,7 @@ async function seekTo(target: number,producer:{side:Side;frame:number;time:numbe
|
|
|
193
203
|
const sample=await seekReferenceFrame(target,signal,selected);
|
|
194
204
|
if(signal.aborted||generation!==latestSeek)throw new DOMException('Reference seek superseded','AbortError');
|
|
195
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');
|
|
196
|
-
const inspected=presentation.inspect(sample,
|
|
206
|
+
const inspected=presentation.inspect(sample,inspectionPlayer('a'),inspectionPlayer('b'),domain,()=>performance.now());
|
|
197
207
|
const value=inspected.inspection!;
|
|
198
208
|
if (generation !== latestSeek || signal.aborted) return;
|
|
199
209
|
requestedProducer=producer;endStopReason=endReason;seeking = false; lastPresented = { time: value.reference, source: value.source }; renderTrace(value);renderPresentation(inspected);
|
|
@@ -217,10 +227,12 @@ async function loadSide(side: Side, value: CompareSide): Promise<number> {
|
|
|
217
227
|
text(`${side}-title`, value.manifest.title);
|
|
218
228
|
if (!value.manifest.model) throw new Error(`${side.toUpperCase()}: compiled model Not supplied`);
|
|
219
229
|
const modelUrl = new URL(sideAsset(side, value.manifest.model), location.href).href;
|
|
220
|
-
const loaded = await loadModel({ modelUrl, setsUrl: null, resolvePage: (name, base) => { const url = new URL(name, base); if (url.origin !== location.origin || !url.pathname.startsWith(`/compare-assets/${side}/`)) throw new Error(`${side}: model page escapes its package`); return url.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; } });
|
|
221
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`);
|
|
222
232
|
const player = new RigPlayer(el(`${side}-root`), loaded, { seekStep: compareSeekStep });
|
|
223
|
-
|
|
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; }
|
|
224
236
|
el(`${side}-empty`).hidden = true; return clip.duration;
|
|
225
237
|
}
|
|
226
238
|
|
|
@@ -237,7 +249,7 @@ async function loadFrameIndex():Promise<void>{
|
|
|
237
249
|
frameControls();
|
|
238
250
|
}
|
|
239
251
|
function loadReference(): Promise<number> {
|
|
240
|
-
const url = new URL(data.reference.url, location.href); if (url.origin !== location.origin || url.pathname !== '/compare-reference' || url.search || url.hash) throw new Error('Reference must be the server-bound local compare video');
|
|
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');
|
|
241
253
|
text('reference-name', data.reference.name); observeVideoFrames();
|
|
242
254
|
return new Promise((resolve, reject) => {
|
|
243
255
|
const timer = setTimeout(() => reject(new Error('Could not read reference video metadata / first frame.')), 15000);
|
|
@@ -286,7 +298,7 @@ function stripStatus(value: string, error = false): void { text('strip-status',
|
|
|
286
298
|
function updateStripControls(): void {
|
|
287
299
|
el('strip-panel').setAttribute('aria-busy', String(stripBusy));
|
|
288
300
|
select('strip-scope').disabled = stripBusy; select('strip-format').disabled = stripBusy; select('strip-basis').disabled = stripBusy || select('strip-scope').value === 'feedback';
|
|
289
|
-
button('strip-export').disabled = stripBusy; button('strip-export').textContent = stripBusy ? 'Generating strip…' : 'Download strip';
|
|
301
|
+
button('strip-export').disabled = stripBusy || !!readOnly; button('strip-export').title = readOnly ?? ''; button('strip-export').textContent = stripBusy ? 'Generating strip…' : 'Download strip';
|
|
290
302
|
}
|
|
291
303
|
async function exportStrip(): Promise<void> {
|
|
292
304
|
if (stripBusy) return;
|
|
@@ -311,7 +323,7 @@ async function exportStrip(): Promise<void> {
|
|
|
311
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.`);
|
|
312
324
|
const request: StripRequest = { format, sourceHash: data.sourceHash, animation: data.animation, intervals };
|
|
313
325
|
stripBusy = true; updateStripControls(); stripStatus(`${data.animation} · ${intervals.length} ranges · Generating ${format.toUpperCase()}…`);
|
|
314
|
-
const response = await fetch('/api/compare/strip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
|
|
326
|
+
const response = await fetch(access.route('/api/compare/strip'), { method: 'POST', headers: access.headers({ 'Content-Type': 'application/json' }), body: JSON.stringify(request) });
|
|
315
327
|
if (!response.ok) {
|
|
316
328
|
const failure: unknown = await response.json().catch(() => null);
|
|
317
329
|
throw new Error(failure && typeof failure === 'object' && 'error' in failure && typeof failure.error === 'string' ? failure.error : `Strip export failed · HTTP ${response.status}`);
|
|
@@ -356,13 +368,16 @@ function bind(): void {
|
|
|
356
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; }
|
|
357
369
|
const target: CompareFeedback['target'] = { kind: kind as CompareFeedback['target']['kind'], name };
|
|
358
370
|
try { validateFeedbackSelection(data[side].feedbackCatalog, { frameStart, frameEnd, target, animation: data.animation }); } catch (error) { message(describe(error), true); return; }
|
|
359
|
-
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,view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,inspected.reference)?.index??null:null } });
|
|
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 } });
|
|
360
372
|
input('summary').value = ''; markDirty(); renderFeedback(); message('Added feedback bound to the target build and both reviewed sources.');
|
|
361
373
|
};
|
|
362
|
-
button('submit').onclick = async () => { 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(); } };
|
|
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(); } };
|
|
363
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'); };
|
|
364
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'); };
|
|
365
|
-
|
|
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);
|
|
366
381
|
for(const side of ['a','b'] as const)overlays[side]=new RigOverlays({canvas:el<HTMLCanvasElement>(side+'-overlay'),rigRoot:el(side+'-root')});
|
|
367
382
|
for(const target of ['a','b','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
368
383
|
viewDisposers.push(bindStageLayout(el('stage-layout'),fit));
|
|
@@ -373,24 +388,51 @@ function bind(): void {
|
|
|
373
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));}});
|
|
374
389
|
const observer = new ResizeObserver(fit); observer.observe(el('a-viewport')); observer.observe(el('b-viewport'));observer.observe(el('reference-viewport'));
|
|
375
390
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
376
|
-
window.addEventListener('pagehide', () => { persistence?.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(); });
|
|
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(); });
|
|
377
392
|
}
|
|
378
393
|
|
|
379
394
|
async function start(): Promise<void> {
|
|
380
395
|
try {
|
|
381
|
-
|
|
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);
|
|
382
399
|
submittedBaseline=structuredClone(session);
|
|
383
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.');
|
|
384
|
-
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(); 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();
|
|
385
|
-
|
|
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; }
|
|
386
409
|
void loadFrameIndex();
|
|
387
|
-
|
|
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;
|
|
388
411
|
input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
|
|
389
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));
|
|
390
|
-
controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
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)});
|
|
391
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.'); }
|
|
392
415
|
}
|
|
393
|
-
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):null;}
|
|
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
|
+
}
|
|
394
436
|
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
395
437
|
const viewport=el(target+'-viewport');if(viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;
|
|
396
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;
|
|
@@ -415,15 +457,15 @@ function updateCamera(target:CameraTarget,value:CameraSignature,rigGroup=false):
|
|
|
415
457
|
}
|
|
416
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));}
|
|
417
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));}
|
|
418
|
-
function fitActiveCamera(target=availableCamera()):void {if(target)updateCamera(target,{zoom:1,pan:{x:0,y:0}});}
|
|
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.');}}
|
|
419
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})`;}
|
|
420
462
|
function redrawOverlays(fresh:boolean):void {
|
|
421
463
|
const warnings:string[]=[];let needsCapture=false;
|
|
422
|
-
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,bones:(data[side].feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers}):overlay.redraw({project,settings:layers});warnings.push(...snapshot.warnings);needsCapture ||= snapshot.needsCapture;}
|
|
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;}
|
|
423
465
|
text('layer-state',warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');
|
|
424
|
-
if(!fresh&&needsCapture&&trace&&!seeking&&!video.seeking&&players.a&&players.b){try{drawSynchronizedPair(
|
|
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);}}
|
|
425
467
|
}
|
|
426
|
-
function bindInspector():void {bindInspectorTabs(()=>{if(document.querySelector('[data-inspector="feedback"][aria-selected="true"]')){idleHeld=true;const current=lastPresented?.time;stop();if(current!==undefined&&!seeking)void seekTo(current);}fit();});}
|
|
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());}
|
|
427
469
|
|
|
428
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;}
|
|
429
471
|
function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · repeat enabled':'Bound non-idle inspection · no automatic playback');}
|
|
@@ -448,7 +490,7 @@ async function restartIdle():Promise<void> {
|
|
|
448
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;}
|
|
449
491
|
function controlState():ControlInspection {
|
|
450
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}};};
|
|
451
|
-
return {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()&&!!feedbackTrace(ready,seeking||video.seeking,trace),capabilities:{camera:!!players.a&&!!players.b,referenceCamera:video.readyState>=1,animation:false,time:ready,playback:ready,layers:{images:!!players.a&&!!players.b,meshes:!!players.a&&!!players.b,bones:!!players.a&&!!players.b}}};
|
|
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}}};
|
|
452
494
|
}
|
|
453
495
|
async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<void> {
|
|
454
496
|
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
@@ -472,10 +514,11 @@ function numericView():ControlView {
|
|
|
472
514
|
}
|
|
473
515
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
474
516
|
function inspectionBinding():InspectionBinding{return {mode:'compare',sourceHash:data.sourceHash,rigs:[data.a.reviewedSource.rig?.sha256??null,data.b.reviewedSource.rig?.sha256??null]};}
|
|
475
|
-
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:{reference:data.reference.sha256,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view'}};}
|
|
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'}};}
|
|
476
518
|
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
477
519
|
await restoreTransaction(async()=>{
|
|
478
520
|
|
|
521
|
+
parts?.validate(bookmark.selection.part);
|
|
479
522
|
if(bookmark.animation!==data.animation||bookmark.selection.reference!==data.reference.sha256)throw new Error('Saved comparison animation/reference requires a different source binding');
|
|
480
523
|
if(!domain)throw new Error('Comparison is not ready');if(bookmark.time!==null)requireCommonTime(bookmark.time,domain);
|
|
481
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');
|
|
@@ -485,18 +528,19 @@ async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal)
|
|
|
485
528
|
}
|
|
486
529
|
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
487
530
|
cameraRestoring=true;try{
|
|
531
|
+
parts?.select(bookmark.selection.part??null);
|
|
488
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');}
|
|
489
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;
|
|
490
|
-
if(bookmark.selection.inspector){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;}
|
|
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();}
|
|
491
535
|
syncStageLayout(el('stage-layout'));fit();
|
|
492
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}};}
|
|
493
537
|
cameraSync=bookmark.view.cameraSync??false;activeCamera=bookmark.view.activeCamera==='b'||bookmark.view.activeCamera==='reference'?bookmark.view.activeCamera:'a';
|
|
494
|
-
fit();redrawOverlays(false);idleHeld=true;stop();
|
|
538
|
+
if(bookmark.selection.part)parts?.assertIdentity(bookmark.selection.part);fit();redrawOverlays(false);idleHeld=true;stop();
|
|
495
539
|
}finally{cameraRestoring=false;}
|
|
496
540
|
}
|
|
497
541
|
async function setupPersistence():Promise<boolean> {
|
|
498
|
-
persistence=await connectInspectionPersistence({scope:'compare:'+data.a.manifest.id+':'+data.b.manifest.id+':'+data.
|
|
499
|
-
ui:()=>({frameBasis:select('frame-basis').value}),restoreUI:value=>{if(value.frameBasis==='a'||value.frameBasis==='b')select('frame-basis').value=value.frameBasis;frameControls();},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;}});
|
|
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;}});
|
|
500
544
|
return persistence.restored;
|
|
501
545
|
}
|
|
502
546
|
void start();
|
package/web/control.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {parseControlCommand,parseControlInspection,preflightControl,type ControlCommand,type ControlDelivery,type ControlInspection,type ControlMode} from '../src/control.ts';
|
|
2
|
-
|
|
2
|
+
/** `route`/`headers` place calls under the page's instance base and add its write capability. */
|
|
3
|
+
interface Adapter {sourceHash:string;mode:ControlMode;route?:(path:string)=>string;headers?:()=>Headers;observe:()=>ControlInspection;apply:(command:ControlCommand,signal:AbortSignal)=>Promise<void>;onStatus?:(value:string)=>void}
|
|
3
4
|
/** Ongoing drags are local changes too; pointer hover is not a revision. */
|
|
4
5
|
export class ControlLocalRevision {
|
|
5
6
|
revision=0;private pointers=new Set<number>();
|
|
@@ -21,8 +22,9 @@ export function connectControl(adapter:Adapter):()=>void{
|
|
|
21
22
|
const local=new ControlLocalRevision(()=>{lastUser=Date.now();inflight?.abort(new Error('CONTROL_SUPERSEDED: local interaction'));});
|
|
22
23
|
const status=(text:string)=>adapter.onStatus?.(text);
|
|
23
24
|
const observe=()=>parseControlInspection(adapter.observe());
|
|
25
|
+
const route=adapter.route??((path:string)=>path),capability=()=>Object.fromEntries(adapter.headers?.()??[]) as Record<string,string>;
|
|
24
26
|
async function call<T>(path:string,value?:unknown):Promise<T>{
|
|
25
|
-
const response=await fetch(path,{method:value===undefined?'GET':'POST',headers:{...(value===undefined?{}:{'Content-Type':'application/json'}),...(token?{Authorization:'Bearer '+token}:{})},...(value===undefined?{}:{body:JSON.stringify(value)}),signal:AbortSignal.timeout(5000)});
|
|
27
|
+
const response=await fetch(route(path),{method:value===undefined?'GET':'POST',headers:{...(value===undefined?{}:{...capability(),'Content-Type':'application/json'}),...(token?{Authorization:'Bearer '+token}:{})},...(value===undefined?{}:{body:JSON.stringify(value)}),signal:AbortSignal.timeout(5000)});
|
|
26
28
|
const result=await response.json();if(!response.ok)throw new Error(typeof result.error==='string'?result.error:'CONTROL_REQUEST_FAILED');return result as T;
|
|
27
29
|
}
|
|
28
30
|
function interaction(event:Event){if(!event.isTrusted)return;local.interaction(event.type,'pointerId' in event?(event as PointerEvent).pointerId:undefined);}
|
|
@@ -53,7 +55,7 @@ export function connectControl(adapter:Adapter):()=>void{
|
|
|
53
55
|
}
|
|
54
56
|
void (async()=>{
|
|
55
57
|
try{
|
|
56
|
-
const response=await fetch('/api/control/config',{signal:AbortSignal.timeout(5000)});if(response.status===404){closed=true;return;}if(!response.ok)throw new Error('CONTROL_UNAVAILABLE');
|
|
58
|
+
const response=await fetch(route('/api/control/config'),{signal:AbortSignal.timeout(5000)});if(response.status===404){closed=true;return;}if(!response.ok)throw new Error('CONTROL_UNAVAILABLE');
|
|
57
59
|
const config=await response.json();if(!config.enabled){closed=true;return;}if(config.sourceHash!==adapter.sourceHash||config.mode!==adapter.mode)throw new Error('CONTROL_STALE_BINDING');
|
|
58
60
|
const page=await call<{pageId:string;pageToken:string;revision:number}>('/api/control/register',{sourceHash:adapter.sourceHash,inspection:observe()});
|
|
59
61
|
pageId=page.pageId;token=page.pageToken;
|
|
@@ -61,5 +63,5 @@ export function connectControl(adapter:Adapter):()=>void{
|
|
|
61
63
|
status('Remote inspection enabled · page '+pageId);void poll();
|
|
62
64
|
}catch(error){closed=true;status(error instanceof Error?error.message:'Remote control unavailable');}
|
|
63
65
|
})();
|
|
64
|
-
return ()=>{closed=true;if(timer)clearTimeout(timer);inflight?.abort();for(const event of events)window.removeEventListener(event,interaction,true);if(pageId)void fetch('/api/control/close',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify({pageId}),keepalive:true}).catch(()=>{});};
|
|
66
|
+
return ()=>{closed=true;if(timer)clearTimeout(timer);inflight?.abort();for(const event of events)window.removeEventListener(event,interaction,true);if(pageId)void fetch(route('/api/control/close'),{method:'POST',headers:{...capability(),'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify({pageId}),keepalive:true}).catch(()=>{});};
|
|
65
67
|
}
|
package/web/index.html
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>rig review</title><link rel="stylesheet" href="
|
|
3
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>rig review</title><link rel="stylesheet" href="styles.css"></head>
|
|
4
4
|
<body>
|
|
5
5
|
<div id="app">
|
|
6
6
|
<header class="topbar"><div class="brand"><span class="brand-mark">r</span>rig review</div><div><strong id="package-title">Review package</strong><span id="revision" class="subtle"></span></div><div class="top-actions"><span id="dirty" class="subtle">Loading…</span><button id="export">Feedback JSON</button><button id="export-markdown">Issue Markdown</button><button id="save" class="primary" disabled>Submit review</button></div></header>
|
|
7
7
|
<div class="layout">
|
|
8
8
|
<aside class="left-panel"><div class="eyebrow">CHECKPOINTS</div><nav id="checkpoints" aria-label="Checkpoints"></nav><section><label for="reference-select" class="label">Reference</label><select id="reference-select"></select><div id="reference-info" class="hint"></div><div id="person-control" hidden><label for="person-select" class="label space-top">Person in input pose</label><select id="person-select"></select></div></section><section><div class="eyebrow">SOURCE FILES · READ ONLY</div><div id="source-files"></div></section></aside>
|
|
9
|
-
<main><div class="viewbar"><div class="segmented" aria-label="View"><button data-view="compare" aria-pressed="true">Side by side</button><button data-view="reference" aria-pressed="false">Reference</button><button data-view="rig" aria-pressed="false">Rig</button></div><div class="zoom-controls"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom out">−</button><output id="zoom-value">100%</output><button id="zoom-in" aria-label="Zoom in">+</button><button id="fit">Fit</button></div></div><div class="stage-meta"><span id="checkpoint-title">Select a checkpoint</span><span id="player-state" class="subtle">No rig supplied</span><span id="renderer-state" class="subtle" aria-label="Last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div><div id="stage-layout" class="stages"><section id="reference-pane" class="stage-pane" aria-label="Reference"><span id="reference-label" class="pane-label">Reference</span><div id="reference-viewport" class="viewport"><div id="reference-content" class="reference-content"></div><div id="reference-empty" class="empty">No reference supplied.</div></div></section><section id="rig-pane" class="stage-pane" aria-label="Rig"><span class="pane-label">Generated rig · read only</span><div id="rig-viewport" class="viewport"><div id="rig-root"></div><canvas id="rig-overlay" class="rig-overlay" aria-label="Actual rig mesh and bone overlay"></canvas><div id="rig-empty" class="empty">No compiled model supplied.</div></div></section></div><div class="stage-footer"><span id="coordinates">Drag to pan · scroll to zoom · cameras synchronized</span><span>Source files remain unchanged.</span></div><section class="inspector" aria-label="Inspection controls"><div class="inspector-heading"><strong>INSPECTOR</strong><div role="tablist" aria-label="Inspector sections"><button role="tab" id="tab-view" data-inspector="view" aria-controls="inspector-view" aria-selected="true" tabindex="0">View</button><button role="tab" id="tab-rig" data-inspector="rig" aria-controls="inspector-rig" aria-selected="false" tabindex="-1">Rig</button><button role="tab" id="tab-playback" data-inspector="playback" aria-controls="inspector-playback" aria-selected="false" tabindex="-1">Playback</button><button role="tab" id="tab-feedback" data-inspector="feedback" aria-controls="inspector-feedback" aria-selected="false" tabindex="-1">Feedback</button></div><span class="hint">Local view controls · source files stay unchanged</span></div><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div><div class="inspector-body"><section id="inspector-view" role="tabpanel" aria-labelledby="tab-view" data-inspector-panel="view"><div class="view-link-controls"><button id="copy-view" type="button">Copy view link</button><span class="hint">Numeric source region · opening this link restores a paused inspection</span></div><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><div class="inspection-controls" aria-label="Inspection display controls"><label>Appearance <select id="appearance"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span></div><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off to inspect each pane independently. Toolbar zoom and Fit use the last active pane. Hold Space while dragging a video to pan without taking over its native controls.</p></section><section id="inspector-rig" role="tabpanel" aria-labelledby="tab-rig" data-inspector-panel="rig" hidden><div class="rig-layer-controls"><fieldset id="layer-controls"><legend>Rig layers</legend><label><input id="layer-images" type="checkbox" checked disabled>Images</label><label><input id="layer-mesh" type="checkbox" disabled>Mesh edges</label><label><input id="layer-bones" type="checkbox" disabled>Bone points</label></fieldset><span id="layer-state" class="hint">No rig supplied</span></div><section id="modeling-tools"><div class="eyebrow">MODELING REVIEW</div><button data-proposal="mesh" class="tool">Mesh density <small>Add feedback</small></button><button data-proposal="contour" class="tool">Contour curvature <small>Add feedback</small></button><button data-proposal="chest" class="tool">Chest position <small>Add feedback</small></button><button data-proposal="angle" class="tool">Angle inspection <small id="axis-state">No inspection axes supplied</small></button><label for="part-set" class="label space-top">Part presets</label><select id="part-set" disabled><option>No sets supplied</option></select><p class="hint">Preview only · does not create motion keys.</p></section><section id="angle-panel" class="angle-panel" hidden><label for="axis-select">Rig inspection axis</label><select id="axis-select"></select><label id="axis-label" for="axis-value"></label><input id="axis-value" type="range" disabled><output id="axis-value-label"></output><p class="hint">Maps one inspection axis to its animation time. This does not rotate the camera.</p></section></section><section id="inspector-playback" role="tabpanel" aria-labelledby="tab-playback" data-inspector-panel="playback" hidden><section id="transport" class="transport"><button id="play" aria-label="Play" disabled>▶</button><label for="animation-select" class="sr-only">Animation</label><select id="animation-select" disabled><option>No animations supplied</option></select><input id="scrub" type="range" min="0" max="1" step="0.001" value="0" aria-label="Playback time"><output id="time-label">0.0000 / 0.0000 s</output><output id="frame-label">rate unknown</output></section><section id="frame-controls" class="frame-controls" hidden aria-label="Frame navigation using the explicit clock"><label for="frame-number">Frame</label><input id="frame-number" type="number" min="0" step="1" inputmode="numeric" aria-label="Frame number to inspect"><button id="frame-jump" type="button">Go</button><input id="frame-scrub" type="range" min="0" max="1" step="1" value="0" aria-label="Scrub by frame"><span id="frame-clock-label" class="hint"></span><p id="frame-outside" class="hint" hidden>The current time is outside the explicit frame range. Enter a frame number to go to its exact time.</p></section><section id="motion-tools"><div class="eyebrow">MOTION · READ ONLY</div><button data-proposal="pose" class="tool">Inspect pose <small>Add feedback</small></button><button data-proposal="timing" class="tool">Inspect interval <small>Add feedback</small></button><button data-proposal="audio" class="tool">Audio links <small>Add feedback</small></button></section><section id="sound-panel" class="sound-panel"><div class="row"><span class="eyebrow">SOUND CUES</span><label><input id="sync-audio" type="checkbox">Play cue audio in sync</label></div><div id="cues"></div><div class="row"><label for="audio-volume">Audio volume</label><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="0.8"><output id="audio-volume-label">80%</output></div><div id="event-log" class="hint" aria-live="polite">Runtime events: none</div></section><section id="report-panel" class="report-panel" aria-labelledby="report-heading"><div class="report-header"><h2 id="report-heading">Report events</h2><span id="report-summary" class="subtle"></span><a id="report-source-link" target="_blank" rel="noopener" hidden>Source report</a></div><p id="report-unavailable" class="hint" hidden>No rig-pose-report/1 events supplied.</p><div id="report-browser"><div class="report-controls"><label for="report-category" class="sr-only">Report event category</label><select id="report-category"><option value="all">All events</option></select><button id="report-previous" type="button" aria-label="Previous event page">Previous</button><output id="report-page"></output><button id="report-next" type="button" aria-label="Next event page">Next</button></div><div id="report-empty-categories" class="report-empty-categories"></div><p id="report-missing" class="hint" hidden></p><ol id="report-events" class="report-event-list" aria-label="Read-only report events"></ol><section id="report-selection" class="report-selection" hidden aria-labelledby="report-selection-heading"><h3 id="report-selection-heading"></h3><p id="report-timing" class="hint"></p><div class="report-controls"><label id="report-time-label" for="report-time" hidden>Select explicit time</label><select id="report-time" hidden></select><button id="report-seek" type="button" disabled>Go to explicit time</button></div><div id="report-target-control" hidden><label for="report-target" class="label space-top">Select a target named in the report</label><select id="report-target"></select></div><p id="report-prefill-note" class="hint"></p><p id="report-provenance" class="hint"></p><details><summary>Source event details</summary><pre id="report-details"></pre></details></section></div></section></section><section id="inspector-feedback" role="tabpanel" aria-labelledby="tab-feedback" data-inspector-panel="feedback" hidden><div class="review-summary"><div class="eyebrow">REVIEW</div><label for="review-status" class="label">Review status</label><select id="review-status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance" aria-label="Reviewed source"><div class="eyebrow">REVIEWED SOURCE</div><div class="provenance-row"><span>Package revision</span><strong id="provenance-version">—</strong></div><div class="provenance-row"><span>rig SHA-256</span><code id="provenance-rig">—</code></div><div class="provenance-row"><span>motion SHA-256</span><code id="provenance-motion">—</code></div><details><summary>Full provenance</summary><pre id="provenance-full"></pre></details><p class="hint">Submits server-verified file hashes and tool versions. Your selected review status is preserved.</p></section><div class="divider"></div></div><div id="checkpoint-detail"><div class="eyebrow">SELECTED CHECKPOINT</div><h1 id="detail-title">Checkpoint</h1><p id="detail-note" class="hint"></p><fieldset class="outcome"><legend>Checkpoint outcome</legend><label><input type="radio" name="outcome" value="pending" checked>Pending</label><label><input type="radio" name="outcome" value="pass">Pass</label><label><input type="radio" name="outcome" value="fail">Needs changes</label></fieldset><label for="checkpoint-note" class="label space-top">Review notes</label><textarea id="checkpoint-note" rows="5" placeholder="Describe the part, conditions and what looks wrong."></textarea></div><div class="divider"></div><section id="proposal-panel"><div class="eyebrow">STRUCTURED FEEDBACK</div><div class="label">Frame range · inclusive</div><div class="frame-range"><label>Start<input id="feedback-start" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 18"></label><label>End<input id="feedback-end" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 32"></label></div><p id="feedback-range-hint" class="hint">Checking frame bounds…</p><label for="feedback-target-kind" class="label space-top">Target type</label><select id="feedback-target-kind"><option value="">Select target</option><option value="rig">Entire rig</option><option value="bone">Bone</option><option value="slot">Slot</option></select><label for="feedback-target-name" class="label space-top">Target name</label><input id="feedback-target-name" type="text" list="feedback-target-names" autocomplete="off" placeholder="Actual bone or slot name"><datalist id="feedback-target-names"></datalist><p id="feedback-target-hint" class="hint"></p><label for="feedback-category" class="label space-top">Category</label><select id="feedback-category"><option value="">Select category</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="feedback-summary" class="label space-top">One-line summary</label><input id="feedback-summary" type="text" maxlength="1000" placeholder="e.g. The left wrist moves outside the sleeve"><button id="add-proposal" class="outline block">Add feedback</button><p id="proposal-boundary" class="hint">Records the current checkpoint, time, reference, camera and inspection axis. Motion is read only: inspect, mark ranges and export feedback. No keys or source files are edited.</p><p id="feedback-warnings" class="hint" role="status" hidden></p><div id="proposal-list"></div></section><details id="strip-panel" class="report-panel"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Inspection time and source files remain unchanged.</p><label for="strip-scope" class="label">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-reference" class="label space-top">Reference video</label><select id="strip-reference"><option value="">Select reference video</option></select><label for="strip-format" class="label space-top">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">PNG requires frameTimes in the selected report. Without it, JSON preserves requested frames with time: null. Batch export requires every feedback item to match the selected animation. Unsubmitted feedback is included; exporting does not submit the review.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading package…</p></details></section></div></section></main>
|
|
9
|
+
<main><div class="viewbar"><div class="segmented" aria-label="View"><button data-view="compare" aria-pressed="true">Side by side</button><button data-view="reference" aria-pressed="false">Reference</button><button data-view="rig" aria-pressed="false">Rig</button></div><div class="zoom-controls"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom out">−</button><output id="zoom-value">100%</output><button id="zoom-in" aria-label="Zoom in">+</button><button id="fit">Fit</button></div></div><div class="stage-meta"><span id="checkpoint-title">Select a checkpoint</span><span id="player-state" class="subtle">No rig supplied</span><span id="layout-state" class="subtle" role="status" aria-label="Effective layout">Loading inputs…</span><span id="renderer-state" class="subtle" aria-label="Last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div><div id="stage-layout" class="stages"><section id="reference-pane" class="stage-pane" aria-label="Reference"><span id="reference-label" class="pane-label">Reference</span><div id="reference-viewport" class="viewport"><div id="reference-content" class="reference-content"></div><div id="reference-empty" class="empty">No reference supplied.</div></div></section><section id="rig-pane" class="stage-pane" aria-label="Rig"><span class="pane-label">Generated rig · read only</span><div id="rig-viewport" class="viewport"><div id="rig-root"></div><canvas id="rig-overlay" class="rig-overlay" aria-label="Actual rig mesh and bone overlay"></canvas><div id="rig-empty" class="empty">No compiled model supplied.</div></div></section></div><div class="stage-footer"><span id="coordinates">Drag to pan · scroll to zoom · cameras synchronized</span><span>Source files remain unchanged.</span></div><section class="inspector" aria-label="Inspection controls"><div class="inspector-heading"><strong>INSPECTOR</strong><div role="tablist" aria-label="Inspector sections"><button role="tab" id="tab-view" data-inspector="view" aria-controls="inspector-view" aria-selected="true" tabindex="0">View</button><button role="tab" id="tab-rig" data-inspector="rig" aria-controls="inspector-rig" aria-selected="false" tabindex="-1">Rig</button><button role="tab" id="tab-playback" data-inspector="playback" aria-controls="inspector-playback" aria-selected="false" tabindex="-1">Playback</button><button role="tab" id="tab-feedback" data-inspector="feedback" aria-controls="inspector-feedback" aria-selected="false" tabindex="-1">Feedback</button></div><span class="hint">Local view controls · source files stay unchanged</span></div><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div><div class="inspector-body"><section id="inspector-view" role="tabpanel" aria-labelledby="tab-view" data-inspector-panel="view"><div class="view-link-controls"><button id="copy-view" type="button">Copy view link</button><span class="hint">Numeric source region · opening this link restores a paused inspection</span></div><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><div class="inspection-controls" aria-label="Inspection display controls"><label>Appearance <select id="appearance"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span></div><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off to inspect each pane independently. Toolbar zoom and Fit use the last active pane. Hold Space while dragging a video to pan without taking over its native controls.</p></section><section id="inspector-rig" role="tabpanel" aria-labelledby="tab-rig" data-inspector-panel="rig" hidden><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><div class="rig-layer-controls"><fieldset id="layer-controls"><legend>Rig layers</legend><label><input id="layer-images" type="checkbox" checked disabled>Images</label><label><input id="layer-mesh" type="checkbox" disabled>Mesh edges</label><label><input id="layer-bones" type="checkbox" disabled>Bone points</label></fieldset><span id="layer-state" class="hint">No rig supplied</span></div><section id="modeling-tools"><div class="eyebrow">MODELING REVIEW</div><button data-proposal="mesh" class="tool">Mesh density <small>Add feedback</small></button><button data-proposal="contour" class="tool">Contour curvature <small>Add feedback</small></button><button data-proposal="chest" class="tool">Chest position <small>Add feedback</small></button><button data-proposal="angle" class="tool">Angle inspection <small id="axis-state">No inspection axes supplied</small></button><label for="part-set" class="label space-top">Part presets</label><select id="part-set" disabled><option>No sets supplied</option></select><p class="hint">Preview only · does not create motion keys.</p></section><section id="angle-panel" class="angle-panel" hidden><label for="axis-select">Rig inspection axis</label><select id="axis-select"></select><label id="axis-label" for="axis-value"></label><input id="axis-value" type="range" disabled><output id="axis-value-label"></output><p class="hint">Maps one inspection axis to its animation time. This does not rotate the camera.</p></section></section><section id="inspector-playback" role="tabpanel" aria-labelledby="tab-playback" data-inspector-panel="playback" hidden><section id="transport" class="transport"><button id="play" aria-label="Play" disabled>▶</button><label for="animation-select" class="sr-only">Animation</label><select id="animation-select" disabled><option>No animations supplied</option></select><input id="scrub" type="range" min="0" max="1" step="0.001" value="0" aria-label="Playback time"><output id="time-label">0.0000 / 0.0000 s</output><output id="frame-label">rate unknown</output></section><section id="frame-controls" class="frame-controls" hidden aria-label="Frame navigation using the explicit clock"><label for="frame-number">Frame</label><input id="frame-number" type="number" min="0" step="1" inputmode="numeric" aria-label="Frame number to inspect"><button id="frame-jump" type="button">Go</button><input id="frame-scrub" type="range" min="0" max="1" step="1" value="0" aria-label="Scrub by frame"><span id="frame-clock-label" class="hint"></span><p id="frame-outside" class="hint" hidden>The current time is outside the explicit frame range. Enter a frame number to go to its exact time.</p></section><section id="motion-tools"><div class="eyebrow">MOTION · READ ONLY</div><button data-proposal="pose" class="tool">Inspect pose <small>Add feedback</small></button><button data-proposal="timing" class="tool">Inspect interval <small>Add feedback</small></button><button data-proposal="audio" class="tool">Audio links <small>Add feedback</small></button></section><section id="sound-panel" class="sound-panel"><div class="row"><span class="eyebrow">SOUND CUES</span><label><input id="sync-audio" type="checkbox">Play cue audio in sync</label></div><div id="cues"></div><div class="row"><label for="audio-volume">Audio volume</label><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="0.8"><output id="audio-volume-label">80%</output></div><div id="event-log" class="hint" aria-live="polite">Runtime events: none</div></section><section id="report-panel" class="report-panel" aria-labelledby="report-heading"><div class="report-header"><h2 id="report-heading">Report events</h2><span id="report-summary" class="subtle"></span><a id="report-source-link" target="_blank" rel="noopener" hidden>Source report</a></div><p id="report-unavailable" class="hint" hidden>No rig-pose-report/1 events supplied.</p><div id="report-browser"><div class="report-controls"><label for="report-category" class="sr-only">Report event category</label><select id="report-category"><option value="all">All events</option></select><button id="report-previous" type="button" aria-label="Previous event page">Previous</button><output id="report-page"></output><button id="report-next" type="button" aria-label="Next event page">Next</button></div><div id="report-empty-categories" class="report-empty-categories"></div><p id="report-missing" class="hint" hidden></p><ol id="report-events" class="report-event-list" aria-label="Read-only report events"></ol><section id="report-selection" class="report-selection" hidden aria-labelledby="report-selection-heading"><h3 id="report-selection-heading"></h3><p id="report-timing" class="hint"></p><div class="report-controls"><label id="report-time-label" for="report-time" hidden>Select explicit time</label><select id="report-time" hidden></select><button id="report-seek" type="button" disabled>Go to explicit time</button></div><div id="report-target-control" hidden><label for="report-target" class="label space-top">Select a target named in the report</label><select id="report-target"></select></div><p id="report-prefill-note" class="hint"></p><p id="report-provenance" class="hint"></p><details><summary>Source event details</summary><pre id="report-details"></pre></details></section></div></section></section><section id="inspector-feedback" role="tabpanel" aria-labelledby="tab-feedback" data-inspector-panel="feedback" hidden><div class="review-summary"><div class="eyebrow">REVIEW</div><label for="review-status" class="label">Review status</label><select id="review-status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance" aria-label="Reviewed source"><div class="eyebrow">REVIEWED SOURCE</div><div class="provenance-row"><span>Package revision</span><strong id="provenance-version">—</strong></div><div class="provenance-row"><span>rig SHA-256</span><code id="provenance-rig">—</code></div><div class="provenance-row"><span>motion SHA-256</span><code id="provenance-motion">—</code></div><details><summary>Full provenance</summary><pre id="provenance-full"></pre></details><p class="hint">Submits server-verified file hashes and tool versions. Your selected review status is preserved.</p></section><div class="divider"></div></div><div id="checkpoint-detail"><div class="eyebrow">SELECTED CHECKPOINT</div><h1 id="detail-title">Checkpoint</h1><p id="detail-note" class="hint"></p><fieldset class="outcome"><legend>Checkpoint outcome</legend><label><input type="radio" name="outcome" value="pending" checked>Pending</label><label><input type="radio" name="outcome" value="pass">Pass</label><label><input type="radio" name="outcome" value="fail">Needs changes</label></fieldset><label for="checkpoint-note" class="label space-top">Review notes</label><textarea id="checkpoint-note" rows="5" placeholder="Describe the part, conditions and what looks wrong."></textarea></div><div class="divider"></div><section id="proposal-panel"><div class="eyebrow">STRUCTURED FEEDBACK</div><div class="label">Frame range · inclusive</div><div class="frame-range"><label>Start<input id="feedback-start" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 18"></label><label>End<input id="feedback-end" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 32"></label></div><p id="feedback-range-hint" class="hint">Checking frame bounds…</p><label for="feedback-target-kind" class="label space-top">Target type</label><select id="feedback-target-kind"><option value="">Select target</option><option value="rig">Entire rig</option><option value="bone">Bone</option><option value="slot">Slot</option></select><label for="feedback-target-name" class="label space-top">Target name</label><input id="feedback-target-name" type="text" list="feedback-target-names" autocomplete="off" placeholder="Actual bone or slot name"><datalist id="feedback-target-names"></datalist><p id="feedback-target-hint" class="hint"></p><label for="feedback-category" class="label space-top">Category</label><select id="feedback-category"><option value="">Select category</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="feedback-summary" class="label space-top">One-line summary</label><input id="feedback-summary" type="text" maxlength="1000" placeholder="e.g. The left wrist moves outside the sleeve"><button id="add-proposal" class="outline block">Add feedback</button><p id="proposal-boundary" class="hint">Records the current checkpoint, time, reference, camera and inspection axis. Motion is read only: inspect, mark ranges and export feedback. No keys or source files are edited.</p><p id="feedback-warnings" class="hint" role="status" hidden></p><div id="proposal-list"></div></section><details id="strip-panel" class="report-panel"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Inspection time and source files remain unchanged.</p><label for="strip-scope" class="label">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-reference" class="label space-top">Reference video</label><select id="strip-reference"><option value="">Select reference video</option></select><label for="strip-format" class="label space-top">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">PNG requires frameTimes in the selected report. Without it, JSON preserves requested frames with time: null. Batch export requires every feedback item to match the selected animation. Unsubmitted feedback is included; exporting does not submit the review.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading package…</p></details></section></div></section></main><aside id="parts-explorer" class="parts-explorer" aria-label="Parts explorer"><h2>PARTS</h2><button type="button" data-part-whole aria-pressed="true">Whole rig</button><label for="part-search">Find slot</label><input id="part-search" data-part-search type="search" autocomplete="off" placeholder="Exact slot name or text"><label class="part-solo"><input id="part-solo" data-part-solo type="checkbox" disabled>Solo selected slot</label><p data-part-status class="hint" role="status">Loading actual slots…</p><ul data-part-list aria-label="Actual rig slots"></ul><p class="hint">Select in context or enable Solo. Type icons and main preview represent actual slots; atlas pages are not part thumbnails.</p></aside>
|
|
10
10
|
|
|
11
11
|
</div><footer><span id="message" role="status" aria-live="polite">Loading local review package…</span><span id="source-hash" class="subtle"></span></footer>
|
|
12
|
-
</div><script type="module" src="
|
|
12
|
+
</div><script type="module" src="main.js"></script>
|
|
13
13
|
</body></html>
|