@rig-c/review 0.13.1 → 0.15.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 +40 -705
- package/docs/DESIGN.md +12 -1
- package/docs/PANELS.md +412 -0
- package/docs/REVIEW_PACKAGE.md +42 -1
- package/docs/WORKFLOWS.md +343 -0
- package/package.json +4 -1
- package/src/cli.ts +5 -3
- package/src/comment-crops.ts +30 -0
- package/src/compare-session.ts +7 -2
- package/src/feedback-export.ts +24 -6
- package/src/feedback-validation.ts +11 -0
- package/src/manifest.ts +4 -3
- package/src/server.ts +1 -1
- package/src/session.ts +44 -10
- package/src/stage-comment.ts +89 -0
- package/src/turn-pad.ts +60 -0
- package/web/compare.css +13 -0
- package/web/compare.html +1 -1
- package/web/compare.ts +45 -12
- package/web/index.html +1 -1
- package/web/inspection-persistence.ts +9 -3
- package/web/main.ts +72 -26
- package/web/stage-comments.ts +107 -0
- package/web/styles.css +13 -0
- package/web/turn-pad.ts +26 -0
- package/web/viewport-capture.ts +73 -0
package/web/main.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import {timeOnlyBasis,feedbackTimingLabel,requireFeedbackFrames,isTimeOnlyFeedback} from '../src/stage-comment.ts';
|
|
2
|
+
import {bindStageComments,type CommentPose} from './stage-comments.ts';
|
|
3
|
+
import {discoverTurnDials,TurnState} from '../src/turn-pad.ts';
|
|
4
|
+
import {bindTurnPad,type TurnPad} from './turn-pad.ts';
|
|
1
5
|
import {PartExplorer,loadPartModel,partBounds} from './part-selection.ts';
|
|
2
6
|
import {AnimationInspectionPanel} from './animation-inspection.ts';
|
|
3
7
|
import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
|
|
4
|
-
import { loadModel, RigPlayer, type BoneAdjust, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
8
|
+
import { CanvasRenderer, loadModel, RigPlayer, type BoneAdjust, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
5
9
|
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
|
|
6
10
|
import {rendererStatus} from './renderer-status.ts';
|
|
7
11
|
import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,bindLayerShortcuts,describeLayerToggles,type ViewPoint} from './view-controls.ts';
|
|
@@ -31,7 +35,7 @@ import {connectControl} from './control.ts';
|
|
|
31
35
|
import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
|
|
32
36
|
import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
|
|
33
37
|
import {singleEligibility,videoReferenceAt,type InputState,type SingleLayout} from '../src/comparison-eligibility.ts';
|
|
34
|
-
import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
38
|
+
import {parseBookmark,connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
35
39
|
import {initialPlayerAnimation,keptAnimation} from '../src/animation-selection.ts';
|
|
36
40
|
import {initialReviewSelection} from '../src/open-selection.ts';
|
|
37
41
|
import type {ReferenceBinding} from '../src/reference.ts';
|
|
@@ -60,7 +64,7 @@ import {assertRigFieldOps,RIG_FIELDS_READOUT_BARS,TURN_BONE} from '../src/rig-fi
|
|
|
60
64
|
import {IDLE_READOUT_BARS,type LocalChannel} from '../src/idle-tracks.ts';
|
|
61
65
|
import type {PatchOp} from '../src/config-patch.ts';
|
|
62
66
|
|
|
63
|
-
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 } }
|
|
67
|
+
interface ReviewResponse { referenceHashes?:Record<string,string>; 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 } }
|
|
64
68
|
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 }[] }
|
|
65
69
|
interface SequenceJoint { state: 'observed' | 'occluded' | 'missing' | 'inferred'; at?: [number, number]; score?: number }
|
|
66
70
|
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 }
|
|
@@ -97,6 +101,7 @@ let lastFrame = 0;
|
|
|
97
101
|
type CameraTarget='rig'|'reference';
|
|
98
102
|
let cameraSync=true,activeCamera:CameraTarget='rig',cameraRestoring=false,cameraLayout='';
|
|
99
103
|
const cameraFrames:Partial<Record<CameraTarget,CameraFrame>>={};
|
|
104
|
+
let comments:ReturnType<typeof bindStageComments>|null=null;
|
|
100
105
|
let view: 'compare' | 'reference' | 'rig' = 'compare';
|
|
101
106
|
// Requested layout persists in the source-bound draft; the effective one follows actual load events only.
|
|
102
107
|
let rigInput:InputState={state:'pending'},referenceInput:InputState={state:'absent'},effectiveView:SingleLayout|null=null;
|
|
@@ -155,7 +160,8 @@ let joltQueue:JoltStep[]=[],environmentNote='';
|
|
|
155
160
|
let idlePanel:IdlePanel|null=null;
|
|
156
161
|
/** Setup · Rig fields (#110): the panel; its turn dial and the idle preview share `player.adjust` (both run, idle first). */
|
|
157
162
|
let rigFieldsPanel:RigFieldsPanel|null=null,idleAdjust:BoneAdjust|null=null,dialAdjust:BoneAdjust|null=null;
|
|
158
|
-
|
|
163
|
+
let turnPad:TurnPad|null=null,turnAdjust:BoneAdjust|null=null;
|
|
164
|
+
function setAdjust():void {if(!player)return;player.adjust=composeAdjust(composeAdjust(idleAdjust,dialAdjust),turnAdjust);if(!playing)drawRig(()=>player!.draw());}
|
|
159
165
|
/** #106: the Setup Mesh panel, and the overlay's last snapshot (bar 1 reads its per-slot triangle count). */
|
|
160
166
|
let meshPanel:MeshPanel|null=null,lastOverlay:OverlaySnapshot|null=null,densityStrip:DensityStripPanel|null=null;
|
|
161
167
|
const physicsPreview=new PhysicsPreview();
|
|
@@ -639,6 +645,14 @@ function tick(now: number): void {
|
|
|
639
645
|
requestAnimationFrame(tick);
|
|
640
646
|
}
|
|
641
647
|
|
|
648
|
+
function configureTurnPad(model:unknown):void {
|
|
649
|
+
const previous=turnPad?.snapshot();turnPad?.dispose();turnPad=null;turnAdjust=null;document.getElementById('turn-pad-host')?.remove();
|
|
650
|
+
const dials=discoverTurnDials(model,assets?.animations);if(!dials.length){setAdjust();return;}
|
|
651
|
+
const host=document.createElement('div');host.id='turn-pad-host';el('stage-layout').before(host);
|
|
652
|
+
turnPad=bindTurnPad({host,stages:[el('rig-viewport')],dials,blocked:()=>!!persistence?.restoring()||!!document.querySelector('dialog[open]')||!!rebuildBusy,
|
|
653
|
+
draw:state=>{if(!player)return;turnAdjust=state.adjust();setAdjust();if(playing)drawRig(()=>player!.draw());},save:()=>persistence?.save(),error:value=>message(value,true)});
|
|
654
|
+
if(previous&&turnPad){try{turnPad.restore(previous);}catch{message('TURN_STATE_MODEL_MISMATCH: turn values reset for this build.',true);}}
|
|
655
|
+
}
|
|
642
656
|
async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
643
657
|
text('renderer-state',rendererStatus(null,'pending').label);
|
|
644
658
|
if (!data.manifest.model) { text('player-state', 'No rig supplied · reference review is available'); return; }
|
|
@@ -651,7 +665,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
651
665
|
} });
|
|
652
666
|
player = new RigPlayer(el('rig-root'), assets);
|
|
653
667
|
// Parts inspection is an add-on: a catalog refusal is shown in the explorer and never aborts the rig.
|
|
654
|
-
try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
668
|
+
try{const catalog=await loadPartModel(modelUrl,data.reviewedSource.rig!.sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;configureTurnPad(catalog.model);refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
655
669
|
player.on('event', event => { text('event-log', `Runtime event: ${event.name} · ${event.time.toFixed(2)} s`); });
|
|
656
670
|
const animations = select('animation-select'); animations.replaceChildren();
|
|
657
671
|
for (const item of assets.animations) animations.append(option(item.name, data.manifest.animations.find(a => a.id === item.name)?.label ?? item.name));
|
|
@@ -668,7 +682,7 @@ async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
|
668
682
|
enableEnvironment(modelUrl);
|
|
669
683
|
describeLayerToggles(document,el('layer-state').textContent??'');
|
|
670
684
|
fitCamera();
|
|
671
|
-
} 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); }
|
|
685
|
+
} catch (error) { turnPad?.dispose();turnPad=null;document.getElementById('turn-pad-host')?.remove();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); }
|
|
672
686
|
}
|
|
673
687
|
|
|
674
688
|
function chooseAxis(): void {
|
|
@@ -685,18 +699,19 @@ function applyAxis(): void {
|
|
|
685
699
|
}
|
|
686
700
|
|
|
687
701
|
function renderProposals(): void {
|
|
702
|
+
comments?.refresh();
|
|
688
703
|
const area = el('proposal-list'); area.replaceChildren();
|
|
689
704
|
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
690
705
|
const unchecked = session.feedback.map(f => appliedCheckRefusal((f.context as Record<string, unknown> | undefined)?.applied)).find(Boolean) ?? null;
|
|
691
706
|
el('feedback-applied-check').hidden = !unchecked; text('feedback-applied-check', unchecked ? `${unchecked} · Submit refuses this review; delete the entry written on that build or Apply a build that passes check.` : '');
|
|
692
707
|
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. The saved session that rig-review feedback exports keeps both the original records and warnings.` : '';
|
|
693
708
|
for (const feedback of session.feedback) {
|
|
694
|
-
const item = document.createElement('div'); item.className = 'proposal-item'; const title = document.createElement('strong'); title.textContent = `${feedback.category} · ${feedback.target.kind}:${feedback.target.name} ·
|
|
709
|
+
const item = document.createElement('div'); item.className = 'proposal-item'; const title = document.createElement('strong'); title.textContent = `${feedback.category} · ${feedback.target.kind}:${feedback.target.name} · ${feedbackTimingLabel(feedback)}`; const description = document.createElement('span'); description.textContent = feedback.summary;
|
|
695
710
|
item.append(title, description);
|
|
696
711
|
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); }
|
|
697
712
|
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);
|
|
698
713
|
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(); };
|
|
699
|
-
item.append(details
|
|
714
|
+
item.append(details);if((feedback.context as any)?.comment?.schema!=='rig-review-comment/1'||!submittedBaseline?.feedback.some(f=>f.id===feedback.id))item.append(remove); area.append(item);
|
|
700
715
|
}
|
|
701
716
|
updateSavedState();
|
|
702
717
|
}
|
|
@@ -742,7 +757,7 @@ async function exportStrip(): Promise<void> {
|
|
|
742
757
|
for (const feedback of session.feedback) {
|
|
743
758
|
const context = feedback.context;
|
|
744
759
|
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.`);
|
|
745
|
-
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: 'single',
|
|
760
|
+
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: 'single', ...requireFeedbackFrames(feedback) });
|
|
746
761
|
}
|
|
747
762
|
} else {
|
|
748
763
|
const start = input('feedback-start').value.trim(), end = input('feedback-end').value.trim();
|
|
@@ -850,7 +865,7 @@ function bind(): void {
|
|
|
850
865
|
try { validateFeedbackSelection(data.feedbackCatalog, { frameStart, frameEnd, target, animation: selectedAnimation }); } catch (error) { message(errorText(error), true); return; }
|
|
851
866
|
const box = assets?.stage ?? player?.bounds();
|
|
852
867
|
const center = cameraCenter ?? (box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null);
|
|
853
|
-
session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {part:parts?.feedback()??null,
|
|
868
|
+
session.feedback.push({ id: crypto.randomUUID(), frameStart, frameEnd, target, category: select('feedback-category').value as Feedback['category'], summary, context: {...(turnPad?{turn:JSON.parse(JSON.stringify(turnPad.snapshot())) as Json}:{}),part:parts?.feedback()??null,
|
|
854
869
|
interval: { frameStart, frameEnd, inclusive: true },
|
|
855
870
|
checkpoint: checkpoint?.id ?? null, animation: selectedAnimation, time, frame: frameAtTime(activeFrameClock(), time),
|
|
856
871
|
reference: select('reference-select').value || null, person: selectedPerson || null,
|
|
@@ -878,7 +893,7 @@ function bind(): void {
|
|
|
878
893
|
saving = true; updateSavedState(); const savedPayload = JSON.stringify(session);
|
|
879
894
|
try { const saved = await api<ReviewSession>('/api/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: savedPayload });
|
|
880
895
|
if (JSON.stringify(session) === savedPayload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;
|
|
881
|
-
await persistence?.submitted(saved);renderProvenance();
|
|
896
|
+
submittedBaseline=structuredClone(saved);comments?.refresh();await persistence?.submitted(saved);renderProvenance();renderProposals();
|
|
882
897
|
message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status, feedback and source provenance.');
|
|
883
898
|
} catch (error) { message(`Submission failed: ${errorText(error)}`, true); } finally { saving = false; updateSavedState(); }
|
|
884
899
|
};
|
|
@@ -931,7 +946,7 @@ function bind(): void {
|
|
|
931
946
|
requirementsPanel=bindRequirementsPanel({check:runRequirements,seek:seekReading,animation:()=>animation,slot:()=>parts?.value()?.slot??null,hash:sha256Hex});
|
|
932
947
|
const tickResize=new ResizeObserver(()=>renderReadingTicks());tickResize.observe(el('transport'));viewDisposers.push(()=>tickResize.disconnect());
|
|
933
948
|
viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
|
|
934
|
-
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
949
|
+
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy)),()=>!comments?.active()));
|
|
935
950
|
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);};
|
|
936
951
|
viewDisposers.push(bindLayerShortcuts(document));describeLayerToggles(document,el('layer-state').textContent??'');
|
|
937
952
|
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playPreview();};
|
|
@@ -941,7 +956,7 @@ function bind(): void {
|
|
|
941
956
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
|
|
942
957
|
const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
|
|
943
958
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
944
|
-
window.addEventListener('pagehide', () => { persistence?.dispose();workspace?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
|
|
959
|
+
window.addEventListener('pagehide', () => { comments?.dispose();persistence?.dispose();turnPad?.dispose();workspace?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
|
|
945
960
|
}
|
|
946
961
|
|
|
947
962
|
async function start(): Promise<void> {
|
|
@@ -971,13 +986,13 @@ async function start(): Promise<void> {
|
|
|
971
986
|
else if (readOnly) message(readOnly, true);
|
|
972
987
|
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.');
|
|
973
988
|
requestAnimationFrame(tick);
|
|
974
|
-
const restored=await setupPersistence();await loadRebuildStatus();void loadRequirements();renderChest();restoreEnvironment();physicsPanel?.restore();bonesPanel?.restore();cutsPanel?.restore();clothPanel?.restore();idlePanel?.restore();rigFieldsPanel?.restore();meshPanel?.restore();void refreshDensityStrip();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
989
|
+
const restored=await setupPersistence();setupComments();await loadRebuildStatus();void loadRequirements();renderChest();restoreEnvironment();physicsPanel?.restore();bonesPanel?.restore();cutsPanel?.restore();clothPanel?.restore();idlePanel?.restore();rigFieldsPanel?.restore();meshPanel?.restore();void refreshDensityStrip();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
|
|
975
990
|
// Only an instance that serves agent control is asked for its config: a plain open has no control endpoint (#164).
|
|
976
991
|
if(!readOnly&&writes.control!==false)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
977
992
|
} catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
|
|
978
993
|
}
|
|
979
994
|
/** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
|
|
980
|
-
function showInspector(name:string,reveal=true):void {if(name==='feedback'){if(reveal)openReview();return;}if(reveal)workspace?.revealInspector();document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`)
|
|
995
|
+
function showInspector(name:string,reveal=true):void {if(name==='feedback'){if(reveal)openReview();return;}if(reveal)workspace?.revealInspector();const selector=document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`);if(selector&&selector.getAttribute('aria-pressed')!=='true'&&selector.getAttribute('aria-selected')!=='true')selector.click();}
|
|
981
996
|
function bindInspector():void {bindInspectorTabs(()=>{workspace?.afterTab();fitCamera();},()=>workspace?.beforeTab());}
|
|
982
997
|
/** Opening the review pauses playback, as selecting Feedback did. Its inputs stay in the document whether or not it is open, so the draft keeps them. */
|
|
983
998
|
function openReview():void {const dialog=el<HTMLDialogElement>('review-dialog');for(const drawer of document.querySelectorAll<HTMLDialogElement>('dialog.workspace-drawer[open]'))drawer.close();pause();if(!dialog.open)dialog.showModal();}
|
|
@@ -1008,7 +1023,7 @@ async function playPreview():Promise<void> {
|
|
|
1008
1023
|
if(video&&referenceInput.state==='loaded'){try{await video.play();}catch(error){pause();message(`Playback is paused: ${errorText(error)}. Use Play to retry.`,true);return;}}
|
|
1009
1024
|
if(intent!==playbackIntent||!desiredPlay){if(!desiredPlay)video?.pause();return;}playing=true;lastFrame=0;player?.resume();button('play').textContent='Ⅱ';button('play').setAttribute('aria-label','Pause');
|
|
1010
1025
|
}
|
|
1011
|
-
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;}
|
|
1026
|
+
function localDraft():boolean{return !!comments?.draft()||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;}
|
|
1012
1027
|
function controlState():ControlInspection {
|
|
1013
1028
|
const box=assets?.stage??player?.bounds(),center=box?{x:box.x+box.width/2,y:box.y+box.height/2}:null;
|
|
1014
1029
|
return {part:parts?.value()??null,partGeneration:parts?.observation()?.generation??null,view:numericView(),theme:appearance?.get()??'system',layout:'auto',effectiveLayout:el('stage-layout').dataset.orientation==='vertical'?'column':'row',cameras:{rig:{zoom:player?zoom:1,pan:{x:center&&cameraCenter?(center.x-cameraCenter.x)*rigScale:0,y:center&&cameraCenter?(cameraCenter.y-center.y)*rigScale:0}},reference:{zoom:referenceSize?referenceZoom:1,pan:{x:referenceSize&&referenceCenter?(referenceSize.width/2-referenceCenter.x)*referenceScale:0,y:referenceSize&&referenceCenter?(referenceSize.height/2-referenceCenter.y)*referenceScale:0}}},animation:animation||null,time:player?.time??time,playing,loop:canRepeatIdle()?'idle':'off',layers:{images:!!player&&layers.images,meshes:!!player&&layers.mesh,bones:!!player&&layers.bones},ready:!!data,seeking:seekingControl||!!video?.seeking||!!persistence?.restoring(),dirty:localDraft(),busy:saving||stripBusy||!!persistence?.restoring(),feedbackEligible:!persistence?.restoring(),comparison:controlComparison(layoutEligibility()),capabilities:{camera:!!player,referenceCamera:!!referenceSize,animation:!!player,time:duration>0,playback:duration>0,layers:{images:!!player,meshes:!!player,bones:!!player}}};
|
|
@@ -1032,7 +1047,7 @@ async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<v
|
|
|
1032
1047
|
pause();seekingControl=true;try{await inspectControlTime(command.seconds,signal);}finally{seekingControl=false;}
|
|
1033
1048
|
}
|
|
1034
1049
|
}
|
|
1035
|
-
async function inspectControlTime(target:number,signal:AbortSignal):Promise<void> {
|
|
1050
|
+
async function inspectControlTime(target:number,signal:AbortSignal,preserveSeconds=false):Promise<void> {
|
|
1036
1051
|
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
1037
1052
|
// #191: a control seek is a clip move like the transport's: a seek to 0 restarts the wind clock while the exported world is in use.
|
|
1038
1053
|
clipMoved({kind:'seek',to:target});
|
|
@@ -1043,7 +1058,8 @@ async function inspectControlTime(target:number,signal:AbortSignal):Promise<void
|
|
|
1043
1058
|
const commit=(actual:number)=>{
|
|
1044
1059
|
if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');
|
|
1045
1060
|
if(!Number.isFinite(actual)||actual<0||actual>duration)throw new Error('CONTROL_TIME_OUTSIDE_DOMAIN');
|
|
1046
|
-
|
|
1061
|
+
const seconds=preserveSeconds?target:actual;
|
|
1062
|
+
if(player){player.seek(seconds);drawRig(()=>player!.draw());}time=seconds;
|
|
1047
1063
|
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();
|
|
1048
1064
|
};
|
|
1049
1065
|
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.`);}
|
|
@@ -1076,9 +1092,9 @@ function numericView():ControlView {
|
|
|
1076
1092
|
}
|
|
1077
1093
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
1078
1094
|
function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
|
|
1079
|
-
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:animation||null,time:player?.time??time,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:selectedInspector()??'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}};}
|
|
1080
|
-
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1081
|
-
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();});
|
|
1095
|
+
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:{...(turnPad?{turn:turnPad.snapshot()}:{}),part:parts?.value()??null,view,checkpoint:checkpoint?.id??null,reference:select('reference-select').value||null,person:selectedPerson||null,inspector:selectedInspector()??'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}};}
|
|
1096
|
+
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal,preserveSeconds=false):Promise<void> {
|
|
1097
|
+
await restoreTransaction(()=>prepareInspection(bookmark,signal),()=>({bookmark:inspectionBookmark(),inputs:captureInputs()}),()=>applyInspection(bookmark,signal,preserveSeconds),async previous=>{await applyInspection(previous.bookmark,new AbortController().signal);restoreInputs(previous.inputs);renderFeedbackRange();});
|
|
1082
1098
|
}
|
|
1083
1099
|
/** Read the SELECTED reference without replacing the currently inspected DOM/player. */
|
|
1084
1100
|
async function inspectionReferenceInfo(id:string|null,signal:AbortSignal):Promise<{width:number;height:number;duration:number|null;people:string[]}> {
|
|
@@ -1092,6 +1108,7 @@ async function inspectionReferenceInfo(id:string|null,signal:AbortSignal):Promis
|
|
|
1092
1108
|
}
|
|
1093
1109
|
async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1094
1110
|
const selection=bookmark.selection;
|
|
1111
|
+
if(selection.turn){if(!turnPad)throw new Error('TURN_UNAVAILABLE');turnPad.state.validate(selection.turn);}
|
|
1095
1112
|
parts?.validate(selection.part);
|
|
1096
1113
|
if(player&&!bookmark.animation||bookmark.animation&&!assets?.animations.some(a=>a.name===bookmark.animation))throw new Error('Saved animation is absent from this rig');
|
|
1097
1114
|
if(selection.reference&&!data.manifest.references.some(r=>r.id===selection.reference))throw new Error('Saved reference is not declared');
|
|
@@ -1111,9 +1128,10 @@ async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal)
|
|
|
1111
1128
|
// Range fitting belongs to the transactional apply after that layout is measured.
|
|
1112
1129
|
if(signal.aborted)throw new Error('Inspection restore superseded');
|
|
1113
1130
|
}
|
|
1114
|
-
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1131
|
+
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal,preserveSeconds=false):Promise<void> {
|
|
1115
1132
|
cameraRestoring=true;try{
|
|
1116
1133
|
const selection=bookmark.selection;
|
|
1134
|
+
if(turnPad){if(selection.turn)turnPad.restore(selection.turn);else{turnPad.state.reset();turnPad.restore(turnPad.snapshot());}}
|
|
1117
1135
|
parts?.select(selection.part??null);
|
|
1118
1136
|
pause();
|
|
1119
1137
|
if(selection.checkpoint!==undefined){checkpoint=data.manifest.checkpoints.find(c=>c.id===selection.checkpoint);renderCheckpoint();renderCheckpoints();}
|
|
@@ -1121,12 +1139,15 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
|
|
|
1121
1139
|
if(bookmark.animation&&player)chooseAnimation(bookmark.animation);
|
|
1122
1140
|
if(selection.axis){select('axis-select').value=selection.axis.id;chooseAxis();input('axis-value').value=String(selection.axis.value);applyAxis();}
|
|
1123
1141
|
if(selection.partSet!==undefined&&player){if(selection.partSet||player.sets().length)player.setSet(selection.partSet||null);select('part-set').value=selection.partSet??'';}
|
|
1124
|
-
if(bookmark.time!==null){if(video&&video.readyState<1)await waitReferenceMetadata(video,signal);await inspectControlTime(bookmark.time,signal);}
|
|
1142
|
+
if(bookmark.time!==null){if(video&&video.readyState<1)await waitReferenceMetadata(video,signal);await inspectControlTime(bookmark.time,signal,preserveSeconds);}
|
|
1125
1143
|
if(selection.person!==undefined){selectedPerson=selection.person??'';select('person-select').value=selectedPerson;lastPoseFrame=-1;renderSequenceFrame();}
|
|
1126
1144
|
if(selection.view)setView(selection.view);
|
|
1127
1145
|
appearance?.set(bookmark.theme);
|
|
1128
1146
|
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;
|
|
1129
|
-
if(selection.inspector)showInspector(selection.inspector,false);syncStageLayout(el('stage-layout'));
|
|
1147
|
+
if(selection.inspector)showInspector(selection.inspector,false);syncStageLayout(el('stage-layout'));
|
|
1148
|
+
// Mode changes schedule workspace layout and resize observers. Settle them
|
|
1149
|
+
// while restoration is held, before measuring/applying the saved pane camera.
|
|
1150
|
+
await new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())));if(signal.aborted)throw new Error('Inspection restore superseded');fitCamera();
|
|
1130
1151
|
const camera=bookmark.view.cameras.rig,ref=bookmark.view.cameras.reference,rigViewport=el('rig-viewport'),referenceViewport=el('reference-viewport');
|
|
1131
1152
|
const nextZoom=camera&&player&&rigViewport.clientWidth&&rigViewport.clientHeight?restoredZoom(camera,rigScale/zoom,rigViewport.clientWidth,rigViewport.clientHeight):camera?.zoom??null;
|
|
1132
1153
|
const nextReferenceZoom=ref&&referenceSize&&referenceViewport.clientWidth&&referenceViewport.clientHeight?restoredZoom(ref,referenceScale/referenceZoom,referenceViewport.clientWidth,referenceViewport.clientHeight):ref?.zoom??null;
|
|
@@ -1138,7 +1159,7 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
|
|
|
1138
1159
|
}
|
|
1139
1160
|
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});});}
|
|
1140
1161
|
async function setupPersistence():Promise<boolean> {
|
|
1141
|
-
persistence=await connectInspectionPersistence({scope:'single:'+data.manifest.id+':'+data.sourceHash,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('review-status').value=session.status;renderProposals();renderCheckpoint();renderCheckpoints();updateSavedState();},bookmark:inspectionBookmark,restoreBookmark:restoreInspection,
|
|
1162
|
+
persistence=await connectInspectionPersistence({scope:'single:'+data.manifest.id+':'+data.sourceHash,binding:inspectionBinding(),baseline:submittedBaseline!,session:()=>session,reviewDirty:()=>dirty,restoreSession:(value,isDirty)=>{session=value;dirty=isDirty;select('review-status').value=session.status;renderProposals();renderCheckpoint();renderCheckpoints();updateSavedState();},bookmark:inspectionBookmark,restoreBookmark:(b,s)=>restoreInspection(b,s,session.feedback.some(f=>isTimeOnlyFeedback(f)&&JSON.stringify((f.context as any).comment.bookmark)===JSON.stringify(b))),
|
|
1142
1163
|
ui:()=>({inspectorScroll:workspace?.scroll()??'{}',...(inspectorBlocks?activeBlocksUI(inspectorBlocks.state()):{}),reportCategory:select('report-category').value,reportPage,reportEvent:selectedReportEvent?.id??null,reportTime:selectedReportTime??null,reportTarget:selectedReportTarget??null}),restoreUI:value=>{if(typeof value.reportCategory==='string'&&[...select('report-category').options].some(o=>o.value===value.reportCategory))select('report-category').value=value.reportCategory;if(typeof value.reportPage==='number'&&Number.isSafeInteger(value.reportPage)&&value.reportPage>=0)reportPage=value.reportPage;selectedReportEvent=typeof value.reportEvent==='string'?data.report?.categories.flatMap(c=>c.events).find(e=>e.id===value.reportEvent)??null:null;selectedReportTime=typeof value.reportTime==='number'?value.reportTime:undefined;selectedReportTarget=typeof value.reportTarget==='number'?value.reportTarget:undefined;renderReportEvents();renderReportSelection();workspace?.restoreScroll(value.inspectorScroll);inspectorBlocks?.restore(restoreActiveBlocksUI(value));},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;text('review-draft-state',value);}});
|
|
1143
1164
|
return persistence.restored;
|
|
1144
1165
|
}
|
|
@@ -1936,7 +1957,7 @@ async function swapRig(modelUrl:string,prefix:string,sha256:string,world:StageWo
|
|
|
1936
1957
|
// A scratch build may hold another set of animations (a retarget adds one, #114): the picker lists what this build has.
|
|
1937
1958
|
{const animations=select('animation-select');animations.replaceChildren(...assets.animations.map(item=>option(item.name,data.manifest.animations.find(a=>a.id===item.name)?.label??item.name)));animations.disabled=assets.animations.length===0;}
|
|
1938
1959
|
player.on('event',event=>{text('event-log',`Runtime event: ${event.name} · ${event.time.toFixed(2)} s`);});
|
|
1939
|
-
try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
1960
|
+
try{const catalog=await loadPartModel(modelUrl,sha256,data.feedbackCatalog.slots??[]);inspectionSourceState='ready';partModel=catalog.model;configureTurnPad(catalog.model);refreshAnimationInspection();weightsView?.refresh();parts?.register('single',catalog.entries,player,new PartThumbnails(pageRegionTable(catalog.model),assets.pageImages));}catch(error){inspectionSourceState='failed';partModel=null;refreshAnimationInspection();parts?.refuseCatalog('single',error);}
|
|
1940
1961
|
if(keepSet){try{player.setSet(keepSet);}catch{select('part-set').value='';player.setSet(null);}}
|
|
1941
1962
|
// The same world before the first pose: a physics Apply resumes the wind (clock and pulse), the pending jolts, gravity and
|
|
1942
1963
|
// whether the controls are engaged as they stood when Apply was pressed; then the seek re-poses under that world.
|
|
@@ -2096,3 +2117,28 @@ async function meshFolds():Promise<string[]> {
|
|
|
2096
2117
|
return lines;
|
|
2097
2118
|
}
|
|
2098
2119
|
void start();
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
/** Only live, unrecorded authoring effects block capture; unsent form ops alone do not. */
|
|
2123
|
+
function commentCaptureRefusal():string|null {
|
|
2124
|
+
if(appliedOnStage)return 'COMMENT_APPLIED_BUILD_UNSUPPORTED: return to the inspected source before commenting on this applied scratch build';
|
|
2125
|
+
if(rebuildBusy)return 'COMMENT_SOURCE_BUSY: wait for the rebuild or candidate model replacement before commenting';
|
|
2126
|
+
const preview=idleAdjust?'Idle tracks':dialAdjust?'Rig fields turn dial':physicsPreview.read().length?'Physics live overrides':stripPending?'Density strip candidate':
|
|
2127
|
+
meshEditor?.overlay()?'Edit Mesh':bonesPanel?.overlay()?'Bones anchors':cutsPanel&&(cutsPanel.overlayRegions().length||cutsPanel.overlayMarkers().length)?'Cuts/patches/joints':
|
|
2128
|
+
clothPanel?.overlayRegions().length?'Cloth links':meshPanel&&(meshPanel.overlayRegions().length||meshPanel.overlayCircles().length)?'Mesh regions':
|
|
2129
|
+
chestRegions().length||chestCircles().length||chestMarkers().length?'Chest regions/landmarks':rigFieldsPanel?.handles().length?'Rig fields stage handles':
|
|
2130
|
+
(()=>{const handles=document.getElementById('idle-handles');return handles&&!handles.hidden&&handles.childElementCount>0;})()?'Idle tracks stage handles':null;
|
|
2131
|
+
return preview?`COMMENT_UNAPPLIED_PREVIEW: ${preview} is active; leave or disable that preview before commenting (nothing is cleared or applied automatically)`:null;
|
|
2132
|
+
}
|
|
2133
|
+
function setupComments():void {
|
|
2134
|
+
comments=bindStageComments({toolbar:el('stage-comment').parentElement!,viewports:{rig:el('rig-viewport'),reference:el('reference-viewport')},
|
|
2135
|
+
pose:view=>{
|
|
2136
|
+
if(view!=='rig'&&view!=='reference')return null;const workspaceMode=document.querySelector<HTMLElement>('[data-mode][aria-pressed="true"]')?.dataset.mode;if(workspaceMode!=='rig'&&workspaceMode!=='playback')return null;const camera=numericView().cameras[view],pane=el(view+'-viewport');if(!camera||pane.clientWidth<=0||pane.clientHeight<=0)return null;
|
|
2137
|
+
const renderer=player?.renderer;const current=player?.time??time,index=frameAtTime(activeFrameClock(),current),referenceId=view==='reference'?select('reference-select').value||null:null,referenceHash=referenceId?data.referenceHashes?.[referenceId]??(data.explicitReference?.id===referenceId?data.explicitReference.sha256:null):null;
|
|
2138
|
+
return {mode:workspaceMode==='playback'?'animate':'setup',capture:{pane,root:view==='rig'?el('rig-root'):undefined,images:view==='reference'||layers.images,canvas:view==='rig'&&renderer instanceof CanvasRenderer?renderer.canvas:undefined,backend:renderer instanceof CanvasRenderer?renderer.backendActive:renderer?.meshBackendActive??'canvas2d'},camera,frame:index===null?null:{index,basis:'producer'},...(index===null?{timeBasis:timeOnlyBasis}:{}),time:current,animation:animation||null,turn:turnPad?.snapshot()??null,sourceHash:data.sourceHash,modelHash:view==='reference'?null:stageModelHash,referenceHash,referenceId,rig:data.manifest.id,...(view==='reference'?{media:video??el('reference-content').querySelector('img')??undefined}:{posed:parts?.frame('single'),assets:assets??undefined})} as CommentPose;
|
|
2139
|
+
},
|
|
2140
|
+
pause:async()=>{const running=playing||!!video&&!video.paused;pause();if(running&&video&&video.readyState>=1){seekingControl=true;try{await inspectControlTime(player?.time??time,new AbortController().signal);}finally{seekingControl=false;}}if(turnPad)turnPad.restore({...turnPad.snapshot(),held:true},false);},bookmark:inspectionBookmark,restore:(b,timed)=>restoreInspection(parseBookmark(b,inspectionBinding()),new AbortController().signal,!!timed),feedback:()=>session.feedback,
|
|
2141
|
+
locked:id=>!!submittedBaseline?.updatedAt&&submittedBaseline.feedback.some(f=>f.id===id),add:f=>session.feedback.push(f),changed:()=>{markDirty();renderProposals();persistence?.save();},error:value=>message(value,true),
|
|
2142
|
+
refusal:commentCaptureRefusal,blocked:()=>saving||!!persistence?.restoring()||seekingControl||!!video?.seeking||!!rebuildBusy||!!appliedOnStage,shortcutBlocked:()=>!!meshEditor?.active()
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** One gesture into the existing feedback record. No authoring or HTTP endpoint. */
|
|
2
|
+
import type {ModelAssets,PosedFrame} from '@rig-c/play';
|
|
3
|
+
import {paintInspectionViewport,pixelAlignedCrop,type ViewportCapture} from './viewport-capture.ts';
|
|
4
|
+
import type {Feedback} from '../src/session.ts';
|
|
5
|
+
import type {ControlNumericCamera} from '../src/control.ts';
|
|
6
|
+
import {drawnSlots,region,projectMark,unprojectMark,parseStageComment,feedbackTimingLabel,type MarkRegion,type MarkPoint,type StageComment} from '../src/stage-comment.ts';
|
|
7
|
+
import {textEntryFocused} from './view-controls.ts';
|
|
8
|
+
export interface CommentPose {
|
|
9
|
+
mode:StageComment['mode'];camera:ControlNumericCamera;frame:StageComment['frame'];timeBasis?:StageComment['timeBasis'];time:number;animation:string|null;
|
|
10
|
+
turn:unknown;sourceHash:string;modelHash:string|null;referenceHash:string|null;referenceId:string|null;frameReason?:string;
|
|
11
|
+
capture?:ViewportCapture;rig:string;side?:'a'|'b';posed?:PosedFrame;assets?:ModelAssets;media?:HTMLVideoElement|HTMLImageElement;
|
|
12
|
+
}
|
|
13
|
+
interface Host {
|
|
14
|
+
toolbar:HTMLElement;viewports:Partial<Record<StageComment['viewport'],HTMLElement>>;
|
|
15
|
+
pose:(view:StageComment['viewport'])=>CommentPose|null;pause:()=>Promise<void>;
|
|
16
|
+
bookmark:()=>unknown;restore:(bookmark:unknown,timeOnly?:boolean)=>Promise<void>;
|
|
17
|
+
feedback:()=>Feedback[];locked:(id:string)=>boolean;
|
|
18
|
+
add:(feedback:Feedback,side?:'a'|'b')=>void;changed:()=>void;error:(message:string)=>void;
|
|
19
|
+
refusal?:()=>string|null;blocked:()=>boolean;shortcutBlocked?:()=>boolean;
|
|
20
|
+
}
|
|
21
|
+
const markOf=(f:Feedback):StageComment|null=>{try{const c=f.context as Record<string,unknown>|undefined;return c?.comment?parseStageComment(c.comment):null;}catch{return null;}};
|
|
22
|
+
const copy=<T>(v:T):T=>JSON.parse(JSON.stringify(v));
|
|
23
|
+
function rectElement(r:MarkRegion):HTMLDivElement{const node=document.createElement('div');node.className='stage-comment-region';Object.assign(node.style,{left:r.x+'px',top:r.y+'px',width:Math.max(2,r.width)+'px',height:Math.max(2,r.height)+'px'});return node;}
|
|
24
|
+
async function crop(pose:CommentPose,r:MarkRegion):Promise<StageComment['crop']>{
|
|
25
|
+
if(!pose.capture)throw new Error('COMMENT_CROP_UNAVAILABLE: viewport pixel source unavailable');
|
|
26
|
+
const camera=pose.camera,padding=24,rawLeft=Math.max(0,Math.floor(r.x-padding)),rawTop=Math.max(0,Math.floor(r.y-padding)),pane=pose.capture.pane,bounds=pane.getBoundingClientRect(),ratio=globalThis.devicePixelRatio||1;
|
|
27
|
+
const {x:left,y:top,width,height}=pixelAlignedCrop({x:rawLeft,y:rawTop,width:Math.min(camera.viewport.width-rawLeft,Math.ceil(r.width+2*padding)),height:Math.min(camera.viewport.height-rawTop,Math.ceil(r.height+2*padding))},{x:bounds.left+pane.clientLeft,y:bounds.top+pane.clientTop},ratio);
|
|
28
|
+
if(width<=0||height<=0)throw new Error('COMMENT_CROP_UNAVAILABLE: mark is outside the viewport');
|
|
29
|
+
const scale=Math.min(ratio,768/width,768/height),out=document.createElement('canvas');out.width=Math.max(1,Math.round(width*scale));out.height=Math.max(1,Math.round(height*scale));const ctx=out.getContext('2d')!;ctx.scale(scale,scale);ctx.translate(-left,-top);
|
|
30
|
+
paintInspectionViewport(ctx,{x:left,y:top,width,height},pose.capture,pose);
|
|
31
|
+
ctx.strokeStyle=getComputedStyle(document.documentElement).getPropertyValue('--link').trim();ctx.lineWidth=2/scale;ctx.strokeRect(r.x,r.y,Math.max(3,r.width),Math.max(3,r.height));
|
|
32
|
+
const png=out.toDataURL('image/png');if(png.length>200000)throw new Error('COMMENT_CROP_LIMIT: mark a smaller region (PNG limit 150 KiB)');
|
|
33
|
+
const bytes=Uint8Array.from(atob(png.split(',')[1]!),c=>c.charCodeAt(0)),sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');
|
|
34
|
+
return {path:`comment-crops/${sha256}.png`,sha256,png,width:out.width,height:out.height};
|
|
35
|
+
}
|
|
36
|
+
export function bindStageComments(host:Host):{refresh:()=>void;dispose:()=>void;active:()=>boolean;draft:()=>boolean}{
|
|
37
|
+
const toggle=document.getElementById('stage-comment') as HTMLButtonElement,listToggle=document.getElementById('stage-comment-list-toggle') as HTMLButtonElement;
|
|
38
|
+
const list=document.createElement('section');list.id='stage-comment-list';list.className='stage-comment-list stage-comment-ui';list.setAttribute('aria-label','Stage comments');list.hidden=true;
|
|
39
|
+
// An absolute overlay is not stage chrome: workspace measures stack children
|
|
40
|
+
// to allocate Inspector height. Keep the list in its positioned main instead.
|
|
41
|
+
const workspace=host.toolbar.closest('main');if(workspace)workspace.append(list);else host.toolbar.after(list);
|
|
42
|
+
const refusalNote=document.createElement('small');refusalNote.id='stage-comment-refusal';refusalNote.setAttribute('role','status');refusalNote.hidden=true;toggle.after(refusalNote);toggle.setAttribute('aria-describedby',refusalNote.id);const originalTitle=toggle.title;
|
|
43
|
+
const updateRefusal=()=>{const reason=host.refusal?.()??null;toggle.disabled=!!reason;toggle.title=reason??originalTitle;refusalNote.textContent=reason??'';refusalNote.hidden=!reason;return reason;};
|
|
44
|
+
const layers=new Map<StageComment['viewport'],HTMLDivElement>();for(const [key,pane]of Object.entries(host.viewports)){const layer=document.createElement('div');layer.className='stage-comment-layer';pane!.append(layer);layers.set(key as StageComment['viewport'],layer);}
|
|
45
|
+
let enabled=false,disposed=false,pending:{view:StageComment['viewport'];start:MarkPoint;end:MarkPoint;mode:StageComment['mode'];id:number;interaction:number;settled:Promise<void>}|null=null,composer:HTMLFormElement|null=null,draftMark:{view:StageComment['viewport'];mode:StageComment['mode'];screen:MarkRegion}|null=null,busy=false,last='',raf=0,interaction=0;
|
|
46
|
+
const setEnabled=(value:boolean)=>{if(value){const reason=updateRefusal();if(reason){host.error(reason);return;}}if(value&&(host.blocked()||host.shortcutBlocked?.())){host.error('COMMENT_BUSY: close the active editor or wait for the inspection to settle');return;}enabled=value;toggle.setAttribute('aria-pressed',String(value));for(const pane of Object.values(host.viewports))pane!.classList.toggle('stage-comment-mode',value);};
|
|
47
|
+
const cancel=()=>{++interaction;const previous=pending;pending=null;if(previous){const pane=host.viewports[previous.view];if(pane?.hasPointerCapture(previous.id))pane.releasePointerCapture(previous.id);}composer?.remove();composer=null;draftMark=null;last='';};
|
|
48
|
+
const assertCapture=()=>{const reason=updateRefusal();if(reason){cancel();setEnabled(false);host.error(reason);throw new Error(reason);}if(host.blocked())throw new Error('COMMENT_BUSY: wait for inspection');};
|
|
49
|
+
const jump=async(f:Feedback)=>{const mark=markOf(f);if(!mark)return;const panes=Object.values(host.viewports).map(p=>({pane:p!,visibility:p!.style.visibility}));try{cancel();for(const {pane}of panes)pane.style.visibility='hidden';await host.restore(mark.bookmark,mark.frame===null);last='';}catch(error){host.error(String(error));}finally{for(const {pane,visibility}of panes)pane.style.visibility=visibility;}};
|
|
50
|
+
const renderList=()=>{list.replaceChildren();const title=document.createElement('strong');title.textContent='Comments';list.append(title);const all=host.feedback().filter(f=>markOf(f));listToggle.textContent=`Comments (${all.length})`;if(!all.length){const empty=document.createElement('p');empty.textContent='Mark a point or region, then type. Enter saves · Escape cancels.';list.append(empty);}for(const [i,f]of all.entries()){
|
|
51
|
+
const mark=markOf(f)!,row=document.createElement('div');row.className='stage-comment-item';row.dataset.commentId=f.id;
|
|
52
|
+
const go=document.createElement('button');go.type='button';go.textContent=`${i+1} · ${mark.mode.toUpperCase()} · ${mark.viewport.toUpperCase()} · ${mark.frame?`F${mark.frame.index} (${mark.frame.basis})`:feedbackTimingLabel(f)} · ${f.summary}`;go.onclick=()=>void jump(f);row.append(go);
|
|
53
|
+
if(!host.locked(f.id)){const edit=document.createElement('button');edit.textContent='Edit';edit.onclick=async()=>{await jump(f);openComposer(mark.viewport,mark.screen,f);};const remove=document.createElement('button');remove.textContent='Delete';remove.onclick=()=>{const all=host.feedback(),index=all.findIndex(x=>x.id===f.id);if(index>=0)all.splice(index,1);cancel();host.changed();refresh();};row.append(edit,remove);}else{const submitted=document.createElement('span');submitted.textContent='Submitted';row.append(submitted);}list.append(row);
|
|
54
|
+
}};
|
|
55
|
+
const refresh=()=>{last='';renderList();};
|
|
56
|
+
const openComposer=(view:StageComment['viewport'],screen:MarkRegion,existing?:Feedback,placementMode=host.pose(view)?.mode)=>{
|
|
57
|
+
try{assertCapture();}catch{return;}
|
|
58
|
+
if(!placementMode||host.pose(view)?.mode!==placementMode){cancel();return;}
|
|
59
|
+
composer?.remove();draftMark={view,mode:placementMode,screen};const generation=interaction;const pane=host.viewports[view]!,form=composer=document.createElement('form');form.className='stage-comment-composer stage-comment-ui';form.setAttribute('aria-label','Comment composer');
|
|
60
|
+
const field=document.createElement('input');field.type='text';field.maxLength=1000;field.setAttribute('aria-label','Comment text');field.placeholder='Describe this spot…';field.value=existing?.summary??'';
|
|
61
|
+
const hint=document.createElement('small');hint.textContent='Enter saves · Escape cancels';const error=document.createElement('p');error.setAttribute('role','alert');form.append(field,hint,error);pane.append(form);
|
|
62
|
+
Object.assign(form.style,{left:Math.max(4,Math.min(screen.x+screen.width+8,pane.clientWidth-294))+'px',top:Math.max(4,Math.min(screen.y+screen.height+8,pane.clientHeight-110))+'px'});
|
|
63
|
+
// Capture all view context now; camera or dial changes while typing cannot rebind the mark.
|
|
64
|
+
const pose=host.pose(view),bookmark=copy(host.bookmark()),turn=pose?copy(pose.turn):null;
|
|
65
|
+
if(pose&&!pose.frame&&!pose.timeBasis)error.textContent=pose.frameReason??'COMMENT_FRAME_UNAVAILABLE: no explicit frame index';
|
|
66
|
+
const previous=existing?markOf(existing):null;
|
|
67
|
+
const prepared=previous?Promise.resolve(previous.crop):pose?crop(pose,screen):Promise.reject(new Error('COMMENT_VIEW_UNAVAILABLE'));
|
|
68
|
+
prepared.catch(()=>{});
|
|
69
|
+
form.onkeydown=e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();cancel();pane.focus();}};
|
|
70
|
+
form.onsubmit=async e=>{e.preventDefault();if(busy)return;if(generation!==interaction||host.pose(view)?.mode!==placementMode){cancel();return;}const summary=field.value.trim();if(!summary){field.focus();return;}try{
|
|
71
|
+
assertCapture();if(host.blocked())throw new Error('COMMENT_BUSY: wait for inspection');if(!pose||!pose.frame&&!pose.timeBasis)throw new Error(pose?.frameReason??'COMMENT_FRAME_UNAVAILABLE: provide an explicit producer clock or a decoded reference frame index');
|
|
72
|
+
busy=true;field.disabled=true;
|
|
73
|
+
if(existing){if(host.locked(existing.id))throw new Error('COMMENT_SUBMITTED: submitted evidence is immutable');existing.summary=summary;}
|
|
74
|
+
else{
|
|
75
|
+
const world=region(unprojectMark(screen,pose.camera),unprojectMark({x:screen.x+screen.width,y:screen.y+screen.height},pose.camera));
|
|
76
|
+
const slots=pose.posed?drawnSlots(pose.posed,world):[];
|
|
77
|
+
const comment:StageComment={schema:'rig-review-comment/1',mode:placementMode,viewport:view,frame:pose.frame,...(pose.timeBasis?{timeBasis:pose.timeBasis}:{}),time:pose.time,animation:pose.animation,sourceHash:pose.sourceHash,modelHash:pose.modelHash,referenceHash:pose.referenceHash,referenceId:pose.referenceId,camera:copy(pose.camera),screen,world,slots,crop:await prepared,bookmark};
|
|
78
|
+
assertCapture();if(host.blocked())throw new Error('COMMENT_BUSY: wait for inspection');
|
|
79
|
+
if(disposed||generation!==interaction||host.pose(view)?.mode!==placementMode)return;
|
|
80
|
+
const n=pose.frame?.index??null;host.add({id:crypto.randomUUID(),frameStart:n,frameEnd:n,target:slots[0]?{kind:'slot',name:slots[0].slot}:{kind:'rig',name:pose.rig},category:'defect',summary,context:{animation:pose.animation,time:pose.time,frame:pose.frame?.basis==='producer'?n:null,...(turn?{turn:turn as any}:{}),interval:n===null?null:{frameStart:n,frameEnd:n,inclusive:true},comment:comment as any}},pose.side);
|
|
81
|
+
}
|
|
82
|
+
cancel();host.changed();refresh();
|
|
83
|
+
}catch(e){error.textContent=e instanceof Error?e.message:String(e);field.disabled=false;field.focus();}finally{busy=false;}};
|
|
84
|
+
field.focus();last='';
|
|
85
|
+
};
|
|
86
|
+
const disposers:(()=>void)[]=[];
|
|
87
|
+
for(const [key,pane]of Object.entries(host.viewports)){
|
|
88
|
+
const view=key as StageComment['viewport'],local=(e:PointerEvent)=>{const r=pane!.getBoundingClientRect();return {x:Math.max(0,Math.min(pane!.clientWidth,e.clientX-r.left-pane!.clientLeft)),y:Math.max(0,Math.min(pane!.clientHeight,e.clientY-r.top-pane!.clientTop))};};
|
|
89
|
+
const down=(e:PointerEvent)=>{if(enabled){try{assertCapture();}catch{return;}}if(!enabled||e.button!==0||!e.isPrimary||host.blocked()||(e.target as Element).closest('.stage-comment-ui,button,input'))return;e.preventDefault();e.stopImmediatePropagation();cancel();const at=local(e),mode=host.pose(view)?.mode;if(!mode)return;const generation=interaction;pending={view,start:at,end:at,mode,id:e.pointerId,interaction:generation,settled:host.pause()};pending.settled.catch(e=>{if(interaction===generation&&!disposed)host.error(String(e));});pane!.setPointerCapture(e.pointerId);};
|
|
90
|
+
const move=(e:PointerEvent)=>{if(!pending||pending.id!==e.pointerId)return;e.preventDefault();e.stopImmediatePropagation();pending.end=local(e);draftMark={view,mode:pending.mode,screen:region(pending.start,pending.end)};last='';};
|
|
91
|
+
const up=async(e:PointerEvent)=>{if(!pending||pending.id!==e.pointerId)return;e.preventDefault();e.stopImmediatePropagation();const p=pending;pending=null;if(pane!.hasPointerCapture(e.pointerId))pane!.releasePointerCapture(e.pointerId);try{await p.settled;assertCapture();if(disposed||interaction!==p.interaction||!enabled)return;const r=Math.hypot(p.end.x-p.start.x,p.end.y-p.start.y)<4?region(p.start,p.start):region(p.start,p.end);openComposer(view,r,undefined,p.mode);}catch(error){host.error(String(error));cancel();}};
|
|
92
|
+
const abort=(e:PointerEvent)=>{if(pending?.view===view&&pending.id===e.pointerId)cancel();};
|
|
93
|
+
pane!.addEventListener('pointerdown',down,true);pane!.addEventListener('pointermove',move,true);pane!.addEventListener('pointerup',up,true);pane!.addEventListener('pointercancel',abort,true);pane!.addEventListener('lostpointercapture',abort,true);
|
|
94
|
+
disposers.push(()=>{pane!.removeEventListener('pointerdown',down,true);pane!.removeEventListener('pointermove',move,true);pane!.removeEventListener('pointerup',up,true);pane!.removeEventListener('pointercancel',abort,true);pane!.removeEventListener('lostpointercapture',abort,true);});
|
|
95
|
+
}
|
|
96
|
+
const key=(e:KeyboardEvent)=>{if(e.key==='Escape'&&(pending||composer||enabled&&!host.shortcutBlocked?.()&&!textEntryFocused(e.target))){e.preventDefault();cancel();setEnabled(false);return;}if(e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||textEntryFocused(e.target)||host.shortcutBlocked?.())return;if(e.key.toLowerCase()==='c'){e.preventDefault();setEnabled(!enabled);}else if(e.key==='Escape'&&enabled){cancel();setEnabled(false);}};
|
|
97
|
+
window.addEventListener('blur',cancel);document.addEventListener('keydown',key);toggle.onclick=()=>setEnabled(!enabled);listToggle.onclick=()=>{list.hidden=!list.hidden;listToggle.setAttribute('aria-expanded',String(!list.hidden));refresh();};
|
|
98
|
+
const paint=()=>{if(disposed)return;const refusal=updateRefusal();if(refusal&&(enabled||pending||composer)){cancel();setEnabled(false);host.error(refusal);}const all=host.feedback().map(f=>({f,mark:markOf(f)})).filter(x=>x.mark);
|
|
99
|
+
const poses=new Map([...layers.keys()].map(view=>[view,host.pose(view)]));
|
|
100
|
+
if(draftMark&&poses.get(draftMark.view)?.mode!==draftMark.mode||pending&&poses.get(pending.view)?.mode!==pending.mode)cancel();
|
|
101
|
+
const signature=JSON.stringify([refusal,all.map(({f,mark})=>[f.id,f.summary,mark!.mode,mark!.world]),[...poses].map(([view,p])=>[view,p?.mode,p?.animation,p?.frame,p?.timeBasis,p?.time,p?.turn,p?.camera,p?.referenceId,p?.referenceHash]),draftMark]);
|
|
102
|
+
if(signature!==last){last=signature;for(const layer of layers.values())layer.replaceChildren();for(const [i,{f,mark:m}]of all.entries()){
|
|
103
|
+
const mark=m!,pose=poses.get(mark.viewport),layer=layers.get(mark.viewport);if(refusal||!pose||!layer||pose.mode!==mark.mode||pose.animation!==mark.animation||pose.sourceHash!==mark.sourceHash||pose.modelHash!==mark.modelHash||pose.referenceId!==mark.referenceId||pose.referenceHash!==mark.referenceHash||JSON.stringify(pose.frame)!==JSON.stringify(mark.frame)||JSON.stringify(pose.timeBasis??null)!==JSON.stringify(mark.timeBasis??null)||Math.abs(pose.time-mark.time)>1e-7||JSON.stringify(pose.turn)!==JSON.stringify((f.context as any)?.turn??null))continue;
|
|
104
|
+
const a=projectMark(mark.world,pose.camera),b=projectMark({x:mark.world.x+mark.world.width,y:mark.world.y+mark.world.height},pose.camera),r=region(a,b),box=rectElement(r),pin=document.createElement('button');pin.type='button';pin.className='stage-comment-pin stage-comment-ui';pin.textContent=String(i+1);pin.setAttribute('aria-label',`Comment ${i+1}: ${f.summary}`);pin.title=f.summary;Object.assign(pin.style,{left:r.x+'px',top:r.y+'px'});pin.onclick=()=>{list.hidden=false;renderList();};layer.append(box,pin);
|
|
105
|
+
}if(draftMark)layers.get(draftMark.view)?.append(rectElement(draftMark.screen));}raf=requestAnimationFrame(paint);};
|
|
106
|
+
refresh();paint();return {active:()=>enabled,draft:()=>!!composer||!!pending,refresh,dispose:()=>{disposed=true;cancelAnimationFrame(raf);cancel();window.removeEventListener('blur',cancel);document.removeEventListener('keydown',key);for(const dispose of disposers)dispose();for(const layer of layers.values())layer.remove();list.remove();refusalNote.remove();}};
|
|
107
|
+
}
|
package/web/styles.css
CHANGED
|
@@ -294,3 +294,16 @@ footer.status-bar{flex-wrap:nowrap}
|
|
|
294
294
|
.animation-bone-pick[aria-pressed=true]{outline:3px solid var(--rv-bone-selected);outline-offset:2px}
|
|
295
295
|
|
|
296
296
|
#motion-tools>.animation-inspection{flex-basis:100%;min-width:0}
|
|
297
|
+
|
|
298
|
+
/* Model-bound inspection dials; stage and Inspector remain separate. */
|
|
299
|
+
#turn-pad-host{flex:none;max-height:180px;overflow:auto;border-bottom:1px solid var(--border);background:var(--bg)}
|
|
300
|
+
.turn-pad{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:8px 12px}
|
|
301
|
+
.turn-pad>span{color:var(--fg-muted);font-size:11px}
|
|
302
|
+
.turn-pad label{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}
|
|
303
|
+
.turn-pad input[type=number]{width:78px;color:var(--fg);background:var(--rv-control);border:1px solid var(--border-strong);padding:4px}
|
|
304
|
+
.turn-pad output{font-variant-numeric:tabular-nums;color:var(--fg-strong)}
|
|
305
|
+
.turn-pad button[aria-pressed=true]{border-color:var(--pass);color:var(--pass)}
|
|
306
|
+
#turn-pad-host>p{margin:0 12px 6px}
|
|
307
|
+
|
|
308
|
+
/* Source-bound stage comments; the layer never intercepts ordinary inspection. */
|
|
309
|
+
main{position:relative}.stage-comment-mode{cursor:crosshair}.stage-comment-mode video{pointer-events:none}.stage-comment-layer{position:absolute;inset:0;pointer-events:none;z-index:20;overflow:hidden}.stage-comment-region{position:absolute;border:2px solid var(--link);pointer-events:none}.stage-comment-pin{position:absolute;transform:translate(-50%,-50%);border-radius:50%;min-width:26px;height:26px;padding:2px 5px;background:var(--link);color:var(--btn-fg);pointer-events:auto}.stage-comment-composer{position:absolute;z-index:30;width:286px;padding:10px;background:var(--rv-panel);border:1px solid var(--link);border-radius:6px;box-shadow:0 4px 20px var(--rv-backdrop)}.stage-comment-composer input{width:100%}.stage-comment-composer small{display:block;margin-top:6px;color:var(--fg-muted)}.stage-comment-composer p{color:var(--fail);margin:4px 0}.stage-comment-list{position:absolute;z-index:40;right:8px;top:55px;width:310px;max-width:90%;max-height:60%;overflow:auto;background:var(--rv-panel);border:1px solid var(--border-strong);padding:12px;border-radius:6px}.stage-comment-item{padding:8px 0;border-bottom:1px solid var(--border)}.stage-comment-item>button:first-child{display:block;width:100%;text-align:left;overflow-wrap:anywhere}.stage-comment-item span{display:block;color:var(--fg-muted)}
|
package/web/turn-pad.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {TurnState,type TurnDial,type TurnSnapshot,type TurnAxis,pointerPosition} from '../src/turn-pad.ts';
|
|
2
|
+
export interface TurnPad {state:TurnState;snapshot():TurnSnapshot;restore(value:unknown,redraw?:boolean):void;dispose():void}
|
|
3
|
+
/** One pad owns the shared values. It never touches the model document or writes animation keys. */
|
|
4
|
+
export function bindTurnPad(options:{host:HTMLElement;stages:HTMLElement[];dials:readonly TurnDial[];draw:(state:TurnState)=>void;save:()=>void;blocked?:()=>boolean;error:(text:string)=>void}):TurnPad|null {
|
|
5
|
+
const {host,stages,dials}=options;if(!dials.length)return null;
|
|
6
|
+
const state=new TurnState(dials),panel=document.createElement('section');panel.id='turn-pad';panel.className='turn-pad';panel.setAttribute('aria-label','Turn pad');
|
|
7
|
+
const title=document.createElement('strong');title.textContent='Turn pad';panel.append(title);
|
|
8
|
+
const hint=document.createElement('span');hint.textContent='Move over the rig · left/right yaw and body · up/down pitch';panel.append(hint);
|
|
9
|
+
const hold=document.createElement('button');hold.type='button';hold.textContent='Hold';hold.setAttribute('aria-pressed','false');panel.append(hold);
|
|
10
|
+
const reset=document.createElement('button');reset.type='button';reset.textContent='Reset 0';panel.append(reset);
|
|
11
|
+
const smoothLabel=document.createElement('label'),smooth=document.createElement('input');smooth.type='checkbox';smooth.checked=true;smooth.setAttribute('aria-label','Turn smoothing');smoothLabel.append(smooth,' Smoothing ω₀ ');
|
|
12
|
+
const omega=document.createElement('input');omega.type='number';omega.min='1';omega.max='100';omega.step='1';omega.value='20';omega.setAttribute('aria-label','Turn smoothing omega');smoothLabel.append(omega,' s⁻¹');panel.append(smoothLabel);
|
|
13
|
+
const rows=new Map<TurnAxis,{number:HTMLInputElement;output:HTMLOutputElement;target:HTMLOutputElement}>();
|
|
14
|
+
for(const d of dials){const label=document.createElement('label');label.textContent=`${d.axis} · ${d.bone} [${d.min}, ${d.max}] ${d.channel==='rotation'?'°':d.channel}`;const number=document.createElement('input');number.type='number';number.min=String(d.min);number.max=String(d.max);number.step='any';number.value='0';number.setAttribute('aria-label',`Turn ${d.axis} value`);label.title=d.provenance??'';const output=document.createElement('output');output.dataset.turnAxis=d.axis;output.setAttribute('aria-label',`Current ${d.axis}`);const target=document.createElement('output');target.dataset.turnTarget=d.axis;label.append(number,' Applied ',output,' · target ',target);panel.append(label);rows.set(d.axis,{number,output,target});number.onchange=()=>{try{if(!number.value.trim())throw new Error('TURN_VALUE_REQUIRED');state.exact(d.axis,Number(number.value));commit();}catch(error){options.error(String(error));render();}};}
|
|
15
|
+
function render():void {hold.setAttribute('aria-pressed',String(state.held));smooth.checked=state.smoothing!==0;omega.disabled=!smooth.checked;if(state.smoothing)omega.value=String(state.smoothing);for(const [axis,row] of rows){const value=state.values[axis]!;row.output.value=String(Number(value.toFixed(6)));row.output.dataset.value=String(value);row.target.value=String(Number(state.targets[axis]!.toFixed(6)));if(document.activeElement!==row.number)row.number.value=String(state.targets[axis]);}}
|
|
16
|
+
let saveTimer:ReturnType<typeof setTimeout>|undefined;
|
|
17
|
+
function commit():void {options.draw(state);render();if(saveTimer)clearTimeout(saveTimer);saveTimer=setTimeout(options.save,150);}
|
|
18
|
+
hold.onclick=()=>{state.hold(!state.held);commit();};reset.onclick=()=>{state.reset();commit();};
|
|
19
|
+
smooth.onchange=()=>{state.smoothing=smooth.checked?Number(omega.value)||20:0;commit();};omega.onchange=()=>{const v=Number(omega.value);if(!Number.isFinite(v)||v<1||v>100){options.error('TURN_SMOOTHING_RANGE: use 1…100 s⁻¹');omega.value=String(state.smoothing||20);return;}state.smoothing=v;commit();};
|
|
20
|
+
const move=(event:PointerEvent)=>{if(event.buttons||options.blocked?.()||state.held)return;const stage=event.currentTarget as HTMLElement;const rect=stage.getBoundingClientRect();const [x,y]=pointerPosition(event.clientX,event.clientY,{left:rect.left,top:rect.top,width:Math.max(1,rect.width-1),height:Math.max(1,rect.height-1)});state.pointer(x,y);if(state.smoothing===0){if(state.step(0))commit();else render();}else render();};
|
|
21
|
+
for(const stage of stages)stage.addEventListener('pointermove',move);
|
|
22
|
+
let disposed=false,last=0,raf=0;
|
|
23
|
+
const step=(now:number)=>{if(disposed)return;if(!document.hidden&&!options.blocked?.()&&state.step(last?Math.min((now-last)/1000,.1):0))commit();last=now;raf=requestAnimationFrame(step);};
|
|
24
|
+
host.append(panel);commit();raf=requestAnimationFrame(step);
|
|
25
|
+
return {state,snapshot:()=>state.snapshot(),restore(value,redraw=true){state.restore(value);if(redraw)commit();else{render();options.save();}},dispose(){disposed=true;if(saveTimer)clearTimeout(saveTimer);cancelAnimationFrame(raf);for(const stage of stages)stage.removeEventListener('pointermove',move);panel.remove();}};
|
|
26
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Pixel composition for the inspection viewport; no DOM serialization or screenshot API. */
|
|
2
|
+
import {CanvasRenderer,type CanvasBackend,type ModelAssets,type PosedFrame} from '@rig-c/play';
|
|
3
|
+
import type {ControlNumericCamera} from '../src/control.ts';
|
|
4
|
+
import {projectMark,type MarkRegion} from '../src/stage-comment.ts';
|
|
5
|
+
export interface CaptureRect {x:number;y:number;width:number;height:number}
|
|
6
|
+
/** Untransformed replaced canvases are painted at snapped device-pixel edges by the page compositor. */
|
|
7
|
+
export function displayedCanvasRect(rect:CaptureRect,origin:{x:number;y:number},ratio=1):CaptureRect{
|
|
8
|
+
const x=Math.round(rect.x*ratio)/ratio,y=Math.round(rect.y*ratio)/ratio;
|
|
9
|
+
return {x:x-origin.x,y:y-origin.y,width:Math.round((rect.x+rect.width)*ratio)/ratio-x,height:Math.round((rect.y+rect.height)*ratio)/ratio-y};
|
|
10
|
+
}
|
|
11
|
+
/** Keep whole displayed device pixels. Fractional CSS pane placement must not resample an already composited screenshot again. */
|
|
12
|
+
export function pixelAlignedCrop(area:CaptureRect,origin:{x:number;y:number},ratio=1):CaptureRect{
|
|
13
|
+
const x=Math.ceil((origin.x+area.x)*ratio)/ratio-origin.x,y=Math.ceil((origin.y+area.y)*ratio)/ratio-origin.y;
|
|
14
|
+
return {x,y,width:Math.max(0,Math.floor((origin.x+area.x+area.width)*ratio)/ratio-origin.x-x),height:Math.max(0,Math.floor((origin.y+area.y+area.height)*ratio)/ratio-origin.y-y)};
|
|
15
|
+
}
|
|
16
|
+
export interface BackgroundLayer {rect:CaptureRect;color:string;image:string;size:string;position:string}
|
|
17
|
+
export interface PixelLayer {image:CanvasImageSource;rect:CaptureRect;opacity:number}
|
|
18
|
+
export interface ViewportCapture {pane:HTMLElement;root?:HTMLElement;images:boolean;canvas?:HTMLCanvasElement;backend:CanvasBackend}
|
|
19
|
+
const transparent=(color:string)=>color==='transparent'||/^rgba\([^)]*,\s*0\s*\)$/.test(color);
|
|
20
|
+
/** Read only this app's computed backgrounds, in their actual viewport coordinates. */
|
|
21
|
+
export function viewportBackground(pane:HTMLElement):BackgroundLayer[]{
|
|
22
|
+
const origin=pane.getBoundingClientRect(),x=origin.left+pane.clientLeft,y=origin.top+pane.clientTop,result:BackgroundLayer[]=[];
|
|
23
|
+
for(let node:HTMLElement|null=pane;node;node=node.parentElement){const style=getComputedStyle(node),rect=node.getBoundingClientRect();result.unshift({rect:{x:rect.left+node.clientLeft-x,y:rect.top+node.clientTop-y,width:node.clientWidth,height:node.clientHeight},color:style.backgroundColor,image:style.backgroundImage,size:style.backgroundSize,position:style.backgroundPosition});}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/** These are the app's solid, stage-ellipse and grid backgrounds, not an HTML rasterizer. */
|
|
27
|
+
export function paintBackground(ctx:CanvasRenderingContext2D,layers:BackgroundLayer[],area:CaptureRect):void {
|
|
28
|
+
for(const layer of layers){const r=layer.rect;if(!transparent(layer.color)){ctx.fillStyle=layer.color;ctx.fillRect(r.x,r.y,r.width,r.height);}if(!layer.image||layer.image==='none')continue;
|
|
29
|
+
const colors=layer.image.match(/rgba?\([^)]+\)|#[\da-f]+/gi)??[];if(!layer.image.startsWith('radial-gradient(')||colors.length!==2)throw new Error('COMMENT_BACKGROUND_UNAVAILABLE: unsupported viewport background');
|
|
30
|
+
const sizes=layer.size.split(/\s+/).map(Number.parseFloat),radius=layer.image.match(/\)\s+([\d.]+)px/);
|
|
31
|
+
if(radius&&sizes.length===2&&sizes.every(n=>Number.isFinite(n)&&n>0)){
|
|
32
|
+
const [sx,sy]=sizes as [number,number],position=layer.position.split(/\s+/),offset=(v:string,extent:number,tile:number)=>v.endsWith('%')?(extent-tile)*parseFloat(v)/100:parseFloat(v);
|
|
33
|
+
const ox=r.x+offset(position[0]??'0%',r.width,sx),oy=r.y+offset(position[1]??'0%',r.height,sy);ctx.save();ctx.beginPath();ctx.rect(r.x,r.y,r.width,r.height);ctx.clip();ctx.fillStyle=colors[0]!;
|
|
34
|
+
for(let y=oy+Math.floor((area.y-oy)/sy)*sy+sy/2;y<area.y+area.height+sy;y+=sy)for(let x=ox+Math.floor((area.x-ox)/sx)*sx+sx/2;x<area.x+area.width+sx;x+=sx){ctx.beginPath();ctx.arc(x,y,Number(radius[1]),0,Math.PI*2);ctx.fill();}ctx.restore();
|
|
35
|
+
}else{
|
|
36
|
+
const position=layer.image.match(/at\s+([\d.]+)%\s+([\d.]+)%/),end=layer.image.match(/([\d.]+)%\s*\)$/);if(!position||!end)throw new Error('COMMENT_BACKGROUND_UNAVAILABLE: unknown stage ellipse');
|
|
37
|
+
const cx=r.width*Number(position[1])/100,cy=r.height*Number(position[2])/100,rx=Math.max(cx,r.width-cx)*Math.SQRT2,ry=Math.max(cy,r.height-cy)*Math.SQRT2;if(rx<=0||ry<=0)continue;
|
|
38
|
+
ctx.save();ctx.translate(r.x+cx,r.y+cy);ctx.scale(rx,ry);const gradient=ctx.createRadialGradient(0,0,0,0,0,1);gradient.addColorStop(0,colors[0]!);gradient.addColorStop(Number(end[1])/100,colors[1]!);gradient.addColorStop(1,colors[1]!);ctx.fillStyle=gradient;ctx.fillRect(-cx/rx,-cy/ry,r.width/rx,r.height/ry);ctx.restore();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function paintViewportLayers(ctx:CanvasRenderingContext2D,area:CaptureRect,background:BackgroundLayer[],images:boolean,drawImages:()=>void,overlays:PixelLayer[]):void {
|
|
43
|
+
ctx.save();ctx.beginPath();ctx.rect(area.x,area.y,area.width,area.height);ctx.clip();paintBackground(ctx,background,area);if(images)drawImages();
|
|
44
|
+
for(const layer of overlays){ctx.save();ctx.globalAlpha=layer.opacity;ctx.drawImage(layer.image,layer.rect.x,layer.rect.y,layer.rect.width,layer.rect.height);ctx.restore();}ctx.restore();
|
|
45
|
+
}
|
|
46
|
+
function displayedPixels(canvas:HTMLCanvasElement,pane:HTMLElement,snap=true):PixelLayer|null {
|
|
47
|
+
const style=getComputedStyle(canvas);if(style.display==='none'||style.visibility==='hidden'||Number(style.opacity)===0)return null;const a=canvas.getBoundingClientRect(),b=pane.getBoundingClientRect(),origin={x:b.left+pane.clientLeft,y:b.top+pane.clientTop};return {image:canvas,rect:snap&&style.transform==='none'?displayedCanvasRect({x:a.left,y:a.top,width:a.width,height:a.height},origin,globalThis.devicePixelRatio||1):{x:a.left-origin.x,y:a.top-origin.y,width:a.width,height:a.height},opacity:Number(style.opacity)};
|
|
48
|
+
}
|
|
49
|
+
/** Copy only unambiguous, normal-filter DOM rasters whose z matches the public held frame.
|
|
50
|
+
* CSS transforms are read from the actual displayed elements; no private player fields. */
|
|
51
|
+
function displayedRig(ctx:CanvasRenderingContext2D,capture:ViewportCapture,frame:PosedFrame,camera:ControlNumericCamera):boolean {
|
|
52
|
+
const root=capture.root;if(!root)return false;const children=[...root.children].filter((n):n is HTMLImageElement|HTMLCanvasElement=>n instanceof HTMLImageElement||n instanceof HTMLCanvasElement).filter(n=>getComputedStyle(n).display!=='none');
|
|
53
|
+
const byZ=new Map<number,HTMLImageElement|HTMLCanvasElement>();for(const node of children){const style=getComputedStyle(node),z=Number(style.zIndex);if(style.filter!=='none'||style.transformOrigin!=='0px 0px'||!Number.isSafeInteger(z)||byZ.has(z))return false;byZ.set(z,node);}
|
|
54
|
+
if(children.length!==frame.order.length||frame.order.some(row=>!byZ.has(row.z)))return false;
|
|
55
|
+
const r=root.getBoundingClientRect(),p=capture.pane.getBoundingClientRect(),rootMatrix=new DOMMatrix(getComputedStyle(root).transform);if(!rootMatrix.is2D||rootMatrix.b!==0||rootMatrix.c!==0||rootMatrix.a<=0||rootMatrix.d<=0||getComputedStyle(root).transformOrigin!=='0px 0px')return false;
|
|
56
|
+
const plans=frame.order.map(row=>{const node=byZ.get(row.z)!,style=getComputedStyle(node);return {row,node,style,m:new DOMMatrix(style.transform),width:parseFloat(style.width),height:parseFloat(style.height)};});if(plans.some(p=>!p.m.is2D||![p.width,p.height].every(n=>Number.isFinite(n)&&n>0)))return false;
|
|
57
|
+
for(const {row,node,style,m,width,height}of plans){
|
|
58
|
+
ctx.save();if(row.clip){ctx.beginPath();if(row.clip.inverse)ctx.rect(0,0,camera.viewport.width,camera.viewport.height);for(let i=0;i<row.clip.polygon.length;i+=2){const at=projectMark({x:row.clip.polygon[i]!,y:row.clip.polygon[i+1]!},camera);if(i===0)ctx.moveTo(at.x,at.y);else ctx.lineTo(at.x,at.y);}ctx.closePath();ctx.clip(row.clip.inverse?'evenodd':'nonzero');}
|
|
59
|
+
ctx.globalAlpha=Number(style.opacity);ctx.globalCompositeOperation=style.mixBlendMode==='plus-lighter'?'lighter':style.mixBlendMode==='normal'?'source-over':style.mixBlendMode as GlobalCompositeOperation;
|
|
60
|
+
ctx.transform(rootMatrix.a*m.a,rootMatrix.d*m.b,rootMatrix.a*m.c,rootMatrix.d*m.d,r.left-p.left-capture.pane.clientLeft+rootMatrix.a*m.e,r.top-p.top-capture.pane.clientTop+rootMatrix.d*m.f);ctx.drawImage(node,0,0,width,height);ctx.restore();
|
|
61
|
+
}return true;
|
|
62
|
+
}
|
|
63
|
+
export function paintInspectionViewport(ctx:CanvasRenderingContext2D,area:MarkRegion,capture:ViewportCapture,pose:{camera:ControlNumericCamera;posed?:PosedFrame;assets?:ModelAssets;media?:HTMLVideoElement|HTMLImageElement}):void {
|
|
64
|
+
const overlays=[...capture.pane.querySelectorAll<HTMLCanvasElement>('canvas')].filter(node=>!capture.root?.contains(node)&&node!==capture.canvas).map(node=>displayedPixels(node,capture.pane)).filter((v):v is PixelLayer=>v!==null);
|
|
65
|
+
paintViewportLayers(ctx,area,viewportBackground(capture.pane),capture.images,()=>{
|
|
66
|
+
if(capture.canvas){const pixels=displayedPixels(capture.canvas,capture.pane,!capture.root?.contains(capture.canvas));if(pixels){ctx.save();ctx.globalAlpha=pixels.opacity;ctx.drawImage(pixels.image,pixels.rect.x,pixels.rect.y,pixels.rect.width,pixels.rect.height);ctx.restore();return;}}
|
|
67
|
+
if(pose.posed&&pose.assets){if(displayedRig(ctx,capture,pose.posed,pose.camera))return;
|
|
68
|
+
if(capture.backend==='canvas2d'&&pose.posed.order.some(row=>row.tint.slice(0,3).some(c=>c!==1)||row.dark?.some(c=>c!==0)))throw new Error('COMMENT_CAPTURE_UNAVAILABLE: Canvas2D cannot reproduce active RGB/dark tint');
|
|
69
|
+
const root=document.createElement('div'),renderer=new CanvasRenderer(root,pose.assets.pageImages);try{renderer.backend=capture.backend;renderer.viewport={...pose.camera.visible};renderer.pixelRatio=pose.camera.viewport.width/pose.camera.visible.width;renderer.render(pose.posed);ctx.drawImage(renderer.canvas,0,0,pose.camera.viewport.width,pose.camera.viewport.height);}finally{renderer.dispose();}
|
|
70
|
+
}else if(pose.media){const media=pose.media,style=getComputedStyle(media),a=media.getBoundingClientRect(),b=capture.pane.getBoundingClientRect();if(!transparent(style.backgroundColor)){ctx.fillStyle=style.backgroundColor;ctx.fillRect(a.left-b.left-capture.pane.clientLeft,a.top-b.top-capture.pane.clientTop,a.width,a.height);}const w=media instanceof HTMLVideoElement?media.videoWidth:media.naturalWidth,h=media instanceof HTMLVideoElement?media.videoHeight:media.naturalHeight,first=projectMark({x:0,y:0},pose.camera),last=projectMark({x:w,y:h},pose.camera);ctx.drawImage(media,first.x,first.y,last.x-first.x,last.y-first.y);
|
|
71
|
+
}else throw new Error('COMMENT_CROP_UNAVAILABLE: this viewport has no rendered pixels');
|
|
72
|
+
},overlays);
|
|
73
|
+
}
|