@rig-c/review 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -705
- package/docs/DESIGN.md +12 -1
- package/docs/PANELS.md +412 -0
- package/docs/REVIEW_PACKAGE.md +42 -1
- package/docs/WORKFLOWS.md +343 -0
- package/package.json +4 -1
- package/src/cli.ts +5 -3
- package/src/comment-crops.ts +30 -0
- package/src/compare-session.ts +7 -2
- package/src/feedback-export.ts +24 -6
- package/src/feedback-validation.ts +11 -0
- package/src/manifest.ts +4 -3
- package/src/server.ts +1 -1
- package/src/session.ts +44 -10
- package/src/stage-comment.ts +89 -0
- package/src/turn-pad.ts +60 -0
- package/web/compare.css +13 -0
- package/web/compare.html +1 -1
- package/web/compare.ts +45 -12
- package/web/index.html +1 -1
- package/web/inspection-persistence.ts +9 -3
- package/web/main.ts +72 -26
- package/web/stage-comments.ts +107 -0
- package/web/styles.css +13 -0
- package/web/turn-pad.ts +26 -0
- package/web/viewport-capture.ts +73 -0
package/src/server.ts
CHANGED
|
@@ -103,7 +103,7 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
103
103
|
return new Response(result.png?new Uint8Array(result.png):stripJson(result),{headers:{...headers,'Content-Type':result.png?'image/png':'application/json; charset=utf-8','Content-Disposition':`attachment; filename="review-strip.${result.png?'png':'json'}"`}});
|
|
104
104
|
}
|
|
105
105
|
if(req.method!=='GET'&&req.method!=='HEAD')return json({error:'Method not allowed'},405);
|
|
106
|
-
if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
|
|
106
|
+
if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),referenceHashes:{...p.referenceHashes,...(referenceBinding(p)?{[referenceBinding(p)!.reference.id]:referenceBinding(p)!.reference.sha256}:{})},report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
|
|
107
107
|
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
108
108
|
if(url.pathname==='/api/rebuild')return json(await rebuilds.describe());
|
|
109
109
|
if(url.pathname==='/api/rebuild/history')return json(await rebuilds.history());
|
package/src/session.ts
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
|
+
import {clockForAnimation,frameAtTime} from './frame-clock.ts';
|
|
2
|
+
import {parseBookmark} from '../web/inspection-persistence.ts';
|
|
3
|
+
import {parseStageComment,projectMark,hasStageComment,isTimeOnlyFeedback,feedbackFrameInterval} from './stage-comment.ts';
|
|
4
|
+
import {validateCommentCrop,saveCommentCrops,assertSubmittedComments} from './comment-crops.ts';
|
|
5
|
+
import {TurnState,type TurnDial} from './turn-pad.ts';
|
|
1
6
|
import { createHash } from 'node:crypto';
|
|
2
7
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
8
|
import { join } from 'node:path';
|
|
4
9
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
10
|
import { homedir } from 'node:os';
|
|
6
11
|
import {referenceBinding,type ReferenceBinding} from './reference.ts';
|
|
7
|
-
import { validateFeedbackSelection } from './feedback-validation.ts';
|
|
12
|
+
import { validateFeedbackSelection,validateTimedFeedbackSelection } from './feedback-validation.ts';
|
|
8
13
|
import { parseEnvironmentContext } from './environment.ts';
|
|
9
14
|
import { appliedCheckRefusal, parseAppliedContext } from './applied-context.ts';
|
|
10
15
|
import { parseReadingsContext } from './readings.ts';
|
|
11
16
|
import { parseRequirementsContext } from './requirements.ts';
|
|
12
17
|
import { parseScheduleContext } from './environment-schedule.ts';
|
|
13
18
|
import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
|
|
14
|
-
export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
19
|
+
export interface Feedback {id:string;frameStart:number|null;frameEnd:number|null;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
15
20
|
export function parseFeedback(item:unknown,strict=true):Feedback {
|
|
16
21
|
const f=object(item,'feedback');const target=object(f.target,'feedback.target');
|
|
22
|
+
if(hasStageComment((f.context as any)?.comment))parseStageComment((f.context as any).comment);
|
|
17
23
|
// Older releases admitted integer values beyond the safe range. Retain those
|
|
18
24
|
// as readable evidence; strict saves and validatePackageFeedback refuse them.
|
|
19
25
|
const integer=strict?Number.isSafeInteger:Number.isInteger;
|
|
20
|
-
if(
|
|
26
|
+
if(f.frameStart===null||f.frameEnd===null){if(!isTimeOnlyFeedback(f))throw new Error('COMMENT_TIME_ONLY_INVALID: null frames require a validated time-only single comment and null interval');}
|
|
27
|
+
else if(!integer(f.frameStart)||!integer(f.frameEnd)||(f.frameStart as number)<0||(f.frameEnd as number)<(f.frameStart as number))throw new Error('feedback frames: expected inclusive integer 0 <= frameStart <= frameEnd');
|
|
21
28
|
const summary=string(f.summary,'feedback.summary');if(summary.length>1000||/[\r\n]/.test(summary))throw new Error('feedback.summary: expected one line <= 1000 characters');
|
|
22
|
-
return {id:string(f.id,'feedback.id'),frameStart:f.frameStart as number,frameEnd:f.frameEnd as number,target:{kind:choice(target.kind,['bone','slot','rig'] as const,'feedback.target.kind'),name:string(target.name,'feedback.target.name')},category:choice(f.category,['defect','quality'] as const,'feedback.category'),summary,...(f.context===undefined?{}:{context:f.context as Json})};
|
|
29
|
+
return {id:string(f.id,'feedback.id'),frameStart:f.frameStart as number|null,frameEnd:f.frameEnd as number|null,target:{kind:choice(target.kind,['bone','slot','rig'] as const,'feedback.target.kind'),name:string(target.name,'feedback.target.name')},category:choice(f.category,['defect','quality'] as const,'feedback.category'),summary,...(f.context===undefined?{}:{context:f.context as Json})};
|
|
23
30
|
}
|
|
24
31
|
export interface FeedbackWarning {id:string;error:string}
|
|
25
32
|
|
|
26
33
|
/** Derive the annotated interval, retaining inspection context as separate data. */
|
|
27
|
-
export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string):Feedback {
|
|
34
|
+
export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string,turnDials:readonly TurnDial[]=p.feedbackCatalog.turnDials??[]):Feedback {
|
|
28
35
|
const context=feedback.context===undefined?{}:object(feedback.context,'feedback.context');
|
|
29
|
-
const
|
|
36
|
+
const comment=hasStageComment(context.comment)?parseStageComment(context.comment):null,timed=comment?.frame===null;
|
|
37
|
+
if(timed&&(!isTimeOnlyFeedback(feedback)||animation!==undefined))throw new Error('COMMENT_TIME_ONLY_INVALID: seconds-only single comment requires null frame and interval');
|
|
38
|
+
const interval=timed?null:{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true};
|
|
30
39
|
if(context.interval!==undefined&&!isDeepStrictEqual(context.interval,interval))throw new Error('feedback.context.interval: must match chosen inclusive frameStart/frameEnd');
|
|
31
40
|
if(context.animation!==undefined&&context.animation!==null&&typeof context.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
|
|
32
41
|
if(animation!==undefined&&context.animation!==undefined&&context.animation!==null&&context.animation!==animation)throw new Error('feedback.context.animation: does not match selected comparison animation');
|
|
@@ -40,12 +49,37 @@ export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animat
|
|
|
40
49
|
if(context.requirements!==undefined)parseRequirementsContext(context.requirements,text=>createHash('sha256').update(text).digest('hex'));
|
|
41
50
|
// The exported environment schedule (#112): absent or null stays valid; a present record carries its bytes and their sha256, recomputed here.
|
|
42
51
|
if(context.environmentSchedule!==undefined)parseScheduleContext(context.environmentSchedule,text=>createHash('sha256').update(text).digest('hex'));
|
|
52
|
+
if(context.turn!==undefined){if(!turnDials.length)throw new Error('TURN_UNAVAILABLE: this inspected model has no supported turn dials');new TurnState(turnDials).validate(context.turn);}
|
|
53
|
+
if(hasStageComment(context.comment)){
|
|
54
|
+
const c=parseStageComment(context.comment);validateCommentCrop(c);const bookmark=parseBookmark(c.bookmark);
|
|
55
|
+
if(!isDeepStrictEqual(bookmark.view.cameras[c.viewport],c.camera))throw new Error('COMMENT_CONTEXT_MISMATCH: saved pane camera');
|
|
56
|
+
if(bookmark.time!==c.time||bookmark.animation!==c.animation||!isDeepStrictEqual(bookmark.selection.turn??null,context.turn??null))throw new Error('COMMENT_CONTEXT_MISMATCH: bookmark pose');
|
|
57
|
+
if(animation===undefined){parseBookmark(c.bookmark,{mode:'single',sourceHash:p.sourceHash,rigs:[p.reviewedSource.rig?.sha256??null]});if(c.viewport!=='rig'&&c.viewport!=='reference')throw new Error('COMMENT_CONTEXT_MISMATCH: single viewport');
|
|
58
|
+
if(c.viewport==='reference'){
|
|
59
|
+
const declared=p.manifest.references.find(r=>r.id===c.referenceId),explicit=referenceBinding(p)?.reference;
|
|
60
|
+
const expectedHash=explicit?.id===c.referenceId?explicit.sha256:c.referenceId?p.referenceHashes?.[c.referenceId]:undefined;
|
|
61
|
+
if(!declared||!expectedHash||c.referenceHash!==expectedHash||bookmark.selection.reference!==c.referenceId)throw new Error('COMMENT_REFERENCE_MISMATCH: selected reference ID/hash must match the source and bookmark');
|
|
62
|
+
const clock=clockForAnimation(p.feedbackCatalog.clock,p.feedbackCatalog.clockAnimation??undefined,c.animation??'',!!p.reviewedSource.rig);
|
|
63
|
+
if(c.frame&&(c.frame.basis!=='producer'||frameAtTime(clock,c.time)!==c.frame.index))throw new Error('COMMENT_FRAME_UNAVAILABLE: producer frame differs from the inspected reference time');
|
|
64
|
+
}else if(c.referenceId!==null||c.referenceHash!==null)throw new Error('COMMENT_REFERENCE_MISMATCH: single rig marks have no reference viewport binding');
|
|
65
|
+
}
|
|
66
|
+
if(c.sourceHash!==p.sourceHash||c.modelHash!==(c.viewport==='reference'?null:p.reviewedSource.rig?.sha256??null))throw new Error('COMMENT_SOURCE_CHANGED: comment differs from inspected source');
|
|
67
|
+
if(c.animation!==(animation??context.animation??null)||c.time!==context.time||(c.frame?.index??null)!==feedback.frameStart||feedback.frameStart!==feedback.frameEnd)throw new Error('COMMENT_CONTEXT_MISMATCH: frame, time or animation differs');
|
|
68
|
+
if(timed){const clock=clockForAnimation(p.feedbackCatalog.clock,p.feedbackCatalog.clockAnimation??undefined,c.animation??'',!!p.reviewedSource.rig);if(frameAtTime(clock,c.time)!==null)throw new Error('COMMENT_TIME_ONLY_INVALID: explicit producer frame exists at this time');}
|
|
69
|
+
const a=projectMark(c.world,c.camera),b=projectMark({x:c.world.x+c.world.width,y:c.world.y+c.world.height},c.camera);
|
|
70
|
+
if(Math.abs(Math.min(a.x,b.x)-c.screen.x)>1e-5||Math.abs(Math.min(a.y,b.y)-c.screen.y)>1e-5||Math.abs(Math.abs(a.x-b.x)-c.screen.width)>1e-5||Math.abs(Math.abs(a.y-b.y)-c.screen.height)>1e-5)throw new Error('COMMENT_CONTEXT_MISMATCH: screen/world region projection');
|
|
71
|
+
if(c.camera.space!==(c.viewport==='reference'?'image':'world'))throw new Error('COMMENT_CONTEXT_MISMATCH: camera space');
|
|
72
|
+
if(c.slots.some(s=>!p.feedbackCatalog.slots?.includes(s.slot))||c.viewport==='reference'&&c.slots.length)throw new Error('COMMENT_SLOT_UNKNOWN: drawn slot not in this model');
|
|
73
|
+
const expected=c.slots[0]?{kind:'slot',name:c.slots[0].slot}:{kind:'rig',name:p.feedbackCatalog.rig};
|
|
74
|
+
if(!isDeepStrictEqual(feedback.target,expected))throw new Error('COMMENT_TARGET_MISMATCH: use topmost drawn slot or rig');
|
|
75
|
+
}
|
|
43
76
|
const selected=animation??(typeof context.animation==='string'?context.animation:null);
|
|
44
|
-
|
|
77
|
+
if(timed)validateTimedFeedbackSelection(p.feedbackCatalog,{target:feedback.target,animation:selected,time:comment!.time});
|
|
78
|
+
else validateFeedbackSelection((context.comment as any)?.frame?.basis==='reference'?{...p.feedbackCatalog,clock:null,clockAnimation:null}:p.feedbackCatalog,{...feedbackFrameInterval(feedback),target:feedback.target,animation:selected});
|
|
45
79
|
return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
|
|
46
80
|
}
|
|
47
|
-
export function readPackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string):{feedback:Feedback;warning?:FeedbackWarning} {
|
|
48
|
-
try{return {feedback:validatePackageFeedback(feedback,p,animation)};}catch(e){
|
|
81
|
+
export function readPackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string,turnDials?:readonly TurnDial[]):{feedback:Feedback;warning?:FeedbackWarning} {
|
|
82
|
+
try{return {feedback:validatePackageFeedback(feedback,p,animation,turnDials)};}catch(e){
|
|
49
83
|
// Old submitted evidence remains readable. Only add a missing derived
|
|
50
84
|
// interval; contradictory supplied evidence is preserved with its warning.
|
|
51
85
|
const context=feedback.context;const normalized=context===undefined?{interval:{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true}}:context&&typeof context==='object'&&!Array.isArray(context)&&!('interval' in context)?{...context,interval:{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true}}:context;
|
|
@@ -70,4 +104,4 @@ export function parseSession(input:unknown,p:ReviewPackage,strict=true):ReviewSe
|
|
|
70
104
|
}
|
|
71
105
|
export function sessionFile(p:ReviewPackage,stateDir=join(homedir(),'.local','state','rig-c-review')):string { const key=createHash('sha256').update(JSON.stringify([p.manifest.id,p.manifest.revision,p.sourceHash,p.reviewedSource])).digest('hex'); return join(stateDir,key+'.json'); }
|
|
72
106
|
export async function readSession(p:ReviewPackage,stateDir?:string):Promise<ReviewSession> {try {const v=JSON.parse(await readFile(sessionFile(p,stateDir),'utf8'));const parsed=parseSession(v,p,false);return {...parsed,updatedAt:v.updatedAt};}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return freshSession(p);throw e;} }
|
|
73
|
-
export async function saveSession(p:ReviewPackage,input:unknown,stateDir?:string):Promise<ReviewSession> {const value=parseSession(input,p);const file=sessionFile(p,stateDir);await mkdir(join(file,'..'),{recursive:true,mode:0o700});const tmp=file+'.'+crypto.randomUUID()+'.tmp';await writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600});await rename(tmp,file);return value;}
|
|
107
|
+
export async function saveSession(p:ReviewPackage,input:unknown,stateDir?:string):Promise<ReviewSession> {const value=parseSession(input,p);assertSubmittedComments((await readSession(p,stateDir)).feedback,value.feedback);await saveCommentCrops(value.feedback,stateDir);const file=sessionFile(p,stateDir);await mkdir(join(file,'..'),{recursive:true,mode:0o700});const tmp=file+'.'+crypto.randomUUID()+'.tmp';await writeFile(tmp,JSON.stringify(value,null,2)+'\n',{mode:0o600});await rename(tmp,file);return value;}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Stage marks are optional inspection evidence on an ordinary feedback row. */
|
|
2
|
+
import type {PosedFrame} from '@rig-c/play';
|
|
3
|
+
import type {ControlNumericCamera} from './control.ts';
|
|
4
|
+
export interface MarkPoint {x:number;y:number}
|
|
5
|
+
export interface MarkRegion {x:number;y:number;width:number;height:number}
|
|
6
|
+
export const commentModes=['setup','animate','compare'] as const;
|
|
7
|
+
export type CommentMode=typeof commentModes[number];
|
|
8
|
+
export const timeOnlyReason='frame is unavailable: no explicit producer frame index at this time (COMMENT_FRAME_UNAVAILABLE)' as const;
|
|
9
|
+
export const timeOnlyBasis={schema:'rig-review-time-only/1',basis:'seconds',reason:timeOnlyReason} as const;
|
|
10
|
+
export interface StageComment {
|
|
11
|
+
schema:'rig-review-comment/1';mode:CommentMode;viewport:'rig'|'a'|'b'|'reference';
|
|
12
|
+
frame:{index:number;basis:'producer'|'reference'}|null;timeBasis?:typeof timeOnlyBasis;time:number;animation:string|null;
|
|
13
|
+
sourceHash:string;modelHash:string|null;referenceHash:string|null;referenceId:string|null;
|
|
14
|
+
camera:ControlNumericCamera;screen:MarkRegion;world:MarkRegion;
|
|
15
|
+
slots:{slot:string;z:number}[];
|
|
16
|
+
crop:{path:string;sha256:string;png:string;width:number;height:number};
|
|
17
|
+
bookmark:unknown;
|
|
18
|
+
}
|
|
19
|
+
export const hasStageComment=(value:unknown):boolean=>!!value&&typeof value==='object'&&!Array.isArray(value)&&(value as Record<string,unknown>).schema==='rig-review-comment/1';
|
|
20
|
+
const fail=(message:string):never=>{throw new Error('COMMENT_INVALID: '+message);};
|
|
21
|
+
const obj=(v:unknown):Record<string,any>=>v!==null&&typeof v==='object'&&!Array.isArray(v)?v as Record<string,any>:fail('object required');
|
|
22
|
+
const exact=(v:Record<string,any>,keys:string[])=>{if(Object.keys(v).length!==keys.length||keys.some(k=>!Object.hasOwn(v,k)))fail('unknown or missing fields');};
|
|
23
|
+
const number=(v:unknown)=>typeof v==='number'&&Number.isFinite(v)&&Math.abs(v)<=Number.MAX_SAFE_INTEGER;
|
|
24
|
+
/** Also usable on derived reports, whose private inline PNG has been removed. */
|
|
25
|
+
export function stageCommentMode(value:unknown):CommentMode {
|
|
26
|
+
const c=obj(value);
|
|
27
|
+
if(!Object.hasOwn(c,'mode'))throw new Error('COMMENT_MODE_MISSING: placement mode is required');
|
|
28
|
+
if(!commentModes.includes(c.mode))throw new Error('COMMENT_MODE_UNKNOWN: expected setup, animate or compare');
|
|
29
|
+
const b=c.bookmark;
|
|
30
|
+
if(c.mode==='compare' ? b?.binding?.mode!=='compare'||c.viewport==='rig' : b?.binding?.mode!=='single'||!['rig','reference'].includes(c.viewport)||b?.selection?.inspector!==(c.mode==='setup'?'rig':'playback'))throw new Error('COMMENT_MODE_MISMATCH: placement mode differs from page, viewport or saved inspector');
|
|
31
|
+
return c.mode;
|
|
32
|
+
}
|
|
33
|
+
export function parseStageComment(value:unknown):StageComment {
|
|
34
|
+
const v=obj(value);stageCommentMode(v);exact(v,['schema','mode','viewport','frame','time','animation','sourceHash','modelHash','referenceHash','referenceId','camera','screen','world','slots','crop','bookmark',...(v.frame===null?['timeBasis']:[])]);
|
|
35
|
+
if(v.schema!=='rig-review-comment/1'||!['rig','a','b','reference'].includes(v.viewport))fail('schema/viewport');
|
|
36
|
+
if(v.frame===null){const b=obj(v.timeBasis);exact(b,['schema','basis','reason']);if(b.schema!==timeOnlyBasis.schema||b.basis!==timeOnlyBasis.basis||b.reason!==timeOnlyReason||!['rig','reference'].includes(v.viewport))fail('time-only basis');}
|
|
37
|
+
else{exact(obj(v.frame),['index','basis']);if(!Number.isSafeInteger(v.frame.index)||v.frame.index<0||!['producer','reference'].includes(v.frame.basis))fail('frame');}
|
|
38
|
+
if(!number(v.time)||v.time<0||v.animation!==null&&typeof v.animation!=='string')fail('time/animation');
|
|
39
|
+
const hash=(x:unknown)=>typeof x==='string'&&/^[a-f0-9]{64}$/.test(x);
|
|
40
|
+
if(!hash(v.sourceHash)||v.modelHash!==null&&!hash(v.modelHash)||v.referenceHash!==null&&!hash(v.referenceHash)||v.referenceId!==null&&(typeof v.referenceId!=='string'||v.referenceId.length>512))fail('source binding');
|
|
41
|
+
for(const key of ['screen','world']){const r=obj(v[key]);exact(r,['x','y','width','height']);if(!Object.values(r).every(number)||r.width<0||r.height<0)fail('region');}
|
|
42
|
+
const c=obj(v.camera);exact(c,['space','center','zoom','viewport','visible']);if(!['world','image'].includes(c.space)||!number(c.zoom)||c.zoom<.25||c.zoom>8)fail('camera');exact(obj(c.center),['x','y']);if(!Object.values(c.center).every(number))fail('camera center');exact(obj(c.viewport),['width','height']);if(!Object.values(c.viewport).every(n=>number(n)&&Number(n)>0&&Number(n)<=65536))fail('camera extent');exact(obj(c.visible),['x','y','width','height']);if(!Object.values(c.visible).every(number)||c.visible.width<=0||c.visible.height<=0)fail('camera visible');
|
|
43
|
+
if(!Array.isArray(v.slots)||v.slots.length>20000)fail('slots');let previous=Infinity;const names=new Set();for(const raw of v.slots){const s=obj(raw);exact(s,['slot','z']);if(typeof s.slot!=='string'||!s.slot||s.slot.length>512||names.has(s.slot)||!Number.isSafeInteger(s.z)||s.z<0||s.z>=previous)fail('nearest-first slots');names.add(s.slot);previous=s.z;}
|
|
44
|
+
const crop=obj(v.crop);exact(crop,['path','sha256','png','width','height']);if(!hash(crop.sha256)||crop.path!==`comment-crops/${crop.sha256}.png`||typeof crop.png!=='string'||crop.png.length>200000||!/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(crop.png)||![crop.width,crop.height].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1024))fail('bounded PNG crop');
|
|
45
|
+
if(v.bookmark===undefined||JSON.stringify(v.bookmark).length>16384)fail('bookmark limit');
|
|
46
|
+
return v as StageComment;
|
|
47
|
+
}
|
|
48
|
+
/** Null frame indices are legal only on the closed, explicitly time-only single-comment shape. */
|
|
49
|
+
export function isTimeOnlyFeedback(value:unknown):boolean {
|
|
50
|
+
try{const f=obj(value),context=obj(f.context),c=parseStageComment(context.comment);return f.frameStart===null&&f.frameEnd===null&&context.frame===null&&context.interval===null&&context.time===c.time&&c.frame===null&&c.timeBasis!==undefined;}catch{return false;}
|
|
51
|
+
}
|
|
52
|
+
export function feedbackTimingLabel(f:{frameStart:number|null;frameEnd:number|null;context?:unknown}):string {
|
|
53
|
+
if(f.frameStart===null||f.frameEnd===null){const c=obj(obj(f.context).comment);if(c.frame!==null||c.timeBasis?.schema!==timeOnlyBasis.schema||c.timeBasis?.reason!==timeOnlyReason||!number(c.time)||c.time<0)fail('time-only display context');return `${c.time} s (seconds basis) · ${timeOnlyReason}`;}
|
|
54
|
+
return `F${f.frameStart}–${f.frameEnd}`;
|
|
55
|
+
}
|
|
56
|
+
export function feedbackFrameInterval(f:{frameStart:number|null;frameEnd:number|null}):{frameStart:number;frameEnd:number}{
|
|
57
|
+
if(f.frameStart===null||f.frameEnd===null)throw new Error(`COMMENT_CLOCK_UNAVAILABLE: ${timeOnlyReason}; a time-only comment has no frame interval for strip export`);
|
|
58
|
+
return {frameStart:f.frameStart,frameEnd:f.frameEnd};
|
|
59
|
+
}
|
|
60
|
+
/** Strip planners address producer frames; a decoded reference index is not that clock. */
|
|
61
|
+
export function requireFeedbackFrames(f:{frameStart:number|null;frameEnd:number|null;context?:unknown}):{frameStart:number;frameEnd:number}{
|
|
62
|
+
const frames=feedbackFrameInterval(f),comment=(f.context as any)?.comment;
|
|
63
|
+
if(hasStageComment(comment)&&comment.frame?.basis==='reference')throw new Error('COMMENT_REFERENCE_STRIP_UNAVAILABLE: decoded reference frames are not producer frames; reference-basis comments cannot be exported as producer-frame strips');
|
|
64
|
+
return frames;
|
|
65
|
+
}
|
|
66
|
+
export function region(a:MarkPoint,b:MarkPoint):MarkRegion{return {x:Math.min(a.x,b.x),y:Math.min(a.y,b.y),width:Math.abs(a.x-b.x),height:Math.abs(a.y-b.y)};}
|
|
67
|
+
export function projectMark(p:MarkPoint,c:ControlNumericCamera):MarkPoint {const s=c.viewport.width/c.visible.width;return {x:(p.x-c.visible.x)*s,y:c.space==='world'?(c.visible.y+c.visible.height-p.y)*s:(p.y-c.visible.y)*s};}
|
|
68
|
+
export function unprojectMark(p:MarkPoint,c:ControlNumericCamera):MarkPoint {const s=c.visible.width/c.viewport.width;return {x:c.visible.x+p.x*s,y:c.space==='world'?c.visible.y+c.visible.height-p.y*s:c.visible.y+p.y*s};}
|
|
69
|
+
const cross=(a:MarkPoint,b:MarkPoint,c:MarkPoint)=>(b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x);
|
|
70
|
+
const points=(v:readonly number[])=>Array.from({length:v.length/2},(_,i)=>({x:v[i*2]!,y:v[i*2+1]!}));
|
|
71
|
+
const area=(p:MarkPoint[])=>Math.abs(p.reduce((n,a,i)=>{const b=p[(i+1)%p.length]!;return n+a.x*b.y-a.y*b.x;},0))/2;
|
|
72
|
+
function inside(p:MarkPoint,poly:MarkPoint[]):boolean {let yes=false;for(let i=0,j=poly.length-1;i<poly.length;j=i++){const a=poly[i]!,b=poly[j]!;if(Math.abs(cross(a,b,p))<1e-9&&p.x>=Math.min(a.x,b.x)&&p.x<=Math.max(a.x,b.x)&&p.y>=Math.min(a.y,b.y)&&p.y<=Math.max(a.y,b.y))return true;if((a.y>p.y)!==(b.y>p.y)&&p.x<(b.x-a.x)*(p.y-a.y)/(b.y-a.y)+a.x)yes=!yes;}return yes;}
|
|
73
|
+
function convexHull(p:MarkPoint[]):MarkPoint[]{const sorted=[...p].sort((a,b)=>a.x-b.x||a.y-b.y),half=(ps:MarkPoint[])=>{const out:MarkPoint[]=[];for(const v of ps){while(out.length>1&&cross(out.at(-2)!,out.at(-1)!,v)<=0)out.pop();out.push(v);}return out;};return [...half(sorted).slice(0,-1),...half(sorted.reverse()).slice(0,-1)];}
|
|
74
|
+
function clip(subject:MarkPoint[],boundary:MarkPoint[]):MarkPoint[]{const signed=boundary.reduce((n,p,i)=>{const q=boundary[(i+1)%boundary.length]!;return n+p.x*q.y-p.y*q.x;},0),sign=signed<0?-1:1;let out=subject;for(let i=0;i<boundary.length&&out.length;i++){const a=boundary[i]!,b=boundary[(i+1)%boundary.length]!,input=out;out=[];for(let j=0;j<input.length;j++){const p=input[j]!,q=input[(j+1)%input.length]!,cp=cross(a,b,p)*sign,cq=cross(a,b,q)*sign;if(cp>=0)out.push(p);if((cp>=0)!==(cq>=0)){const t=cp/(cp-cq);out.push({x:p.x+t*(q.x-p.x),y:p.y+t*(q.y-p.y)});}}}return out;}
|
|
75
|
+
function triangles(poly:MarkPoint[]):MarkPoint[][]{const signed=poly.reduce((n,p,i)=>{const q=poly[(i+1)%poly.length]!;return n+p.x*q.y-p.y*q.x;},0);let ring=signed<0?[...poly].reverse():[...poly];const out:MarkPoint[][]=[];while(ring.length>3){let found=false;for(let i=0;i<ring.length;i++){const tri=[ring[(i+ring.length-1)%ring.length]!,ring[i]!,ring[(i+1)%ring.length]!];if(cross(...tri as [MarkPoint,MarkPoint,MarkPoint])<=0||ring.some(p=>!tri.includes(p)&&inside(p,tri)))continue;out.push(tri);ring.splice(i,1);found=true;break;}if(!found)throw new Error('COMMENT_CLIP_UNAVAILABLE: degenerate clipping polygon');}if(ring.length===3)out.push(ring);return out;}
|
|
76
|
+
/** Triangle coverage with the held runtime clip; alpha transparency is not a picking oracle. */
|
|
77
|
+
export function drawnSlots(frame:PosedFrame,r:MarkRegion):StageComment['slots'] {
|
|
78
|
+
const box=[{x:r.x,y:r.y},{x:r.x+r.width,y:r.y},{x:r.x+r.width,y:r.y+r.height},{x:r.x,y:r.y+r.height}],point=r.width===0&&r.height===0;
|
|
79
|
+
const hits=frame.order.filter(row=>{
|
|
80
|
+
if(row.tint[3]<=0)return false;const vertices=points(row.kind==='mesh'?row.vertices:row.corners),indices=row.kind==='mesh'?row.triangles:[0,1,2,0,2,3];
|
|
81
|
+
let boundary=row.clip?points(row.clip.polygon):null;if(boundary&&(row.clip!.inverse||row.clip!.convex))boundary=convexHull(boundary);
|
|
82
|
+
for(let i=0;i<indices.length;i+=3){const t=indices.slice(i,i+3).map(n=>vertices[n]!);if(area(t)===0)continue;
|
|
83
|
+
if(point){if(inside(box[0]!,t)&&(!boundary||(row.clip!.inverse?!inside(box[0]!,boundary):inside(box[0]!,boundary))))return true;continue;}
|
|
84
|
+
const selected=clip(t,box);if(area(selected)<=1e-12)continue;if(!boundary)return true;
|
|
85
|
+
if(row.clip!.inverse){if(area(selected)-area(clip(selected,boundary))>1e-10)return true;}
|
|
86
|
+
else if(triangles(boundary).some(piece=>area(clip(selected,piece))>1e-12))return true;
|
|
87
|
+
}return false;
|
|
88
|
+
});return hits.sort((a,b)=>b.z-a.z).map(({slot,z})=>({slot,z}));
|
|
89
|
+
}
|
package/src/turn-pad.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Inspection dials, not keys. Read the compiled slider's mapping; never invent a degree range. */
|
|
2
|
+
import type {BoneAdjust,BoneLocals} from '@rig-c/play';
|
|
3
|
+
export type TurnAxis='yaw'|'pitch'|'body';
|
|
4
|
+
export interface TurnDial {axis:TurnAxis;bone:string;slider:string;channel:keyof BoneLocals;min:number;max:number;provenance?:string}
|
|
5
|
+
export interface TurnSnapshot {schema:'rig-review-turn/1';values:Partial<Record<TurnAxis,number>>;held:boolean;smoothing:number}
|
|
6
|
+
const object=(v:unknown):Record<string,any>|null=>v!==null&&typeof v==='object'&&!Array.isArray(v)?v as Record<string,any>:null;
|
|
7
|
+
const finite=(v:unknown):v is number=>typeof v==='number'&&Number.isFinite(v);
|
|
8
|
+
const clamp=(v:number,min:number,max:number)=>Math.max(min,Math.min(max,v))||0;
|
|
9
|
+
const channels:Record<string,keyof BoneLocals>={rotate:'rotation',x:'x',y:'y',scaleX:'scaleX',scaleY:'scaleY',shearY:'shearY'};
|
|
10
|
+
/** `body_turn` is the producer's compiled body slider animation, including a custom turn.body.slider bone.
|
|
11
|
+
* This is compiled metadata only; no producer config or opaque Play document is read. */
|
|
12
|
+
export function discoverTurnDials(model:unknown,durations?:readonly {name:string;duration:number}[]):TurnDial[] {
|
|
13
|
+
const m=object(model);if(!m||!Array.isArray(m.bones)||!Array.isArray(m.constraints))return [];
|
|
14
|
+
const names=new Set(m.bones.map((b:any)=>b.name)),animations=durations??m.animations??[];
|
|
15
|
+
const result:TurnDial[]=[];
|
|
16
|
+
for(const axis of ['yaw','pitch','body'] as const){
|
|
17
|
+
const canonical=axis==='body'?'body_yaw_dial':axis+'_dial';
|
|
18
|
+
const candidates=m.constraints.filter((c:any)=>c&&c.kind==='slider'&&typeof c.name==='string'&&names.has(c.bone)&&(c.bone===canonical||axis==='body'&&c.animation==='body_turn'));
|
|
19
|
+
if(candidates.length!==1)continue;
|
|
20
|
+
const c=candidates[0],duration=animations.find((a:any)=>a.name===c.animation)?.duration,from=c.from??0,to=c.to??0,scale=c.scale??1,channel=channels[c.property??'rotate'];
|
|
21
|
+
if(!c.local||c.loop||!channel||![duration,from,to,scale].every(finite)||duration<=0||scale===0)continue;
|
|
22
|
+
// Same inverse slider mapping as the official head-turn demo; max is not a clamp in the runtime.
|
|
23
|
+
const a=from-to/scale,b=from+(duration-to)/scale;
|
|
24
|
+
if(!finite(a)||!finite(b)||a===b||Math.min(a,b)>0||Math.max(a,b)<0)continue;
|
|
25
|
+
if(result.some(d=>d.bone===c.bone&&d.channel===channel))continue;
|
|
26
|
+
result.push({axis,bone:c.bone,slider:c.name,channel,min:Math.min(a,b),max:Math.max(a,b),provenance:`model slider ${c.name} → bone ${c.bone}, ${c.property??'rotate'}, animation ${c.animation}${c.bone!==canonical?' (compiled body_turn binding)':''}`});
|
|
27
|
+
}return result;
|
|
28
|
+
}
|
|
29
|
+
export function sharedTurnDials(a:readonly TurnDial[],b:readonly TurnDial[]):TurnDial[]{
|
|
30
|
+
return a.flatMap(d=>{const other=b.find(e=>e.axis===d.axis);if(!other)return [];const min=Math.max(d.min,other.min),max=Math.min(d.max,other.max);return min<max?[{...d,min,max}]:[];});
|
|
31
|
+
}
|
|
32
|
+
export function pointerPosition(x:number,y:number,rect:{left:number;top:number;width:number;height:number}):[number,number]{
|
|
33
|
+
return rect.width>0&&rect.height>0?[clamp((x-rect.left)/rect.width*2-1,-1,1),clamp(1-(y-rect.top)/rect.height*2,-1,1)]:[0,0];
|
|
34
|
+
}
|
|
35
|
+
export function normalizedDial(n:number,range:{min:number;max:number}):number {const v=clamp(n,-1,1),zero=clamp(0,range.min,range.max);return clamp(zero+Math.abs(v)*((v<0?range.min:range.max)-zero),range.min,range.max);}
|
|
36
|
+
/** Exact critically damped solution from https://rig-c.org/demos/head-turn/bundle/app.js (HEAD_SPRING_OMEGA0=20). */
|
|
37
|
+
export function springStep(s:{x:number;v:number},target:number,dt:number,omega=20):void {
|
|
38
|
+
if(!(dt>0)||!finite(dt))return;const e=s.x-target,tau=omega*dt,exp=Math.exp(-tau),x=target+(e*(1+tau)+s.v*dt)*exp,v=(s.v*(1-tau)-omega*omega*dt*e)*exp;
|
|
39
|
+
s.x=Math.abs(x-target)<1e-12&&Math.abs(v)<1e-9?target:x;s.v=s.x===target?0:v;
|
|
40
|
+
}
|
|
41
|
+
export function parseTurnSnapshot(value:unknown):TurnSnapshot {
|
|
42
|
+
const o=object(value);if(!o||o.schema!=='rig-review-turn/1'||Object.keys(o).some(k=>!['schema','values','held','smoothing'].includes(k))||typeof o.held!=='boolean'||!finite(o.smoothing)||o.smoothing<0||o.smoothing>100)throw new Error('TURN_STATE_INVALID');
|
|
43
|
+
const values=object(o.values);if(!values||Object.keys(values).some(k=>!['yaw','pitch','body'].includes(k)||!finite(values[k])))throw new Error('TURN_VALUES_INVALID');
|
|
44
|
+
return {schema:o.schema,values:{...values},held:o.held,smoothing:o.smoothing};
|
|
45
|
+
}
|
|
46
|
+
const normalizedValue=(value:number,d:TurnDial)=>value<0?d.min<0?-value/d.min:0:d.max>0?value/d.max:0;
|
|
47
|
+
export class TurnState {
|
|
48
|
+
values:Partial<Record<TurnAxis,number>>={};held=false;smoothing=20;
|
|
49
|
+
readonly targets:Partial<Record<TurnAxis,number>>={};private springs=new Map<TurnAxis,{x:number;v:number}>();
|
|
50
|
+
constructor(readonly dials:readonly TurnDial[]){for(const d of dials){this.values[d.axis]=0;this.targets[d.axis]=0;this.springs.set(d.axis,{x:0,v:0});}}
|
|
51
|
+
pointer(x:number,y:number):void {if(this.held)return;for(const d of this.dials)this.targets[d.axis]=normalizedDial(d.axis==='pitch'?y:x,d);}
|
|
52
|
+
step(dt:number):boolean {let changed=false;if(this.held)return false;for(const d of this.dials){const s=this.springs.get(d.axis)!,target=this.targets[d.axis]!;if(this.smoothing===0){s.x=normalizedValue(target,d);s.v=0;}else springStep(s,normalizedValue(target,d),dt,this.smoothing);const next=this.smoothing===0?target:normalizedDial(s.x,d);changed||=next!==this.values[d.axis];this.values[d.axis]=next;}return changed;}
|
|
53
|
+
hold(value:boolean):void {this.held=value;for(const d of this.dials){const x=this.values[d.axis]!;this.targets[d.axis]=x;Object.assign(this.springs.get(d.axis)!,{x:normalizedValue(x,d),v:0});}}
|
|
54
|
+
exact(axis:TurnAxis,value:number):void {const d=this.dials.find(d=>d.axis===axis);if(!d||!finite(value)||value<d.min||value>d.max)throw new Error('TURN_VALUE_OUT_OF_RANGE');this.hold(true);this.values[axis]=value;this.targets[axis]=value;Object.assign(this.springs.get(axis)!,{x:normalizedValue(value,d),v:0});}
|
|
55
|
+
reset():void {this.hold(true);for(const d of this.dials)this.exact(d.axis,0);}
|
|
56
|
+
snapshot():TurnSnapshot{return {schema:'rig-review-turn/1',values:{...this.values},held:this.held,smoothing:this.smoothing};}
|
|
57
|
+
validate(value:unknown):TurnSnapshot {const v=parseTurnSnapshot(value);if(Object.keys(v.values).length!==this.dials.length||this.dials.some(d=>!finite(v.values[d.axis])||v.values[d.axis]!<d.min||v.values[d.axis]!>d.max))throw new Error('TURN_STATE_MODEL_MISMATCH');return v;}
|
|
58
|
+
restore(value:unknown):void {const v=this.validate(value);for(const d of this.dials)this.exact(d.axis,v.values[d.axis]!);this.smoothing=v.smoothing;this.hold(v.held);}
|
|
59
|
+
adjust(dials:readonly TurnDial[]=this.dials):BoneAdjust {return bones=>{for(const d of dials){const value=this.values[d.axis];if(value!==undefined)bones.bone(d.bone)[d.channel]=value;}};}
|
|
60
|
+
}
|
package/web/compare.css
CHANGED
|
@@ -121,3 +121,16 @@ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-templa
|
|
|
121
121
|
|
|
122
122
|
@media(pointer:coarse){.workspace-separator::after{inset:0 -7px}.workspace-inspectorHeight::after{inset:-7px 0}.workspace-drawer-triggers button{min-height:38px}}
|
|
123
123
|
#stage-layout[data-panes="1"]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
|
|
124
|
+
|
|
125
|
+
/* Model-bound inspection dials; stage and Inspector remain separate. */
|
|
126
|
+
#turn-pad-host{flex:none;max-height:180px;overflow:auto;border-bottom:1px solid var(--border);background:var(--bg)}
|
|
127
|
+
.turn-pad{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:8px 12px}
|
|
128
|
+
.turn-pad>span{color:var(--fg-muted);font-size:11px}
|
|
129
|
+
.turn-pad label{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}
|
|
130
|
+
.turn-pad input[type=number]{width:78px;color:var(--fg);background:var(--rv-control);border:1px solid var(--border-strong);padding:4px}
|
|
131
|
+
.turn-pad output{font-variant-numeric:tabular-nums;color:var(--fg-strong)}
|
|
132
|
+
.turn-pad button[aria-pressed=true]{border-color:var(--pass);color:var(--pass)}
|
|
133
|
+
#turn-pad-host>p{margin:0 12px 6px}
|
|
134
|
+
|
|
135
|
+
/* Source-bound stage comments; the layer never intercepts ordinary inspection. */
|
|
136
|
+
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/compare.html
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>rig review · compare</title><link rel="stylesheet" href="styles.css"></head><body>
|
|
3
3
|
<header><div class="brand">rig review <span>COMPARE</span></div><div><h1 id="title">A / B comparison</h1><span id="animation" class="hint"></span></div><div class="actions"><span id="save-state" class="hint">Loading…</span><button id="export-json">Feedback JSON</button><button id="export-markdown">Issue Markdown</button><button id="submit" class="primary" disabled>Submit review</button></div></header>
|
|
4
|
-
<div class="layout"><main><div class="toolbar"><span>Inspect A and B at the reference video time</span><div class="row"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom active view out">−</button><output id="zoom-label">100%</output><button id="zoom-in" aria-label="Zoom active view in">+</button><button id="fit">Fit synchronized views</button></div></div>
|
|
4
|
+
<div class="layout"><main><div class="toolbar"><button id="stage-comment" type="button" aria-pressed="false" title="Comment · C (active mesh editor keeps C)">Comment <kbd>C</kbd></button><button id="stage-comment-list-toggle" type="button" aria-expanded="false" aria-controls="stage-comment-list">Comments (0)</button><span>Inspect A and B at the reference video time</span><div class="row"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom active view out">−</button><output id="zoom-label">100%</output><button id="zoom-in" aria-label="Zoom active view in">+</button><button id="fit">Fit synchronized views</button></div></div>
|
|
5
5
|
<div id="stage-layout" class="stages"><section class="pane"><div class="pane-head"><strong>REFERENCE</strong><span id="reference-name"></span></div><div id="reference-viewport" class="reference-viewport"><video id="reference-video" preload="auto" playsinline muted></video><p id="reference-empty" class="empty">Loading reference video…</p></div><div class="pane-foot"><span id="reference-time">— s</span><label><input id="reference-audio" type="checkbox">Video audio</label></div></section><section class="pane"><div class="pane-head"><strong class="side-label">A</strong><span id="a-title"></span></div><div id="a-viewport" class="rig-viewport"><div id="a-root" class="rig-root"></div><canvas id="a-overlay" class="rig-overlay" aria-label="A actual rig mesh and bone overlay"></canvas><p id="a-empty" class="empty">Loading rig…</p></div><div class="pane-foot"><span id="a-time">— s</span><span id="a-frame">rate unknown</span><span id="a-renderer" aria-label="A last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div></section><section class="pane"><div class="pane-head"><strong class="side-label">B</strong><span id="b-title"></span></div><div id="b-viewport" class="rig-viewport"><div id="b-root" class="rig-root"></div><canvas id="b-overlay" class="rig-overlay" aria-label="B actual rig mesh and bone overlay"></canvas><p id="b-empty" class="empty">Loading rig…</p></div><div class="pane-foot"><span id="b-time">— s</span><span id="b-frame">rate unknown</span><span id="b-renderer" aria-label="B last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div></section></div>
|
|
6
6
|
<section class="inspector" aria-label="Inspection controls"><div class="inspector-heading"><strong>INSPECTOR</strong><div role="tablist" aria-label="Inspector sections"><button role="tab" id="tab-view" data-inspector="view" aria-controls="inspector-view" aria-selected="true" tabindex="0">View</button><button role="tab" id="tab-rig" data-inspector="rig" aria-controls="inspector-rig" aria-selected="false" tabindex="-1">Rig</button><button role="tab" id="tab-playback" data-inspector="playback" aria-controls="inspector-playback" aria-selected="false" tabindex="-1">Playback</button><button role="tab" id="tab-feedback" data-inspector="feedback" aria-controls="inspector-feedback" aria-selected="false" tabindex="-1">Feedback</button></div><span class="hint">Local view controls · source files stay unchanged</span></div><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div><div class="inspector-body"><section id="inspector-view" role="tabpanel" aria-labelledby="tab-view" data-inspector-panel="view"><div class="view-link-controls"><button id="copy-view" type="button">Copy view link</button><span class="hint">Numeric source region · opening this link restores a paused inspection</span></div><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><div class="inspection-controls" aria-label="Inspection display controls"><label>Appearance <select id="appearance"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span></div><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off for independent Reference, A and B views. Toolbar zoom and Fit use the last active pane.</p></section><section id="inspector-rig" role="tabpanel" aria-labelledby="tab-rig" data-inspector-panel="rig" hidden><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><div class="rig-layer-controls"><fieldset id="layer-controls"><legend>Rig layers</legend><label><input id="layer-images" type="checkbox" checked disabled>Images</label><label><input id="layer-mesh" type="checkbox" disabled>Mesh edges</label><label><input id="layer-bones" type="checkbox" disabled>Bone points</label></fieldset><span id="layer-state" class="hint">No rig supplied</span></div><p class="hint">These controls show actual images, last-drawn mesh edges and named bone points. Both builds use the fixed animation named above. This inspector does not author geometry or motion.</p></section><section id="inspector-playback" role="tabpanel" aria-labelledby="tab-playback" data-inspector-panel="playback" hidden><section class="transport"><button id="play" aria-label="Play reference video and both rigs" disabled>▶</button><input id="scrub" type="range" min="0" max="1" step="0.0001" value="0" aria-label="Shared comparison time" disabled><output id="time">— / — s</output><label for="time-input" class="sr-only">Reference video time to inspect</label><input id="time-input" type="number" min="0" step="0.0001" placeholder="Seconds" disabled><button id="seek" disabled>Go to time</button></section><div class="compare-info"><output id="sync-status">Presentation pending</output><span id="sync-warning"></span></div><div class="compare-info"><label for="frame-basis">Review frame</label><select id="frame-basis"><option value="a">A explicit samples</option><option value="b">B explicit samples</option></select><input id="frame-input" type="number" min="0" step="1" aria-label="Explicit producer frame" disabled><button id="frame-seek" type="button" disabled>Inspect frame</button><span id="frame-index-status">Loading reference timestamp index…</span></div><div class="compare-info"><span id="domain">Checking shared playback range…</span><span id="clock-source">Waiting for video time</span></div><details class="trace"><summary>Synchronization trace</summary><pre id="trace">No frame presented yet.</pre></details><p class="boundary">Motion is read only. The reference video FPS is not used to infer the pose frame clock.</p></section><section id="inspector-feedback" role="tabpanel" aria-labelledby="tab-feedback" data-inspector-panel="feedback" hidden><div class="review-summary"><div class="eyebrow">COMPARE REVIEW</div><label for="status">Review status</label><select id="status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance"><h2>Source provenance</h2><div id="provenance-brief"></div><details><summary>Both builds, versions and reference video hash</summary><pre id="provenance-full"></pre></details></section></div><section class="feedback-form"><h2>Frame-range feedback</h2><label for="side">Target build</label><select id="side"><option value="">Select A or B</option><option value="a">A</option><option value="b">B</option></select><div class="frame-range"><label>Start frame<input id="frame-start" type="number" min="0" step="1" inputmode="numeric"></label><label>End frame<input id="frame-end" type="number" min="0" step="1" inputmode="numeric"></label></div><p id="feedback-range-hint" class="hint">Select A or B to check frame bounds.</p><label for="target-kind">Target type</label><select id="target-kind"><option value="">Select</option><option value="bone">Bone</option><option value="slot">Slot</option><option value="rig">Entire rig</option></select><label for="target-name">Actual target name</label><input id="target-name" type="text" list="target-names" autocomplete="off"><datalist id="target-names"></datalist><p id="target-hint" class="hint">Select a target build and type.</p><label for="category">Category</label><select id="category"><option value="">Select</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="summary">One-line summary</label><input id="summary" type="text" maxlength="1000" placeholder="What looks wrong, and over which frames?"><button id="add-feedback" class="block" disabled>Add feedback</button><p id="feedback-warnings" class="hint" role="status" hidden></p><div id="feedback-list"></div></section><details id="strip-panel" class="trace"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Exports Reference / A / B rows without changing inspection time.</p><label for="strip-scope">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-basis">Report defining the range frames</label><select id="strip-basis"><option value="">Select A or B as frame basis</option><option value="a">A report frameTimes</option><option value="b">B report frameTimes</option></select><label for="strip-format">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">The same frame number in A and B is not assumed to mean the same time. Batch export uses each feedback side as its frame basis. Without frameTimes, PNG is refused and JSON preserves time: null.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading comparison provenance…</p></details></section></div></section></main><aside id="parts-explorer" class="parts-explorer" aria-label="Parts explorer"><h2>PARTS</h2><button type="button" data-part-whole aria-pressed="true">Whole rig</button><label for="part-search">Find slot</label><input id="part-search" data-part-search type="search" autocomplete="off" placeholder="Exact slot name or text"><label class="part-solo"><input id="part-solo" data-part-solo type="checkbox" disabled>Solo selected slot</label><p data-part-status class="hint" role="status">Loading actual slots…</p><ul data-part-list aria-label="Actual rig slots"></ul><p class="hint">Select in context or enable Solo. Type icons and main preview represent actual slots; atlas pages are not part thumbnails.</p></aside>
|
|
7
7
|
</div><footer><span id="message" role="status" aria-live="polite">Loading comparison provenance from the server…</span><span>Source files remain unchanged.</span></footer><script type="module" src="main.js"></script></body></html>
|
package/web/compare.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import {requireFeedbackFrames} from '../src/stage-comment.ts';
|
|
2
|
+
import {bindStageComments,type CommentPose} from './stage-comments.ts';
|
|
3
|
+
import {sharedTurnDials,type TurnDial} from '../src/turn-pad.ts';
|
|
4
|
+
import {bindTurnPad,type TurnPad} from './turn-pad.ts';
|
|
1
5
|
import {PartExplorer,loadPartCatalog} from './part-selection.ts';
|
|
2
6
|
import {cameraFrame,cameraSignature,cameraFromSignature,assertCameraView,boundsFitSignature,dragSignature,zoomSignature,type CameraFrame,type CameraSignature,type PaneCamera} from './camera-view.ts';
|
|
3
|
-
import { loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
7
|
+
import { CanvasRenderer, loadModel, RigPlayer, type ModelAssets, type PoseBounds } from '@rig-c/play';
|
|
4
8
|
import type { CompareResponse, CompareSide } from '../src/compare.ts';
|
|
5
9
|
import type { CompareFeedback, CompareSession } from '../src/compare-session.ts';
|
|
6
10
|
import { clockForAnimation, lastFrame, timeAtFrame } from '../src/frame-clock.ts';
|
|
@@ -15,7 +19,7 @@ import {connectControl} from './control.ts';
|
|
|
15
19
|
import {pageInstanceAccess,readOnlyReason} from './instance-access.ts';
|
|
16
20
|
import {controlComparison,type ControlCommand,type ControlInspection,type ControlView} from '../src/control.ts';
|
|
17
21
|
import {pairEligibility,type InputState,type PairDomain} from '../src/comparison-eligibility.ts';
|
|
18
|
-
import {connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
22
|
+
import {parseBookmark,connectInspectionPersistence,serializableView,numericCamera,restoredZoom,restoreTransaction,captureInputs,restoreInputs,type InspectionBookmark,type InspectionBinding,type PersistenceConnection} from './inspection-persistence.ts';
|
|
19
23
|
import {RendererBadge} from './renderer-status.ts';
|
|
20
24
|
import {ComparePresentation,type PresentationState} from '../src/compare-presentation.ts';
|
|
21
25
|
import {nextReferenceInterval,ReferenceSelectionUnavailable,inspectionReferenceFrame,lastSupportedReferenceFrame,producerFrameAssociations,referenceFrameAtTime,referenceSeekPosition,type ReferenceFrameIndex,type VideoInfo,type VideoFrame} from '../src/reference-frames.ts';
|
|
@@ -41,7 +45,8 @@ let cameraSync=true,activeCamera:CameraTarget='a',cameraRestoring=false,cameraLa
|
|
|
41
45
|
let idleHeld=true,loopRestarting=false,suspendedPreview=false;
|
|
42
46
|
let appearance:ReturnType<typeof bindAppearance>|null=null,controlDispose:(()=>void)|null=null,workspace:WorkspaceUI|null=null;
|
|
43
47
|
const viewDisposers:(()=>void)[]=[];
|
|
44
|
-
let parts:PartExplorer|null=null;
|
|
48
|
+
let parts:PartExplorer|null=null,turnPad:TurnPad|null=null;
|
|
49
|
+
let comments:ReturnType<typeof bindStageComments>|null=null;
|
|
45
50
|
const overlays:Partial<Record<Side,RigOverlays>>={};
|
|
46
51
|
function inspectionPlayer(side:Side):InspectionPlayer {const player=players[side];if(!player)throw new Error('Part player unavailable');return {get time(){return player.time;},pause:()=>player.pause(),seek:time=>player.seek(time),draw:()=>{player.draw();parts?.present(side);}};}
|
|
47
52
|
const layers={images:true,mesh:false,bones:false,meshOpacity:.65};
|
|
@@ -225,6 +230,14 @@ function fit(): void {
|
|
|
225
230
|
redrawOverlays(false);referenceFit();renderCameraControls();renderNumericView();
|
|
226
231
|
}
|
|
227
232
|
|
|
233
|
+
function configureTurnPad():void {
|
|
234
|
+
const a=data.a.feedbackCatalog.turnDials??[],b=data.b.feedbackCatalog.turnDials??[],dials=sharedTurnDials(a,b);
|
|
235
|
+
if(!dials.length)return;
|
|
236
|
+
const host=document.createElement('div');host.id='turn-pad-host';el('stage-layout').before(host);
|
|
237
|
+
turnPad=bindTurnPad({host,stages:[el('a-viewport'),el('b-viewport')],dials,blocked:()=>seeking||video.seeking||!!persistence?.restoring()||!ready,
|
|
238
|
+
draw:state=>{if(!players.a||!players.b)return;try{for(const side of ['a','b'] as const){const source=side==='a'?a:b;players[side]!.adjust=state.adjust(source.filter(d=>dials.some(shared=>shared.axis===d.axis)));}players.a.draw();parts?.present('a');players.b.draw();parts?.present('b');redrawOverlays(true);}catch(error){failPair(error);throw error;}},save:()=>persistence?.save(),error:value=>message(value,true)});
|
|
239
|
+
const note=document.createElement('p');note.className='hint';note.textContent='Both panes share the same values over the intersection of their declared ranges. Axes absent on either side are not offered.';host.append(note);
|
|
240
|
+
}
|
|
228
241
|
async function loadSide(side: Side, value: CompareSide): Promise<number> {
|
|
229
242
|
text(`${side}-title`, value.manifest.title);
|
|
230
243
|
if (!value.manifest.model) throw new Error(`${side.toUpperCase()}: compiled model Not supplied`);
|
|
@@ -280,6 +293,7 @@ function renderFeedbackSelection(): void {
|
|
|
280
293
|
}
|
|
281
294
|
|
|
282
295
|
function renderFeedback(): void {
|
|
296
|
+
comments?.refresh();
|
|
283
297
|
const list = el('feedback-list'); list.replaceChildren();
|
|
284
298
|
const warnings = el('feedback-warnings'); warnings.hidden = session.feedbackWarnings.length === 0;
|
|
285
299
|
warnings.textContent = session.feedbackWarnings.length ? `${session.feedbackWarnings.length} validation warnings in existing feedback · original records are preserved. Review the marked entries, delete them and add valid replacements before submitting. JSON and Markdown exports preserve both the original records and warnings.` : '';
|
|
@@ -289,7 +303,7 @@ function renderFeedback(): void {
|
|
|
289
303
|
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}`; row.append(note); }
|
|
290
304
|
const details = document.createElement('details'), heading = document.createElement('summary'), source = document.createElement('pre'); heading.textContent = 'Saved feedback and inspection context'; source.textContent = JSON.stringify(feedback, null, 2); details.append(heading, source);
|
|
291
305
|
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = 'Delete'; remove.onclick = () => { session.feedback = session.feedback.filter(value => value.id !== feedback.id); session.feedbackWarnings = session.feedbackWarnings.filter(warning => warning.id !== feedback.id); renderFeedback(); markDirty(); };
|
|
292
|
-
row.append(details
|
|
306
|
+
row.append(details);if((feedback.context as any)?.comment?.schema!=='rig-review-comment/1'||!submittedBaseline?.feedback.some(f=>f.id===feedback.id))row.append(remove); list.append(row);
|
|
293
307
|
}
|
|
294
308
|
savedState();
|
|
295
309
|
}
|
|
@@ -314,7 +328,7 @@ async function exportStrip(): Promise<void> {
|
|
|
314
328
|
for (const feedback of session.feedback) {
|
|
315
329
|
const context = feedback.context;
|
|
316
330
|
if (!context || typeof context !== 'object' || Array.isArray(context) || context.animation !== data.animation) throw new Error(`Feedback ${feedback.id}: Its animation is missing or differs from the comparison animation. The range was not reassigned to another animation.`);
|
|
317
|
-
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: feedback.side,
|
|
331
|
+
intervals.push({ id: feedback.id, feedbackId: feedback.id, basis: feedback.side, ...requireFeedbackFrames(feedback) });
|
|
318
332
|
}
|
|
319
333
|
} else {
|
|
320
334
|
const basis = select('strip-basis').value, start = input('frame-start').value.trim(), end = input('frame-end').value.trim();
|
|
@@ -370,10 +384,10 @@ function bind(): void {
|
|
|
370
384
|
if (!['bone', 'slot', 'rig'].includes(kind) || !['defect', 'quality'].includes(category) || !name || !summary || /[\r\n]/.test(summary) || summary.length > 1000) { message('Enter a target type, actual name, category and one-line summary of up to 1,000 characters.', true); return; }
|
|
371
385
|
const target: CompareFeedback['target'] = { kind: kind as CompareFeedback['target']['kind'], name };
|
|
372
386
|
try { validateFeedbackSelection(data[side].feedbackCatalog, { frameStart, frameEnd, target, animation: data.animation }); } catch (error) { message(describe(error), true); return; }
|
|
373
|
-
session.feedback.push({ id: crypto.randomUUID(), side, frameStart, frameEnd, target, category: category as CompareFeedback['category'], summary, context: { interval: { frameStart, frameEnd, inclusive: true }, animation: data.animation, mediaTime: inspected.reference, clockSource: inspected.source, frame: currentFrame(side), frames: { a: currentFrame('a'), b: currentFrame('b') }, playheads: { reference: inspected.reference, a: inspected.a, b: inspected.b },reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')},requestedProducer,part:parts?.feedback()??null,view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,inspected.reference)?.index??null:null } });
|
|
387
|
+
session.feedback.push({ id: crypto.randomUUID(), side, frameStart, frameEnd, target, category: category as CompareFeedback['category'], summary, context: { ...(turnPad?{turn:JSON.parse(JSON.stringify(turnPad.snapshot()))}:{}),interval: { frameStart, frameEnd, inclusive: true }, animation: data.animation, mediaTime: inspected.reference, clockSource: inspected.source, frame: currentFrame(side), frames: { a: currentFrame('a'), b: currentFrame('b') }, playheads: { reference: inspected.reference, a: inspected.a, b: inspected.b },reviewFrames:{a:currentReviewFrame('a'),b:currentReviewFrame('b')},requestedProducer,part:parts?.feedback()??null,view:{...serializableView(numericView()),theme:appearance?.get()??'system',layers:{...layers}},decodedIndex:referenceFrames?referenceFrameAtTime(referenceFrames,inspected.reference)?.index??null:null } });
|
|
374
388
|
input('summary').value = ''; markDirty(); renderFeedback(); message('Added feedback bound to the target build and both reviewed sources.');
|
|
375
389
|
};
|
|
376
|
-
button('submit').onclick = async () => { if(readOnly){message(readOnly,true);return;}if(draftConflict){message('Resolve the saved local draft conflict before submitting.',true);return;}if (session.feedbackWarnings.length) { message('Resolve validation warnings in existing feedback before submitting. Delete the marked entries and add valid replacements.', true); return; } saving = true; savedState(); const payload = JSON.stringify(session); try { const saved = await api<CompareSession>('/api/compare/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload }); if (JSON.stringify(session) === payload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;await persistence?.submitted(saved); message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status and A/B/reference-video provenance.'); } catch (error) { message(`Submission failed: ${describe(error)}`, true); } finally { saving = false; savedState(); } };
|
|
390
|
+
button('submit').onclick = async () => { if(readOnly){message(readOnly,true);return;}if(draftConflict){message('Resolve the saved local draft conflict before submitting.',true);return;}if (session.feedbackWarnings.length) { message('Resolve validation warnings in existing feedback before submitting. Delete the marked entries and add valid replacements.', true); return; } saving = true; savedState(); const payload = JSON.stringify(session); try { const saved = await api<CompareSession>('/api/compare/session', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payload }); if (JSON.stringify(session) === payload) { session = saved; dirty = false; } else session.updatedAt = saved.updatedAt;submittedBaseline=structuredClone(saved);comments?.refresh();await persistence?.submitted(saved);renderFeedback(); message(dirty ? 'Changes were made during submission. Submit again.' : 'Submitted the selected review status and A/B/reference-video provenance.'); } catch (error) { message(`Submission failed: ${describe(error)}`, true); } finally { saving = false; savedState(); } };
|
|
377
391
|
button('export-json').onclick = () => { download(`${session.id}-feedback.json`, JSON.stringify({ ...session, schema: 'rig-review-compare-feedback/1', issues: session.feedback.map(issue) }, null, 2) + '\n', 'application/json'); };
|
|
378
392
|
button('export-markdown').onclick = () => { download(`${session.id}-issues.md`, [`# ${md(data.title)}`, '', 'Ordered A/B and reference provenance:', ...JSON.stringify(session.binding, null, 2).split('\n').map(line => ' ' + line), '', 'Validation warnings:', ...JSON.stringify(session.feedbackWarnings, null, 2).split('\n').map(line => ' ' + line), '', ...session.feedback.flatMap(feedback => { const entry = issue(feedback); return [`## ${md(entry.title)}`, '', entry.body, '', '---', '']; })].join('\n'), 'text/markdown'); };
|
|
379
393
|
// Saved browser defaults seed a new inspection; explicit links and source drafts restore over them.
|
|
@@ -381,7 +395,7 @@ function bind(): void {
|
|
|
381
395
|
cameraSync=workspace.defaults().defaultCameraSync;input('camera-sync').checked=cameraSync;input('idle-repeat').checked=workspace.defaults().defaultIdleRepeat;
|
|
382
396
|
appearance=bindAppearance(select('appearance'),workspace.defaults().theme);
|
|
383
397
|
for(const side of ['a','b'] as const)overlays[side]=new RigOverlays({canvas:el<HTMLCanvasElement>(side+'-overlay'),rigRoot:el(side+'-root')});
|
|
384
|
-
for(const target of ['a','b','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy))));
|
|
398
|
+
for(const target of ['a','b','reference'] as const)viewDisposers.push(bindPan(el(target+'-viewport'),(dx,dy)=>cameraAction(()=>panCamera(target,dx,dy)),()=>!comments?.active()));
|
|
385
399
|
viewDisposers.push(bindStageLayout(el('stage-layout'),fit));
|
|
386
400
|
for(const [id,key] of [['layer-images','images'],['layer-mesh','mesh'],['layer-bones','bones']] as const)input(id).onchange=()=>{layers[key]=input(id).checked;redrawOverlays(false);};
|
|
387
401
|
input('idle-repeat').onchange=()=>{renderIdle();if(canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));};
|
|
@@ -390,7 +404,7 @@ function bind(): void {
|
|
|
390
404
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){suspendedPreview=!video.paused;stop();}else if(suspendedPreview&&!idleHeld){suspendedPreview=false;void playComparison().catch(error=>message(describe(error),true));}});
|
|
391
405
|
const observer = new ResizeObserver(fit); observer.observe(el('a-viewport')); observer.observe(el('b-viewport'));observer.observe(el('reference-viewport'));
|
|
392
406
|
window.addEventListener('beforeunload', event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } });
|
|
393
|
-
window.addEventListener('pagehide', () => { persistence?.dispose();workspace?.dispose();disposed = true; seekAbort?.abort();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());for(const overlay of Object.values(overlays))overlay.dispose();stop(); if (videoCallback !== null) video.cancelVideoFrameCallback(videoCallback); if (fallbackCallback !== null) cancelAnimationFrame(fallbackCallback); for (const side of ['a', 'b'] as const) { players[side]?.dispose(); assets[side]?.dispose(); } observer.disconnect(); });
|
|
407
|
+
window.addEventListener('pagehide', () => { comments?.dispose();persistence?.dispose();turnPad?.dispose();workspace?.dispose();disposed = true; seekAbort?.abort();controlDispose?.();appearance?.dispose();viewDisposers.forEach(dispose=>dispose());for(const overlay of Object.values(overlays))overlay.dispose();stop(); if (videoCallback !== null) video.cancelVideoFrameCallback(videoCallback); if (fallbackCallback !== null) cancelAnimationFrame(fallbackCallback); for (const side of ['a', 'b'] as const) { players[side]?.dispose(); assets[side]?.dispose(); } observer.disconnect(); });
|
|
394
408
|
}
|
|
395
409
|
|
|
396
410
|
async function start(): Promise<void> {
|
|
@@ -411,7 +425,7 @@ async function start(): Promise<void> {
|
|
|
411
425
|
void loadFrameIndex();
|
|
412
426
|
input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
|
|
413
427
|
input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
|
|
414
|
-
await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
428
|
+
await seekTo(0);configureTurnPad();activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();setupComments();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
|
|
415
429
|
if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
|
|
416
430
|
} catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
|
|
417
431
|
}
|
|
@@ -489,7 +503,7 @@ async function restartIdle():Promise<void> {
|
|
|
489
503
|
try{await seekTo(0,null,null,true);if(!idleHeld&&canRepeatIdle()&&!disposed&&!document.hidden&&trace)await playComparison();}
|
|
490
504
|
catch(error){idleHeld=true;message(describe(error),true);}finally{loopRestarting=false;}
|
|
491
505
|
}
|
|
492
|
-
function localDraft():boolean{return dirty||!!input('summary').value.trim()||!!input('frame-start').value.trim()||!!input('frame-end').value.trim()||!!input('target-name').value.trim()||!!select('category').value;}
|
|
506
|
+
function localDraft():boolean{return !!comments?.draft()||dirty||!!input('summary').value.trim()||!!input('frame-start').value.trim()||!!input('frame-end').value.trim()||!!input('target-name').value.trim()||!!select('category').value;}
|
|
493
507
|
function controlState():ControlInspection {
|
|
494
508
|
const css=(target:CameraTarget)=>{const frame=paneFrame(target,true);if(!frame)return {zoom:1,pan:{x:0,y:0}};const value=cameraSignature(cameras[target],frame);return {zoom:value.zoom,pan:{x:value.pan.x*frame.width,y:value.pan.y*frame.height}};};
|
|
495
509
|
return {part:parts?.value()??null,partGeneration:parts?.observation()?.generation??null,view:numericView(),theme:appearance?.get()??'system',layout:'auto',effectiveLayout:el('stage-layout').dataset.orientation==='vertical'?'column':'row',cameras:{rig:css('a'),a:css('a'),b:css('b'),reference:css('reference')},animation:data.animation,time:trace?.reference??null,playing:!video.paused,loop:canRepeatIdle()?'idle':'off',layers:{images:!!players.a&&layers.images,meshes:!!players.a&&layers.mesh,bones:!!players.a&&layers.bones},ready,seeking:seeking||video.seeking||!!persistence?.restoring(),dirty:localDraft(),busy:saving||stripBusy||!!persistence?.restoring(),feedbackEligible:!persistence?.restoring()&&pairEligibility(pairInputs).feedback&&!!feedbackTrace(ready,seeking||video.seeking,trace),comparison:controlComparison(pairEligibility(pairInputs)),capabilities:{camera:!!players.a&&!!players.b,referenceCamera:video.readyState>=1,animation:false,time:ready,playback:ready&&pairEligibility(pairInputs).playback,layers:{images:!!players.a&&!!players.b,meshes:!!players.a&&!!players.b,bones:!!players.a&&!!players.b}}};
|
|
@@ -516,10 +530,11 @@ function numericView():ControlView {
|
|
|
516
530
|
}
|
|
517
531
|
function renderNumericView():void {const node=document.getElementById('view-coordinates');if(node)node.textContent=JSON.stringify(numericView(),null,2);}
|
|
518
532
|
function inspectionBinding():InspectionBinding{return {mode:'compare',sourceHash:data.sourceHash,rigs:[data.a.reviewedSource.rig?.sha256??null,data.b.reviewedSource.rig?.sha256??null]};}
|
|
519
|
-
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:data.animation,time:trace?.reference??presentation.snapshot().lastDrawn?.reference??null,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{part:parts?.value()??null,reference:data.reference.sha256,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view'}};}
|
|
533
|
+
function inspectionBookmark():InspectionBookmark {return {schema:'rig-review-inspection/1',binding:inspectionBinding(),animation:data.animation,time:trace?.reference??presentation.snapshot().lastDrawn?.reference??null,theme:appearance?.get()??'system',layers:{images:layers.images,meshes:layers.mesh,bones:layers.bones},view:numericView(),selection:{...(turnPad?{turn:turnPad.snapshot()}:{}),part:parts?.value()??null,reference:data.reference.sha256,inspector:document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector??'view'}};}
|
|
520
534
|
async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
521
535
|
await restoreTransaction(async()=>{
|
|
522
536
|
|
|
537
|
+
if(bookmark.selection.turn){if(!turnPad)throw new Error('TURN_UNAVAILABLE');turnPad.state.validate(bookmark.selection.turn);}
|
|
523
538
|
parts?.validate(bookmark.selection.part);
|
|
524
539
|
if(bookmark.animation!==data.animation||bookmark.selection.reference!==data.reference.sha256)throw new Error('Saved comparison animation/reference requires a different source binding');
|
|
525
540
|
if(!domain)throw new Error('Comparison is not ready');if(bookmark.time!==null)requireCommonTime(bookmark.time,domain);
|
|
@@ -530,6 +545,7 @@ async function restoreInspection(bookmark:InspectionBookmark,signal:AbortSignal)
|
|
|
530
545
|
}
|
|
531
546
|
async function applyInspection(bookmark:InspectionBookmark,signal:AbortSignal):Promise<void> {
|
|
532
547
|
cameraRestoring=true;try{
|
|
548
|
+
if(turnPad){if(bookmark.selection.turn)turnPad.restore(bookmark.selection.turn);else{turnPad.state.reset();turnPad.restore(turnPad.snapshot());}}
|
|
533
549
|
parts?.select(bookmark.selection.part??null);
|
|
534
550
|
idleHeld=true;stop();if(bookmark.time!==null){await seekTo(bookmark.time);if(signal.aborted||!trace)throw new Error('Saved reference inspection could not settle');}
|
|
535
551
|
appearance?.set(bookmark.theme);Object.assign(layers,{images:bookmark.layers.images,mesh:bookmark.layers.meshes,bones:bookmark.layers.bones});input('layer-images').checked=layers.images;input('layer-mesh').checked=layers.mesh;input('layer-bones').checked=layers.bones;
|
|
@@ -545,4 +561,21 @@ async function setupPersistence():Promise<boolean> {
|
|
|
545
561
|
ui:()=>({inspectorScroll:workspace?.scroll()??'{}',frameBasis:select('frame-basis').value}),restoreUI:value=>{if(value.frameBasis==='a'||value.frameBasis==='b')select('frame-basis').value=value.frameBasis;frameControls();workspace?.restoreScroll(value.inspectorScroll);},afterInputs:()=>{video.muted=!input('reference-audio').checked;const name=input('target-name').value;renderFeedbackSelection();input('target-name').value=name;},onConflict:value=>{draftConflict=value;savedState();},onStatus:value=>{text('draft-state',value);el('draft-state').title=value;}});
|
|
546
562
|
return persistence.restored;
|
|
547
563
|
}
|
|
564
|
+
|
|
565
|
+
function setupComments():void {
|
|
566
|
+
comments=bindStageComments({toolbar:el('stage-comment').parentElement!,viewports:{a:el('a-viewport'),b:el('b-viewport'),reference:el('reference-viewport')},
|
|
567
|
+
pose:view=>{
|
|
568
|
+
if(view==='rig'||!trace)return null;const side=view==='b'?'b':'a',camera=numericView().cameras[view];if(!camera||hostHidden(el(view+'-viewport')))return null;
|
|
569
|
+
const raw=currentReviewFrame(side)??currentFrame(side),decoded=referenceFrames?referenceFrameAtTime(referenceFrames,trace.reference):null;
|
|
570
|
+
const renderer=players[side]?.renderer;const index=view==='reference'?decoded?.index:raw??decoded?.index,basis=view==='reference'||raw===null?'reference':'producer';
|
|
571
|
+
return {mode:'compare',capture:{pane:el(view+'-viewport'),root:view==='reference'?undefined:el(side+'-root'),images:view==='reference'||layers.images,canvas:view!=='reference'&&renderer instanceof CanvasRenderer?renderer.canvas:undefined,backend:renderer instanceof CanvasRenderer?renderer.backendActive:renderer?.meshBackendActive??'canvas2d'},camera,frame:index===null||index===undefined?null:{index,basis},time:trace.reference,animation:data.animation,turn:turnPad?.snapshot()??null,sourceHash:data[side].sourceHash,modelHash:view==='reference'?null:data[side].reviewedSource.rig?.sha256??null,referenceHash:data.reference.sha256,referenceId:data.reference.sha256,rig:data[side].manifest.id,side,...(view==='reference'?{media:video}:{posed:parts?.frame(side),assets:assets[side]})} as CommentPose;
|
|
572
|
+
},
|
|
573
|
+
pause:async()=>{idleHeld=true;const running=!video.paused,at=trace?.reference??lastPresented?.time;stop();if(running&&at!==undefined){await seekTo(at);if(!trace)throw new Error('COMMENT_INSPECTION_UNAVAILABLE');}if(turnPad)turnPad.restore({...turnPad.snapshot(),held:true},false);},
|
|
574
|
+
bookmark:inspectionBookmark,restore:b=>restoreInspection(parseBookmark(b,inspectionBinding()),new AbortController().signal),feedback:()=>session.feedback,
|
|
575
|
+
locked:id=>!!submittedBaseline?.updatedAt&&submittedBaseline.feedback.some(f=>f.id===id),
|
|
576
|
+
add:(f,side)=>session.feedback.push({...f,side:side??'a'}),changed:()=>{markDirty();renderFeedback();persistence?.save();},error:value=>message(value,true),blocked:()=>saving||seeking||video.seeking||!!persistence?.restoring()||!ready
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function hostHidden(node:HTMLElement):boolean{return node.clientWidth<=0||node.clientHeight<=0;}
|
|
580
|
+
|
|
548
581
|
void start();
|