@rig-c/review 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/docs/DESIGN.md +1 -1
- package/docs/PANELS.md +12 -0
- package/docs/REVIEW_PACKAGE.md +19 -1
- package/docs/WORKFLOWS.md +6 -0
- package/package.json +1 -1
- package/src/cli.ts +5 -3
- package/src/comment-crops.ts +30 -0
- package/src/compare-session.ts +5 -2
- package/src/feedback-export.ts +24 -6
- package/src/feedback-validation.ts +9 -0
- package/src/manifest.ts +2 -2
- package/src/server.ts +1 -1
- package/src/session.ts +39 -7
- package/src/stage-comment.ts +89 -0
- package/web/compare.css +7 -4
- package/web/compare.html +1 -1
- package/web/compare.ts +34 -11
- package/web/index.html +1 -1
- package/web/inspection-persistence.ts +5 -1
- package/web/main.ts +57 -22
- package/web/stage-comments.ts +107 -0
- package/web/styles.css +7 -4
- package/web/turn-pad.ts +2 -2
- package/web/viewport-capture.ts +73 -0
package/web/main.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import {timeOnlyBasis,feedbackTimingLabel,requireFeedbackFrames,isTimeOnlyFeedback} from '../src/stage-comment.ts';
|
|
2
|
+
import {bindStageComments,type CommentPose} from './stage-comments.ts';
|
|
1
3
|
import {discoverTurnDials,TurnState} from '../src/turn-pad.ts';
|
|
2
4
|
import {bindTurnPad,type TurnPad} from './turn-pad.ts';
|
|
3
5
|
import {PartExplorer,loadPartModel,partBounds} from './part-selection.ts';
|
|
4
6
|
import {AnimationInspectionPanel} from './animation-inspection.ts';
|
|
5
7
|
import {PartThumbnails,pageRegionTable} from './part-thumbnails.ts';
|
|
6
|
-
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';
|
|
7
9
|
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature} from './camera-view.ts';
|
|
8
10
|
import {rendererStatus} from './renderer-status.ts';
|
|
9
11
|
import {bindAppearance,bindPan,bindStageLayout,syncStageLayout,bindInspectorTabs,idleAvailable,initialIdleAnimation,automaticIdlePreview,bindLayerShortcuts,describeLayerToggles,type ViewPoint} from './view-controls.ts';
|
|
@@ -33,7 +35,7 @@ import {connectControl} from './control.ts';
|
|
|
33
35
|
import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
|
|
34
36
|
import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
|
|
35
37
|
import {singleEligibility,videoReferenceAt,type InputState,type SingleLayout} from '../src/comparison-eligibility.ts';
|
|
36
|
-
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';
|
|
37
39
|
import {initialPlayerAnimation,keptAnimation} from '../src/animation-selection.ts';
|
|
38
40
|
import {initialReviewSelection} from '../src/open-selection.ts';
|
|
39
41
|
import type {ReferenceBinding} from '../src/reference.ts';
|
|
@@ -62,7 +64,7 @@ import {assertRigFieldOps,RIG_FIELDS_READOUT_BARS,TURN_BONE} from '../src/rig-fi
|
|
|
62
64
|
import {IDLE_READOUT_BARS,type LocalChannel} from '../src/idle-tracks.ts';
|
|
63
65
|
import type {PatchOp} from '../src/config-patch.ts';
|
|
64
66
|
|
|
65
|
-
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 } }
|
|
66
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 }[] }
|
|
67
69
|
interface SequenceJoint { state: 'observed' | 'occluded' | 'missing' | 'inferred'; at?: [number, number]; score?: number }
|
|
68
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 }
|
|
@@ -74,7 +76,8 @@ const el = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
|
74
76
|
const input = (id: string) => el<HTMLInputElement>(id);
|
|
75
77
|
const select = (id: string) => el<HTMLSelectElement>(id);
|
|
76
78
|
const button = (id: string) => el<HTMLButtonElement>(id);
|
|
77
|
-
const
|
|
79
|
+
const titledIds = new Set(['message', 'player-state', 'layout-state', 'renderer-state', 'layer-state', 'idle-state', 'control-state', 'draft-state', 'rig-label', 'reference-label', 'coordinates']);
|
|
80
|
+
const text = (id: string, value: string) => { const node = el(id); node.textContent = value; if (titledIds.has(id)) node.title = value; };
|
|
78
81
|
const option = (value: string, label: string) => { const item = document.createElement('option'); item.value = value; item.textContent = label; return item; };
|
|
79
82
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
80
83
|
// First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
|
|
@@ -99,6 +102,7 @@ let lastFrame = 0;
|
|
|
99
102
|
type CameraTarget='rig'|'reference';
|
|
100
103
|
let cameraSync=true,activeCamera:CameraTarget='rig',cameraRestoring=false,cameraLayout='';
|
|
101
104
|
const cameraFrames:Partial<Record<CameraTarget,CameraFrame>>={};
|
|
105
|
+
let comments:ReturnType<typeof bindStageComments>|null=null;
|
|
102
106
|
let view: 'compare' | 'reference' | 'rig' = 'compare';
|
|
103
107
|
// Requested layout persists in the source-bound draft; the effective one follows actual load events only.
|
|
104
108
|
let rigInput:InputState={state:'pending'},referenceInput:InputState={state:'absent'},effectiveView:SingleLayout|null=null;
|
|
@@ -644,11 +648,12 @@ function tick(now: number): void {
|
|
|
644
648
|
|
|
645
649
|
function configureTurnPad(model:unknown):void {
|
|
646
650
|
const previous=turnPad?.snapshot();turnPad?.dispose();turnPad=null;turnAdjust=null;document.getElementById('turn-pad-host')?.remove();
|
|
647
|
-
const dials=discoverTurnDials(model,assets?.animations);if(!dials.length){setAdjust();return;}
|
|
651
|
+
const dials=discoverTurnDials(model,assets?.animations);if(!dials.length){setAdjust();workspace?.refresh();return;}
|
|
648
652
|
const host=document.createElement('div');host.id='turn-pad-host';el('stage-layout').before(host);
|
|
649
653
|
turnPad=bindTurnPad({host,stages:[el('rig-viewport')],dials,blocked:()=>!!persistence?.restoring()||!!document.querySelector('dialog[open]')||!!rebuildBusy,
|
|
650
654
|
draw:state=>{if(!player)return;turnAdjust=state.adjust();setAdjust();if(playing)drawRig(()=>player!.draw());},save:()=>persistence?.save(),error:value=>message(value,true)});
|
|
651
655
|
if(previous&&turnPad){try{turnPad.restore(previous);}catch{message('TURN_STATE_MODEL_MISMATCH: turn values reset for this build.',true);}}
|
|
656
|
+
workspace?.refresh();
|
|
652
657
|
}
|
|
653
658
|
async function initPlayer(initialCheckpoint?:Checkpoint): Promise<void> {
|
|
654
659
|
text('renderer-state',rendererStatus(null,'pending').label);
|
|
@@ -696,18 +701,19 @@ function applyAxis(): void {
|
|
|
696
701
|
}
|
|
697
702
|
|
|
698
703
|
function renderProposals(): void {
|
|
704
|
+
comments?.refresh();
|
|
699
705
|
const area = el('proposal-list'); area.replaceChildren();
|
|
700
706
|
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
701
707
|
const unchecked = session.feedback.map(f => appliedCheckRefusal((f.context as Record<string, unknown> | undefined)?.applied)).find(Boolean) ?? null;
|
|
702
708
|
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.` : '');
|
|
703
709
|
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.` : '';
|
|
704
710
|
for (const feedback of session.feedback) {
|
|
705
|
-
const item = document.createElement('div'); item.className = 'proposal-item'; const title = document.createElement('strong'); title.textContent = `${feedback.category} · ${feedback.target.kind}:${feedback.target.name} ·
|
|
711
|
+
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;
|
|
706
712
|
item.append(title, description);
|
|
707
713
|
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); }
|
|
708
714
|
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);
|
|
709
715
|
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(); };
|
|
710
|
-
item.append(details
|
|
716
|
+
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);
|
|
711
717
|
}
|
|
712
718
|
updateSavedState();
|
|
713
719
|
}
|
|
@@ -753,7 +759,7 @@ async function exportStrip(): Promise<void> {
|
|
|
753
759
|
for (const feedback of session.feedback) {
|
|
754
760
|
const context = feedback.context;
|
|
755
761
|
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.`);
|
|
756
|
-
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: 'single',
|
|
762
|
+
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: 'single', ...requireFeedbackFrames(feedback) });
|
|
757
763
|
}
|
|
758
764
|
} else {
|
|
759
765
|
const start = input('feedback-start').value.trim(), end = input('feedback-end').value.trim();
|
|
@@ -889,7 +895,7 @@ function bind(): void {
|
|
|
889
895
|
saving = true; updateSavedState(); const savedPayload = JSON.stringify(session);
|
|
890
896
|
try { const saved = await api<ReviewSession>('/api/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: savedPayload });
|
|
891
897
|
if (JSON.stringify(session) === savedPayload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;
|
|
892
|
-
await persistence?.submitted(saved);renderProvenance();
|
|
898
|
+
submittedBaseline=structuredClone(saved);comments?.refresh();await persistence?.submitted(saved);renderProvenance();renderProposals();
|
|
893
899
|
message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status, feedback and source provenance.');
|
|
894
900
|
} catch (error) { message(`Submission failed: ${errorText(error)}`, true); } finally { saving = false; updateSavedState(); }
|
|
895
901
|
};
|
|
@@ -942,7 +948,7 @@ function bind(): void {
|
|
|
942
948
|
requirementsPanel=bindRequirementsPanel({check:runRequirements,seek:seekReading,animation:()=>animation,slot:()=>parts?.value()?.slot??null,hash:sha256Hex});
|
|
943
949
|
const tickResize=new ResizeObserver(()=>renderReadingTicks());tickResize.observe(el('transport'));viewDisposers.push(()=>tickResize.disconnect());
|
|
944
950
|
viewDisposers.push(bindStageLayout(el('stage-layout'),fitCamera));
|
|
945
|
-
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
951
|
+
for(const target of ['rig','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy)),()=>!comments?.active()));
|
|
946
952
|
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);};
|
|
947
953
|
viewDisposers.push(bindLayerShortcuts(document));describeLayerToggles(document,el('layer-state').textContent??'');
|
|
948
954
|
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playPreview();};
|
|
@@ -952,7 +958,7 @@ function bind(): void {
|
|
|
952
958
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){const resume=playing;pause();suspendedPreview=resume;}else if(suspendedPreview){suspendedPreview=false;void playPreview();}});
|
|
953
959
|
const resize = new ResizeObserver(fitCamera); resize.observe(el('rig-viewport')); resize.observe(el('reference-viewport'));
|
|
954
960
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
955
|
-
window.addEventListener('pagehide', () => { persistence?.dispose();turnPad?.dispose();workspace?.dispose();pause();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());overlays?.dispose(); player?.dispose(); assets?.dispose(); resize.disconnect(); });
|
|
961
|
+
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(); });
|
|
956
962
|
}
|
|
957
963
|
|
|
958
964
|
async function start(): Promise<void> {
|
|
@@ -982,13 +988,13 @@ async function start(): Promise<void> {
|
|
|
982
988
|
else if (readOnly) message(readOnly, true);
|
|
983
989
|
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.');
|
|
984
990
|
requestAnimationFrame(tick);
|
|
985
|
-
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();
|
|
991
|
+
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();
|
|
986
992
|
// Only an instance that serves agent control is asked for its config: a plain open has no control endpoint (#164).
|
|
987
993
|
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)});
|
|
988
994
|
} catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
|
|
989
995
|
}
|
|
990
996
|
/** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
|
|
991
|
-
function showInspector(name:string,reveal=true):void {if(name==='feedback'){if(reveal)openReview();return;}if(reveal)workspace?.revealInspector();document.querySelector<HTMLButtonElement>(`[data-inspector="${name}"]`)
|
|
997
|
+
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();}
|
|
992
998
|
function bindInspector():void {bindInspectorTabs(()=>{workspace?.afterTab();fitCamera();},()=>workspace?.beforeTab());}
|
|
993
999
|
/** 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. */
|
|
994
1000
|
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();}
|
|
@@ -1019,7 +1025,7 @@ async function playPreview():Promise<void> {
|
|
|
1019
1025
|
if(video&&referenceInput.state==='loaded'){try{await video.play();}catch(error){pause();message(`Playback is paused: ${errorText(error)}. Use Play to retry.`,true);return;}}
|
|
1020
1026
|
if(intent!==playbackIntent||!desiredPlay){if(!desiredPlay)video?.pause();return;}playing=true;lastFrame=0;player?.resume();button('play').textContent='Ⅱ';button('play').setAttribute('aria-label','Pause');
|
|
1021
1027
|
}
|
|
1022
|
-
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;}
|
|
1028
|
+
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;}
|
|
1023
1029
|
function controlState():ControlInspection {
|
|
1024
1030
|
const box=assets?.stage??player?.bounds(),center=box?{x:box.x+box.width/2,y:box.y+box.height/2}:null;
|
|
1025
1031
|
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}}};
|
|
@@ -1043,7 +1049,7 @@ async function applyControl(command:ControlCommand,signal:AbortSignal):Promise<v
|
|
|
1043
1049
|
pause();seekingControl=true;try{await inspectControlTime(command.seconds,signal);}finally{seekingControl=false;}
|
|
1044
1050
|
}
|
|
1045
1051
|
}
|
|
1046
|
-
async function inspectControlTime(target:number,signal:AbortSignal):Promise<void> {
|
|
1052
|
+
async function inspectControlTime(target:number,signal:AbortSignal,preserveSeconds=false):Promise<void> {
|
|
1047
1053
|
if(signal.aborted)throw new Error('CONTROL_SUPERSEDED');
|
|
1048
1054
|
// #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.
|
|
1049
1055
|
clipMoved({kind:'seek',to:target});
|
|
@@ -1054,7 +1060,8 @@ async function inspectControlTime(target:number,signal:AbortSignal):Promise<void
|
|
|
1054
1060
|
const commit=(actual:number)=>{
|
|
1055
1061
|
if(signal.aborted||media!==video)throw new Error('CONTROL_SUPERSEDED');
|
|
1056
1062
|
if(!Number.isFinite(actual)||actual<0||actual>duration)throw new Error('CONTROL_TIME_OUTSIDE_DOMAIN');
|
|
1057
|
-
|
|
1063
|
+
const seconds=preserveSeconds?target:actual;
|
|
1064
|
+
if(player){player.seek(seconds);drawRig(()=>player!.draw());}time=seconds;
|
|
1058
1065
|
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();
|
|
1059
1066
|
};
|
|
1060
1067
|
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.`);}
|
|
@@ -1088,8 +1095,8 @@ function numericView():ControlView {
|
|
|
1088
1095
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
1089
1096
|
function inspectionBinding():InspectionBinding{return {mode:'single',sourceHash:data.sourceHash,rigs:[data.reviewedSource.rig?.sha256??null]};}
|
|
1090
1097
|
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}};}
|
|
1091
|
-
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1092
|
-
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();});
|
|
1098
|
+
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal,preserveSeconds=false):Promise<void> {
|
|
1099
|
+
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();});
|
|
1093
1100
|
}
|
|
1094
1101
|
/** Read the SELECTED reference without replacing the currently inspected DOM/player. */
|
|
1095
1102
|
async function inspectionReferenceInfo(id:string|null,signal:AbortSignal):Promise<{width:number;height:number;duration:number|null;people:string[]}> {
|
|
@@ -1123,7 +1130,7 @@ async function prepareInspection(bookmark:InspectionBookmark,signal:AbortSignal)
|
|
|
1123
1130
|
// Range fitting belongs to the transactional apply after that layout is measured.
|
|
1124
1131
|
if(signal.aborted)throw new Error('Inspection restore superseded');
|
|
1125
1132
|
}
|
|
1126
|
-
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
1133
|
+
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal,preserveSeconds=false):Promise<void> {
|
|
1127
1134
|
cameraRestoring=true;try{
|
|
1128
1135
|
const selection=bookmark.selection;
|
|
1129
1136
|
if(turnPad){if(selection.turn)turnPad.restore(selection.turn);else{turnPad.state.reset();turnPad.restore(turnPad.snapshot());}}
|
|
@@ -1134,12 +1141,15 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
|
|
|
1134
1141
|
if(bookmark.animation&&player)chooseAnimation(bookmark.animation);
|
|
1135
1142
|
if(selection.axis){select('axis-select').value=selection.axis.id;chooseAxis();input('axis-value').value=String(selection.axis.value);applyAxis();}
|
|
1136
1143
|
if(selection.partSet!==undefined&&player){if(selection.partSet||player.sets().length)player.setSet(selection.partSet||null);select('part-set').value=selection.partSet??'';}
|
|
1137
|
-
if(bookmark.time!==null){if(video&&video.readyState<1)await waitReferenceMetadata(video,signal);await inspectControlTime(bookmark.time,signal);}
|
|
1144
|
+
if(bookmark.time!==null){if(video&&video.readyState<1)await waitReferenceMetadata(video,signal);await inspectControlTime(bookmark.time,signal,preserveSeconds);}
|
|
1138
1145
|
if(selection.person!==undefined){selectedPerson=selection.person??'';select('person-select').value=selectedPerson;lastPoseFrame=-1;renderSequenceFrame();}
|
|
1139
1146
|
if(selection.view)setView(selection.view);
|
|
1140
1147
|
appearance?.set(bookmark.theme);
|
|
1141
1148
|
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;
|
|
1142
|
-
if(selection.inspector)showInspector(selection.inspector,false);syncStageLayout(el('stage-layout'));
|
|
1149
|
+
if(selection.inspector)showInspector(selection.inspector,false);syncStageLayout(el('stage-layout'));
|
|
1150
|
+
// Mode changes schedule workspace layout and resize observers. Settle them
|
|
1151
|
+
// while restoration is held, before measuring/applying the saved pane camera.
|
|
1152
|
+
await new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())));if(signal.aborted)throw new Error('Inspection restore superseded');fitCamera();
|
|
1143
1153
|
const camera=bookmark.view.cameras.rig,ref=bookmark.view.cameras.reference,rigViewport=el('rig-viewport'),referenceViewport=el('reference-viewport');
|
|
1144
1154
|
const nextZoom=camera&&player&&rigViewport.clientWidth&&rigViewport.clientHeight?restoredZoom(camera,rigScale/zoom,rigViewport.clientWidth,rigViewport.clientHeight):camera?.zoom??null;
|
|
1145
1155
|
const nextReferenceZoom=ref&&referenceSize&&referenceViewport.clientWidth&&referenceViewport.clientHeight?restoredZoom(ref,referenceScale/referenceZoom,referenceViewport.clientWidth,referenceViewport.clientHeight):ref?.zoom??null;
|
|
@@ -1151,7 +1161,7 @@ async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):P
|
|
|
1151
1161
|
}
|
|
1152
1162
|
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});});}
|
|
1153
1163
|
async function setupPersistence():Promise<boolean> {
|
|
1154
|
-
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,
|
|
1164
|
+
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))),
|
|
1155
1165
|
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);}});
|
|
1156
1166
|
return persistence.restored;
|
|
1157
1167
|
}
|
|
@@ -2109,3 +2119,28 @@ async function meshFolds():Promise<string[]> {
|
|
|
2109
2119
|
return lines;
|
|
2110
2120
|
}
|
|
2111
2121
|
void start();
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
/** Only live, unrecorded authoring effects block capture; unsent form ops alone do not. */
|
|
2125
|
+
function commentCaptureRefusal():string|null {
|
|
2126
|
+
if(appliedOnStage)return 'COMMENT_APPLIED_BUILD_UNSUPPORTED: return to the inspected source before commenting on this applied scratch build';
|
|
2127
|
+
if(rebuildBusy)return 'COMMENT_SOURCE_BUSY: wait for the rebuild or candidate model replacement before commenting';
|
|
2128
|
+
const preview=idleAdjust?'Idle tracks':dialAdjust?'Rig fields turn dial':physicsPreview.read().length?'Physics live overrides':stripPending?'Density strip candidate':
|
|
2129
|
+
meshEditor?.overlay()?'Edit Mesh':bonesPanel?.overlay()?'Bones anchors':cutsPanel&&(cutsPanel.overlayRegions().length||cutsPanel.overlayMarkers().length)?'Cuts/patches/joints':
|
|
2130
|
+
clothPanel?.overlayRegions().length?'Cloth links':meshPanel&&(meshPanel.overlayRegions().length||meshPanel.overlayCircles().length)?'Mesh regions':
|
|
2131
|
+
chestRegions().length||chestCircles().length||chestMarkers().length?'Chest regions/landmarks':rigFieldsPanel?.handles().length?'Rig fields stage handles':
|
|
2132
|
+
(()=>{const handles=document.getElementById('idle-handles');return handles&&!handles.hidden&&handles.childElementCount>0;})()?'Idle tracks stage handles':null;
|
|
2133
|
+
return preview?`COMMENT_UNAPPLIED_PREVIEW: ${preview} is active; leave or disable that preview before commenting (nothing is cleared or applied automatically)`:null;
|
|
2134
|
+
}
|
|
2135
|
+
function setupComments():void {
|
|
2136
|
+
comments=bindStageComments({toolbar:el('stage-comment').parentElement!,viewports:{rig:el('rig-viewport'),reference:el('reference-viewport')},
|
|
2137
|
+
pose:view=>{
|
|
2138
|
+
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;
|
|
2139
|
+
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;
|
|
2140
|
+
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;
|
|
2141
|
+
},
|
|
2142
|
+
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,
|
|
2143
|
+
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),
|
|
2144
|
+
refusal:commentCaptureRefusal,blocked:()=>saving||!!persistence?.restoring()||seekingControl||!!video?.seeking||!!rebuildBusy||!!appliedOnStage,shortcutBlocked:()=>!!meshEditor?.active()
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
@@ -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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/* Theme tokens: site values copied from rig-c.org styles.css (dark); light and --rv-* are review-local derivations. No literal colour outside these blocks. */
|
|
2
2
|
:root{--rv-region-a:#ff9f43;--rv-region-a-fill:rgba(255,159,67,.18);--rv-region-b:#4dd6ff;--rv-region-b-fill:rgba(77,214,255,.18);--font-sans:Inter,system-ui,sans-serif;--font-display:Inter,system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace;--radius:10px;--bg:#070b18;--fg:#c9d4e8;--fg-strong:#f3f6fc;--fg-soft:#aebbd4;--fg-muted:#7f8ba6;--border:#18213a;--border-strong:#2a3657;--hover-bg:#0f1730;--active-bg:#142042;--link:#7cc8ff;--link-hover:#b3e0ff;--btn-fg:#08101f;--code-bg:#050814;--code-fg:#d6e2f5;--code-border:#18213a;--code-inline-bg:#121a30;--footer-bg:#050813;--pass:#30d158;--warn:#ffcc00;--fail:#ff453a;--rv-bar:#0b1124;--rv-panel:#0d152b;--rv-raised:var(--code-inline-bg);--rv-control:var(--code-inline-bg);--rv-control-hover:var(--active-bg);--rv-field:var(--code-bg);--rv-segmented:var(--code-bg);--rv-pressed-bg:#133733;--rv-pressed-border:#207c44;--rv-pressed-fg:var(--fg-strong);--rv-focus:var(--link-hover);--rv-backdrop:rgba(7,11,24,.82);--rv-pane-hi:#142042;--rv-pane-lo:#070b18;--rv-pane-label-bg:rgba(7,11,24,.85);--rv-stage-fg:#aebbd4;--rv-grid-dot:rgba(127,139,166,.2);--rv-media-bg:#0b1020;--rv-scrollbar-thumb:#2a3657;--rv-scrollbar-thumb-hover:#3d4c74;--rv-scrollbar-track:transparent;--rv-separator:#1a2747;--rv-separator-hover:#3f7fb8;--rv-tick-inspected:#7cc8ff;--rv-tick-scratch:#ff9f43}
|
|
3
3
|
:root[data-theme=light]{--rv-region-a:#ff9f43;--rv-region-a-fill:rgba(255,159,67,.18);--rv-region-b:#4dd6ff;--rv-region-b-fill:rgba(77,214,255,.18);--bg:#f5f7fb;--fg:#26324a;--fg-strong:#0c1324;--fg-soft:#3b4864;--fg-muted:#5d6982;--border:#d5dcea;--border-strong:#b3bed3;--hover-bg:#e8edf6;--active-bg:#dbe3f3;--link:#1a6fc0;--link-hover:#0f528f;--btn-fg:#ffffff;--code-bg:#eef1f7;--code-fg:#1d2940;--code-border:#d5dcea;--code-inline-bg:#e3e8f2;--footer-bg:#eceff6;--pass:#1e8a3c;--warn:#8a6400;--fail:#b3261e;--rv-bar:#edf1f7;--rv-panel:#e9eef6;--rv-raised:var(--code-inline-bg);--rv-control:var(--code-inline-bg);--rv-control-hover:#d5ddec;--rv-field:#ffffff;--rv-segmented:var(--code-inline-bg);--rv-pressed-bg:#c9ded8;--rv-pressed-border:#6fb286;--rv-pressed-fg:var(--fg-strong);--rv-focus:var(--link);--rv-backdrop:rgba(12,19,36,.45);--rv-pane-hi:#142042;--rv-pane-lo:#070b18;--rv-pane-label-bg:rgba(7,11,24,.85);--rv-stage-fg:#aebbd4;--rv-grid-dot:rgba(127,139,166,.2);--rv-media-bg:#0b1020;--rv-scrollbar-thumb:#b3bed3;--rv-scrollbar-thumb-hover:#8e9bb5;--rv-scrollbar-track:transparent;--rv-separator:#c9d6ec;--rv-separator-hover:#5b93cf;--rv-tick-inspected:#1a6fc0;--rv-tick-scratch:#c25e00}
|
|
4
|
-
:root{font-family:var(--font-sans);color:var(--fg);background:var(--bg);font-size:13px;color-scheme:dark;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{color:inherit;background:var(--rv-control);border:1px solid var(--border-strong);border-radius:6px;padding:7px 10px;cursor:pointer}button:hover{background:var(--rv-control-hover)}button:disabled{opacity:.45;cursor:default}button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border);color:var(--rv-pressed-fg)}button.primary{background:var(--link);border-color:var(--link);color:var(--btn-fg);font-weight:600}button.outline{background:transparent}button.block{width:100%;margin-top:10px}a{color:var(--link)}input,select,textarea{color:var(--fg);background:var(--rv-field);border:1px solid var(--border-strong);border-radius:6px;padding:8px;max-width:100%}select,input[type=text],textarea{width:100%}textarea{resize:vertical;line-height:1.6}input[type=range]{accent-color:var(--link);padding:0;min-width:0}input[type=checkbox],input[type=radio]{accent-color:var(--link)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--rv-focus);outline-offset:3px}[hidden]{display:none!important}.topbar{min-height:72px;padding:15px 22px;display:flex;align-items:center;gap:25px;border-bottom:1px solid var(--border);flex-wrap:wrap;background:var(--rv-bar)}.brand{font-size:19px;letter-spacing:-.6px;white-space:nowrap}.brand-mark{display:inline-grid;place-items:center;width:24px;height:24px;border:1px solid var(--link);color:var(--link);border-radius:50%;margin-right:9px;font-size:14px}.topbar strong{font-weight:500;font-size:13px}.topbar #revision{margin-left:9px}.top-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}.subtle,.hint{color:var(--fg-muted);font-size:11px}.hint{line-height:1.6}.workspaces{display:flex;gap:10px;padding:0 18px;border-bottom:1px solid var(--border);background:var(--rv-bar)}.workspaces button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;padding:15px 13px;font-size:12px;letter-spacing:.06em;color:var(--fg-soft)}.workspaces button[aria-pressed=true]{border-bottom-color:var(--link);color:var(--fg-strong);background:transparent}.workspaces button span{font-size:11px;margin-left:8px;letter-spacing:0;color:var(--fg-muted)}.layout{display:grid;grid-template-columns:190px minmax(250px,1fr) 286px;min-height:calc(100vh - 160px)}aside{background:var(--rv-panel);padding:20px 15px}.left-panel{border-right:1px solid var(--border)}.right-panel{border-left:1px solid var(--border)}.eyebrow{font-size:11px;letter-spacing:.08em;color:var(--fg-muted);margin:0 0 11px}.label{display:block;color:var(--fg-muted);font-size:11px;margin:0 0 7px}.space-top{margin-top:15px}.left-panel section{margin-top:26px}.tool{width:100%;display:flex;justify-content:space-between;align-items:center;text-align:left;gap:5px;border-color:transparent;background:transparent;padding:9px 6px;margin:3px 0}.tool small{font-size:11px;color:var(--fg-muted)}.checkpoint{display:block;width:100%;background:transparent;border:1px solid transparent;text-align:left;margin:4px 0;padding:10px 9px;white-space:normal;line-height:1.5}.checkpoint[aria-current=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border)}.checkpoint small{display:block;font-size:11px;color:var(--fg-muted);margin-top:3px}.source-link{display:block;font-size:11px;overflow-wrap:anywhere;margin:8px 0}main{min-width:0;display:flex;flex-direction:column;background:var(--bg)}.viewbar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:12px;border-bottom:1px solid var(--border)}.zoom-controls{display:flex;gap:4px;align-items:center}.zoom-controls button{padding:4px 8px;font-size:12px}.zoom-controls output{font:11px var(--font-mono);min-width:39px;text-align:center;color:var(--fg-muted)}.stages{display:flex;gap:10px;padding:12px 12px 0;flex:1;min-height:460px}.stage-pane{position:relative;min-width:0;flex:1;border:1px solid var(--border-strong);border-radius:8px;overflow:hidden;background:radial-gradient(ellipse at 50% 40%,var(--rv-pane-hi),var(--rv-pane-lo) 74%)}.pane-label{position:absolute;left:10px;top:10px;z-index:2;background:var(--rv-pane-label-bg);border-radius:4px;padding:4px 7px;color:var(--rv-stage-fg);font-size:11px;pointer-events:none}.viewport{position:relative;overflow:hidden;width:100%;height:100%;min-height:450px;touch-action:none;background-image:radial-gradient(var(--rv-grid-dot) .7px,transparent .7px);background-size:22px 22px}.empty{position:absolute;inset:65px 22px 30px;display:grid;place-items:center;text-align:center;color:var(--rv-stage-fg);font-size:12px;line-height:1.8}.reference-content,#rig-root{position:absolute;transform-origin:0 0}.reference-content img,.reference-content video{display:block;max-width:none}.reference-content svg{display:block;max-width:none}.reference-content video{object-fit:contain}.stage-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:
|
|
4
|
+
:root{font-family:var(--font-sans);color:var(--fg);background:var(--bg);font-size:13px;color-scheme:dark;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{color:inherit;background:var(--rv-control);border:1px solid var(--border-strong);border-radius:6px;padding:7px 10px;cursor:pointer}button:hover{background:var(--rv-control-hover)}button:disabled{opacity:.45;cursor:default}button[aria-pressed=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border);color:var(--rv-pressed-fg)}button.primary{background:var(--link);border-color:var(--link);color:var(--btn-fg);font-weight:600}button.outline{background:transparent}button.block{width:100%;margin-top:10px}a{color:var(--link)}input,select,textarea{color:var(--fg);background:var(--rv-field);border:1px solid var(--border-strong);border-radius:6px;padding:8px;max-width:100%}select,input[type=text],textarea{width:100%}textarea{resize:vertical;line-height:1.6}input[type=range]{accent-color:var(--link);padding:0;min-width:0}input[type=checkbox],input[type=radio]{accent-color:var(--link)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--rv-focus);outline-offset:3px}[hidden]{display:none!important}.topbar{min-height:72px;padding:15px 22px;display:flex;align-items:center;gap:25px;border-bottom:1px solid var(--border);flex-wrap:wrap;background:var(--rv-bar)}.brand{font-size:19px;letter-spacing:-.6px;white-space:nowrap}.brand-mark{display:inline-grid;place-items:center;width:24px;height:24px;border:1px solid var(--link);color:var(--link);border-radius:50%;margin-right:9px;font-size:14px}.topbar strong{font-weight:500;font-size:13px}.topbar #revision{margin-left:9px}.top-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}.subtle,.hint{color:var(--fg-muted);font-size:11px}.hint{line-height:1.6}.workspaces{display:flex;gap:10px;padding:0 18px;border-bottom:1px solid var(--border);background:var(--rv-bar)}.workspaces button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;padding:15px 13px;font-size:12px;letter-spacing:.06em;color:var(--fg-soft)}.workspaces button[aria-pressed=true]{border-bottom-color:var(--link);color:var(--fg-strong);background:transparent}.workspaces button span{font-size:11px;margin-left:8px;letter-spacing:0;color:var(--fg-muted)}.layout{display:grid;grid-template-columns:190px minmax(250px,1fr) 286px;min-height:calc(100vh - 160px)}aside{background:var(--rv-panel);padding:20px 15px}.left-panel{border-right:1px solid var(--border)}.right-panel{border-left:1px solid var(--border)}.eyebrow{font-size:11px;letter-spacing:.08em;color:var(--fg-muted);margin:0 0 11px}.label{display:block;color:var(--fg-muted);font-size:11px;margin:0 0 7px}.space-top{margin-top:15px}.left-panel section{margin-top:26px}.tool{width:100%;display:flex;justify-content:space-between;align-items:center;text-align:left;gap:5px;border-color:transparent;background:transparent;padding:9px 6px;margin:3px 0}.tool small{font-size:11px;color:var(--fg-muted)}.checkpoint{display:block;width:100%;background:transparent;border:1px solid transparent;text-align:left;margin:4px 0;padding:10px 9px;white-space:normal;line-height:1.5}.checkpoint[aria-current=true]{background:var(--rv-pressed-bg);border-color:var(--rv-pressed-border)}.checkpoint small{display:block;font-size:11px;color:var(--fg-muted);margin-top:3px}.source-link{display:block;font-size:11px;overflow-wrap:anywhere;margin:8px 0}main{min-width:0;display:flex;flex-direction:column;background:var(--bg)}.viewbar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:12px;border-bottom:1px solid var(--border)}.zoom-controls{display:flex;gap:4px;align-items:center}.zoom-controls button{padding:4px 8px;font-size:12px}.zoom-controls output{font:11px var(--font-mono);font-variant-numeric:tabular-nums;min-width:39px;text-align:center;color:var(--fg-muted)}.stages{display:flex;gap:10px;padding:12px 12px 0;flex:1;min-height:460px}.stage-pane{position:relative;min-width:0;flex:1;border:1px solid var(--border-strong);border-radius:8px;overflow:hidden;background:radial-gradient(ellipse at 50% 40%,var(--rv-pane-hi),var(--rv-pane-lo) 74%)}.pane-label{position:absolute;left:10px;top:10px;z-index:2;background:var(--rv-pane-label-bg);border-radius:4px;padding:4px 7px;color:var(--rv-stage-fg);font-size:11px;pointer-events:none}.viewport{position:relative;overflow:hidden;width:100%;height:100%;min-height:450px;touch-action:none;background-image:radial-gradient(var(--rv-grid-dot) .7px,transparent .7px);background-size:22px 22px}.empty{position:absolute;inset:65px 22px 30px;display:grid;place-items:center;text-align:center;color:var(--rv-stage-fg);font-size:12px;line-height:1.8}.reference-content,#rig-root{position:absolute;transform-origin:0 0}.reference-content img,.reference-content video{display:block;max-width:none}.reference-content svg{display:block;max-width:none}.reference-content video{object-fit:contain}.stage-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:nowrap;height:30px;min-height:30px;max-height:30px;padding:0 16px;box-sizing:border-box;flex:none;overflow:hidden;color:var(--fg-muted);font-size:11px}.stage-footer>*{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.stage-footer>span:last-child{flex:none}.transport{display:flex;align-items:center;gap:11px;padding:13px;border-top:1px solid var(--border);background:var(--rv-panel)}.transport select{width:auto;max-width:150px;font-size:11px}.transport input{flex:1}.transport output{font:11px var(--font-mono);font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--fg-muted)}#time-label{min-width:17ch;display:inline-block;text-align:right}.sound-panel,.angle-panel{padding:15px;border-top:1px solid var(--border);background:var(--rv-panel)}.row{display:flex;align-items:center;gap:11px;flex-wrap:wrap}.row .eyebrow{margin:0}.sound-panel .row{font-size:11px;margin-bottom:9px}.sound-panel input[type=range]{flex:1;max-width:160px}.cue{display:flex;gap:9px;align-items:center;justify-content:space-between;border-top:1px solid var(--border);padding:9px 0;font-size:11px;flex-wrap:wrap}.cue button{padding:4px 7px;font-size:11px}.angle-panel{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:11px}.angle-panel select{width:auto;max-width:150px}.angle-panel .hint{width:100%;margin:0}.angle-panel input{flex:1}.divider{height:1px;background:var(--border-strong);margin:22px 0}h1{font-size:15px;font-weight:500;line-height:1.6;margin:0 0 7px}.outcome{border:0;padding:0;margin:18px 0 0;display:flex;gap:11px;flex-wrap:wrap}.outcome legend{font-size:11px;color:var(--fg-muted);margin-bottom:8px;padding:0}.outcome label{display:flex;align-items:center;gap:3px;font-size:11px}.proposal-item{border-top:1px solid var(--border);padding:11px 0;margin-top:12px;font-size:11px;overflow-wrap:anywhere}.proposal-item strong{display:block;font-weight:500;margin-bottom:5px}.proposal-item button{font-size:11px;padding:3px 6px;margin-top:7px}footer{display:flex;justify-content:space-between;align-items:center;gap:6px 15px;flex-wrap:nowrap;height:40px;box-sizing:border-box;overflow:hidden;padding:0 18px;border-top:1px solid var(--border);background:var(--footer-bg);font-size:11px;color:var(--fg-soft)}.error{color:var(--fail)!important}.dirty{color:var(--warn)!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
|
|
5
5
|
@media(min-width:1500px){.layout{grid-template-columns:218px minmax(250px,1fr) 310px}.stages{min-height:600px}}
|
|
6
6
|
@media(max-width:1000px){.layout{grid-template-columns:158px minmax(250px,1fr) 250px}aside{padding:17px 12px}.topbar{gap:14px}.topbar #revision{display:block;margin-left:0}.stages{min-height:400px}.viewport{min-height:400px}.workspaces span{display:none}}
|
|
7
7
|
@media(max-width:780px){.layout{grid-template-columns:150px minmax(0,1fr)}.right-panel{grid-column:1/-1;border-left:0;border-top:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:12px 24px}.right-panel>.eyebrow,.right-panel>.label,.right-panel>#review-status{grid-column:1/-1}.right-panel>.divider{display:none}#checkpoint-detail,#proposal-panel{min-width:0}.top-actions{margin-left:0}.stages{min-height:420px}.viewport{min-height:420px}.transport{flex-wrap:wrap}.transport input{min-width:130px}.topbar{padding:14px 16px}}
|
|
8
|
-
@media(max-width:540px){.layout{grid-template-columns:1fr}.left-panel{border-right:0;border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:12px}.left-panel>.eyebrow,.left-panel>#checkpoints{grid-column:1/-1}.left-panel section{margin-top:8px}#checkpoints{display:flex;gap:5px;flex-wrap:wrap}.checkpoint{width:auto;flex:1;min-width:120px}.right-panel{display:block}#proposal-panel{margin-top:25px}.stages{gap:6px;padding:8px}.stage-pane{border-radius:5px}.viewport{min-height:365px}.stages{min-height:365px}.pane-label{left:5px;top:6px;font-size:11px;padding:3px}.viewbar{gap:8px}.workspaces{padding:0 8px}.workspaces button{padding:13px 9px}.stage-footer{padding:
|
|
8
|
+
@media(max-width:540px){.layout{grid-template-columns:1fr}.left-panel{border-right:0;border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr 1fr;gap:12px}.left-panel>.eyebrow,.left-panel>#checkpoints{grid-column:1/-1}.left-panel section{margin-top:8px}#checkpoints{display:flex;gap:5px;flex-wrap:wrap}.checkpoint{width:auto;flex:1;min-width:120px}.right-panel{display:block}#proposal-panel{margin-top:25px}.stages{gap:6px;padding:8px}.stage-pane{border-radius:5px}.viewport{min-height:365px}.stages{min-height:365px}.pane-label{left:5px;top:6px;font-size:11px;padding:3px}.viewbar{gap:8px}.workspaces{padding:0 8px}.workspaces button{padding:13px 9px}.stage-footer{padding:0 12px}.source-link{font-size:11px}footer{padding:0 12px}}
|
|
9
9
|
@media(pointer:coarse){button{min-height:42px}input,select,textarea{font-size:16px}.outcome label{min-height:40px}.zoom-controls button{min-width:38px}}
|
|
10
10
|
|
|
11
11
|
.frame-range{display:grid;grid-template-columns:1fr 1fr;gap:9px}.frame-range label{font-size:11px;color:var(--fg-muted)}.frame-range input{width:100%;margin-top:6px}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
.report-panel{padding:15px;border-top:1px solid var(--border);background:var(--rv-panel)}.report-header{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.report-header h2{font-size:13px;font-weight:500;margin:0}.report-header a{font-size:11px;margin-left:auto}.report-controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px}.report-controls select{width:auto;max-width:250px;flex:1;font-size:11px}.report-controls button{font-size:11px;padding:5px 9px}.report-controls output{font:11px var(--font-mono);color:var(--fg-muted)}.report-empty-categories{display:flex;gap:6px;flex-wrap:wrap;margin-top:11px}.report-empty-categories span{font-size:11px;color:var(--fg-muted);background:var(--rv-raised);padding:2px 6px;border-radius:4px}.report-event-list{list-style:none;padding:0;margin:10px 0 0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px}.report-event-list li{min-width:0}.report-event-list button{display:flex;flex-direction:column;gap:2px;text-align:left;width:100%;height:100%;padding:8px 9px;border-color:var(--border);background:var(--rv-raised);white-space:normal;overflow-wrap:anywhere;line-height:1.5}.report-event-list button[aria-current=true]{border-color:var(--rv-pressed-border);background:var(--rv-pressed-bg)}.report-event-list strong{font-size:11px;font-weight:500}.report-event-list small{font-size:11px;color:var(--fg-muted)}.report-event-list .no-time{color:var(--warn)}.report-selection{border-top:1px solid var(--border);margin-top:12px;padding-top:12px}.report-selection h3{font-size:12px;font-weight:500;margin:0}.report-selection details{font-size:11px;color:var(--fg-muted);margin-top:10px}.report-selection summary{cursor:pointer}.report-selection pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.6 var(--font-mono);background:var(--code-bg);padding:10px;border-radius:5px}.report-selection #report-time{max-width:100%}@media(max-width:1000px){.report-event-list{grid-template-columns:1fr}}@media(max-width:540px){.report-controls select{min-width:140px}.report-header a{margin-left:0}}
|
|
16
16
|
|
|
17
|
-
.transport #frame-label{color:var(--fg-strong);font-size:11px}.frame-controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:10px 14px;background:var(--rv-panel);border-top:1px solid var(--border);font-size:11px}.frame-controls label{color:var(--fg-muted)}.frame-controls input[type=number]{width:86px;font-variant-numeric:tabular-nums}.frame-controls input[type=range]{flex:1;min-width:100px}.frame-controls button{font-size:11px;padding:5px 9px}.frame-controls .hint{margin:0}.frame-controls #frame-outside{width:100%}
|
|
17
|
+
.transport #frame-label{color:var(--fg-strong);font-size:11px;min-width:10ch;display:inline-block;font-variant-numeric:tabular-nums}.frame-controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:10px 14px;background:var(--rv-panel);border-top:1px solid var(--border);font-size:11px}.frame-controls label{color:var(--fg-muted)}.frame-controls input[type=number]{width:86px;font-variant-numeric:tabular-nums}.frame-controls input[type=range]{flex:1;min-width:100px}.frame-controls button{font-size:11px;padding:5px 9px}.frame-controls .hint{margin:0}.frame-controls #frame-outside{width:100%}
|
|
18
18
|
|
|
19
19
|
/* Inspection layout is bounded independently of pane content and review forms. */
|
|
20
20
|
#stage-layout{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:minmax(0,1fr);height:clamp(480px,calc(100dvh - 300px),900px);min-height:0;flex:none;align-items:stretch}
|
|
@@ -69,7 +69,7 @@ main{min-height:0;overflow:hidden;display:flex;flex-direction:column}
|
|
|
69
69
|
#inspector-playback #motion-tools{display:flex;gap:8px;flex-wrap:wrap}
|
|
70
70
|
#inspector-playback .tool{width:auto}
|
|
71
71
|
|
|
72
|
-
@media(max-width:780px){.layout{grid-template-columns:130px minmax(0,1fr)!important}.inspector{height:36dvh}.stage-footer{padding:
|
|
72
|
+
@media(max-width:780px){.layout{grid-template-columns:130px minmax(0,1fr)!important}.inspector{height:36dvh}.stage-footer{padding:0 10px}.inspector-heading>.hint{display:none}}
|
|
73
73
|
@media(max-width:540px){.layout{grid-template-columns:minmax(0,1fr)!important}.left-panel{display:block;padding:8px;font-size:11px}.layout{grid-template-columns:105px minmax(0,1fr)!important}.topbar,header{padding:7px 10px;gap:8px}.topbar .brand,header .brand{font-size:14px}.top-actions,.actions{gap:4px}.top-actions button,.actions button{padding:4px 6px;font-size:10px}.workspaces button{padding:6px}.inspector{height:40dvh;min-height:150px}.inspector-heading{gap:8px;padding:5px 8px}.inspector-heading button{padding:4px 7px}.stage-footer{display:none}}
|
|
74
74
|
|
|
75
75
|
.inspector .feedback-form>label,.inspector .review-summary>label{display:block;font-size:11px;margin:10px 0 5px}.inspector .frame-range input{width:100%}.inspector [data-inspector-panel]{overscroll-behavior:contain}.left-panel{overscroll-behavior:contain}#stage-layout .pane-label{max-width:calc(100% - 15px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
@@ -304,3 +304,6 @@ footer.status-bar{flex-wrap:nowrap}
|
|
|
304
304
|
.turn-pad output{font-variant-numeric:tabular-nums;color:var(--fg-strong)}
|
|
305
305
|
.turn-pad button[aria-pressed=true]{border-color:var(--pass);color:var(--pass)}
|
|
306
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
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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):void;dispose():void}
|
|
2
|
+
export interface TurnPad {state:TurnState;snapshot():TurnSnapshot;restore(value:unknown,redraw?:boolean):void;dispose():void}
|
|
3
3
|
/** One pad owns the shared values. It never touches the model document or writes animation keys. */
|
|
4
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
5
|
const {host,stages,dials}=options;if(!dials.length)return null;
|
|
@@ -22,5 +22,5 @@ export function bindTurnPad(options:{host:HTMLElement;stages:HTMLElement[];dials
|
|
|
22
22
|
let disposed=false,last=0,raf=0;
|
|
23
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
24
|
host.append(panel);commit();raf=requestAnimationFrame(step);
|
|
25
|
-
return {state,snapshot:()=>state.snapshot(),restore(value){state.restore(value);commit();},dispose(){disposed=true;if(saveTimer)clearTimeout(saveTimer);cancelAnimationFrame(raf);for(const stage of stages)stage.removeEventListener('pointermove',move);panel.remove();}};
|
|
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
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
|
+
}
|