@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/main.ts
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
2
|
+
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
|
|
3
|
+
import {rendererStatus} from './renderer-status.ts';
|
|
4
|
+
import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,type ViewPoint} from './view-controls.ts';
|
|
5
|
+
import {RigOverlays} from './rig-overlays.ts';
|
|
6
|
+
import {connectControl} from './control.ts';
|
|
7
|
+
import type {ControlCommand,ControlInspection,ControlView} from '../src/control.ts';
|
|
8
|
+
import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
9
|
+
import {initialPlayerAnimation} from '../src/animation-selection.ts';
|
|
10
|
+
import {initialReviewSelection} from '../src/open-selection.ts';
|
|
11
|
+
import type {ReferenceBinding} from '../src/reference.ts';
|
|
12
|
+
import type { Checkpoint, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
|
|
13
|
+
import type { Feedback, ReviewSession } from '../src/session.ts';
|
|
14
|
+
import type { ReportEvent, ReportSeeds } from '../src/report.ts';
|
|
15
|
+
import { clockForAnimation, frameAtTime, lastFrame as lastProducerFrame, timeAtFrame } from '../src/frame-clock.ts';
|
|
16
|
+
import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
|
|
17
|
+
import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
|
|
18
|
+
import type { StripInterval, StripRequest } from '../src/strip.ts';
|
|
19
|
+
|
|
20
|
+
interface ReviewResponse { initialAnimation?:string|null; manifest: ReviewManifest; referenceBinding?:ReferenceBinding; explicitReference?:{id:string;name:string;sha256:string;url:string}; sourceHash: string; reviewedSource: ReviewedSource; report: ReportSeeds | null; feedbackCatalog: FeedbackCatalog; assetsBase: string; capabilities: { playback: boolean; modeling: false; motion: false; feedback: boolean; audio: boolean } }
|
|
21
|
+
interface PoseReference { schema: 'rig-review-pose/1'; coordinateSystem: 'image-pixels-y-down'; width: number; height: number; joints: { id: string; x: number; y: number; parent?: string }[] }
|
|
22
|
+
interface SequenceJoint { state: 'observed' | 'occluded' | 'missing' | 'inferred'; at?: [number, number]; score?: number }
|
|
23
|
+
interface PoseSequence { spec: 'rig-pose-sequence/1'; space: { units: 'picture-px'; origin: 'top-left'; y: 'down' }; width: number; height: number; source?: string; frames: { t: number; people: { id: string; joints: Record<string, SequenceJoint> }[] }[]; camera?: { s: number; tx: number; ty: number; inliers: number }[]; cameraSource?: unknown }
|
|
24
|
+
const el = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
25
|
+
const node = document.getElementById(id);
|
|
26
|
+
if (!node) throw new Error(`Missing UI element: ${id}`);
|
|
27
|
+
return node as T;
|
|
28
|
+
};
|
|
29
|
+
const input = (id: string) => el<HTMLInputElement>(id);
|
|
30
|
+
const select = (id: string) => el<HTMLSelectElement>(id);
|
|
31
|
+
const button = (id: string) => el<HTMLButtonElement>(id);
|
|
32
|
+
const text = (id: string, value: string) => { el(id).textContent = value; };
|
|
33
|
+
const option = (value: string, label: string) => { const item = document.createElement('option'); item.value = value; item.textContent = label; return item; };
|
|
34
|
+
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
35
|
+
let data: ReviewResponse;
|
|
36
|
+
let session: ReviewSession;
|
|
37
|
+
let dirty = false;
|
|
38
|
+
let saving = false;
|
|
39
|
+
let stripBusy = false;
|
|
40
|
+
let persistence:PersistenceConnection|null=null,submittedBaseline:ReviewSession|null=null,draftConflict=false;
|
|
41
|
+
let checkpoint: Checkpoint | undefined;
|
|
42
|
+
let assets: ModelAssets | null = null;
|
|
43
|
+
let player: RigPlayer | null = null;
|
|
44
|
+
let animation = '';
|
|
45
|
+
let playing = false;
|
|
46
|
+
let time = 0;
|
|
47
|
+
let duration = 0;
|
|
48
|
+
let lastFrame = 0;
|
|
49
|
+
type CameraTarget='rig'|'reference';
|
|
50
|
+
let cameraSync=true,activeCamera:CameraTarget='rig',cameraRestoring=false,cameraLayout='';
|
|
51
|
+
const cameraFrames:Partial<Record<CameraTarget,CameraFrame>>={};
|
|
52
|
+
let view: 'compare' | 'reference' | 'rig' = 'compare';
|
|
53
|
+
let zoom = 1;
|
|
54
|
+
let cameraCenter: { x: number; y: number } | null = null;
|
|
55
|
+
let referenceCenter:ViewPoint|null=null;
|
|
56
|
+
let rigScale=1,referenceScale=1,referenceZoom=1;
|
|
57
|
+
let displayedCenter:ViewPoint|null=null;
|
|
58
|
+
let playbackIntent=0,desiredPlay=false,seekingControl=false;
|
|
59
|
+
let overlays:RigOverlays|null=null;
|
|
60
|
+
let appearance:ReturnType<typeof bindAppearance>|null=null;
|
|
61
|
+
let idleHeld=false;
|
|
62
|
+
let suspendedPreview=false;
|
|
63
|
+
let controlDispose:(()=>void)|null=null;
|
|
64
|
+
const viewDisposers:(()=>void)[]=[];
|
|
65
|
+
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
66
|
+
let referenceSize: { width: number; height: number } | null = null;
|
|
67
|
+
let video: HTMLVideoElement | null = null;
|
|
68
|
+
let controlledVideoSample:{media:HTMLVideoElement;position:number;time:number}|null=null;
|
|
69
|
+
let referenceGeneration = 0;
|
|
70
|
+
let axisInspection = false;
|
|
71
|
+
let sequence: PoseSequence | null = null;
|
|
72
|
+
let sequenceSvg: SVGSVGElement | null = null;
|
|
73
|
+
let selectedPerson = '';
|
|
74
|
+
let lastPoseFrame = -1;
|
|
75
|
+
const audioPlaying = new Set<HTMLAudioElement>();
|
|
76
|
+
const reportPageSize = 8;
|
|
77
|
+
let reportPage = 0;
|
|
78
|
+
let selectedReportEvent: ReportEvent | null = null;
|
|
79
|
+
let selectedReportTime: number | undefined;
|
|
80
|
+
let selectedReportTarget: number | undefined;
|
|
81
|
+
|
|
82
|
+
function message(value: string, error = false): void {
|
|
83
|
+
text('message', value);
|
|
84
|
+
el('message').classList.toggle('error', error);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|
88
|
+
const response = await fetch(path, init);
|
|
89
|
+
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
|
|
90
|
+
return await response.json() as T;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function referenceUrl(reference:Reference):string {return data.explicitReference?.id===reference.id?data.explicitReference.url:assetUrl(reference.path);}
|
|
94
|
+
|
|
95
|
+
function assetUrl(path: string): string {
|
|
96
|
+
if (!path || path.startsWith('/') || /[\\%?#\x00-\x1f]/.test(path) || /^[a-zA-Z]:/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error(`Unsupported package asset path: ${path}`);
|
|
97
|
+
return '/assets/' + path.split('/').map(encodeURIComponent).join('/');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function markDirty(): void {
|
|
101
|
+
dirty = true;
|
|
102
|
+
updateSavedState();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function updateSavedState(): void {
|
|
106
|
+
text('dirty', dirty ? 'Unsubmitted changes' : session.updatedAt ? 'Submitted' : 'Not submitted');
|
|
107
|
+
el('dirty').classList.toggle('dirty', dirty);
|
|
108
|
+
button('save').disabled = saving || draftConflict || session.feedbackWarnings.length > 0;
|
|
109
|
+
button('save').title = session.feedbackWarnings.length ? 'Delete feedback with validation warnings, then add it again with a valid target.' : '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderProvenance(): void {
|
|
113
|
+
const provenance = session.reviewedSource;
|
|
114
|
+
text('provenance-version', provenance.packageRevision);
|
|
115
|
+
text('provenance-rig', provenance.rig ? provenance.rig.sha256.slice(0, 12) + '…' : 'Not supplied');
|
|
116
|
+
text('provenance-motion', provenance.motion ? provenance.motion.sha256.slice(0, 12) + '…' : 'Not supplied');
|
|
117
|
+
text('provenance-full', JSON.stringify({ sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: provenance }, null, 2));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkpointEntry(): ReviewSession['checkpoints'][string] | undefined {
|
|
121
|
+
if (!checkpoint) return;
|
|
122
|
+
return session.checkpoints[checkpoint.id] ??= { outcome: 'pending', note: '' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderCheckpoints(): void {
|
|
126
|
+
const list = el('checkpoints'); list.replaceChildren();
|
|
127
|
+
for (const point of data.manifest.checkpoints) {
|
|
128
|
+
const item = document.createElement('button'); item.type = 'button'; item.className = 'checkpoint';
|
|
129
|
+
item.setAttribute('aria-current', String(point.id === checkpoint?.id));
|
|
130
|
+
const label = document.createElement('span'); label.textContent = point.title;
|
|
131
|
+
const status = document.createElement('small');
|
|
132
|
+
status.textContent = { pending: '○ Pending', pass: '✓ Pass', fail: '↗ Needs changes' }[session.checkpoints[point.id]?.outcome ?? 'pending'];
|
|
133
|
+
item.append(label, status); item.onclick = () => { void chooseCheckpoint(point); }; list.append(item);
|
|
134
|
+
}
|
|
135
|
+
if (!data.manifest.checkpoints.length) list.textContent = 'No checkpoints supplied.';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function chooseCheckpoint(point: Checkpoint,initial?:{referenceId?:string;updateUrl:boolean}): Promise<void> {
|
|
139
|
+
pause(); checkpoint = point; axisInspection = false;
|
|
140
|
+
if(initial?.updateUrl!==false){const url = new URL(location.href); url.searchParams.set('checkpoint', point.id); window.history.replaceState(null, '', url);}
|
|
141
|
+
if (point.animation) chooseAnimation(point.animation);
|
|
142
|
+
time = point.time ?? 0;
|
|
143
|
+
const referenceId=initial?.referenceId??point.reference;
|
|
144
|
+
if (referenceId && select('reference-select').value !== referenceId) {
|
|
145
|
+
select('reference-select').value = referenceId; await showReference(referenceId);
|
|
146
|
+
}
|
|
147
|
+
zoom = Math.min(8, Math.max(.25, point.camera?.zoom ?? 1));
|
|
148
|
+
if(cameraSync)activeCamera=player?'rig':'reference';
|
|
149
|
+
cameraCenter = point.camera ? { x: point.camera.x, y: point.camera.y } : null;
|
|
150
|
+
seek(time);
|
|
151
|
+
if (point.axis && player && !input('axis-value').disabled) {
|
|
152
|
+
select('axis-select').value = point.axis.id; chooseAxis(); input('axis-value').value = String(point.axis.value); applyAxis();
|
|
153
|
+
}
|
|
154
|
+
if(cameraSync)alignCameras();fitCamera(); renderCheckpoint(); renderCheckpoints();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderCheckpoint(): void {
|
|
158
|
+
text('checkpoint-title', checkpoint?.title ?? 'Review entire package');
|
|
159
|
+
text('detail-title', checkpoint?.title ?? 'No checkpoint supplied');
|
|
160
|
+
text('detail-note', checkpoint?.note ?? 'Record checkpoint notes and outcomes.');
|
|
161
|
+
const entry = checkpoint ? session.checkpoints[checkpoint.id] : undefined;
|
|
162
|
+
el<HTMLTextAreaElement>('checkpoint-note').value = entry?.note ?? '';
|
|
163
|
+
el<HTMLTextAreaElement>('checkpoint-note').disabled = !checkpoint;
|
|
164
|
+
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="outcome"]')) { radio.checked = radio.value === (entry?.outcome ?? 'pending'); radio.disabled = !checkpoint; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setView(value: typeof view): void {
|
|
168
|
+
view = value;
|
|
169
|
+
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')) item.setAttribute('aria-pressed', String(item.dataset.view === value));
|
|
170
|
+
el('reference-pane').hidden = value === 'rig'; el('rig-pane').hidden = value === 'reference'; fitCamera();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function svgNode(tag: string, attrs: Record<string, string | number>, parent: SVGElement): SVGElement {
|
|
174
|
+
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
175
|
+
Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value))); parent.append(node); return node;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readPose(value: unknown): PoseReference {
|
|
179
|
+
if (!value || typeof value !== 'object') throw new Error('Pose reference: expected object');
|
|
180
|
+
const pose = value as Partial<PoseReference>;
|
|
181
|
+
if (pose.schema !== 'rig-review-pose/1' || pose.coordinateSystem !== 'image-pixels-y-down' || !Number.isFinite(pose.width) || !Number.isFinite(pose.height) || !(pose.width! > 0) || !(pose.height! > 0) || !Array.isArray(pose.joints)) throw new Error('Pose reference: expected rig-review-pose/1 with positive dimensions');
|
|
182
|
+
const ids = new Set<string>();
|
|
183
|
+
for (const joint of pose.joints) {
|
|
184
|
+
if (!joint || typeof joint.id !== 'string' || !joint.id || ids.has(joint.id) || !Number.isFinite(joint.x) || !Number.isFinite(joint.y)) throw new Error('Pose reference: joints require unique IDs and finite x/y');
|
|
185
|
+
ids.add(joint.id);
|
|
186
|
+
}
|
|
187
|
+
for (const joint of pose.joints) if (joint.parent && !ids.has(joint.parent)) throw new Error(`Pose reference: unknown parent ${joint.parent}`);
|
|
188
|
+
return pose as PoseReference;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readSequence(value: unknown): PoseSequence | null {
|
|
192
|
+
if (!value || typeof value !== 'object' || !('spec' in value) || value.spec !== 'rig-pose-sequence/1') return null;
|
|
193
|
+
const pose = value as Partial<PoseSequence>;
|
|
194
|
+
if (pose.space?.units !== 'picture-px' || pose.space.origin !== 'top-left' || pose.space.y !== 'down' || !(pose.width! > 0) || !(pose.height! > 0) || !Number.isFinite(pose.width) || !Number.isFinite(pose.height) || !Array.isArray(pose.frames) || !pose.frames.length) throw new Error('rig-pose-sequence/1: expected picture-px, top-left, y-down and nonempty frames');
|
|
195
|
+
let previous = -Infinity;
|
|
196
|
+
for (const frame of pose.frames) {
|
|
197
|
+
if (!frame || !Number.isFinite(frame.t) || frame.t <= previous || !Array.isArray(frame.people)) throw new Error('rig-pose-sequence/1: frame times must strictly increase');
|
|
198
|
+
previous = frame.t; const ids = new Set<string>();
|
|
199
|
+
for (const person of frame.people) {
|
|
200
|
+
if (!person || typeof person.id !== 'string' || ids.has(person.id) || !person.joints || typeof person.joints !== 'object') throw new Error('rig-pose-sequence/1: each person needs a unique id and joints');
|
|
201
|
+
ids.add(person.id);
|
|
202
|
+
for (const joint of Object.values(person.joints)) {
|
|
203
|
+
if (!joint || !['observed', 'occluded', 'missing', 'inferred'].includes(joint.state)) throw new Error('rig-pose-sequence/1: unknown joint state');
|
|
204
|
+
if (joint.state !== 'missing' && (!Array.isArray(joint.at) || joint.at.length !== 2 || !joint.at.every(Number.isFinite))) throw new Error('rig-pose-sequence/1: located joints require finite at[x,y]');
|
|
205
|
+
if (joint.score !== undefined && !Number.isFinite(joint.score)) throw new Error('rig-pose-sequence/1: score must be finite');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return pose as PoseSequence;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function renderSequenceFrame(): void {
|
|
213
|
+
if (!sequence || !sequenceSvg) return;
|
|
214
|
+
const target = sequence.frames[0]!.t + time;
|
|
215
|
+
let lo = 0, hi = sequence.frames.length - 1;
|
|
216
|
+
while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (sequence.frames[mid]!.t <= target) lo = mid; else hi = mid - 1; }
|
|
217
|
+
if (lo === lastPoseFrame) return;
|
|
218
|
+
lastPoseFrame = lo; sequenceSvg.replaceChildren();
|
|
219
|
+
const frame = sequence.frames[lo]!, person = frame.people.find(item => item.id === selectedPerson);
|
|
220
|
+
text('reference-label', `Input pose · ${selectedPerson || 'No person selected'} · t ${frame.t.toFixed(3)} s`);
|
|
221
|
+
if (!person) { el('reference-empty').hidden = false; text('reference-empty', selectedPerson ? 'The selected person is absent from this frame.' : 'Select a person.'); return; }
|
|
222
|
+
el('reference-empty').hidden = true;
|
|
223
|
+
const pairs = [['nose', 'neck'], ['neck', 'r_shoulder'], ['r_shoulder', 'r_elbow'], ['r_elbow', 'r_wrist'], ['neck', 'l_shoulder'], ['l_shoulder', 'l_elbow'], ['l_elbow', 'l_wrist'], ['neck', 'r_hip'], ['r_hip', 'r_knee'], ['r_knee', 'r_ankle'], ['neck', 'l_hip'], ['l_hip', 'l_knee'], ['l_knee', 'l_ankle']];
|
|
224
|
+
for (const [a, b] of pairs) { const from = person.joints[a!], to = person.joints[b!]; if (from?.at && to?.at && from.state !== 'missing' && to.state !== 'missing') svgNode('line', { x1: from.at[0], y1: from.at[1], x2: to.at[0], y2: to.at[1], stroke: '#b2deca', 'stroke-width': 2, 'stroke-dasharray': from.state === 'observed' && to.state === 'observed' ? 'none' : '5 4' }, sequenceSvg); }
|
|
225
|
+
for (const [name, joint] of Object.entries(person.joints)) { if (!joint.at || joint.state === 'missing') continue; const dot = svgNode('circle', { cx: joint.at[0], cy: joint.at[1], r: 4, fill: joint.state === 'observed' ? '#c6e6d7' : '#294535', stroke: '#c6e6d7', 'stroke-width': 1.5 }, sequenceSvg); const title = document.createElementNS(sequenceSvg.namespaceURI, 'title'); title.textContent = `${name}: ${joint.state}${joint.score === undefined ? '' : ` · score ${joint.score}`}`; dot.append(title); }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function showReference(id: string): Promise<void> {
|
|
229
|
+
const generation = ++referenceGeneration;
|
|
230
|
+
video?.pause(); video = null;controlledVideoSample=null;delete cameraFrames.reference; referenceCenter=null; referenceSize = null; sequence = null; sequenceSvg = null; selectedPerson=''; lastPoseFrame = -1; el('person-control').hidden = true;
|
|
231
|
+
const content = el('reference-content'); content.replaceChildren();
|
|
232
|
+
const reference = data.manifest.references.find(item => item.id === id);
|
|
233
|
+
el('reference-empty').hidden = false;
|
|
234
|
+
if (!reference) { text('reference-empty', 'No reference supplied.'); text('reference-info', 'Add image, video or input-pose references to the package.'); return; }
|
|
235
|
+
text('reference-label', reference.label + (reference.type === 'pose' ? ' · Static input pose' : ' · Reference'));
|
|
236
|
+
text('reference-empty', 'Loading reference…');
|
|
237
|
+
text('reference-info', `${reference.type} · ${reference.path}`);
|
|
238
|
+
const fail = () => { if (generation !== referenceGeneration) return; el('reference-empty').hidden = false; text('reference-empty', 'Could not open the reference. Check the package files.'); };
|
|
239
|
+
try {
|
|
240
|
+
if (reference.type === 'pose') {
|
|
241
|
+
const response = await fetch(referenceUrl(reference)); if (!response.ok) throw new Error(`Pose reference HTTP ${response.status}`);
|
|
242
|
+
const value: unknown = await response.json(); if (generation !== referenceGeneration) return;
|
|
243
|
+
const canonical = readSequence(value);
|
|
244
|
+
if (canonical) {
|
|
245
|
+
sequence = canonical; referenceSize = { width: canonical.width, height: canonical.height };
|
|
246
|
+
sequenceSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sequenceSvg.setAttribute('width', String(canonical.width)); sequenceSvg.setAttribute('height', String(canonical.height)); sequenceSvg.setAttribute('viewBox', `0 0 ${canonical.width} ${canonical.height}`); sequenceSvg.setAttribute('role', 'img'); sequenceSvg.setAttribute('aria-label', 'Original input pose sequence · joint states and confidence preserved');
|
|
247
|
+
content.append(sequenceSvg); const people = [...new Set(canonical.frames.flatMap(frame => frame.people.map(person => person.id)))];
|
|
248
|
+
select('person-select').replaceChildren(); if (people.length !== 1) select('person-select').append(option('', 'Select person')); for (const id of people) select('person-select').append(option(id, id)); selectedPerson = people.length === 1 ? people[0]! : ''; select('person-select').value = selectedPerson; el('person-control').hidden = false;
|
|
249
|
+
if (!player) duration = canonical.frames[canonical.frames.length - 1]!.t - canonical.frames[0]!.t;
|
|
250
|
+
text('reference-info', `${reference.path} · picture-px / top-left / y↓ · ${canonical.frames.length} frames${canonical.camera ? ' · Camera metadata preserved (normalization is not applied twice)' : ''}`);
|
|
251
|
+
renderSequenceFrame(); fitCamera(); renderTransport(); return;
|
|
252
|
+
}
|
|
253
|
+
const pose = readPose(value);
|
|
254
|
+
referenceSize = { width: pose.width, height: pose.height };
|
|
255
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', String(pose.width)); svg.setAttribute('height', String(pose.height)); svg.setAttribute('viewBox', `0 0 ${pose.width} ${pose.height}`); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', reference.label + ' · Unmodified static input pose');
|
|
256
|
+
const byId = new Map(pose.joints.map(joint => [joint.id, joint]));
|
|
257
|
+
for (const joint of pose.joints) {
|
|
258
|
+
if (joint.parent) { const parent = byId.get(joint.parent)!; svgNode('line', { x1: parent.x, y1: parent.y, x2: joint.x, y2: joint.y, stroke: '#b2deca', 'stroke-width': 2 }, svg); }
|
|
259
|
+
const dot = svgNode('circle', { cx: joint.x, cy: joint.y, r: 4, fill: '#203d31', stroke: '#caead9', 'stroke-width': 1.5 }, svg);
|
|
260
|
+
const title = document.createElementNS(svg.namespaceURI, 'title'); title.textContent = joint.id; dot.append(title);
|
|
261
|
+
}
|
|
262
|
+
content.append(svg); el('reference-empty').hidden = true; fitCamera();
|
|
263
|
+
} else if (reference.type === 'image') {
|
|
264
|
+
const image = new Image(); image.draggable=false; image.alt = reference.label; image.onload = () => { if (generation !== referenceGeneration) return; referenceSize = { width: image.naturalWidth, height: image.naturalHeight }; image.width = image.naturalWidth; image.height = image.naturalHeight; el('reference-empty').hidden = true; fitCamera(); }; await new Promise<void>((resolve,reject)=>{const loaded=image.onload;const timer=setTimeout(()=>reject(new Error('Reference image load timed out')),10000);image.onload=event=>{clearTimeout(timer);if(typeof loaded==='function')loaded.call(image,event);resolve();};image.onerror=()=>{clearTimeout(timer);fail();reject(new Error('Reference image load failed'));};image.src=referenceUrl(reference);content.append(image);});
|
|
265
|
+
} else {
|
|
266
|
+
const media = document.createElement('video'); media.controls = true; media.muted = true; media.preload = 'metadata'; media.playsInline = true;
|
|
267
|
+
media.onloadedmetadata = () => { if (generation !== referenceGeneration) return; referenceSize = { width: media.videoWidth, height: media.videoHeight }; media.width = media.videoWidth; media.height = media.videoHeight; if (Number.isFinite(media.duration) && !player) duration = media.duration; media.currentTime = Math.min(time, Math.max(0, media.duration - .01)); el('reference-empty').hidden = true; fitCamera(); renderTransport(); };
|
|
268
|
+
media.onerror = fail; media.src = referenceUrl(reference); video = media; content.append(media);
|
|
269
|
+
}
|
|
270
|
+
} catch (error) { fail(); message(errorText(error), true); }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function referenceFit(): void {
|
|
274
|
+
if (!referenceSize || !paneFrame('reference')) return;
|
|
275
|
+
const viewport = el('reference-viewport'), content = el('reference-content');
|
|
276
|
+
const scale = referenceScale = Math.max(.001,Math.min((viewport.clientWidth - 24) / referenceSize.width, (viewport.clientHeight - 56) / referenceSize.height) * referenceZoom);
|
|
277
|
+
const center=referenceCenter??{x:referenceSize.width/2,y:referenceSize.height/2};
|
|
278
|
+
content.style.left = `${viewport.clientWidth/2-center.x*scale}px`;
|
|
279
|
+
content.style.top = `${viewport.clientHeight/2-center.y*scale}px`;
|
|
280
|
+
content.style.transform = `scale(${scale})`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fitCamera(): void {
|
|
284
|
+
const boxKey=assets?.stage??player?.bounds();
|
|
285
|
+
const layout=['rig','reference'].map(target=>{const v=el(target+'-viewport');return `${v.clientWidth}:${v.clientHeight}`;}).join('|')+view+referenceGeneration+!!player+JSON.stringify(referenceSize)+JSON.stringify(boxKey);
|
|
286
|
+
if(cameraSync&&!cameraRestoring&&layout!==cameraLayout)alignCameras();cameraLayout=layout;
|
|
287
|
+
referenceFit();renderNumericView();renderCameraControls();
|
|
288
|
+
if (!player || !assets) return;
|
|
289
|
+
const box: PoseBounds | null = assets.stage ?? player.bounds(); if (!box || box.width <= 0 || box.height <= 0) return;
|
|
290
|
+
const viewport = el('rig-viewport');if(!paneFrame('rig'))return;
|
|
291
|
+
const scale = rigScale = Math.max(.001,Math.min((viewport.clientWidth - 28) / box.width, (viewport.clientHeight - 58) / box.height) * zoom);
|
|
292
|
+
const center = cameraCenter ?? { x: box.x + box.width / 2, y: box.y + box.height / 2 };displayedCenter={...center};
|
|
293
|
+
const root = el('rig-root'); root.style.left = `${viewport.clientWidth / 2 - center.x * scale}px`; root.style.top = `${viewport.clientHeight / 2 + center.y * scale}px`; root.style.transform = `scale(${scale})`;
|
|
294
|
+
player.renderer.syncPixelRatio();redrawOverlay(false);renderNumericView();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderTransport(): void {
|
|
298
|
+
const end = duration > 0 ? ` / ${duration.toFixed(4)}` : '';
|
|
299
|
+
text('time-label', sequence ? `Source ${(time + sequence.frames[0]!.t).toFixed(4)} ↔ ${time.toFixed(4)}${end} s` : `${time.toFixed(4)}${end} s`);
|
|
300
|
+
input('scrub').max = String(Math.max(duration, .001)); input('scrub').value = String(time); input('scrub').disabled = duration <= 0;
|
|
301
|
+
button('play').disabled = (!player && !video && !sequence) || duration <= 0;
|
|
302
|
+
renderFrameTransport();
|
|
303
|
+
renderFeedbackRange();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function feedbackAnimation(): string | null { return player && player.current !== animation ? null : animation || null; }
|
|
307
|
+
|
|
308
|
+
function renderFeedbackRange(): void {
|
|
309
|
+
const last = feedbackLastFrame(data.feedbackCatalog, feedbackAnimation());
|
|
310
|
+
for (const id of ['feedback-start', 'feedback-end']) {
|
|
311
|
+
if (last === null) input(id).removeAttribute('max'); else input(id).max = String(Math.max(0, last));
|
|
312
|
+
}
|
|
313
|
+
text('feedback-range-hint', last === null ? 'Bounds unknown · enter an inclusive integer frame range. FPS is not inferred.' : last < 0 ? 'No frames overlap the explicit clock and selected clip.' : `F0–F${last} for the explicit clock and current clip · inclusive`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderFeedbackTargets(preserve = false): void {
|
|
317
|
+
const kind = select('feedback-target-kind').value;
|
|
318
|
+
const names = ['bone', 'slot', 'rig'].includes(kind) ? feedbackTargetNames(data.feedbackCatalog, kind as Feedback['target']['kind']) : null;
|
|
319
|
+
const list = el<HTMLDataListElement>('feedback-target-names'); list.replaceChildren();
|
|
320
|
+
for (const name of names ?? []) list.append(option(name, name));
|
|
321
|
+
const field = input('feedback-target-name'); field.readOnly = kind === 'rig'; field.disabled = !names?.length;
|
|
322
|
+
if (kind === 'rig') field.value = data.feedbackCatalog.rig;
|
|
323
|
+
else if (!preserve || !names?.includes(field.value)) field.value = '';
|
|
324
|
+
text('feedback-target-hint', !kind ? 'Select a target type first.' : names === null ? 'Actual target catalog unavailable · bone and slot feedback cannot be added.' : !names.length ? 'No targets of this type.' : kind === 'rig' ? 'Actual rig identifier for this package.' : `Choose or enter an exact name from this rig: ${names.length} targets.`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderFrameTransport(): void {
|
|
328
|
+
const clock = activeFrameClock();
|
|
329
|
+
const last = lastProducerFrame(clock), frame = frameAtTime(clock, time);
|
|
330
|
+
el('frame-controls').hidden = last === null;
|
|
331
|
+
if (!clock) { text('frame-label', data.report?.clock ? data.report.animation ? 'Frame unknown · report and current clip do not match' : 'Frame unknown · report animation not supplied' : 'rate unknown'); return; }
|
|
332
|
+
if (last === null) { text('frame-label', 'F? / ? · frame range unknown'); return; }
|
|
333
|
+
text('frame-label', `F${frame === null ? '?' : frame} / ${last}${frame === null ? ' · Out of range' : ''}`);
|
|
334
|
+
text('frame-clock-label', clock.kind === 'uniform' ? `${clock.fps} fps · frame0 ${clock.frame0} s` : `frameTimes · ${clock.frameTimes.length} explicit times`);
|
|
335
|
+
input('frame-number').max = String(last);
|
|
336
|
+
if (document.activeElement !== input('frame-number')) input('frame-number').value = frame === null ? '' : String(frame);
|
|
337
|
+
input('frame-scrub').max = String(last); input('frame-scrub').hidden = frame === null;
|
|
338
|
+
if (frame !== null) input('frame-scrub').value = String(frame);
|
|
339
|
+
input('frame-scrub').disabled = last === 0;
|
|
340
|
+
el('frame-outside').hidden = frame !== null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function activeFrameClock() {
|
|
344
|
+
const active = player && player.current !== animation ? '' : animation;
|
|
345
|
+
return clockForAnimation(data.report?.clock, data.report?.animation, active, !!player);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function seekFrame(frame: number): void {
|
|
349
|
+
const exact = timeAtFrame(activeFrameClock(), frame);
|
|
350
|
+
if (exact === null) { message('Select an integer frame within the explicit clock bounds.', true); renderFrameTransport(); return; }
|
|
351
|
+
pause();
|
|
352
|
+
if ((duration > 0 && exact > duration) || (video && video.readyState >= 1 && Number.isFinite(video.duration) && exact > video.duration)) { message(`F${frame} maps to ${exact} s, outside the current reference range. The requested time was not changed.`, true); renderFrameTransport(); return; }
|
|
353
|
+
seek(exact);
|
|
354
|
+
message(`F${frame} · Explicit time ${exact} s${!player && !video && !sequence ? ' (No playback reference · time display only)' : ''}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function drawRig(action:()=>void):void {
|
|
358
|
+
try{action();text('renderer-state',rendererStatus(player?.renderer??null,'succeeded').label);redrawOverlay(true);}
|
|
359
|
+
catch(error){overlays?.clear();text('renderer-state',rendererStatus(null,'failed').label);throw error;}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function chooseAnimation(name: string): void {
|
|
363
|
+
pause(); animation = name; axisInspection = false; el('angle-panel').hidden = true;
|
|
364
|
+
const runtimeAnimation = assets?.animations.find(item => item.name === name);
|
|
365
|
+
duration = runtimeAnimation?.duration ?? data.manifest.animations.find(item => item.id === name)?.duration ?? 0;
|
|
366
|
+
select('animation-select').value = name;
|
|
367
|
+
if (player && runtimeAnimation) { drawRig(()=>{player!.play(name,{loop:false});player!.pause();player!.draw();}); }
|
|
368
|
+
else if (player && name){text('renderer-state',rendererStatus(null,'failed').label);message(`Animation "${name}" is not present in the rig.`, true);}
|
|
369
|
+
time = 0; renderTransport(); renderCues();renderIdle();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function seek(value: number): void {
|
|
373
|
+
time = duration > 0 ? Math.min(Math.max(0, value), duration) : Math.max(0, value);
|
|
374
|
+
if (player && player.current) { try { player.seek(time); drawRig(()=>player!.draw()); } catch (error) {text('renderer-state',rendererStatus(null,'failed').label); message(errorText(error), true); } }
|
|
375
|
+
if (video && video.readyState >= 1 && Number.isFinite(video.duration)) video.currentTime = Math.min(time, video.duration);
|
|
376
|
+
renderSequenceFrame(); renderTransport();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function stopAudio(): void { for (const audio of audioPlaying) audio.pause(); audioPlaying.clear(); }
|
|
380
|
+
function pause(): void { ++playbackIntent;desiredPlay=false;suspendedPreview=false;idleHeld=true; playing = false; lastFrame = 0; player?.pause(); video?.pause(); stopAudio(); button('play').textContent = '▶'; button('play').setAttribute('aria-label', 'Play'); }
|
|
381
|
+
|
|
382
|
+
async function audition(clipId: string): Promise<void> {
|
|
383
|
+
const clip = data.manifest.audio.clips.find(item => item.id === clipId); if (!clip) return;
|
|
384
|
+
const audio = new Audio(assetUrl(clip.path)); audio.volume = Number(input('audio-volume').value); audioPlaying.add(audio);
|
|
385
|
+
audio.onended = () => audioPlaying.delete(audio); audio.onerror = () => { audioPlaying.delete(audio); message(`Could not open audio: ${clip.label ?? clip.id}`, true); };
|
|
386
|
+
try { await audio.play(); } catch (error) { audioPlaying.delete(audio); message(`Audio playback was refused: ${errorText(error)}`, true); }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function activeCues() { return data.manifest.audio.cues.filter(cue => !cue.animation || cue.animation === animation); }
|
|
390
|
+
function renderCues(): void {
|
|
391
|
+
const area = el('cues'); area.replaceChildren();
|
|
392
|
+
for (const cue of activeCues()) {
|
|
393
|
+
const clip = data.manifest.audio.clips.find(item => item.id === cue.clip);
|
|
394
|
+
const row = document.createElement('div'); row.className = 'cue';
|
|
395
|
+
const label = document.createElement('span'); label.textContent = `◇ ${cue.id} · ${cue.time.toFixed(2)} s → ${clip?.label ?? cue.clip}`;
|
|
396
|
+
const jump = document.createElement('button'); jump.type = 'button'; jump.textContent = 'Go to cue'; jump.onclick = () => { pause(); seek(cue.time); };
|
|
397
|
+
const play = document.createElement('button'); play.type = 'button'; play.textContent = 'Audition'; play.disabled = !clip; play.onclick = () => { void audition(cue.clip); };
|
|
398
|
+
row.append(label, jump, play); area.append(row);
|
|
399
|
+
}
|
|
400
|
+
if (!activeCues().length) area.textContent = 'No linked sound cues.';
|
|
401
|
+
input('sync-audio').disabled = !data.manifest.audio.clips.length;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function reportEvents(): ReportEvent[] {
|
|
405
|
+
const field = select('report-category').value;
|
|
406
|
+
return data.report?.categories.filter(category => field === 'all' || category.field === field).flatMap(category => category.events) ?? [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function initReport(): void {
|
|
410
|
+
const report = data.report;
|
|
411
|
+
el('report-browser').hidden = !report; el('report-unavailable').hidden = !!report;
|
|
412
|
+
if (!report) { text('report-summary', 'Not supplied'); return; }
|
|
413
|
+
const counts = reportCounts(report); text('report-summary', `${counts.events} events · ${counts.empty.length} explicitly empty categories`);
|
|
414
|
+
const source = el<HTMLAnchorElement>('report-source-link'); source.hidden = false; source.href = assetUrl(report.path);
|
|
415
|
+
const filter = select('report-category'); filter.replaceChildren(option('all', 'All events'));
|
|
416
|
+
for (const category of report.categories) filter.append(option(category.field, `${category.field} · ${category.state === 'empty' ? 'none' : category.events.length}`));
|
|
417
|
+
const empty = el('report-empty-categories'); empty.replaceChildren();
|
|
418
|
+
for (const field of counts.empty) { const label = document.createElement('span'); label.textContent = `${field}: none`; empty.append(label); }
|
|
419
|
+
el('report-missing').hidden = !counts.missing.length;
|
|
420
|
+
text('report-missing', counts.missing.length ? `Fields not supplied: ${counts.missing.join(', ')} (not classified as none)` : '');
|
|
421
|
+
renderReportEvents();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderReportEvents(): void {
|
|
425
|
+
const events = reportEvents();
|
|
426
|
+
const pages = Math.ceil(events.length / reportPageSize); reportPage = Math.max(0, Math.min(reportPage, Math.max(0, pages - 1)));
|
|
427
|
+
const start = reportPage * reportPageSize, rows = events.slice(start, start + reportPageSize);
|
|
428
|
+
text('report-page', rows.length ? `${start + 1}–${start + rows.length} / ${events.length}` : '0 / 0');
|
|
429
|
+
button('report-previous').disabled = reportPage === 0; button('report-next').disabled = reportPage >= pages - 1;
|
|
430
|
+
const list = el('report-events'); list.replaceChildren();
|
|
431
|
+
for (const event of rows) {
|
|
432
|
+
const row = document.createElement('li'), item = document.createElement('button'); item.type = 'button'; item.setAttribute('aria-current', String(event.id === selectedReportEvent?.id));
|
|
433
|
+
const title = document.createElement('strong'); title.textContent = `${event.field} · ${eventCoordinates(event)}`;
|
|
434
|
+
const targets = document.createElement('small'); targets.textContent = event.targets.length ? event.targets.map(targetLabel).join(' · ') : 'No bone/slot mapping';
|
|
435
|
+
const timing = document.createElement('small'); timing.textContent = eventTimingLabel(event); if (!event.seek) timing.className = 'no-time';
|
|
436
|
+
item.append(title, targets, timing); item.onclick = () => chooseReportEvent(event); row.append(item); list.append(row);
|
|
437
|
+
}
|
|
438
|
+
if (!rows.length) { const empty = document.createElement('li'); empty.className = 'hint'; empty.textContent = select('report-category').value === 'all' ? 'No events supplied.' : 'none · explicitly supplied empty event array'; list.append(empty); }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function prefillReportEvent(): void {
|
|
442
|
+
if (!selectedReportEvent) return;
|
|
443
|
+
const selection = eventSelection(selectedReportEvent, selectedReportTarget);
|
|
444
|
+
input('feedback-start').value = selection.frames ? String(selection.frames[0]) : '';
|
|
445
|
+
input('feedback-end').value = selection.frames ? String(selection.frames[1]) : '';
|
|
446
|
+
select('feedback-target-kind').value = selection.target?.kind ?? '';
|
|
447
|
+
input('feedback-target-name').value = selection.target?.name ?? '';
|
|
448
|
+
renderFeedbackTargets(true);
|
|
449
|
+
text('report-prefill-note', `${selection.frames ? 'Copied the explicit frame range.' : 'No frame data · enter the range manually.'} ${selection.target ? input('feedback-target-name').value === selection.target.name ? 'Copied a target present in the rig catalog.' : 'The report target is absent from the current rig catalog; choose a target manually.' : selection.targets.length > 1 ? 'Select one of the named targets.' : 'No bone/slot mapping · choose a target manually.'} Choose defect or quality yourself.`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function chooseReportEvent(event: ReportEvent): void {
|
|
453
|
+
const draft = reportDraftForMarker(selectedReportEvent?.id ?? null, event.id, { category: select('feedback-category').value, summary: input('feedback-summary').value });
|
|
454
|
+
select('feedback-category').value = draft.category; input('feedback-summary').value = draft.summary;
|
|
455
|
+
pause(); selectedReportEvent = event; selectedReportTime = undefined; selectedReportTarget = undefined;
|
|
456
|
+
const selection = eventSelection(event);
|
|
457
|
+
const times = select('report-time'); times.replaceChildren();
|
|
458
|
+
for (const [index, value] of selection.exactTimes.entries()) times.append(option(String(index), `${index + 1}. ${value} s`));
|
|
459
|
+
times.hidden = !selection.exactTimes.length; el('report-time-label').hidden = times.hidden;
|
|
460
|
+
const targets = select('report-target'); targets.replaceChildren(option('', 'Select target'));
|
|
461
|
+
for (const [index, target] of selection.targets.entries()) targets.append(option(String(index), targetLabel(target)));
|
|
462
|
+
el('report-target-control').hidden = selection.targets.length <= 1;
|
|
463
|
+
prefillReportEvent(); renderReportSelection(); renderReportEvents();
|
|
464
|
+
if (event.seek) seekReportEvent();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function renderReportSelection(): void {
|
|
468
|
+
const event = selectedReportEvent; el('report-selection').hidden = !event; if (!event) return;
|
|
469
|
+
text('report-selection-heading', `${event.field} · ${eventCoordinates(event)}`);
|
|
470
|
+
const exact = eventSeekTime(event, selectedReportTime);
|
|
471
|
+
text('report-timing', event.times?.length ? `${eventTimingLabel(event)} · Preserves ${event.times.length} discrete times. Gaps are not expanded into a continuous interval.` : eventTimingLabel(event));
|
|
472
|
+
button('report-seek').disabled = exact === null;
|
|
473
|
+
text('report-provenance', `Source ${event.source.path}${event.source.pointer}${Object.keys(event.provenance).length ? ' · Original identifiers: ' + JSON.stringify(event.provenance) : ''}`);
|
|
474
|
+
text('report-details', JSON.stringify({ source: event.source, frames: event.frames, times: event.times, timeInterval: event.timeInterval, durationSeconds: event.durationSeconds, targets: event.targets, provenance: event.provenance, details: event.details }, null, 2));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function seekReportEvent(): void {
|
|
478
|
+
const event = selectedReportEvent; if (!event) return;
|
|
479
|
+
const exact = eventSeekTime(event, selectedReportTime); if (exact === null) return;
|
|
480
|
+
pause();
|
|
481
|
+
const reportAnimation = data.report?.animation;
|
|
482
|
+
if (reportAnimation && player && !assets?.animations.some(item => item.name === reportAnimation)) { message(`Report animation "${reportAnimation}" is absent from the current rig; no seek was performed.`, true); return; }
|
|
483
|
+
if (reportAnimation && assets?.animations.some(item => item.name === reportAnimation) && animation !== reportAnimation) chooseAnimation(reportAnimation);
|
|
484
|
+
if (duration > 0 && exact > duration) { message(`Explicit time ${exact} s is outside the current range of ${duration} s. No substitute time was used.`, true); return; }
|
|
485
|
+
seek(exact);
|
|
486
|
+
message((player || video || sequence) ? `Selected report time ${exact} s.` : `Report time ${exact} s · no playback reference; time display only.`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function tick(now: number): void {
|
|
490
|
+
if (playing) {
|
|
491
|
+
const dt = lastFrame ? Math.min((now - lastFrame) / 1000, .1) : 0;
|
|
492
|
+
const previous = time; time = Math.min(duration, time + dt);
|
|
493
|
+
if (player && player.current) { try { drawRig(()=>player!.frame(dt)); } catch (error) { pause(); message(errorText(error), true); } }
|
|
494
|
+
if (video && Math.abs(video.currentTime - time) > .15) video.currentTime = Math.min(time, video.duration || time);
|
|
495
|
+
if (input('sync-audio').checked) for (const cue of activeCues()) if ((previous < cue.time && cue.time <= time) || (previous === 0 && cue.time === 0 && dt > 0)) void audition(cue.clip);
|
|
496
|
+
renderSequenceFrame(); renderTransport(); if(time>=duration){if(canRepeatIdle()&&!idleHeld){stopAudio();seek(0);player?.resume();if(video)void video.play().catch(error=>{pause();message(errorText(error),true);});}else pause();} lastFrame = now;
|
|
497
|
+
}
|
|
498
|
+
requestAnimationFrame(tick);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
502
|
+
text('renderer-state',rendererStatus(null,'pending').label);
|
|
503
|
+
if (!data.manifest.model) { text('player-state', 'No rig supplied · reference review is available'); return; }
|
|
504
|
+
text('player-state', 'Loading compiled rig…');
|
|
505
|
+
try {
|
|
506
|
+
const modelUrl = new URL(assetUrl(data.manifest.model), location.href).href;
|
|
507
|
+
assets = await loadModel({ modelUrl, setsUrl: null, resolvePage: (name, url) => {
|
|
508
|
+
const target = new URL(name, url); if (target.origin !== location.origin || !target.pathname.startsWith('/assets/')) throw new Error(`Model page escapes local package: ${name}`); return target.href;
|
|
509
|
+
} });
|
|
510
|
+
player = new RigPlayer(el('rig-root'), assets);
|
|
511
|
+
player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
|
|
512
|
+
const animations = select('animation-select'); animations.replaceChildren();
|
|
513
|
+
for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
|
|
514
|
+
animations.disabled = assets.animations.length === 0;
|
|
515
|
+
const names=assets.animations.map(clip=>clip.name),initial=initialIdleAnimation(names,initialPlayerAnimation(names,data.initialAnimation??undefined,initialCheckpoint),!!data.initialAnimation||!!initialCheckpoint?.animation||!!initialCheckpoint?.axis);
|
|
516
|
+
if(initial)chooseAnimation(initial);else drawRig(()=>player!.draw());
|
|
517
|
+
el('rig-empty').hidden = true; text('player-state', '@rig-c/play · Actual rig');
|
|
518
|
+
const presets = player.sets(); if (presets.length) { select('part-set').replaceChildren(option('', 'Original set')); for (const set of presets) select('part-set').append(option(set.name, set.name)); select('part-set').disabled = false; }
|
|
519
|
+
const validAxes = data.manifest.axes.filter(axis => assets?.animations.some(item => item.name === axis.animation));
|
|
520
|
+
text('axis-state', validAxes.length ? `${validAxes.length} axes` : 'No inspection axes supplied');
|
|
521
|
+
select('axis-select').replaceChildren(); for (const axis of validAxes) select('axis-select').append(option(axis.id, axis.label));
|
|
522
|
+
input('axis-value').disabled = !validAxes.length;
|
|
523
|
+
for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;
|
|
524
|
+
fitCamera();
|
|
525
|
+
} catch (error) { text('renderer-state',rendererStatus(null,'failed').label);player?.dispose(); player = null; assets?.dispose(); assets = null; text('player-state', 'Rig load failed'); text('rig-empty', `Could not open rig: ${errorText(error)}`); message(errorText(error), true); }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function chooseAxis(): void {
|
|
529
|
+
const axis = data.manifest.axes.find(item => item.id === select('axis-select').value);
|
|
530
|
+
const clip = assets?.animations.find(item => item.name === axis?.animation); if (!axis || !clip || !player) return;
|
|
531
|
+
pause(); chooseAnimation(clip.name); axisInspection = true; el('angle-panel').hidden = false;
|
|
532
|
+
input('axis-value').min = String(axis.min); input('axis-value').max = String(axis.max); input('axis-value').step = String((axis.max - axis.min) / 200); input('axis-value').value = String((axis.max + axis.min) / 2); text('axis-label', axis.label); applyAxis();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function applyAxis(): void {
|
|
536
|
+
const axis = data.manifest.axes.find(item => item.id === select('axis-select').value);
|
|
537
|
+
if (!axis || !player) return;
|
|
538
|
+
const value = Number(input('axis-value').value); text('axis-value-label', value.toFixed(3)); seek((value - axis.min) / (axis.max - axis.min) * duration);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function renderProposals(): void {
|
|
542
|
+
const area = el('proposal-list'); area.replaceChildren();
|
|
543
|
+
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
544
|
+
warnings.textContent = session.feedbackWarnings.length ? `${session.feedbackWarnings.length} validation warnings in existing feedback · original records are preserved. Review the marked entries, delete them and add valid replacements before submitting. JSON and Markdown exports preserve both the original records and warnings.` : '';
|
|
545
|
+
for (const feedback of session.feedback) {
|
|
546
|
+
const item = document.createElement('div'); item.className = 'proposal-item'; const title = document.createElement('strong'); title.textContent = `${feedback.category} · ${feedback.target.kind}:${feedback.target.name} · F${feedback.frameStart}–${feedback.frameEnd}`; const description = document.createElement('span'); description.textContent = feedback.summary;
|
|
547
|
+
item.append(title, description);
|
|
548
|
+
for (const warning of session.feedbackWarnings.filter(value => value.id === feedback.id)) { const note = document.createElement('p'); note.className = 'hint'; note.textContent = `Validation warning · ${warning.error}`; item.append(note); }
|
|
549
|
+
const details = document.createElement('details'), summary = document.createElement('summary'), source = document.createElement('pre'); summary.textContent = 'Saved feedback and inspection context'; source.textContent = JSON.stringify(feedback, null, 2); details.append(summary, source);
|
|
550
|
+
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete'; remove.onclick = () => { session.feedback = session.feedback.filter(item => item.id !== feedback.id); session.feedbackWarnings = session.feedbackWarnings.filter(warning => warning.id !== feedback.id); markDirty(); renderProposals(); };
|
|
551
|
+
item.append(details, remove); area.append(item);
|
|
552
|
+
}
|
|
553
|
+
updateSavedState();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function propose(tool: string): void {
|
|
557
|
+
if (tool === 'angle' && !input('axis-value').disabled) { showInspector('rig');chooseAxis(); return; }
|
|
558
|
+
const choices: Record<string, { target: Feedback['target']['kind']; hint: string }> = {
|
|
559
|
+
mesh: { target: 'slot', hint: 'One-line feedback on mesh density' }, contour: { target: 'slot', hint: 'One-line feedback on contour curvature' }, chest: { target: 'rig', hint: 'One-line feedback on chest position' },
|
|
560
|
+
angle: { target: 'rig', hint: 'One-line feedback on face or torso angle deformation' }, pose: { target: 'bone', hint: 'One-line feedback on differences from the input pose' }, timing: { target: 'rig', hint: 'One-line feedback on timing in the selected range' }, audio: { target: 'rig', hint: 'One-line feedback on event or audio links' },
|
|
561
|
+
};
|
|
562
|
+
const choice = choices[tool]; if (!choice) return;
|
|
563
|
+
select('feedback-target-kind').value = choice.target; input('feedback-target-name').value = choice.target === 'rig' ? data.manifest.id : ''; input('feedback-summary').placeholder = choice.hint;
|
|
564
|
+
renderFeedbackTargets();
|
|
565
|
+
showInspector('feedback');input('feedback-summary').focus(); message('Specify a frame range and actual bone or slot name to add feedback. Motion keys are not edited.');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function download(name: string, content: string, mime: string): void {
|
|
569
|
+
const url = URL.createObjectURL(new Blob([content], { type: mime })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function downloadStrip(name: string, content: Blob): void {
|
|
573
|
+
const url = URL.createObjectURL(content), link = document.createElement('a'); link.href = url; link.download = name; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function stripStatus(value: string, error = false): void { text('strip-status', value); el('strip-status').setAttribute('role', error ? 'alert' : 'status'); }
|
|
577
|
+
|
|
578
|
+
function updateStripControls(): void {
|
|
579
|
+
el('strip-panel').setAttribute('aria-busy', String(stripBusy));
|
|
580
|
+
for (const id of ['strip-scope', 'strip-reference', 'strip-format']) select(id).disabled = stripBusy;
|
|
581
|
+
button('strip-export').disabled = stripBusy; button('strip-export').textContent = stripBusy ? 'Generating strip…' : 'Download strip';
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function exportStrip(): Promise<void> {
|
|
585
|
+
if (stripBusy) return;
|
|
586
|
+
stripStatus('Checking the selected range…');
|
|
587
|
+
try {
|
|
588
|
+
const format = select('strip-format').value, selectedAnimation = feedbackAnimation();
|
|
589
|
+
if (format !== 'png' && format !== 'json') throw new Error('Select PNG or JSON.');
|
|
590
|
+
if (!selectedAnimation) throw new Error('Explicitly select an animation to export.');
|
|
591
|
+
const intervals: StripInterval[] = [];
|
|
592
|
+
if (select('strip-scope').value === 'feedback') {
|
|
593
|
+
if (!session.feedback.length) throw new Error('No feedback ranges to export.');
|
|
594
|
+
for (const feedback of session.feedback) {
|
|
595
|
+
const context = feedback.context;
|
|
596
|
+
if (!context || typeof context !== 'object' || Array.isArray(context) || context.animation !== selectedAnimation) throw new Error(`Feedback ${feedback.id}: Its animation is missing or differs from the selected animation. Batch export supports ranges from the same animation only.`);
|
|
597
|
+
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: 'single', frameStart: feedback.frameStart, frameEnd: feedback.frameEnd });
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
const start = input('feedback-start').value.trim(), end = input('feedback-end').value.trim();
|
|
601
|
+
if (!start || !end) throw new Error('Enter the strip start and end frames in the feedback form.');
|
|
602
|
+
intervals.push({ id: 'selection', basis: 'single', frameStart: Number(start), frameEnd: Number(end) });
|
|
603
|
+
}
|
|
604
|
+
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.`);
|
|
605
|
+
const referenceId = select('strip-reference').value;
|
|
606
|
+
const request: StripRequest = { format, sourceHash: data.sourceHash, animation: selectedAnimation, ...(referenceId ? { referenceId } : {}), intervals };
|
|
607
|
+
stripBusy = true; updateStripControls(); stripStatus(`${selectedAnimation} · ${intervals.length} ranges · Generating ${format.toUpperCase()}…`);
|
|
608
|
+
const response = await fetch('/api/strip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
|
|
609
|
+
if (!response.ok) {
|
|
610
|
+
const failure: unknown = await response.json().catch(() => null);
|
|
611
|
+
throw new Error(failure && typeof failure === 'object' && 'error' in failure && typeof failure.error === 'string' ? failure.error : `Strip export failed · HTTP ${response.status}`);
|
|
612
|
+
}
|
|
613
|
+
const mime = format === 'png' ? 'image/png' : 'application/json';
|
|
614
|
+
if (!(response.headers.get('content-type') ?? '').toLowerCase().startsWith(mime)) throw new Error('The strip response format does not match the request.');
|
|
615
|
+
downloadStrip(`review-strip.${format}`, await response.blob()); stripStatus(`${selectedAnimation} · ${intervals.length} ranges · Requested ${format.toUpperCase()} download. Review status was not changed.`);
|
|
616
|
+
} catch (error) { stripStatus(errorText(error), true); }
|
|
617
|
+
finally { stripBusy = false; updateStripControls(); }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const markdown = (value: string) => value.replace(/[\\`*_{}\[\]<>]/g, '\\$&');
|
|
621
|
+
function issueFields(feedback: Feedback): { title: string; body: string; labels: string[] } {
|
|
622
|
+
const title = `[${feedback.category}] ${feedback.target.kind}:${feedback.target.name} F${feedback.frameStart}–${feedback.frameEnd} · ${feedback.summary}`;
|
|
623
|
+
const body = [
|
|
624
|
+
`- Package: ${markdown(session.id)} / ${markdown(session.revision)}`,
|
|
625
|
+
`- Source hash: ${session.sourceHash}`, ...(session.referenceBinding?[`- Reference binding: ${JSON.stringify(session.referenceBinding)}`]:[]),
|
|
626
|
+
`- Package revision: ${markdown(session.reviewedSource.packageRevision)}`,
|
|
627
|
+
`- Viewer version: ${markdown(session.reviewedSource.viewerVersion)}`,
|
|
628
|
+
`- Runtime version: ${markdown(session.reviewedSource.runtimeVersion)}`,
|
|
629
|
+
`- Rig: ${session.reviewedSource.rig ? `${markdown(session.reviewedSource.rig.path)} / SHA-256 ${session.reviewedSource.rig.sha256}` : 'not supplied'}`,
|
|
630
|
+
`- Motion: ${session.reviewedSource.motion ? `${markdown(session.reviewedSource.motion.path)} / SHA-256 ${session.reviewedSource.motion.sha256}` : 'not supplied'}`,
|
|
631
|
+
`- Frames: ${feedback.frameStart}–${feedback.frameEnd} (inclusive; producer frame numbers; no inferred FPS)`,
|
|
632
|
+
`- Target: ${feedback.target.kind} / ${markdown(feedback.target.name)}`,
|
|
633
|
+
`- Category: ${feedback.category}`, '', markdown(feedback.summary), '', 'Inspection context:',
|
|
634
|
+
...JSON.stringify(feedback.context ?? null, null, 2).split('\n').map(line => ' ' + line),
|
|
635
|
+
'', 'Validation warnings (must be corrected before submission):',
|
|
636
|
+
...JSON.stringify(session.feedbackWarnings.filter(warning => warning.id === feedback.id), null, 2).split('\n').map(line => ' ' + line),
|
|
637
|
+
].join('\n');
|
|
638
|
+
return { title, body, labels: [feedback.category] };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
|
|
642
|
+
const viewport=el(target+'-viewport'),pane=el(target+'-pane');
|
|
643
|
+
const box=target==='rig'?assets?.stage??player?.bounds():referenceSize?{x:0,y:0,...referenceSize}:null;
|
|
644
|
+
if(!box||target==='rig'&&!player){delete cameraFrames[target];return null;}
|
|
645
|
+
if(pane.hidden||viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;
|
|
646
|
+
const frame=cameraFrame(target==='rig'?'world':'image',box,viewport.clientWidth,viewport.clientHeight,target==='rig'?28:24,target==='rig'?58:56);
|
|
647
|
+
if(frame)cameraFrames[target]=frame;return frame;
|
|
648
|
+
}
|
|
649
|
+
function paneCamera(target:CameraTarget){return target==='rig'?{zoom,center:cameraCenter}:{zoom:referenceZoom,center:referenceCenter};}
|
|
650
|
+
function writeCamera(target:CameraTarget,value:ReturnType<typeof cameraFromSignature>):void {if(target==='rig'){zoom=value.zoom;cameraCenter=value.center;}else{referenceZoom=value.zoom;referenceCenter=value.center;}}
|
|
651
|
+
function activateCamera(target:CameraTarget):void {if(paneFrame(target)){activeCamera=target;renderCameraControls();}}
|
|
652
|
+
function availableCamera():CameraTarget|null {return paneFrame(activeCamera)?activeCamera:paneFrame('rig')?'rig':paneFrame('reference')?'reference':null;}
|
|
653
|
+
function alignCameras():void {
|
|
654
|
+
const target=availableCamera();if(!target)return;
|
|
655
|
+
const frame=paneFrame(target)!;assertCameraView(paneCamera(target),frame);const signature=cameraSignature(paneCamera(target),frame);
|
|
656
|
+
const updates:Partial<Record<CameraTarget,ReturnType<typeof cameraFromSignature>>>={};
|
|
657
|
+
for(const other of ['rig','reference'] as const){if(other===target)continue;const next=paneFrame(other);if(next)updates[other]=cameraFromSignature(signature,next);}
|
|
658
|
+
for(const other of ['rig','reference'] as const){const update=updates[other];if(update)writeCamera(other,update);}activeCamera=target;
|
|
659
|
+
}
|
|
660
|
+
function renderCameraControls():void {input('camera-sync').checked=cameraSync;const target=availableCamera();text('zoom-value',`${Math.round((target?paneCamera(target).zoom:1)*100)}% · ${target==='reference'?'Reference':'Rig'}`);button('fit').textContent=cameraSync?'Fit views':'Fit active';text('coordinates',cameraSync?'Cameras synchronized · drag to pan · scroll to zoom':'Independent cameras · drag to pan · scroll to zoom');}
|
|
661
|
+
function setCameraSync(value:boolean):void {if(value)alignCameras();cameraSync=value;fitCamera();renderCameraControls();}
|
|
662
|
+
function cameraAction(action:()=>void):void {try{action();}catch(error){renderCameraControls();message(errorText(error),true);}}
|
|
663
|
+
function updateCamera(target:CameraTarget,value:CameraSignature):void {
|
|
664
|
+
const frame=paneFrame(target);if(!frame)return;
|
|
665
|
+
const updates:Partial<Record<CameraTarget,ReturnType<typeof cameraFromSignature>>>={[target]:cameraFromSignature(value,frame)};
|
|
666
|
+
if(cameraSync)for(const other of ['rig','reference'] as const){if(other===target)continue;const next=paneFrame(other);if(next)updates[other]=cameraFromSignature(value,next);}
|
|
667
|
+
for(const other of ['rig','reference'] as const){const update=updates[other];if(update)writeCamera(other,update);}activeCamera=target;
|
|
668
|
+
fitCamera();renderCameraControls();
|
|
669
|
+
}
|
|
670
|
+
function zoomCamera(factor:number,target=availableCamera()):void {if(!target)return;const frame=paneFrame(target);if(frame)updateCamera(target,zoomSignature(cameraSignature(paneCamera(target),frame),factor));}
|
|
671
|
+
function panCamera(target:CameraTarget,dx:number,dy:number):void {const frame=paneFrame(target);if(frame)updateCamera(target,dragSignature(cameraSignature(paneCamera(target),frame),frame,dx,dy));}
|
|
672
|
+
function fitActiveCamera(target=availableCamera()):void {if(target)updateCamera(target,{zoom:1,pan:{x:0,y:0}});}
|
|
673
|
+
function zoomViewport(target:CameraTarget,deltaY:number):void {zoomCamera(deltaY<0?1.1:1/1.1,target);}
|
|
674
|
+
|
|
675
|
+
function bind(): void {
|
|
676
|
+
button('strip-export').onclick = () => { void exportStrip(); };
|
|
677
|
+
for (const id of ['strip-scope', 'strip-reference', 'strip-format']) select(id).onchange = () => stripStatus('Selection changed. Check the ranges, animation and reference video before downloading.');
|
|
678
|
+
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-view]')) item.onclick = () => setView(item.dataset.view as typeof view);
|
|
679
|
+
for (const item of document.querySelectorAll<HTMLButtonElement>('[data-proposal]')) item.onclick = () => propose(item.dataset.proposal ?? '');
|
|
680
|
+
select('reference-select').onchange = () => { void showReference(select('reference-select').value); };
|
|
681
|
+
select('person-select').onchange = () => { selectedPerson = select('person-select').value; lastPoseFrame = -1; renderSequenceFrame(); };
|
|
682
|
+
select('animation-select').onchange = () => {chooseAnimation(select('animation-select').value);if(canRepeatIdle())void playPreview();};
|
|
683
|
+
select('part-set').onchange = () => { try { if(!player)return;player.setSet(select('part-set').value || null);drawRig(()=>player!.draw());fitCamera(); } catch (error) {text('renderer-state',rendererStatus(null,'failed').label); message(errorText(error), true); } };
|
|
684
|
+
select('axis-select').onchange = chooseAxis; input('axis-value').oninput = applyAxis;
|
|
685
|
+
button('zoom-in').onclick=()=>cameraAction(()=>zoomCamera(1.25));button('zoom-out').onclick=()=>cameraAction(()=>zoomCamera(1/1.25));button('fit').onclick=()=>cameraAction(()=>fitActiveCamera());
|
|
686
|
+
input('camera-sync').onchange=()=>cameraAction(()=>setCameraSync(input('camera-sync').checked));
|
|
687
|
+
for(const target of ['rig','reference'] as const)for(const event of ['pointerdown','focusin'] as const)el(target+'-viewport').addEventListener(event,()=>activateCamera(target));
|
|
688
|
+
for (const id of ['reference-viewport', 'rig-viewport']) el(id).addEventListener('wheel', event => { event.preventDefault(); cameraAction(()=>zoomViewport(id==='rig-viewport'?'rig':'reference',event.deltaY)); }, { passive: false });
|
|
689
|
+
input('scrub').oninput = () => { pause(); seek(Number(input('scrub').value)); };
|
|
690
|
+
input('frame-number').onfocus = pause;
|
|
691
|
+
input('frame-number').onkeydown = event => { if (event.key === 'Enter') { event.preventDefault(); const value = input('frame-number').value.trim(); seekFrame(value ? Number(value) : NaN); } };
|
|
692
|
+
button('frame-jump').onclick = () => { const value = input('frame-number').value.trim(); seekFrame(value ? Number(value) : NaN); };
|
|
693
|
+
input('frame-scrub').oninput = () => seekFrame(Number(input('frame-scrub').value));
|
|
694
|
+
button('play').onclick = () => {
|
|
695
|
+
if (playing) { pause(); return; }
|
|
696
|
+
void playPreview();
|
|
697
|
+
};
|
|
698
|
+
input('audio-volume').oninput = () => { text('audio-volume-label', `${Math.round(Number(input('audio-volume').value) * 100)}%`); for (const audio of audioPlaying) audio.volume = Number(input('audio-volume').value); };
|
|
699
|
+
input('sync-audio').onchange = () => { if (!input('sync-audio').checked) stopAudio(); };
|
|
700
|
+
select('report-category').onchange = () => { reportPage = 0; renderReportEvents(); };
|
|
701
|
+
button('report-previous').onclick = () => { reportPage--; renderReportEvents(); };
|
|
702
|
+
button('report-next').onclick = () => { reportPage++; renderReportEvents(); };
|
|
703
|
+
button('report-seek').onclick = seekReportEvent;
|
|
704
|
+
select('report-time').onchange = () => { selectedReportTime = Number(select('report-time').value); renderReportSelection(); seekReportEvent(); };
|
|
705
|
+
select('report-target').onchange = () => { const value = select('report-target').value; selectedReportTarget = value ? Number(value) : undefined; prefillReportEvent(); };
|
|
706
|
+
select('review-status').onchange = () => { session.status = select('review-status').value as ReviewSession['status']; markDirty(); };
|
|
707
|
+
el<HTMLTextAreaElement>('checkpoint-note').oninput = () => { const entry = checkpointEntry(); if (entry) { entry.note = el<HTMLTextAreaElement>('checkpoint-note').value; markDirty(); } };
|
|
708
|
+
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="outcome"]')) radio.onchange = () => { const entry = checkpointEntry(); if (entry) { entry.outcome = radio.value as typeof entry.outcome; markDirty(); renderCheckpoints(); } };
|
|
709
|
+
select('feedback-target-kind').onchange = () => renderFeedbackTargets();
|
|
710
|
+
button('add-proposal').onclick = () => {
|
|
711
|
+
const start = input('feedback-start').value.trim(), end = input('feedback-end').value.trim(), frameStart = Number(start), frameEnd = Number(end);
|
|
712
|
+
const name = input('feedback-target-name').value.trim(), summary = input('feedback-summary').value.trim();
|
|
713
|
+
if (!start || !end || !Number.isSafeInteger(frameStart) || !Number.isSafeInteger(frameEnd) || frameStart < 0 || frameEnd < frameStart) { message('Enter nonnegative integer start and end frames, with the end at or after the start.', true); return; }
|
|
714
|
+
if (!['bone', 'slot', 'rig'].includes(select('feedback-target-kind').value) || !['defect', 'quality'].includes(select('feedback-category').value)) { message('Select a target type and defect or quality category.', true); return; }
|
|
715
|
+
if (!name || !summary || /[\r\n]/.test(summary) || summary.length > 1000) { message('Enter the actual target name and a one-line summary of up to 1,000 characters.', true); return; }
|
|
716
|
+
const target: Feedback['target'] = { kind: select('feedback-target-kind').value as Feedback['target']['kind'], name };
|
|
717
|
+
const selectedAnimation = feedbackAnimation();
|
|
718
|
+
try { validateFeedbackSelection(data.feedbackCatalog, { frameStart, frameEnd, target, animation: selectedAnimation }); } catch (error) { message(errorText(error), true); return; }
|
|
719
|
+
const box = assets?.stage ?? player?.bounds();
|
|
720
|
+
const center = cameraCenter ?? (box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null);
|
|
721
|
+
session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {
|
|
722
|
+
interval: { frameStart, frameEnd, inclusive: true },
|
|
723
|
+
checkpoint: checkpoint?.id ?? null, animation: selectedAnimation, time, frame: frameAtTime(activeFrameClock(), time),
|
|
724
|
+
reference: select('reference-select').value || null, person: selectedPerson || null,
|
|
725
|
+
sourceTime: sequence ? sequence.frames[0]!.t + time : time,
|
|
726
|
+
camera: center ? { zoom, x: center.x, y: center.y, coordinateSystem: 'world-y-up' } : { zoom, coordinateSystem: 'reference-fit' },
|
|
727
|
+
view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},
|
|
728
|
+
axis: axisInspection ? { id: select('axis-select').value, value: Number(input('axis-value').value) } : null,
|
|
729
|
+
reportEvent: selectedReportEvent ? { id: selectedReportEvent.id, field: selectedReportEvent.field, source: { ...selectedReportEvent.source }, frames: selectedReportEvent.frames ? [...selectedReportEvent.frames] : null, exactTime: eventSeekTime(selectedReportEvent, selectedReportTime), targets: selectedReportEvent.targets.map(target => ({ ...target })) } : null,
|
|
730
|
+
} });
|
|
731
|
+
input('feedback-summary').value = ''; markDirty(); renderProposals(); message('Added structured feedback. Submit the review to save it.');
|
|
732
|
+
};
|
|
733
|
+
button('save').onclick = async () => {
|
|
734
|
+
if(draftConflict){message('Resolve the saved local draft conflict before submitting.',true);return;}
|
|
735
|
+
if (session.feedbackWarnings.length) { message('Resolve validation warnings in existing feedback before submitting. Delete the marked entries and add valid replacements.', true); return; }
|
|
736
|
+
saving = true; updateSavedState(); const savedPayload = JSON.stringify(session);
|
|
737
|
+
try { const saved = await api<ReviewSession>('/api/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: savedPayload });
|
|
738
|
+
if (JSON.stringify(session) === savedPayload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;
|
|
739
|
+
await persistence?.submitted(saved);renderProvenance();
|
|
740
|
+
message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status, feedback and source provenance.');
|
|
741
|
+
} catch (error) { message(`Submission failed: ${errorText(error)}`, true); } finally { saving = false; updateSavedState(); }
|
|
742
|
+
};
|
|
743
|
+
button('export').onclick = () => { download(`${session.id}-feedback.json`, JSON.stringify({ schema: 'rig-review-feedback/1', id: session.id, revision: session.revision, sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: session.reviewedSource, status: session.status, checkpoints: session.checkpoints, feedback: session.feedback, feedbackWarnings: session.feedbackWarnings, issues: session.feedback.map(issueFields) }, null, 2) + '\n', 'application/json'); message('Exported source provenance, feedback, validation warnings and issue fields as JSON. Source files remain unchanged.'); };
|
|
744
|
+
button('export-markdown').onclick = () => { const content = [`# ${markdown(session.id)} review feedback`, '', 'Reviewed source:', ...JSON.stringify({ sourceHash: session.sourceHash, referenceBinding:session.referenceBinding, reviewedSource: session.reviewedSource }, null, 2).split('\n').map(line => ' ' + line), '', 'Validation warnings:', ...JSON.stringify(session.feedbackWarnings, null, 2).split('\n').map(line => ' ' + line), '', ...session.feedback.flatMap(item => { const issue = issueFields(item); return [`## ${markdown(issue.title)}`, '', issue.body, '', '---', '']; })].join('\n'); download(`${session.id}-issues.md`, content, 'text/markdown'); message('Exported source provenance, validation warnings and structured issue content as Markdown.'); };
|
|
745
|
+
appearance=bindAppearance(select('appearance'));
|
|
746
|
+
overlays=new RigOverlays({canvas:el<HTMLCanvasElement>('rig-overlay'),rigRoot:el('rig-root')});
|
|
747
|
+
viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
|
|
748
|
+
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
749
|
+
for(const [id,key] of [['layer-images','images'],['layer-mesh','mesh'],['layer-bones','bones']] as const)input(id).onchange=()=>{layers[key]=input(id).checked;redrawOverlay(false);};
|
|
750
|
+
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playPreview();};
|
|
751
|
+
for(const id of ['proposal-panel','checkpoint-detail'])el(id).addEventListener('focusin',pause);
|
|
752
|
+
bindInspector();
|
|
753
|
+
document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
|
|
754
|
+
const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
|
|
755
|
+
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
756
|
+
window.addEventListener('pagehide', () => { persistence?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function start(): Promise<void> {
|
|
760
|
+
try {
|
|
761
|
+
[data, session] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session')]);
|
|
762
|
+
submittedBaseline=structuredClone(session);
|
|
763
|
+
text('package-title', data.manifest.title); text('revision', data.manifest.revision); document.title = `${data.manifest.title} · rig review`; text('source-hash', `source ${data.sourceHash.slice(0, 12)}`); select('review-status').value = session.status; select('feedback-target-kind').value = 'rig'; input('feedback-target-name').value = data.manifest.id;
|
|
764
|
+
select('reference-select').replaceChildren(); for (const reference of data.manifest.references) select('reference-select').append(option(reference.id, `${reference.label} · ${reference.type}`));
|
|
765
|
+
for (const reference of data.manifest.references.filter(item => item.type === 'video')) select('strip-reference').append(option(reference.id, reference.label));
|
|
766
|
+
if (!data.manifest.references.length) { select('reference-select').append(option('', 'No references supplied')); select('reference-select').disabled = true; }
|
|
767
|
+
const sourceFiles = el('source-files'); for (const file of data.manifest.authoring) { const a = document.createElement('a'); a.href = assetUrl(file.path); a.textContent = file.label; a.className = 'source-link'; a.target = '_blank'; a.rel = 'noopener'; sourceFiles.append(a); } if (!data.manifest.authoring.length) sourceFiles.textContent = 'No source files supplied';
|
|
768
|
+
bind(); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackTargets(true); renderFeedbackRange(); renderCheckpoints(); renderProposals(); renderProvenance(); updateSavedState(); renderCues(); initReport();
|
|
769
|
+
const requestedId = new URLSearchParams(location.search).get('checkpoint'),initial=initialReviewSelection(data.manifest,requestedId,data.explicitReference?.id,data.initialAnimation??undefined),first=initial.checkpoint;
|
|
770
|
+
if(initial.referenceId){select('reference-select').value=initial.referenceId;await showReference(initial.referenceId);}
|
|
771
|
+
await initPlayer(first);
|
|
772
|
+
if (first) await chooseCheckpoint(first,initial); else renderCheckpoint();
|
|
773
|
+
activeCamera=player?'rig':'reference';if(cameraSync)alignCameras();renderTransport();fitCamera();
|
|
774
|
+
if (requestedId && !first) message(`Checkpoint "${requestedId}" is not in this package. Select one from the list.`, true);
|
|
775
|
+
else message(data.manifest.model ? 'Record review status and structured feedback while preserving sources. Motion is read only.' : 'No rig supplied. Review the available references and checkpoints.');
|
|
776
|
+
requestAnimationFrame(tick);
|
|
777
|
+
const restored=await setupPersistence();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
778
|
+
controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
779
|
+
} catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
|
|
780
|
+
}
|
|
781
|
+
function showInspector(name:string):void {document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`)?.click();}
|
|
782
|
+
function bindInspector():void {bindInspectorTabs(()=>{if(document.querySelector('[data-inspector="feedback"][aria-selected="true"]'))pause();fitCamera();});}
|
|
783
|
+
|
|
784
|
+
function redrawOverlay(fresh:boolean):void {
|
|
785
|
+
if(!overlays||!player)return;
|
|
786
|
+
const root=el('rig-root'),project=(x:number,y:number)=>({x:parseFloat(root.style.left||'0')+x*rigScale,y:parseFloat(root.style.top||'0')-y*rigScale});
|
|
787
|
+
const value=fresh?overlays.refresh({player,bones:(data.feedbackCatalog.bones??[]).map(name=>({name})),project,settings:layers}):overlays.redraw({project,settings:layers});
|
|
788
|
+
text('layer-state',value.warnings.join(' · ')||'Actual last-drawn mesh edges / bone points');if(!fresh&&value.needsCapture&&!playing&&!seekingControl)drawRig(()=>player!.draw());
|
|
789
|
+
}
|
|
790
|
+
function canRepeatIdle():boolean{return !!player&&idleAvailable(assets?.animations.map(a=>a.name)??[],animation)&&!axisInspection&&input('idle-repeat').checked&&duration>0;}
|
|
791
|
+
function renderIdle():void {text('idle-state',canRepeatIdle()?'Actual idle · repeat enabled':animation==='idle'?'Idle repeat paused':'Non-idle inspection · no automatic playback');}
|
|
792
|
+
async function playPreview():Promise<void> {
|
|
793
|
+
if((!player&&!video&&!sequence)||duration<=0)return;
|
|
794
|
+
const intent=++playbackIntent;desiredPlay=true;idleHeld=false;if(time>=duration)seek(0);
|
|
795
|
+
if(video){try{await video.play();}catch(error){pause();message(`Playback is paused: ${errorText(error)}. Use Play to retry.`,true);return;}}
|
|
796
|
+
if(intent!==playbackIntent||!desiredPlay){if(!desiredPlay)video?.pause();return;}playing=true;lastFrame=0;player?.resume();button('play').textContent='Ⅱ';button('play').setAttribute('aria-label','Pause');
|
|
797
|
+
}
|
|
798
|
+
function localDraft():boolean{return dirty||!!input('feedback-summary').value.trim()||!!input('feedback-start').value.trim()||!!input('feedback-end').value.trim()||!!select('feedback-category').value||!!input('feedback-target-name').value.trim()&&input('feedback-target-name').value!==data.manifest.id;}
|
|
799
|
+
function controlState():ControlInspection {
|
|
800
|
+
const box=assets?.stage??player?.bounds(),center=box?{x:box.x+box.width/2,y:box.y+box.height/2}:null;
|
|
801
|
+
return {view:numericView(),theme:appearance?.get()??'system',layout:'auto',effectiveLayout:el('stage-layout').dataset.orientation==='vertical'?'column':'row',cameras:{rig:{zoom:player?zoom:1,pan:{x:center&&cameraCenter?(center.x-cameraCenter.x)*rigScale:0,y:center&&cameraCenter?(cameraCenter.y-center.y)*rigScale:0}},reference:{zoom:referenceSize?referenceZoom:1,pan:{x:referenceSize&&referenceCenter?(referenceSize.width/2-referenceCenter.x)*referenceScale:0,y:referenceSize&&referenceCenter?(referenceSize.height/2-referenceCenter.y)*referenceScale:0}}},animation:animation||null,time:player?.time??time,playing,loop:canRepeatIdle()?'idle':'off',layers:{images:!!player&&layers.images,meshes:!!player&&layers.mesh,bones:!!player&&layers.bones},ready:!!data,seeking:seekingControl||!!video?.seeking||!!persistence?.restoring(),dirty:localDraft(),busy:saving||stripBusy||!!persistence?.restoring(),feedbackEligible:!persistence?.restoring(),capabilities:{camera:!!player,referenceCamera:!!referenceSize,animation:!!player,time:duration>0,playback:duration>0,layers:{images:!!player,meshes:!!player,bones:!!player}}};
|
|
802
|
+
}
|
|
803
|
+
async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<void> {
|
|
804
|
+
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
805
|
+
if(['animation','time','playback'].includes(command.type)&&(localDraft()||saving||stripBusy))throw new Error('CONTROL_DRAFT_BUSY: preserve review draft');
|
|
806
|
+
if(command.type==='theme'){appearance?.set(command.value);return;}
|
|
807
|
+
if(command.type==='layout'){fitCamera();return;}
|
|
808
|
+
if(command.type==='camera-sync'){setCameraSync(command.value);return;}
|
|
809
|
+
if(command.type==='layers'){if(!player)throw new Error('CONTROL_LAYERS_UNAVAILABLE');Object.assign(layers,{images:command.images,mesh:command.meshes,bones:command.bones});input('layer-images').checked=layers.images;input('layer-mesh').checked=layers.mesh;input('layer-bones').checked=layers.bones;redrawOverlay(false);return;}
|
|
810
|
+
if(command.type==='camera'||command.type==='fit'){
|
|
811
|
+
const target=command.target;if(target!=='rig'&&target!=='reference')throw new Error('CONTROL_CAMERA_MODE: single review has no A/B panes');
|
|
812
|
+
const frame=paneFrame(target);if(!frame)throw new Error('CONTROL_CAMERA_UNAVAILABLE');
|
|
813
|
+
if(command.type==='fit')fitActiveCamera(target);else updateCamera(target,{zoom:command.zoom,pan:{x:command.pan.x/frame.width,y:command.pan.y/frame.height}});return;
|
|
814
|
+
}
|
|
815
|
+
if(command.type==='animation'){if(!assets?.animations.some(a=>a.name===command.name))throw new Error('CONTROL_ANIMATION_UNAVAILABLE');chooseAnimation(command.name);return;}
|
|
816
|
+
if(command.type==='playback'){if(command.value==='pause')pause();else{await playPreview();if(!playing)throw new Error('CONTROL_PLAYBACK_REFUSED');}return;}
|
|
817
|
+
if(command.type==='time'){
|
|
818
|
+
if(!Number.isFinite(command.seconds)||command.seconds<0||command.seconds>duration)throw new Error('CONTROL_TIME_OUTSIDE_DOMAIN');
|
|
819
|
+
pause();seekingControl=true;try{await inspectControlTime(command.seconds,signal);}finally{seekingControl=false;}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
async function inspectControlTime(target:number,signal:AbortSignal):Promise<void> {
|
|
823
|
+
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
824
|
+
const media=video;
|
|
825
|
+
if(!media){if(player){player.seek(target);drawRig(()=>player!.draw());}time=target;renderSequenceFrame();renderTransport();return;}
|
|
826
|
+
if(media.readyState<1||target>media.duration)throw new Error('CONTROL_REFERENCE_UNAVAILABLE');
|
|
827
|
+
const previousPosition=media.currentTime;
|
|
828
|
+
const commit=(actual:number)=>{
|
|
829
|
+
if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');
|
|
830
|
+
if(!Number.isFinite(actual)||actual<0||actual>duration)throw new Error('CONTROL_TIME_OUTSIDE_DOMAIN');
|
|
831
|
+
if(player){player.seek(actual);drawRig(()=>player!.draw());}time=actual;
|
|
832
|
+
controlledVideoSample={media,position:media.currentTime,time:actual};if(axisInspection&&duration>0){const axis=data.manifest.axes.find(item=>item.id===select('axis-select').value&&item.animation===animation);if(axis){const value=axis.min+actual/duration*(axis.max-axis.min);input('axis-value').value=String(value);text('axis-value-label',value.toFixed(3));}}renderSequenceFrame();renderTransport();
|
|
833
|
+
};
|
|
834
|
+
try{const actual=await decodedControlTime(media,target,signal);if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');commit(actual);if(Math.abs(actual-target)>1e-6)message(`Reference decoded ${actual.toFixed(4)} s · requested ${target.toFixed(4)} s. The rig and saved view use the decoded timestamp.`);}
|
|
835
|
+
catch(error){
|
|
836
|
+
// Superseding local/remote input owns its position; never undo it. Other
|
|
837
|
+
// refusals recover the prior reference through the same decoded-frame path.
|
|
838
|
+
if(signal.aborted||media!==video)throw error;
|
|
839
|
+
try{commit(await decodedControlTime(media,previousPosition,signal));}catch(recovery){throw new Error(`Reference inspection failed (${errorText(error)}); prior inspection recovery failed (${errorText(recovery)})`);}
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async function decodedControlTime(media:HTMLVideoElement,target:number,signal:AbortSignal):Promise<number> {
|
|
844
|
+
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
845
|
+
if(!media.seeking&&media.currentTime===target&&controlledVideoSample?.media===media&&controlledVideoSample.position===target)return controlledVideoSample.time;
|
|
846
|
+
return await new Promise<number>((resolve,reject)=>{
|
|
847
|
+
let actual:number|null=null,done=false,handle:number|null=null;
|
|
848
|
+
const cleanup=()=>{clearTimeout(timer);media.removeEventListener('seeked',onSeek);signal.removeEventListener('abort',abort);if(handle!==null)media.cancelVideoFrameCallback(handle);};
|
|
849
|
+
const finish=()=>{if(!done||actual===null)return;cleanup();if(signal.aborted){reject(new Error('CONTROL_SUPERSEDED'));return;}resolve(actual);};
|
|
850
|
+
const onSeek=()=>{done=true;if(typeof media.requestVideoFrameCallback!=='function')actual=media.currentTime;finish();};
|
|
851
|
+
const abort=()=>{cleanup();reject(new Error('CONTROL_SUPERSEDED'));};
|
|
852
|
+
const timer=setTimeout(()=>{cleanup();reject(new Error('CONTROL_REFERENCE_TIMEOUT'));},10000);
|
|
853
|
+
media.addEventListener('seeked',onSeek);signal.addEventListener('abort',abort,{once:true});
|
|
854
|
+
if(typeof media.requestVideoFrameCallback==='function')handle=media.requestVideoFrameCallback((_n,m)=>{handle=null;actual=m.mediaTime;finish();});
|
|
855
|
+
media.currentTime=target;
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
function numericView():ControlView {
|
|
859
|
+
const numeric=(target:CameraTarget)=>{const frame=paneFrame(target,true);if(!frame)return null;const camera=paneCamera(target);return numericCamera(frame.space,camera.center??frame.center,camera.zoom,frame.baseScale*camera.zoom,frame.width,frame.height);};
|
|
860
|
+
return {schema:'rig-review-view/1',cameraSync,activeCamera,cameras:{rig:numeric('rig'),a:null,b:null,reference:numeric('reference')}};
|
|
861
|
+
}
|
|
862
|
+
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
863
|
+
function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
|
|
864
|
+
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:animation||null,time:player?.time??time,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view',partSet:select('part-set').disabled?null:select('part-set').value,axis:axisInspection?{id:select('axis-select').value,value:Number(input('axis-value').value)}:null}};}
|
|
865
|
+
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
866
|
+
await restoreTransaction(()=>prepareInspection(bookmark,signal),()=>({bookmark:inspectionBookmark(),inputs:captureInputs()}),()=>applyInspection(bookmark,signal),async previous=>{await applyInspection(previous.bookmark,new AbortController().signal);restoreInputs(previous.inputs);renderFeedbackRange();});
|
|
867
|
+
}
|
|
868
|
+
/** Read the SELECTED reference without replacing the currently inspected DOM/player. */
|
|
869
|
+
async function inspectionReferenceInfo(id:string|null,signal:AbortSignal):Promise<{width:number;height:number;duration:number|null;people:string[]}> {
|
|
870
|
+
if(!id)return {width:0,height:0,duration:null,people:[]};
|
|
871
|
+
const reference=data.manifest.references.find(r=>r.id===id);if(!reference)throw new Error('Saved reference is not declared');
|
|
872
|
+
if(id===select('reference-select').value&&referenceSize)return {...referenceSize,duration:reference.type==='video'&&video?video.duration:sequence?sequence.frames[sequence.frames.length-1]!.t-sequence.frames[0]!.t:null,people:sequence?[...new Set(sequence.frames.flatMap(f=>f.people.map(p=>p.id)))]:[]};
|
|
873
|
+
if(reference.type==='pose'){const response=await fetch(referenceUrl(reference),{signal});if(!response.ok)throw new Error('Saved pose reference is unavailable');const value:unknown=await response.json(),canonical=readSequence(value);if(canonical)return {width:canonical.width,height:canonical.height,duration:canonical.frames[canonical.frames.length-1]!.t-canonical.frames[0]!.t,people:[...new Set(canonical.frames.flatMap(f=>f.people.map(p=>p.id)))]};const pose=readPose(value);return {width:pose.width,height:pose.height,duration:null,people:[]};}
|
|
874
|
+
if(reference.type==='image')return await new Promise((resolve,reject)=>{const image=new Image(),clean=()=>{clearTimeout(timer);signal.removeEventListener('abort',fail);image.onload=null;image.onerror=null;},fail=()=>{clean();reject(new Error('Saved reference image unavailable'));},timer=setTimeout(fail,10000);image.onload=()=>{clean();resolve({width:image.naturalWidth,height:image.naturalHeight,duration:null,people:[]});};image.onerror=fail;signal.addEventListener('abort',fail,{once:true});image.src=referenceUrl(reference);});
|
|
875
|
+
const media=document.createElement('video');media.preload='metadata';media.muted=true;
|
|
876
|
+
try{media.src=referenceUrl(reference);await waitReferenceMetadata(media,signal);return {width:media.videoWidth,height:media.videoHeight,duration:media.duration,people:[]};}finally{media.pause();media.removeAttribute('src');media.load();}
|
|
877
|
+
}
|
|
878
|
+
async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
879
|
+
const selection=bookmark.selection;
|
|
880
|
+
if(player&&!bookmark.animation||bookmark.animation&&!assets?.animations.some(a=>a.name===bookmark.animation))throw new Error('Saved animation is absent from this rig');
|
|
881
|
+
if(selection.reference&&!data.manifest.references.some(r=>r.id===selection.reference))throw new Error('Saved reference is not declared');
|
|
882
|
+
if(selection.checkpoint&&!data.manifest.checkpoints.some(c=>c.id===selection.checkpoint))throw new Error('Saved checkpoint is absent');
|
|
883
|
+
const axis=selection.axis?data.manifest.axes.find(a=>a.id===selection.axis!.id):null;
|
|
884
|
+
if(selection.axis&&(!axis||selection.axis.value<axis.min||selection.axis.value>axis.max||axis.animation!==bookmark.animation))throw new Error('Saved inspection axis is unavailable or outside its range');
|
|
885
|
+
if(selection.partSet&&!player?.sets().some(set=>set.name===selection.partSet))throw new Error('Saved part preset is absent');
|
|
886
|
+
if(bookmark.view.cameras.a||bookmark.view.cameras.b||bookmark.view.cameras.rig&&!player)throw new Error('Saved camera is unavailable in this single review');
|
|
887
|
+
const info=await inspectionReferenceInfo(selection.reference===undefined?select('reference-select').value:selection.reference,signal);
|
|
888
|
+
if(selection.person&&!info.people.includes(selection.person))throw new Error('Saved pose person is absent');
|
|
889
|
+
const clipDuration=bookmark.animation?assets?.animations.find(a=>a.name===bookmark.animation)?.duration:undefined,referenceId=selection.reference===undefined?select('reference-select').value:selection.reference,isVideo=data.manifest.references.find(r=>r.id===referenceId)?.type==='video';const limit=clipDuration===undefined?info.duration??0:isVideo&&info.duration!==null?Math.min(clipDuration,info.duration):clipDuration;
|
|
890
|
+
if(bookmark.time!==null&&(bookmark.time<0||bookmark.time>limit&&limit>=0))throw new Error('Saved inspection time is outside the selected clip/reference');
|
|
891
|
+
const rig=bookmark.view.cameras.rig,ref=bookmark.view.cameras.reference;
|
|
892
|
+
if(rig&&!player)throw new Error('Saved rig camera is unavailable');
|
|
893
|
+
if(ref&&(info.width<=0||info.height<=0))throw new Error('Saved reference camera has no selected image/video/pose');
|
|
894
|
+
// Viewport aspect, Inspector and pose bounds may differ from the current view.
|
|
895
|
+
// Range fitting belongs to the transactional apply after that layout is measured.
|
|
896
|
+
if(signal.aborted)throw new Error('Inspection restore superseded');
|
|
897
|
+
}
|
|
898
|
+
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
899
|
+
cameraRestoring=true;try{
|
|
900
|
+
const selection=bookmark.selection;
|
|
901
|
+
pause();
|
|
902
|
+
if(selection.checkpoint!==undefined){checkpoint=data.manifest.checkpoints.find(c=>c.id===selection.checkpoint);renderCheckpoint();renderCheckpoints();}
|
|
903
|
+
if(selection.reference!==undefined&&select('reference-select').value!==(selection.reference??'')){select('reference-select').value=selection.reference??'';await showReference(selection.reference??'');if(selection.reference&&!referenceSize&& !video)throw new Error('Saved reference did not load');}
|
|
904
|
+
if(bookmark.animation&&player)chooseAnimation(bookmark.animation);
|
|
905
|
+
if(selection.axis){select('axis-select').value=selection.axis.id;chooseAxis();input('axis-value').value=String(selection.axis.value);applyAxis();}
|
|
906
|
+
if(selection.partSet!==undefined&&player){if(selection.partSet||player.sets().length)player.setSet(selection.partSet||null);select('part-set').value=selection.partSet??'';}
|
|
907
|
+
if(bookmark.time!==null){if(video&&video.readyState<1)await waitReferenceMetadata(video,signal);await inspectControlTime(bookmark.time,signal);}
|
|
908
|
+
if(selection.person!==undefined){selectedPerson=selection.person??'';select('person-select').value=selectedPerson;lastPoseFrame=-1;renderSequenceFrame();}
|
|
909
|
+
if(selection.view)setView(selection.view);
|
|
910
|
+
appearance?.set(bookmark.theme);
|
|
911
|
+
Object.assign(layers,{images:bookmark.layers.images,mesh:bookmark.layers.meshes,bones:bookmark.layers.bones});input('layer-images').checked=layers.images;input('layer-mesh').checked=layers.mesh;input('layer-bones').checked=layers.bones;
|
|
912
|
+
if(selection.inspector)showInspector(selection.inspector);syncStageLayout(el('stage-layout'));fitCamera();
|
|
913
|
+
const camera=bookmark.view.cameras.rig,ref=bookmark.view.cameras.reference,rigViewport=el('rig-viewport'),referenceViewport=el('reference-viewport');
|
|
914
|
+
const nextZoom=camera&&player&&rigViewport.clientWidth&&rigViewport.clientHeight?restoredZoom(camera,rigScale/zoom,rigViewport.clientWidth,rigViewport.clientHeight):camera?.zoom??null;
|
|
915
|
+
const nextReferenceZoom=ref&&referenceSize&&referenceViewport.clientWidth&&referenceViewport.clientHeight?restoredZoom(ref,referenceScale/referenceZoom,referenceViewport.clientWidth,referenceViewport.clientHeight):ref?.zoom??null;
|
|
916
|
+
if(nextZoom!==null&&camera){zoom=nextZoom;cameraCenter={...camera.center};}
|
|
917
|
+
if(nextReferenceZoom!==null&&ref){referenceZoom=nextReferenceZoom;referenceCenter={...ref.center};}
|
|
918
|
+
cameraSync=bookmark.view.cameraSync??false;activeCamera=bookmark.view.activeCamera==='reference'?'reference':'rig';
|
|
919
|
+
fitCamera();redrawOverlay(false);pause();renderTransport();renderCameraControls();
|
|
920
|
+
}finally{cameraRestoring=false;}
|
|
921
|
+
}
|
|
922
|
+
async function waitReferenceMetadata(media:HTMLVideoElement,signal:AbortSignal):Promise<void> {if(media.readyState>=1)return;await new Promise<void>((resolve,reject)=>{const clean=()=>{clearTimeout(timer);media.removeEventListener('loadedmetadata',ready);media.removeEventListener('error',fail);signal.removeEventListener('abort',fail);};const ready=()=>{clean();resolve();},fail=()=>{clean();reject(new Error('Saved reference metadata unavailable'));};const timer=setTimeout(fail,10000);media.addEventListener('loadedmetadata',ready,{once:true});media.addEventListener('error',fail,{once:true});signal.addEventListener('abort',fail,{once:true});});}
|
|
923
|
+
async function setupPersistence():Promise<boolean> {
|
|
924
|
+
persistence=await connectInspectionPersistence({scope:'single:'+data.manifest.id,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('review-status').value=session.status;renderProposals();renderCheckpoint();renderCheckpoints();updateSavedState();},bookmark:inspectionBookmark,restoreBookmark:restoreInspection,
|
|
925
|
+
ui:()=>({reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();},afterInputs:()=>{const target=input('feedback-target-name').value;renderFeedbackTargets(true);if(select('feedback-target-kind').value!=='rig')input('feedback-target-name').value=target;renderFeedbackRange();text('audio-volume-label',`${Math.round(Number(input('audio-volume').value)*100)}%`);},onConflict:value=>{draftConflict=value;updateSavedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;}});
|
|
926
|
+
return persistence.restored;
|
|
927
|
+
}
|
|
928
|
+
void start();
|