@rig-c/review 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +285 -2
- package/docs/DESIGN.md +43 -0
- package/docs/REVIEW_PACKAGE.md +607 -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 +47 -4
- package/src/animation-selection.ts +29 -0
- package/src/build.ts +8 -0
- package/src/cli.ts +114 -0
- package/src/compare-playhead.ts +55 -0
- package/src/compare-presentation.ts +43 -0
- package/src/compare-server.ts +60 -0
- package/src/compare-session.ts +26 -0
- package/src/compare.ts +57 -0
- package/src/control-server.ts +83 -0
- package/src/control.ts +103 -0
- package/src/feedback-export.ts +197 -0
- package/src/feedback-validation.ts +38 -0
- package/src/frame-clock.ts +36 -0
- package/src/manifest.ts +140 -0
- package/src/open-protocol.ts +14 -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 +126 -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 +61 -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 +34 -0
- package/web/compare.css +96 -0
- package/web/compare.html +7 -0
- package/web/compare.ts +502 -0
- package/web/control.ts +65 -0
- package/web/index.html +13 -0
- package/web/inspection-persistence.ts +200 -0
- package/web/main.ts +928 -0
- package/web/renderer-status.ts +31 -0
- package/web/report-view.ts +51 -0
- package/web/rig-overlays.ts +147 -0
- package/web/styles.css +98 -0
- package/web/view-controls.ts +67 -0
package/web/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
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="/styles.css"></head>
|
|
4
|
+
<body>
|
|
5
|
+
<div id="app">
|
|
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
|
+
<div class="layout">
|
|
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>
|
|
10
|
+
|
|
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="/main.js"></script>
|
|
13
|
+
</body></html>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** Versioned local drafts and public numeric view links. No capability or review decision is put in a URL. */
|
|
2
|
+
import {parseControlView,type ControlView} from '../src/control.ts';
|
|
3
|
+
import type {Json} from '../src/manifest.ts';
|
|
4
|
+
/** Feedback stores an inert JSON value, independently copied from the typed control observation. */
|
|
5
|
+
export function serializableView(view:ControlView):Record<string,Json> {
|
|
6
|
+
const camera=(value:ControlView['cameras']['rig']):Record<string,Json>|null=>value===null?null:{space:value.space,center:{x:value.center.x,y:value.center.y},zoom:value.zoom,viewport:{width:value.viewport.width,height:value.viewport.height},visible:{x:value.visible.x,y:value.visible.y,width:value.visible.width,height:value.visible.height}};
|
|
7
|
+
const value:Record<string,Json>={schema:view.schema,cameras:{rig:camera(view.cameras.rig),a:camera(view.cameras.a),b:camera(view.cameras.b),reference:camera(view.cameras.reference)}};
|
|
8
|
+
if(view.cameraSync!==undefined)value.cameraSync=view.cameraSync;if(view.activeCamera!==undefined)value.activeCamera=view.activeCamera;return value;
|
|
9
|
+
}
|
|
10
|
+
export interface InspectionBinding {mode:'single'|'compare';sourceHash:string;rigs:(string|null)[]}
|
|
11
|
+
export interface InspectionSelection {workspace?:'modeling'|'motion';view?:'compare'|'reference'|'rig';checkpoint?:string|null;reference?:string|null;person?:string|null;inspector?:string;partSet?:string|null;axis?:{id:string;value:number}|null}
|
|
12
|
+
export interface InspectionBookmark {
|
|
13
|
+
schema:'rig-review-inspection/1';binding:InspectionBinding;animation:string|null;time:number|null;
|
|
14
|
+
theme:'system'|'dark'|'light';layers:{images:boolean;meshes:boolean;bones:boolean};view:ControlView;selection:InspectionSelection;
|
|
15
|
+
}
|
|
16
|
+
export interface InputValue {value:string;checked?:boolean}
|
|
17
|
+
export type FormInputs=Record<string,InputValue>;
|
|
18
|
+
export interface LocalInspectionDraft {schema:'rig-review-local-draft/1';sourceHash:string;baseline:string;savedAt:string;reviewDirty:boolean;bookmark:InspectionBookmark;inputs:FormInputs;session:unknown;ui:Record<string,string|number|null>}
|
|
19
|
+
export const maximumLinkBytes=16384,maximumDraftBytes=512*1024;
|
|
20
|
+
const record=(value:unknown,name:string):Record<string,unknown>=>{if(!value||typeof value!=='object'||Array.isArray(value))throw new Error(`${name}: expected object`);return value as Record<string,unknown>;};
|
|
21
|
+
const keys=(o:Record<string,unknown>,allowed:string[],name:string)=>{for(const key of Object.keys(o))if(!allowed.includes(key))throw new Error(`${name}.${key}: unknown field`);};
|
|
22
|
+
const text=(value:unknown,name:string,max=256):string=>{if(typeof value!=='string'||value.length>max||/[\x00-\x1f]/.test(value))throw new Error(`${name}: invalid text`);return value;};
|
|
23
|
+
const hash=(value:unknown):string=>{const s=text(value,'source hash',64);if(!/^[a-f0-9]{64}$/.test(s))throw new Error('View source hash: expected SHA-256');return s;};
|
|
24
|
+
export function sameBinding(a:InspectionBinding,b:InspectionBinding):boolean{return a.mode===b.mode&&a.sourceHash===b.sourceHash&&JSON.stringify(a.rigs)===JSON.stringify(b.rigs);}
|
|
25
|
+
export function parseBookmark(value:unknown,expected?:InspectionBinding):InspectionBookmark {
|
|
26
|
+
const o=record(value,'inspection');keys(o,['schema','binding','animation','time','theme','layers','view','selection'],'inspection');
|
|
27
|
+
if(o.schema!=='rig-review-inspection/1')throw new Error('View link version unsupported');
|
|
28
|
+
const b=record(o.binding,'inspection.binding');keys(b,['mode','sourceHash','rigs'],'binding');if(b.mode!=='single'&&b.mode!=='compare')throw new Error('View mode unavailable');
|
|
29
|
+
if(!Array.isArray(b.rigs)||b.rigs.length!==(b.mode==='single'?1:2))throw new Error('View rig binding invalid');
|
|
30
|
+
const binding:InspectionBinding={mode:b.mode,sourceHash:hash(b.sourceHash),rigs:b.rigs.map(v=>v===null?null:hash(v))};
|
|
31
|
+
if(expected&&!sameBinding(binding,expected))throw new Error('VIEW_SOURCE_CHANGED: view belongs to different reviewed inputs');
|
|
32
|
+
const animation=o.animation===null?null:text(o.animation,'animation');if(animation==='')throw new Error('View animation must be named or null');
|
|
33
|
+
if(o.time!==null&&(typeof o.time!=='number'||!Number.isFinite(o.time)||o.time<0||o.time>Number.MAX_SAFE_INTEGER))throw new Error('View time requires finite nonnegative seconds');
|
|
34
|
+
if(!['system','dark','light'].includes(String(o.theme)))throw new Error('View appearance invalid');
|
|
35
|
+
const layers=record(o.layers,'layers');keys(layers,['images','meshes','bones'],'layers');for(const k of ['images','meshes','bones'])if(typeof layers[k]!=='boolean')throw new Error('View layers must be boolean');
|
|
36
|
+
const selection=record(o.selection,'selection');keys(selection,['workspace','view','checkpoint','reference','person','inspector','partSet','axis'],'selection');
|
|
37
|
+
if(selection.workspace!==undefined&&!['modeling','motion'].includes(String(selection.workspace)))throw new Error('View workspace invalid');
|
|
38
|
+
if(selection.view!==undefined&&!['compare','reference','rig'].includes(String(selection.view)))throw new Error('View selection invalid');
|
|
39
|
+
for(const k of ['checkpoint','reference','person','inspector','partSet'])if(selection[k]!==undefined&&selection[k]!==null)text(selection[k],k);
|
|
40
|
+
if(selection.axis!==undefined&&selection.axis!==null){const axis=record(selection.axis,'axis');keys(axis,['id','value'],'axis');text(axis.id,'axis id');if(typeof axis.value!=='number'||!Number.isFinite(axis.value))throw new Error('View axis value invalid');}
|
|
41
|
+
if(selection.inspector!==undefined&&!['view','rig','playback','feedback'].includes(String(selection.inspector)))throw new Error('Inspector selection invalid');
|
|
42
|
+
const view=parseControlView(o.view);
|
|
43
|
+
if(view.activeCamera&&(binding.mode==='single'&&(view.activeCamera==='a'||view.activeCamera==='b')||binding.mode==='compare'&&view.activeCamera==='rig'))throw new Error('View active camera is unavailable in this review mode');
|
|
44
|
+
return {schema:o.schema,binding,animation,time:o.time as number|null,theme:o.theme as InspectionBookmark['theme'],layers:{images:layers.images as boolean,meshes:layers.meshes as boolean,bones:layers.bones as boolean},view,selection:{...selection} as InspectionSelection};
|
|
45
|
+
}
|
|
46
|
+
export function encodeBookmark(value:InspectionBookmark):string {
|
|
47
|
+
const parsed=parseBookmark(value),bytes=new TextEncoder().encode(JSON.stringify(parsed));
|
|
48
|
+
let binary='';for(const byte of bytes)binary+=String.fromCharCode(byte);
|
|
49
|
+
const fragment='#review='+btoa(binary).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
|
|
50
|
+
if(fragment.length>maximumLinkBytes)throw new Error('View link exceeds 16 KiB');return fragment;
|
|
51
|
+
}
|
|
52
|
+
export function decodeBookmark(fragment:string,expected:InspectionBinding):InspectionBookmark|null {
|
|
53
|
+
if(!fragment.startsWith('#review='))return null;if(fragment.length>maximumLinkBytes)throw new Error('View link exceeds 16 KiB');
|
|
54
|
+
const encoded=fragment.slice(8);if(!/^[A-Za-z0-9_-]+$/.test(encoded))throw new Error('View link encoding invalid');
|
|
55
|
+
const binary=atob(encoded.replaceAll('-','+').replaceAll('_','/'));const bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));
|
|
56
|
+
return parseBookmark(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)),expected);
|
|
57
|
+
}
|
|
58
|
+
/** Preserve only public fixed-instance landing digests, never arbitrary query capabilities. */
|
|
59
|
+
export function inspectionLink(current:string,bookmark:InspectionBookmark):URL {
|
|
60
|
+
const source=new URL(current),url=new URL(source.pathname,source.origin);
|
|
61
|
+
for(const name of ['input','handoff']){const values=source.searchParams.getAll(name);if(values.length>1||values.length===1&&!/^[a-f0-9]{64}$/.test(values[0]!))throw new Error('View landing binding invalid: '+name);if(values.length)url.searchParams.set(name,values[0]!);}
|
|
62
|
+
url.hash=encodeBookmark(bookmark);return url;
|
|
63
|
+
}
|
|
64
|
+
/** Preparation is nonmutating; runtime failures restore the previously inspected state. */
|
|
65
|
+
export async function restoreTransaction<T>(prepare:()=>Promise<void>,capture:()=>T,apply:()=>Promise<void>,rollback:(previous:T)=>Promise<void>):Promise<void> {
|
|
66
|
+
await prepare();const previous=capture();
|
|
67
|
+
try{await apply();}catch(error){try{await rollback(previous);}catch(recovery){throw new Error(`Inspection restore failed (${error instanceof Error?error.message:String(error)}); previous inspection recovery failed (${recovery instanceof Error?recovery.message:String(recovery)})`);}throw error;}
|
|
68
|
+
}
|
|
69
|
+
/** CSS camera rectangles are measured in source units; world y is up, image y is down. */
|
|
70
|
+
export function numericCamera(space:'world'|'image',center:{x:number;y:number},zoom:number,scale:number,width:number,height:number) {
|
|
71
|
+
if(![center.x,center.y,zoom,scale,width,height].every(Number.isFinite)||scale<=0||width<=0||height<=0)return null;
|
|
72
|
+
return {space,center:{...center},zoom,viewport:{width,height},visible:{x:center.x-width/(2*scale),y:center.y-height/(2*scale),width:width/scale,height:height/scale}};
|
|
73
|
+
}
|
|
74
|
+
/** Restore saved source-region coverage under a different viewport aspect, without pixel-pan guesses. */
|
|
75
|
+
export function restoredZoom(camera:NonNullable<ControlView['cameras']['rig']>,baseScale:number,width:number,height:number):number {
|
|
76
|
+
const scale=Math.min(width/camera.visible.width,height/camera.visible.height),zoom=scale/baseScale;
|
|
77
|
+
if(!Number.isFinite(zoom)||zoom<.25||zoom>8)throw new Error('VIEW_CAMERA_RANGE: saved region cannot fit within zoom 0.25..8');return zoom;
|
|
78
|
+
}
|
|
79
|
+
export function captureInputs(root:ParentNode=document):FormInputs {
|
|
80
|
+
const result:FormInputs={};for(const node of root.querySelectorAll<HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement>('input,select,textarea')){
|
|
81
|
+
const key=node.id||(node instanceof HTMLInputElement&&node.type==='radio'&&node.name?`radio:${node.name}:${node.value}`:'');
|
|
82
|
+
if(!key||node instanceof HTMLInputElement&&['password','file'].includes(node.type))continue;
|
|
83
|
+
result[key]={value:node.value,...(node instanceof HTMLInputElement&&['checkbox','radio'].includes(node.type)?{checked:node.checked}:{})};
|
|
84
|
+
}return result;
|
|
85
|
+
}
|
|
86
|
+
export function restoreInputs(inputs:FormInputs,excluded:ReadonlySet<string>=new Set(),root:ParentNode=document):void {
|
|
87
|
+
for(const [key,value]of Object.entries(inputs)){
|
|
88
|
+
if(excluded.has(key))continue;
|
|
89
|
+
const node=key.startsWith('radio:')?[...root.querySelectorAll<HTMLInputElement>('input[type=radio]')].find(n=>`radio:${n.name}:${n.value}`===key):root.querySelector<HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement>('#'+CSS.escape(key));
|
|
90
|
+
if(!node)continue;if(node instanceof HTMLSelectElement&&![...node.options].some(o=>o.value===value.value))continue;
|
|
91
|
+
if(node instanceof HTMLInputElement&&['password','file'].includes(node.type))continue;
|
|
92
|
+
node.value=value.value;if(node instanceof HTMLInputElement&&value.checked!==undefined)node.checked=value.checked;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function parseInputs(value:unknown):FormInputs {
|
|
96
|
+
const o=record(value,'draft inputs'),entries=Object.entries(o);if(entries.length>256)throw new Error('Draft input count exceeds 256');const result:FormInputs={};
|
|
97
|
+
for(const [k,v]of entries){text(k,'input id');if(['__proto__','constructor','prototype'].includes(k))throw new Error('Draft input key invalid');const input=record(v,'input');keys(input,['value','checked'],'input');if(typeof input.value!=='string'||input.value.length>32768||input.checked!==undefined&&typeof input.checked!=='boolean')throw new Error('Draft input invalid');result[k]={value:input.value,...(input.checked===undefined?{}:{checked:input.checked as boolean})};}return result;
|
|
98
|
+
}
|
|
99
|
+
export async function sessionFingerprint(value:unknown):Promise<string> {const bytes=new TextEncoder().encode(JSON.stringify(value));return [...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');}
|
|
100
|
+
export function recoverSession<T extends {id:string;revision:string;sourceHash:string;status:string;feedback:unknown[]}>(value:unknown,baseline:T):T|null {
|
|
101
|
+
if(!value||typeof value!=='object'||Array.isArray(value))return null;const o=value as Record<string,unknown>;for(const key of Object.keys(o))if(!Object.keys(baseline).includes(key))return null;
|
|
102
|
+
for(const k of ['id','revision','sourceHash','reviewedSource','referenceBinding','binding'])if(JSON.stringify(o[k])!==JSON.stringify((baseline as Record<string,unknown>)[k]))return null;
|
|
103
|
+
if(!['pending','approved','changes-requested'].includes(String(o.status))||!Array.isArray(o.feedback)||!Array.isArray(o.feedbackWarnings))return null;
|
|
104
|
+
for(const item of o.feedback){if(!item||typeof item!=='object')return null;const f=item as Record<string,unknown>,target=f.target as Record<string,unknown>|undefined;if(typeof f.id!=='string'||typeof f.summary!=='string'||!Number.isInteger(f.frameStart)||!Number.isInteger(f.frameEnd)||!target||!['bone','slot','rig'].includes(String(target.kind))||typeof target.name!=='string'||!['defect','quality'].includes(String(f.category)))return null;}
|
|
105
|
+
if('checkpoints'in baseline){if(!o.checkpoints||typeof o.checkpoints!=='object'||Array.isArray(o.checkpoints))return null;for(const value of Object.values(o.checkpoints)){if(!value||typeof value!=='object'||Array.isArray(value))return null;const item=value as Record<string,unknown>;if(!['pending','pass','fail'].includes(String(item.outcome))||typeof item.note!=='string'||item.note.length>32768)return null;}}
|
|
106
|
+
return o as T;
|
|
107
|
+
}
|
|
108
|
+
/** Storage is confined to this app/scope. A source change drops that scope's old draft only. */
|
|
109
|
+
export class InspectionDraftStore {
|
|
110
|
+
private storage:Storage|null=null;
|
|
111
|
+
private blocked=false;
|
|
112
|
+
private rawRecovery:string|null=null;
|
|
113
|
+
recovery():string|null{return this.rawRecovery;}
|
|
114
|
+
readonly key:string;
|
|
115
|
+
constructor(private readonly scope:string,private readonly binding:InspectionBinding,private readonly warn:(message:string)=>void,storage?:Storage){
|
|
116
|
+
this.key=`rig-review-draft/1:${scope}:${binding.sourceHash}`;
|
|
117
|
+
try{this.storage=storage??localStorage;const active=`rig-review-active/1:${scope}`,previous=this.storage.getItem(active);if(previous&&previous!==this.key&&previous.startsWith(`rig-review-draft/1:${scope}:`)){this.storage.removeItem(previous);warn('Previous input draft cleared for this review scope because its source changed.');}this.storage.setItem(active,this.key);}catch{this.storage=null;warn('Local draft storage unavailable. Keep this page open or export feedback before leaving.');}
|
|
118
|
+
}
|
|
119
|
+
read():LocalInspectionDraft|null {
|
|
120
|
+
if(!this.storage)return null;try{const raw=this.storage.getItem(this.key);if(!raw)return null;this.rawRecovery=raw;if(new TextEncoder().encode(raw).length>maximumDraftBytes)throw new Error('Draft exceeds 512 KiB');const o=record(JSON.parse(raw),'draft');keys(o,['schema','sourceHash','baseline','savedAt','reviewDirty','bookmark','inputs','session','ui'],'draft');if(o.schema!=='rig-review-local-draft/1'||o.sourceHash!==this.binding.sourceHash)throw new Error('Draft binding/version differs');if(typeof o.reviewDirty!=='boolean')throw new Error('Draft review state invalid');const bookmark=parseBookmark(o.bookmark,this.binding),baseline=hash(o.baseline);if(typeof o.savedAt!=='string'||!Number.isFinite(Date.parse(o.savedAt)))throw new Error('Draft timestamp invalid');const ui=record(o.ui,'draft UI');for(const [k,v]of Object.entries(ui)){text(k,'draft UI key');if(v!==null&&typeof v!=='string'&&typeof v!=='number'||typeof v==='number'&&!Number.isFinite(v))throw new Error('Draft UI value invalid');}return {schema:o.schema,sourceHash:this.binding.sourceHash,baseline,savedAt:o.savedAt,reviewDirty:o.reviewDirty,bookmark,inputs:parseInputs(o.inputs),session:o.session,ui:ui as LocalInspectionDraft['ui']};}catch(error){this.blocked=true;this.warn(`Saved local draft was not restored: ${error instanceof Error?error.message:String(error)}. It remains in storage for recovery.`);return null;}
|
|
121
|
+
}
|
|
122
|
+
write(draft:LocalInspectionDraft):boolean {
|
|
123
|
+
if(!this.storage||this.blocked)return false;try{const value=JSON.stringify(draft);if(new TextEncoder().encode(value).length>maximumDraftBytes)throw new Error('Draft exceeds 512 KiB');this.storage.setItem(this.key,value);return true;}catch(error){this.warn(`Local draft could not be saved: ${error instanceof Error?error.message:String(error)}. Export feedback before leaving.`);return false;}
|
|
124
|
+
}
|
|
125
|
+
clear():void {try{this.storage?.removeItem(this.key);this.blocked=false;this.rawRecovery=null;}catch{this.warn('Local draft could not be cleared.');}}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface PersistenceSession {id:string;revision:string;sourceHash:string;status:string;feedback:unknown[]}
|
|
129
|
+
interface PersistenceAdapter<T extends PersistenceSession> {
|
|
130
|
+
scope:string;binding:InspectionBinding;baseline:T;session:()=>T;reviewDirty:()=>boolean;
|
|
131
|
+
restoreSession:(value:T,dirty:boolean)=>void;bookmark:()=>InspectionBookmark;
|
|
132
|
+
restoreBookmark:(bookmark:InspectionBookmark,signal:AbortSignal)=>Promise<void>;
|
|
133
|
+
ui:()=>Record<string,string|number|null>;restoreUI:(ui:Record<string,string|number|null>)=>void;
|
|
134
|
+
afterInputs:()=>void;onConflict:(conflict:boolean)=>void;onStatus:(message:string)=>void;
|
|
135
|
+
}
|
|
136
|
+
export interface PersistenceConnection {restored:boolean;restoring:()=>boolean;save:()=>void;submitted:(session:unknown)=>Promise<void>;dispose:()=>void}
|
|
137
|
+
const viewInputIds=new Set(['camera-sync','appearance','layer-images','layer-mesh','layer-bones','animation-select','reference-select','person-select','axis-select','axis-value','scrub','frame-number','frame-scrub','time-input','frame-input','part-set']);
|
|
138
|
+
/** Restore once after normal startup; snapshots never perform a server mutation. */
|
|
139
|
+
export async function connectInspectionPersistence<T extends PersistenceSession>(adapter:PersistenceAdapter<T>):Promise<PersistenceConnection> {
|
|
140
|
+
let restoring=true,closed=false,conflict=false,timer:ReturnType<typeof setTimeout>|undefined;
|
|
141
|
+
let navigationGeneration=0,navigationAbort:AbortController|null=null,navigationQueue=Promise.resolve(),lastNavigationHash=location.hash;
|
|
142
|
+
let previousInert:boolean|null=null;
|
|
143
|
+
const holdRestore=(value:boolean)=>{restoring=value;const body=document.body;if(value){if(body&&previousInert===null){previousInert=body.inert;body.inert=true;}}else if(body&&previousInert!==null){body.inert=previousInert;previousInert=null;}};
|
|
144
|
+
let baseline=await sessionFingerprint(adapter.baseline);const status=adapter.onStatus,initialInputs=captureInputs();
|
|
145
|
+
const store=new InspectionDraftStore(adapter.scope,adapter.binding,status),draft=store.read();
|
|
146
|
+
const notice=document.getElementById('draft-recovery');if(notice)notice.replaceChildren();
|
|
147
|
+
const download=(raw:string)=>{const url=URL.createObjectURL(new Blob([raw],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='review-local-draft.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
|
148
|
+
const raw=store.recovery();
|
|
149
|
+
if(raw&¬ice){const button=document.createElement('button');button.type='button';button.textContent='Download saved local draft';button.onclick=()=>download(raw);notice.append(button);}
|
|
150
|
+
let explicit:InspectionBookmark|null=null;
|
|
151
|
+
try{explicit=decodeBookmark(location.hash,adapter.binding);}catch(error){status(error instanceof Error?error.message:String(error));}
|
|
152
|
+
if(draft){
|
|
153
|
+
const recovered=recoverSession(draft.session,adapter.baseline);conflict=draft.baseline!==baseline||!recovered;
|
|
154
|
+
if(!conflict&&recovered&&draft.reviewDirty){try{adapter.restoreSession(recovered,true);status('Restored local draft · not submitted');}catch(error){conflict=true;adapter.onConflict(true);status(`Local draft needs explicit recovery: ${error instanceof Error?error.message:String(error)}`);}}
|
|
155
|
+
else if(conflict){status('Saved local draft conflicts with the current submitted review. Server status is unchanged; download it or explicitly recover as pending.');adapter.onConflict(true);
|
|
156
|
+
if(recovered&¬ice){const button=document.createElement('button');button.type='button';button.textContent='Recover local draft as pending';button.onclick=()=>{try{adapter.restoreSession({...recovered,status:'pending'},true);conflict=false;adapter.onConflict(false);status('Recovered local draft as pending · not submitted');save();}catch(error){status(`Local draft recovery refused: ${error instanceof Error?error.message:String(error)}`);}};notice.append(button);}
|
|
157
|
+
}
|
|
158
|
+
try{adapter.restoreUI(draft.ui);}catch(error){status(`Saved panel selection unavailable: ${error instanceof Error?error.message:String(error)}`);}
|
|
159
|
+
}
|
|
160
|
+
if(conflict&¬ice){const discard=document.createElement('button');discard.type='button';discard.textContent='Discard conflicting local draft';discard.onclick=()=>{adapter.restoreSession(structuredClone(adapter.baseline),false);restoreInputs(initialInputs,viewInputIds);adapter.afterInputs();store.clear();conflict=false;adapter.onConflict(false);notice.replaceChildren();status('Local draft discarded · current submitted review retained.');save();};notice.append(discard);}
|
|
161
|
+
const selected=location.hash.startsWith('#review=')?explicit:draft?.bookmark;
|
|
162
|
+
let restored=location.hash.startsWith('#review=')||!!draft;
|
|
163
|
+
if(selected){try{await adapter.restoreBookmark(selected,new AbortController().signal);restored=true;}catch(error){status(`Inspection was not restored: ${error instanceof Error?error.message:String(error)}`);}}
|
|
164
|
+
if(draft){
|
|
165
|
+
const excluded=new Set(viewInputIds);if(conflict){excluded.add('review-status');excluded.add('status');for(const key of Object.keys(draft.inputs))if(key.startsWith('radio:outcome:'))excluded.add(key);}
|
|
166
|
+
restoreInputs(draft.inputs,excluded);adapter.afterInputs();
|
|
167
|
+
}
|
|
168
|
+
if(raw&&!draft&¬ice){const button=document.createElement('button');button.type='button';button.textContent='Discard unusable local draft';button.onclick=()=>{store.clear();notice.replaceChildren();status('Unusable local draft discarded for this review only.');save();};notice.append(button);}
|
|
169
|
+
restoring=false;
|
|
170
|
+
function save():void {
|
|
171
|
+
if(closed||restoring||conflict)return;
|
|
172
|
+
try{const bookmark=parseBookmark(adapter.bookmark(),adapter.binding);const value:LocalInspectionDraft={schema:'rig-review-local-draft/1',sourceHash:adapter.binding.sourceHash,baseline,savedAt:new Date().toISOString(),reviewDirty:adapter.reviewDirty(),bookmark,inputs:captureInputs(),session:adapter.session(),ui:adapter.ui()};
|
|
173
|
+
if(store.write(value)){const url=new URL(location.href);url.hash=encodeBookmark(bookmark);history.replaceState(null,'',url);lastNavigationHash=url.hash;}
|
|
174
|
+
}catch(error){status(`Local view was not saved: ${error instanceof Error?error.message:String(error)}`);}
|
|
175
|
+
}
|
|
176
|
+
function navigateView():void {
|
|
177
|
+
if(closed||location.hash===lastNavigationHash)return;
|
|
178
|
+
lastNavigationHash=location.hash;const generation=++navigationGeneration;
|
|
179
|
+
navigationAbort?.abort();navigationAbort=null;if(timer)clearTimeout(timer);
|
|
180
|
+
let bookmark:InspectionBookmark|null=null;
|
|
181
|
+
try{bookmark=decodeBookmark(location.hash,adapter.binding);}catch(error){status(`View link refused: ${error instanceof Error?error.message:String(error)}`);}
|
|
182
|
+
// A cleared or invalid fragment cancels pending restoration, but never
|
|
183
|
+
// applies a local draft or changes the submitted review/session.
|
|
184
|
+
if(!bookmark){void navigationQueue.finally(()=>{if(!closed&&generation===navigationGeneration)holdRestore(false);});return;}
|
|
185
|
+
const requested=bookmark,controller=new AbortController();navigationAbort=controller;
|
|
186
|
+
holdRestore(true);status('Restoring source-bound view · inspection controls temporarily paused.');
|
|
187
|
+
navigationQueue=navigationQueue.then(async()=>{
|
|
188
|
+
if(closed||generation!==navigationGeneration||controller.signal.aborted)return;
|
|
189
|
+
try{await adapter.restoreBookmark(requested,controller.signal);if(!closed&&generation===navigationGeneration&&!controller.signal.aborted)status(adapter.reviewDirty()?'View link restored · paused inspection; local draft not submitted.':'View link restored · paused inspection; submitted review unchanged.');}
|
|
190
|
+
catch(error){if(!closed&&generation===navigationGeneration&&!controller.signal.aborted)status(`View link was not restored: ${error instanceof Error?error.message:String(error)}`);}
|
|
191
|
+
finally{if(!closed&&generation===navigationGeneration){navigationAbort=null;holdRestore(false);save();}}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('hashchange',navigateView);window.addEventListener('popstate',navigateView);
|
|
195
|
+
function schedule():void {if(timer)clearTimeout(timer);timer=setTimeout(save,120);}
|
|
196
|
+
const events=['input','change','click','pointerup','wheel'] as const;for(const event of events)window.addEventListener(event,schedule,{passive:true});
|
|
197
|
+
const interval=setInterval(()=>{if(!document.hidden)save();},1000);
|
|
198
|
+
const copy=document.getElementById('copy-view');const copyView=async()=>{if(closed)return;try{const url=inspectionLink(location.href,adapter.bookmark());await navigator.clipboard.writeText(url.href);history.replaceState(null,'',url);lastNavigationHash=url.hash;status('View link copied · source-bound numeric region, no review decisions.');}catch(error){status(`View link was not copied: ${error instanceof Error?error.message:String(error)}`);}};if(copy)copy.onclick=copyView;
|
|
199
|
+
return {restored,restoring:()=>restoring,save,submitted:async session=>{baseline=await sessionFingerprint(session);save();},dispose:()=>{save();closed=true;++navigationGeneration;navigationAbort?.abort();holdRestore(false);window.removeEventListener('hashchange',navigateView);window.removeEventListener('popstate',navigateView);if(copy?.onclick===copyView)copy.onclick=null;if(timer)clearTimeout(timer);clearInterval(interval);for(const event of events)window.removeEventListener(event,schedule);}};
|
|
200
|
+
}
|