@rig-c/review 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +376 -2
- package/docs/DESIGN.md +72 -0
- package/docs/REVIEW_PACKAGE.md +627 -0
- package/examples/demo/SOURCE.md +5 -0
- package/examples/demo/chime.wav +0 -0
- package/examples/demo/lantern.svg +10 -0
- package/examples/demo/pose.json +244 -0
- package/examples/demo/review.json +67 -0
- package/package.json +49 -4
- package/src/animation-selection.ts +29 -0
- package/src/build.ts +8 -0
- package/src/cli.ts +116 -0
- package/src/compare-playhead.ts +55 -0
- package/src/compare-presentation.ts +43 -0
- package/src/compare-server.ts +63 -0
- package/src/compare-session.ts +26 -0
- package/src/compare.ts +57 -0
- package/src/comparison-eligibility.ts +54 -0
- package/src/control-server.ts +83 -0
- package/src/control.ts +132 -0
- package/src/feedback-export.ts +197 -0
- package/src/feedback-validation.ts +38 -0
- package/src/frame-clock.ts +36 -0
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/manifest.ts +140 -0
- package/src/open-protocol.ts +31 -0
- package/src/open-selection.ts +11 -0
- package/src/open-shell.ts +2 -0
- package/src/open-worker.ts +2 -0
- package/src/open.ts +142 -0
- package/src/pose.ts +27 -0
- package/src/probe-reference.ts +16 -0
- package/src/reference-frame-index.ts +16 -0
- package/src/reference-frames.ts +62 -0
- package/src/reference.ts +63 -0
- package/src/report.ts +88 -0
- package/src/server.ts +62 -0
- package/src/session-history.ts +99 -0
- package/src/session.ts +58 -0
- package/src/strip-export.ts +69 -0
- package/src/strip-render.ts +171 -0
- package/src/strip.ts +51 -0
- package/src/versions.ts +20 -0
- package/web/camera-view.ts +44 -0
- package/web/compare.css +140 -0
- package/web/compare.html +7 -0
- package/web/compare.ts +546 -0
- package/web/control.ts +67 -0
- package/web/index.html +13 -0
- package/web/inspection-persistence.ts +219 -0
- package/web/instance-access.ts +23 -0
- package/web/main.ts +972 -0
- package/web/part-selection.ts +136 -0
- package/web/renderer-status.ts +31 -0
- package/web/report-view.ts +51 -0
- package/web/rig-overlays.ts +150 -0
- package/web/styles.css +139 -0
- package/web/view-controls.ts +69 -0
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** CLI-only historical retrieval. Current UI/save identities remain strict. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { open, opendir } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { object, string, type ReviewedSource, type ReviewPackage } from './manifest.ts';
|
|
8
|
+
import { freshSession, parseSession, sessionFile, type ReviewSession } from './session.ts';
|
|
9
|
+
import { freshCompareSession, parseCompareSession, compareSessionFile, type CompareSession } from './compare-session.ts';
|
|
10
|
+
import { assertCompareUnchanged, type CompareBinding, type ComparePackage } from './compare.ts';
|
|
11
|
+
|
|
12
|
+
type Tools=Pick<ReviewedSource,'viewerVersion'|'runtimeVersion'>;
|
|
13
|
+
export interface HistoryRetrieval {kind:'prior-tools';toolsChangedSince:true;matchingSubmissions:number;currentTools:Tools|{a:Tools;b:Tools}}
|
|
14
|
+
export type FeedbackHistory<T> = T & {retrieval?:HistoryRetrieval;historyWarnings?:{file:string;error:string}[]};
|
|
15
|
+
const MAX_FILE_BYTES=32*1024*1024,MAX_SCAN_BYTES=64*1024*1024;
|
|
16
|
+
const tools=(source:ReviewedSource):Tools=>({viewerVersion:source.viewerVersion,runtimeVersion:source.runtimeVersion});
|
|
17
|
+
function content(source:unknown):unknown {if(!source||typeof source!=='object'||Array.isArray(source))return null;return {...source,viewerVersion:null,runtimeVersion:null};}
|
|
18
|
+
function recordedSource(value:unknown,current:ReviewedSource):ReviewedSource|null {
|
|
19
|
+
if(!isDeepStrictEqual(content(value),content(current)))return null;
|
|
20
|
+
const source=object(value,'history.reviewedSource');
|
|
21
|
+
return {...current,viewerVersion:string(source.viewerVersion,'history.viewerVersion'),runtimeVersion:string(source.runtimeVersion,'history.runtimeVersion')};
|
|
22
|
+
}
|
|
23
|
+
/** Read a regular file through a no-follow descriptor with a fixed byte ceiling. */
|
|
24
|
+
async function readRecord(file:string,budget:{bytes:number}):Promise<unknown>{
|
|
25
|
+
const handle=await open(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
|
26
|
+
try{
|
|
27
|
+
const before=await handle.stat();if(!before.isFile())throw new Error('not a regular file');
|
|
28
|
+
if(before.size>MAX_FILE_BYTES)throw new Error('exceeds 32 MiB history file limit');
|
|
29
|
+
budget.bytes+=before.size;if(budget.bytes>MAX_SCAN_BYTES)throw new Error('feedback history: exceeds 64 MiB search limit');
|
|
30
|
+
const buffer=Buffer.alloc(before.size+1);let size=0;
|
|
31
|
+
while(size<buffer.length){const read=await handle.read(buffer,size,buffer.length-size,size);if(!read.bytesRead)break;size+=read.bytesRead;}
|
|
32
|
+
const after=await handle.stat();if(size!==before.size||after.size!==before.size||after.mtimeMs!==before.mtimeMs)throw new Error('changed while reading');
|
|
33
|
+
try{return JSON.parse(buffer.subarray(0,size).toString('utf8'));}catch{throw new Error('invalid JSON');}
|
|
34
|
+
}finally{await handle.close();}
|
|
35
|
+
}
|
|
36
|
+
function submittedAt(value:unknown):number {
|
|
37
|
+
if(typeof value!=='string'||!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value))throw new Error('feedback history.updatedAt: expected saved ISO timestamp');
|
|
38
|
+
const time=Date.parse(value);if(!Number.isFinite(time)||new Date(time).toISOString()!==value)throw new Error('feedback history.updatedAt: expected saved ISO timestamp');return time;
|
|
39
|
+
}
|
|
40
|
+
/** Recheck at the return boundary: a concurrent current save beats history. */
|
|
41
|
+
export async function preferCurrentSession<T>(readCurrent:()=>Promise<T|null>,readHistory:()=>Promise<T>):Promise<T>{
|
|
42
|
+
const current=await readCurrent();if(current!==null)return current;
|
|
43
|
+
try{const historical=await readHistory();return (await readCurrent())??historical;}
|
|
44
|
+
catch(error){const concurrent=await readCurrent();if(concurrent!==null)return concurrent;throw error;}
|
|
45
|
+
}
|
|
46
|
+
async function currentRecord<T>(file:string,parse:(value:unknown)=>T):Promise<T|null>{
|
|
47
|
+
try{return parse(await readRecord(file,{bytes:0}));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw e;}
|
|
48
|
+
}
|
|
49
|
+
async function discover<T extends {updatedAt:string|null}>(file:string,readCurrent:()=>Promise<T|null>,fresh:T,candidate:(value:Record<string,unknown>,name:string)=>T|null,currentTools:HistoryRetrieval['currentTools']):Promise<FeedbackHistory<T>>{
|
|
50
|
+
return preferCurrentSession<FeedbackHistory<T>>(readCurrent,async()=>{
|
|
51
|
+
let directory:Awaited<ReturnType<typeof opendir>>;
|
|
52
|
+
try{directory=await opendir(dirname(file));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return fresh;throw e;}
|
|
53
|
+
let best:{session:T;time:number;name:string}|undefined,count=0;const warnings:{file:string;error:string}[]=[],budget={bytes:0};
|
|
54
|
+
for await(const entry of directory){
|
|
55
|
+
if(entry.name===basename(file)||!/^[a-f0-9]{64}\.json$/.test(entry.name))continue;
|
|
56
|
+
if(!entry.isFile()){warnings.push({file:entry.name,error:'not a regular file'});if(warnings.length>100)throw new Error('feedback history: more than 100 unreadable state files');continue;}
|
|
57
|
+
let raw:unknown;
|
|
58
|
+
try{raw=await readRecord(join(dirname(file),entry.name),budget);}catch(e){
|
|
59
|
+
const error=e instanceof Error?e.message:String(e);if(error.includes('64 MiB'))throw e;
|
|
60
|
+
warnings.push({file:entry.name,error});if(warnings.length>100)throw new Error('feedback history: more than 100 unreadable state files');continue;
|
|
61
|
+
}
|
|
62
|
+
if(!raw||typeof raw!=='object'||Array.isArray(raw))continue;
|
|
63
|
+
// Matching records with corrupt provenance/timestamps fail by name. Unrelated
|
|
64
|
+
// records never become a substitute for the current package's review.
|
|
65
|
+
const session=candidate(raw as Record<string,unknown>,entry.name);if(!session)continue;
|
|
66
|
+
const time=submittedAt(session.updatedAt);count++;
|
|
67
|
+
if(!best||time>best.time||(time===best.time&&entry.name<best.name))best={session,time,name:entry.name};
|
|
68
|
+
}
|
|
69
|
+
return {...(best?{...best.session,retrieval:{kind:'prior-tools' as const,toolsChangedSince:true as const,matchingSubmissions:count,currentTools}}:fresh),...(warnings.length?{historyWarnings:warnings}:{})};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readFeedbackSession(p:ReviewPackage,stateDir?:string):Promise<FeedbackHistory<ReviewSession>>{
|
|
74
|
+
const file=sessionFile(p,stateDir);
|
|
75
|
+
return discover(file,()=>currentRecord(file,value=>({...parseSession(value,p,false),updatedAt:object(value,'session').updatedAt as string|null})),freshSession(p),(value,name)=>{
|
|
76
|
+
if(value.schema!=='rig-review-session/1'||value.id!==p.manifest.id||value.revision!==p.manifest.revision||value.sourceHash!==p.sourceHash)return null;
|
|
77
|
+
const reviewedSource=recordedSource(value.reviewedSource,p.reviewedSource);if(!reviewedSource)return null;
|
|
78
|
+
const historical={...p,reviewedSource};if(basename(sessionFile(historical,stateDir))!==name)throw new Error('feedback history: session filename does not match recorded binding');
|
|
79
|
+
if(isDeepStrictEqual(tools(reviewedSource),tools(p.reviewedSource)))throw new Error('feedback history: unexpected current-tools record');
|
|
80
|
+
return {...parseSession(value,historical,false),updatedAt:value.updatedAt as string|null};
|
|
81
|
+
},tools(p.reviewedSource));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function readFeedbackCompareSession(p:ComparePackage,stateDir?:string):Promise<FeedbackHistory<CompareSession>>{
|
|
85
|
+
const file=compareSessionFile(p,stateDir);
|
|
86
|
+
return discover(file,async()=>{await assertCompareUnchanged(p);return currentRecord(file,value=>({...parseCompareSession(value,p,false),updatedAt:object(value,'compare session').updatedAt as string|null}));},freshCompareSession(p),(value,name)=>{
|
|
87
|
+
if(value.schema!=='rig-review-compare-session/1')return null;
|
|
88
|
+
const binding=value.binding;if(!binding||typeof binding!=='object'||Array.isArray(binding))return null;
|
|
89
|
+
const old=binding as Record<string,unknown>;if(!isDeepStrictEqual(old.reference,p.response.binding.reference)||old.animation!==p.response.animation)return null;
|
|
90
|
+
const sides:ReviewPackage[]=[];
|
|
91
|
+
for(const side of ['a','b'] as const){const raw=old[side];if(!raw||typeof raw!=='object'||Array.isArray(raw))return null;const row=raw as Record<string,unknown>;if(row.sourceHash!==p[side].sourceHash)return null;const reviewedSource=recordedSource(row.reviewedSource,p[side].reviewedSource);if(!reviewedSource)return null;sides.push({...p[side],reviewedSource});}
|
|
92
|
+
const a=sides[0]!,b=sides[1]!,historicalBinding:CompareBinding={a:{sourceHash:a.sourceHash,reviewedSource:a.reviewedSource},b:{sourceHash:b.sourceHash,reviewedSource:b.reviewedSource},reference:p.response.binding.reference,animation:p.response.animation};
|
|
93
|
+
const sourceHash=createHash('sha256').update(JSON.stringify(historicalBinding)).digest('hex');
|
|
94
|
+
const historical:ComparePackage={...p,a,b,response:{...p.response,id:'compare-'+sourceHash.slice(0,16),sourceHash,binding:historicalBinding}};
|
|
95
|
+
if(basename(compareSessionFile(historical,stateDir))!==name)throw new Error('feedback history: comparison filename does not match recorded binding');
|
|
96
|
+
if(isDeepStrictEqual(historicalBinding,p.response.binding))throw new Error('feedback history: unexpected current-tools comparison');
|
|
97
|
+
return {...parseCompareSession(value,historical,false),updatedAt:value.updatedAt as string|null};
|
|
98
|
+
},{a:tools(p.a.reviewedSource),b:tools(p.b.reviewedSource)});
|
|
99
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import {referenceBinding,type ReferenceBinding} from './reference.ts';
|
|
7
|
+
import { validateFeedbackSelection } from './feedback-validation.ts';
|
|
8
|
+
import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
|
|
9
|
+
export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
10
|
+
export function parseFeedback(item:unknown,strict=true):Feedback {
|
|
11
|
+
const f=object(item,'feedback');const target=object(f.target,'feedback.target');
|
|
12
|
+
// Older releases admitted integer values beyond the safe range. Retain those
|
|
13
|
+
// as readable evidence; strict saves and validatePackageFeedback refuse them.
|
|
14
|
+
const integer=strict?Number.isSafeInteger:Number.isInteger;
|
|
15
|
+
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');
|
|
16
|
+
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');
|
|
17
|
+
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})};
|
|
18
|
+
}
|
|
19
|
+
export interface FeedbackWarning {id:string;error:string}
|
|
20
|
+
|
|
21
|
+
/** Derive the annotated interval, retaining inspection context as separate data. */
|
|
22
|
+
export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string):Feedback {
|
|
23
|
+
const context=feedback.context===undefined?{}:object(feedback.context,'feedback.context');
|
|
24
|
+
const interval={frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true};
|
|
25
|
+
if(context.interval!==undefined&&!isDeepStrictEqual(context.interval,interval))throw new Error('feedback.context.interval: must match chosen inclusive frameStart/frameEnd');
|
|
26
|
+
if(context.animation!==undefined&&context.animation!==null&&typeof context.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
|
|
27
|
+
if(animation!==undefined&&context.animation!==undefined&&context.animation!==null&&context.animation!==animation)throw new Error('feedback.context.animation: does not match selected comparison animation');
|
|
28
|
+
const selected=animation??(typeof context.animation==='string'?context.animation:null);
|
|
29
|
+
validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
|
|
30
|
+
return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
|
|
31
|
+
}
|
|
32
|
+
export function readPackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string):{feedback:Feedback;warning?:FeedbackWarning} {
|
|
33
|
+
try{return {feedback:validatePackageFeedback(feedback,p,animation)};}catch(e){
|
|
34
|
+
// Old submitted evidence remains readable. Only add a missing derived
|
|
35
|
+
// interval; contradictory supplied evidence is preserved with its warning.
|
|
36
|
+
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;
|
|
37
|
+
return {feedback:{...feedback,...(normalized===undefined?{}:{context:normalized})},warning:{id:feedback.id,error:e instanceof Error?e.message:String(e)}};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export interface ReviewSession { schema:'rig-review-session/1'; id:string; revision:string; sourceHash:string; reviewedSource:ReviewedSource; referenceBinding?:ReferenceBinding; status:'pending'|'approved'|'changes-requested'; checkpoints:Record<string,{outcome:'pending'|'pass'|'fail';note:string}>; feedback:Feedback[]; feedbackWarnings:FeedbackWarning[]; updatedAt:string|null }
|
|
41
|
+
export function freshSession(p:ReviewPackage):ReviewSession { return {schema:'rig-review-session/1',id:p.manifest.id,revision:p.manifest.revision,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p)}:{}),status:'pending',checkpoints:{},feedback:[],feedbackWarnings:[],updatedAt:null}; }
|
|
42
|
+
export function parseSession(input:unknown,p:ReviewPackage,strict=true):ReviewSession {
|
|
43
|
+
const v=object(input,'session'); const fresh=freshSession(p);
|
|
44
|
+
for(const k of ['schema','id','revision','sourceHash'] as const) if(v[k]!==fresh[k]) throw new Error(`session.${k}: does not match loaded package`);
|
|
45
|
+
if(!isDeepStrictEqual(v.reviewedSource,p.reviewedSource))throw new Error('session.reviewedSource: does not match exact rig/motion hashes and tool versions of loaded package');
|
|
46
|
+
if(!isDeepStrictEqual(v.referenceBinding,fresh.referenceBinding))throw new Error('session.referenceBinding: does not match selected reference and package hash');
|
|
47
|
+
const status=choice(v.status,['pending','approved','changes-requested'],'session.status');
|
|
48
|
+
const entries=object(v.checkpoints,'session.checkpoints'); const checkpoints:ReviewSession['checkpoints']={};
|
|
49
|
+
for(const [key,value] of Object.entries(entries)) { if(!p.manifest.checkpoints.some(c=>c.id===key)) throw new Error(`session.checkpoints: unknown checkpoint ${key}`);const c=object(value,'checkpoint');if(typeof c.note!=='string'||c.note.length>10000)throw new Error('checkpoint.note: expected string <= 10000 characters');checkpoints[key]={outcome:choice(c.outcome,['pending','pass','fail'],'checkpoint.outcome'),note:c.note}; }
|
|
50
|
+
if(v.corrections!==undefined)throw new Error('session.corrections: authoring proposals are not accepted; use read-only structured feedback');
|
|
51
|
+
if(!Array.isArray(v.feedback)||v.feedback.length>1000)throw new Error('session.feedback: expected array <= 1000');
|
|
52
|
+
const feedbackWarnings:FeedbackWarning[]=[];const feedback:Feedback[]=v.feedback.map((item:unknown)=>{const parsed=parseFeedback(item,strict);if(strict)return validatePackageFeedback(parsed,p);const read=readPackageFeedback(parsed,p);if(read.warning)feedbackWarnings.push(read.warning);return read.feedback;});
|
|
53
|
+
if(new Set(feedback.map(f=>f.id)).size!==feedback.length)throw new Error('session.feedback: duplicate ids');
|
|
54
|
+
return {...fresh,status,checkpoints,feedback,feedbackWarnings,updatedAt:new Date().toISOString()};
|
|
55
|
+
}
|
|
56
|
+
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'); }
|
|
57
|
+
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;} }
|
|
58
|
+
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;}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { realpath, stat, lstat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, relative, resolve, parse, sep, type posix } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { safeFile, type ReviewPackage } from './manifest.ts';
|
|
7
|
+
import {assertReviewUnchanged,selectedReference,safeSelectedReference,packageSourceHash} from './reference.ts';
|
|
8
|
+
import { assertCompareUnchanged, safeCompareReference, type ComparePackage } from './compare.ts';
|
|
9
|
+
import { parseStripRequest, planStrip, type StripBinding, type StripPlan, type StripRenderInput, type StripRenderResult } from './strip.ts';
|
|
10
|
+
export interface StripExportOptions {render?:(input:StripRenderInput)=>Promise<StripRenderResult>}
|
|
11
|
+
export interface StripExport {plan:StripPlan;rendering:(Omit<StripRenderResult,'png'>&{pngSha256:string})|null;png:Uint8Array|null}
|
|
12
|
+
const sha=(bytes:Uint8Array)=>createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
async function unchanged(p:ReviewPackage){try{await assertReviewUnchanged(p);}catch(e){throw new Error(`Strip inputs changed. Restart review before export. ${e instanceof Error?e.message:String(e)}`);}}
|
|
14
|
+
/** Streaming identity remains bounded even for long source videos and JSON plans. */
|
|
15
|
+
export async function hashStripReference(path:string,onChunk?:(size:number)=>void){
|
|
16
|
+
const canonical=await realpath(path),before=await stat(canonical);if(!before.isFile())throw new Error('strip.reference: expected regular video');
|
|
17
|
+
const hash=createHash('sha256');for await(const chunk of createReadStream(canonical,{highWaterMark:65536})){hash.update(chunk);onChunk?.(chunk.length);}
|
|
18
|
+
const after=await stat(canonical);if(await realpath(path)!==canonical||before.dev!==after.dev||before.ino!==after.ino||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)throw new Error('Strip inputs changed: reference changed while hashing');
|
|
19
|
+
return {path:canonical,name:basename(canonical),sha256:hash.digest('hex')};
|
|
20
|
+
}
|
|
21
|
+
const referenceFile=hashStripReference;
|
|
22
|
+
async function verifyReference(original:{path:string;sha256:string}){const current=await referenceFile(original.path);if(current.path!==original.path||current.sha256!==original.sha256)throw new Error('Strip inputs changed: reference video changed during export');}
|
|
23
|
+
function requireAnimation(p:ReviewPackage,animation:string,png:boolean){const clips=p.feedbackCatalog.animations;if(clips&&!clips.some(c=>c.name===animation))throw new Error(`strip.animation: ${JSON.stringify(animation)} not in model`);if(png&&(!p.manifest.model||!clips))throw new Error('strip.model: compiled model required for PNG');}
|
|
24
|
+
async function finish(plan:StripPlan,referencePath:string,packages:{id:'single'|'a'|'b';p:ReviewPackage}[],png:boolean,verify:()=>Promise<void>,options:StripExportOptions):Promise<StripExport>{
|
|
25
|
+
let rendered:StripRenderResult|null=null;
|
|
26
|
+
if(png){const rigs:StripRenderInput['rigs']=[];for(const {id,p} of packages){requireAnimation(p,plan.animation,true);rigs.push({id,root:p.root,modelPath:await safeFile(p.root,p.manifest.model!),animation:plan.animation,clock:p.report?.animation===plan.animation?p.report.clock:null});}const render=options.render??(await import('./strip-render.ts')).renderStrip;rendered=await render({plan,referencePath,rigs});}
|
|
27
|
+
await verify();
|
|
28
|
+
if(!rendered)return {plan,rendering:null,png:null};
|
|
29
|
+
const {png:bytes,...details}=rendered;return {plan,rendering:{...details,pngSha256:sha(bytes)},png:bytes};
|
|
30
|
+
}
|
|
31
|
+
export async function exportReviewStrip(p:ReviewPackage,input:unknown,options:StripExportOptions={}):Promise<StripExport>{
|
|
32
|
+
const request=parseStripRequest(input);await unchanged(p);requireAnimation(p,request.animation,false);
|
|
33
|
+
if(!request.referenceId)throw new Error('strip.referenceId: explicitly select a declared reference video');
|
|
34
|
+
const selected=p.manifest.references.find(r=>r.id===request.referenceId&&r.type==='video');if(!selected)throw new Error('strip.referenceId: expected declared reference video id');
|
|
35
|
+
const explicit=selectedReference(p),external=explicit?.binding.reference.id===selected.id;
|
|
36
|
+
const reference=await referenceFile(external?await safeSelectedReference(p):await safeFile(p.root,selected.path));
|
|
37
|
+
if(external&&reference.sha256!==explicit!.binding.reference.sha256)throw new Error('Strip inputs changed: selected reference hash differs');
|
|
38
|
+
const binding:StripBinding={sourceHash:p.sourceHash,packages:[{id:'single',label:p.manifest.title,sourceHash:packageSourceHash(p),reviewedSource:p.reviewedSource}],reference:{name:reference.name,sha256:reference.sha256},compare:null};
|
|
39
|
+
const plan=planStrip(request,{mode:'single',binding,reports:{single:p.report}});
|
|
40
|
+
return finish(plan,reference.path,[{id:'single',p}],request.format==='png',async()=>{await unchanged(p);const current=external?await safeSelectedReference(p):await safeFile(p.root,selected.path);if(current!==reference.path)throw new Error('Strip inputs changed: reference path changed');await verifyReference(reference);},options);
|
|
41
|
+
}
|
|
42
|
+
export async function exportCompareStrip(p:ComparePackage,input:unknown,options:StripExportOptions={}):Promise<StripExport>{
|
|
43
|
+
const request=parseStripRequest(input);await assertCompareUnchanged(p);if(request.animation!==p.response.animation)throw new Error('strip.animation: expected fixed comparison animation');
|
|
44
|
+
const reference=await referenceFile(await safeCompareReference(p));if(reference.sha256!==p.response.binding.reference.sha256)throw new Error('Strip inputs changed: reference hash differs');
|
|
45
|
+
const binding:StripBinding={sourceHash:p.response.sourceHash,packages:(['a','b'] as const).map(id=>({id,label:`${id.toUpperCase()}: ${p[id].manifest.title}`,sourceHash:p[id].sourceHash,reviewedSource:p[id].reviewedSource})),reference:p.response.binding.reference,compare:p.response.binding};
|
|
46
|
+
const plan=planStrip(request,{mode:'compare',binding,reports:{a:p.a.report,b:p.b.report}});
|
|
47
|
+
return finish(plan,reference.path,[{id:'a',p:p.a},{id:'b',p:p.b}],request.format==='png',async()=>{await assertCompareUnchanged(p);await safeCompareReference(p);await verifyReference(reference);},options);
|
|
48
|
+
}
|
|
49
|
+
export function stripJson(result:StripExport):string{return JSON.stringify({plan:result.plan,rendering:result.rendering},null,2)+'\n';}
|
|
50
|
+
/** Pure boundary rules also exercise Windows paths on a non-Windows verifier. */
|
|
51
|
+
export function stripPathIsInside(root:string,target:string,paths:Pick<typeof posix,'relative'|'isAbsolute'|'sep'>={relative,isAbsolute,sep}):boolean{
|
|
52
|
+
const rel=paths.relative(root,target);return rel===''||(!rel.startsWith('..'+paths.sep)&&rel!=='..'&&!paths.isAbsolute(rel));
|
|
53
|
+
}
|
|
54
|
+
export function stripParentPaths(target:string,paths:Pick<typeof posix,'relative'|'parse'|'dirname'|'resolve'|'sep'>={relative,parse,dirname,resolve,sep}):string[]{
|
|
55
|
+
const base=paths.parse(target).root,parts=paths.relative(base,paths.dirname(target)).split(paths.sep).filter(Boolean),parents:string[]=[];let cursor=base;
|
|
56
|
+
for(const part of parts){cursor=paths.resolve(cursor,part);parents.push(cursor);}return parents;
|
|
57
|
+
}
|
|
58
|
+
/** No symlink parent or package-contained path can become a derived output target. */
|
|
59
|
+
export async function validateStripOutput(output:string,roots:string[]):Promise<string>{
|
|
60
|
+
const target=resolve(output);
|
|
61
|
+
for(const parent of stripParentPaths(target)){const info=await lstat(parent);if(info.isSymbolicLink()||!info.isDirectory())throw new Error('strip.output: parent must be an existing directory without symlinks');}
|
|
62
|
+
for(const root of roots)if(stripPathIsInside(await realpath(root),target))throw new Error('strip.output: derived files must be outside every source package');
|
|
63
|
+
try{await lstat(target);throw new Error('strip.output: destination already exists');}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
|
|
64
|
+
return target;
|
|
65
|
+
}
|
|
66
|
+
export async function writeStripOutput(result:StripExport,output:string,roots:string[]):Promise<void>{
|
|
67
|
+
const destination=await validateStripOutput(output,roots);const bytes=result.png??stripJson(result);
|
|
68
|
+
if(result.png){const sidecar=await validateStripOutput(destination+'.json',roots);await writeFile(destination,bytes,{flag:'wx'});await writeFile(sidecar,stripJson(result),{flag:'wx'});}else await writeFile(destination,bytes,{flag:'wx'});
|
|
69
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Browser-free host for the published player. No posing or raster math lives here. */
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { CanvasRenderer, loadModel, RigPlayer, type ModelAssets } from '@rig-c/play';
|
|
7
|
+
import type { Canvas, Image, SKRSContext2D } from '@napi-rs/canvas';
|
|
8
|
+
import { frameAtTime } from './frame-clock.ts';
|
|
9
|
+
import { packagePath, safeFile } from './manifest.ts';
|
|
10
|
+
import { compareSeekStep } from './compare-playhead.ts';
|
|
11
|
+
import { stripNoClock, type StripRenderInput, type StripRenderResult, type StripRenderSample, type StripSide } from './strip.ts';
|
|
12
|
+
import { toolVersions } from './versions.ts';
|
|
13
|
+
|
|
14
|
+
export interface StripTools { ffmpeg?:string; ffprobe?:string }
|
|
15
|
+
import {nearestReferenceFrame} from './reference-frames.ts';
|
|
16
|
+
import {probeReference} from './probe-reference.ts';
|
|
17
|
+
export {nearestReferenceFrame} from './reference-frames.ts';
|
|
18
|
+
type NativeCanvas=typeof import('@napi-rs/canvas');
|
|
19
|
+
interface OpenRig {id:StripSide;assets:ModelAssets;player:RigPlayer;renderer:CanvasRenderer;clock:StripRenderInput['rigs'][number]['clock']}
|
|
20
|
+
function fail(code:string,text:string):never {throw new Error(`${code}: ${text}`);}
|
|
21
|
+
let hostQueue=Promise.resolve();
|
|
22
|
+
|
|
23
|
+
function command(binary:string,args:string[],kind:string):Promise<Buffer>{
|
|
24
|
+
return new Promise((resolveCommand,reject)=>{
|
|
25
|
+
execFile(binary,args,{encoding:'buffer',timeout:60000,maxBuffer:32*1024*1024,windowsHide:true},(error,stdout,stderr)=>{
|
|
26
|
+
if(error){reject(new Error(`STRIP_${kind}: ${binary}: ${error.message}; ${stderr.toString('utf8').slice(0,1200)}`));return;}
|
|
27
|
+
resolveCommand(stdout);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function referenceFramePng(path:string,index:number,binary:string,width:number,height:number):Promise<Buffer>{
|
|
32
|
+
// Downsize before native image decode; output cells must not retain hundreds
|
|
33
|
+
// of full-resolution source rasters merely because the video was 4K.
|
|
34
|
+
return command(binary,['-v','error','-threads','1','-filter_threads','1','-i',path,'-map','0:v:0','-vf',`select=eq(n\\,${index}),scale=${width}:${height}:force_original_aspect_ratio=decrease`,'-fps_mode','passthrough','-frames:v','1','-f','image2pipe','-c:v','png','-threads','1','pipe:1'],'FFMPEG');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Only DOM ownership and canvas/image IO are adapted; all drawing remains native/player code. */
|
|
38
|
+
function installHost(native:NativeCanvas,images:Map<string,Buffer>):()=>void {
|
|
39
|
+
const globals=globalThis as unknown as Record<string,unknown>;
|
|
40
|
+
const saved=new Map(['document','Image'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
|
41
|
+
function canvas(){const c=native.createCanvas(1,1);Object.assign(c,{style:{},remove(){}});return c;}
|
|
42
|
+
const imageSrc=Object.getOwnPropertyDescriptor(native.Image.prototype,'src');
|
|
43
|
+
if(!imageSrc?.get||!imageSrc.set)fail('STRIP_CANVAS','native Image src accessor unavailable');
|
|
44
|
+
class LocalImage extends native.Image {}
|
|
45
|
+
Object.defineProperty(LocalImage.prototype,'src',{get(){return imageSrc.get!.call(this);},set(value:unknown){
|
|
46
|
+
if(typeof value!=='string')fail('STRIP_IMAGE','only declared page URLs may be loaded');
|
|
47
|
+
const bytes=images.get(value);if(!bytes)fail('STRIP_IMAGE',`undeclared image ${value}`);
|
|
48
|
+
imageSrc.set!.call(this,bytes);
|
|
49
|
+
}});
|
|
50
|
+
globals.document={baseURI:'https://strip.invalid/',createElement(tag:string){if(tag!=='canvas')fail('STRIP_HOST',`unexpected element ${tag}`);return canvas();}};
|
|
51
|
+
globals.Image=LocalImage;
|
|
52
|
+
return ()=>{for(const [key,descriptor] of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globals[key];};
|
|
53
|
+
}
|
|
54
|
+
async function openRig(input:StripRenderInput['rigs'][number],images:Map<string,Buffer>):Promise<OpenRig>{
|
|
55
|
+
const model=await safeFile(input.root,packagePath(relative(input.root,input.modelPath).split('\\').join('/')));
|
|
56
|
+
if(!model.endsWith('.model.json'))fail('STRIP_MODEL','compiled .model.json required');
|
|
57
|
+
const bytes=await readFile(model);const metadata=JSON.parse(bytes.toString('utf8')) as {pages?:{name:string}[]};
|
|
58
|
+
if(!Array.isArray(metadata.pages))fail('STRIP_MODEL','declared model pages required');
|
|
59
|
+
const urls=new Map<string,string>();
|
|
60
|
+
for(const [index,page] of metadata.pages.entries()){
|
|
61
|
+
if(typeof page.name!=='string'||/[\\%?#:\x00-\x1f]/.test(page.name))fail('STRIP_MODEL','literal relative page path required');
|
|
62
|
+
const name=packagePath(relative(input.root,resolve(dirname(model),page.name)).split('\\').join('/'));
|
|
63
|
+
const path=await safeFile(input.root,name),url=`https://strip.invalid/${input.id}/page-${index}`;
|
|
64
|
+
urls.set(page.name,url);images.set(url,await readFile(path));
|
|
65
|
+
}
|
|
66
|
+
const modelUrl=`https://strip.invalid/${input.id}/skeleton.model.json`;
|
|
67
|
+
const fileFetch=new Proxy(fetch,{apply:(_target,_this,args:Parameters<typeof fetch>)=>{
|
|
68
|
+
if(String(args[0])!==modelUrl)return Promise.reject(new Error('STRIP_FETCH: undeclared model URL'));
|
|
69
|
+
return Promise.resolve(new Response(new Uint8Array(bytes)));
|
|
70
|
+
}});
|
|
71
|
+
const assets=await loadModel({modelUrl,fetch:fileFetch,setsUrl:null,regions:'none',resolvePage:name=>{const url=urls.get(name);if(!url)fail('STRIP_MODEL',`undeclared page ${name}`);return url;}});
|
|
72
|
+
try {
|
|
73
|
+
if(!assets.animations.some(animation=>animation.name===input.animation))fail('STRIP_ANIMATION',`missing ${input.animation}`);
|
|
74
|
+
const root={style:{clipPath:''},appendChild(node:unknown){return node;}} as unknown as HTMLElement;
|
|
75
|
+
const player=new RigPlayer(root,assets,{mode:'canvas',seekStep:compareSeekStep});
|
|
76
|
+
const renderer=player.renderer;if(!(renderer instanceof CanvasRenderer))fail('STRIP_RENDERER','published canvas renderer required');
|
|
77
|
+
renderer.backend='canvas2d';renderer.pixelRatio=1;player.environment(null);player.play(input.animation,{loop:false});player.pause();
|
|
78
|
+
return {id:input.id,assets,player,renderer,clock:input.clock};
|
|
79
|
+
}catch(error){assets.dispose();throw error;}
|
|
80
|
+
}
|
|
81
|
+
function seek(rigs:OpenRig[],time:number):void {
|
|
82
|
+
for(const rig of rigs){
|
|
83
|
+
rig.player.seek(time);
|
|
84
|
+
if(!Number.isFinite(rig.player.time)||Math.abs(rig.player.time-time)>1e-9)fail('STRIP_PLAYHEAD',`actual ${rig.id} time ${rig.player.time} differs from ${time}`);
|
|
85
|
+
for(const slot of rig.player.posedFrame().order)if(slot.tint.slice(0,3).some(channel=>channel!==1)||slot.dark?.some(channel=>channel!==0))fail('STRIP_UNSUPPORTED_TINT',`${rig.id} slot ${slot.slot}: canvas2d cannot reproduce RGB/dark tint`);
|
|
86
|
+
}
|
|
87
|
+
if(rigs.some(rig=>rig.player.time!==rigs[0]!.player.time))fail('STRIP_PLAYHEAD','players did not reach the same actual PTS');
|
|
88
|
+
}
|
|
89
|
+
function contain(ctx:SKRSContext2D,image:Canvas|Image,x:number,y:number,width:number,height:number):void {
|
|
90
|
+
const scale=Math.min(width/image.width,height/image.height);if(!Number.isFinite(scale)||scale<=0)fail('STRIP_IMAGE','decoded image has no extent');
|
|
91
|
+
ctx.drawImage(image,x+(width-image.width*scale)/2,y+(height-image.height*scale)/2,image.width*scale,image.height*scale);
|
|
92
|
+
}
|
|
93
|
+
function footer(plan:StripRenderInput['plan'],renderer:StripRenderResult['renderer']):string[]{
|
|
94
|
+
const lines=[`${renderer.name} ${renderer.version} | ${renderer.backend}`,`animation ${plan.animation} | nearest decoded PTS; ties earlier | seekStep ${compareSeekStep}`,`source SHA256 ${plan.binding.sourceHash}`,`reference ${plan.binding.reference.name}`,`reference SHA256 ${plan.binding.reference.sha256}`];
|
|
95
|
+
for(const item of plan.binding.packages){const p=item.reviewedSource;lines.push(`${item.id.toUpperCase()} ${item.label} | revision ${p.packageRevision}`,`viewer ${p.viewerVersion} | runtime ${p.runtimeVersion}`,`package SHA256 ${item.sourceHash}`,`rig ${p.rig?.path??'unavailable'}`,`rig SHA256 ${p.rig?.sha256??'unavailable'}`,`motion ${p.motion?.path??'unavailable'}`,`motion SHA256 ${p.motion?.sha256??'unavailable'}`);}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
function wrapped(ctx:SKRSContext2D,lines:string[],maxWidth:number):string[]{
|
|
99
|
+
return lines.flatMap(line=>{const chunks:string[]=[];let current='';for(const char of line){if(current&&ctx.measureText(current+char).width>maxWidth){chunks.push(current);current='';}current+=char;}chunks.push(current);return chunks;});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function renderStrip(input:StripRenderInput,tools:StripTools={}):Promise<StripRenderResult>{
|
|
103
|
+
const {plan}=input;
|
|
104
|
+
if(plan.groups.some(group=>group.samples.some(sample=>sample.time===null)))throw new Error(stripNoClock);
|
|
105
|
+
const previous=hostQueue;let release=()=>{};hostQueue=new Promise<void>(done=>{release=done;});await previous;
|
|
106
|
+
const rigs:OpenRig[]=[];let restore=()=>{};
|
|
107
|
+
try {
|
|
108
|
+
let native:NativeCanvas;
|
|
109
|
+
try{native=await import('@napi-rs/canvas');}catch(error){fail('STRIP_CANVAS',`@napi-rs/canvas unavailable: ${error instanceof Error?error.message:String(error)}`);}
|
|
110
|
+
const images=new Map<string,Buffer>();restore=installHost(native,images);
|
|
111
|
+
const ffmpeg=tools.ffmpeg??process.env.RIG_REVIEW_FFMPEG??'ffmpeg',ffprobe=tools.ffprobe??process.env.RIG_REVIEW_FFPROBE??'ffprobe';
|
|
112
|
+
const video=await probeReference(input.referencePath,ffprobe);
|
|
113
|
+
for(const source of input.rigs)rigs.push(await openRig(source,images));
|
|
114
|
+
if(!rigs.length)fail('STRIP_MODEL','at least one compiled rig required');
|
|
115
|
+
const end=Math.min(video.duration,...rigs.map(rig=>rig.assets.animations.find(a=>a.name===input.plan.animation)!.duration));
|
|
116
|
+
const selected=plan.groups.flatMap(group=>group.samples.map(sample=>{
|
|
117
|
+
const time=sample.time!;if(!Number.isFinite(time)||time<video.frames[0]!.time||time>end)fail('STRIP_DOMAIN',`requested ${time} outside common domain ${video.frames[0]!.time}..${end}`);
|
|
118
|
+
const presented=nearestReferenceFrame(video.frames,time);
|
|
119
|
+
if(presented.time<video.frames[0]!.time||presented.time>end)fail('STRIP_DOMAIN',`decoded PTS ${presented.time} outside common domain; no clamp`);
|
|
120
|
+
return {group,sample,presented};
|
|
121
|
+
}));
|
|
122
|
+
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
|
123
|
+
for(const item of selected){seek(rigs,item.presented.time);for(const rig of rigs){const box=rig.player.bounds();if(box){minX=Math.min(minX,box.x);minY=Math.min(minY,box.y);maxX=Math.max(maxX,box.x+box.width);maxY=Math.max(maxY,box.y+box.height);}}}
|
|
124
|
+
if(!(maxX>minX&&maxY>minY))fail('STRIP_VIEWPORT','selected poses have no drawable extent');
|
|
125
|
+
const padding=Math.max(maxX-minX,maxY-minY)*.04;
|
|
126
|
+
const x=Math.floor(minX-padding),y=Math.floor(minY-padding),worldWidth=Math.ceil(maxX+padding)-x,worldHeight=Math.ceil(maxY+padding)-y;
|
|
127
|
+
const layout=plan.layout,width=layout.rowLabelWidth+Math.max(...plan.groups.map(group=>group.samples.length))*layout.cellWidth;
|
|
128
|
+
const versions=await toolVersions();const packageJson=JSON.parse(await readFile(resolve(dirname(fileURLToPath(import.meta.resolve('@napi-rs/canvas'))),'package.json'),'utf8')) as {version:string};
|
|
129
|
+
const renderer={name:'@rig-c/play',version:versions.runtimeVersion,backend:`canvas2d / @napi-rs/canvas ${packageJson.version}`};
|
|
130
|
+
// The plan reserves a maximum footer budget. Measure the complete wrapped
|
|
131
|
+
// provenance with the drawing font before allocating the final raster.
|
|
132
|
+
const measure=native.createCanvas(1,1).getContext('2d');measure.font='12px monospace';
|
|
133
|
+
const lines=wrapped(measure,footer(plan,renderer),width-20),footerHeight=lines.length*16+16;
|
|
134
|
+
if(footerHeight>layout.footerHeight)fail('STRIP_LAYOUT_FOOTER',`need ${footerHeight}px for complete provenance, have ${layout.footerHeight}`);
|
|
135
|
+
const groupHeight=layout.labelHeight*2+plan.rows.length*layout.cellHeight+layout.groupGap,height=plan.groups.length*groupHeight+footerHeight;
|
|
136
|
+
if(!Number.isSafeInteger(width)||!Number.isSafeInteger(height)||width<=0||height<=0||width>16384||height>16384||width*height>64000000)fail('STRIP_LAYOUT','invalid or excessive canvas size');
|
|
137
|
+
for(const rig of rigs){rig.renderer.viewport={x,y,width:worldWidth,height:worldHeight};rig.renderer.pixelRatio=Math.min(layout.cellWidth/worldWidth,layout.cellHeight/worldHeight);}
|
|
138
|
+
const out=native.createCanvas(width,height),ctx=out.getContext('2d');ctx.fillStyle='#f1f1f1';ctx.fillRect(0,0,width,height);ctx.font='14px sans-serif';ctx.textBaseline='top';
|
|
139
|
+
const samples:StripRenderSample[]=[];const decoded=new Map<number,Image>();
|
|
140
|
+
for(const [groupIndex,group] of plan.groups.entries()){
|
|
141
|
+
const top=groupIndex*groupHeight;ctx.fillStyle='#202b24';ctx.fillText(`${group.id} | ${group.basis} F${group.frameStart}–${group.frameEnd}`,8,top+8);
|
|
142
|
+
for(const [column,sample] of group.samples.entries()){
|
|
143
|
+
const left=layout.rowLabelWidth+column*layout.cellWidth,item=selected.find(value=>value.group===group&&value.sample===sample)!;
|
|
144
|
+
const actual=item.presented.time;seek(rigs,actual);
|
|
145
|
+
const record:StripRenderSample={groupId:group.id,requestedFrame:sample.requestedFrame,requestedTime:sample.time!,presentedTime:actual,delta:actual-sample.time!,decodedFrame:item.presented.index,sampling:'video-frame',playerTimes:{},frames:{}};
|
|
146
|
+
ctx.fillStyle='#202b24';ctx.fillText(`F${sample.requestedFrame} | ${sample.time!.toFixed(5)}s`,left+6,top+layout.labelHeight+7);
|
|
147
|
+
let reference=decoded.get(item.presented.index);if(!reference){reference=await native.loadImage(await referenceFramePng(input.referencePath,item.presented.index,ffmpeg,layout.cellWidth,layout.cellHeight));decoded.set(item.presented.index,reference);if(decoded.size>2)decoded.delete(decoded.keys().next().value!);}
|
|
148
|
+
for(const [rowIndex,row] of plan.rows.entries()){
|
|
149
|
+
const cellTop=top+layout.labelHeight*2+rowIndex*layout.cellHeight;
|
|
150
|
+
ctx.fillStyle='#202b24';if(column===0)ctx.fillText(row.label.slice(0,100),8,cellTop+10,layout.rowLabelWidth-16);
|
|
151
|
+
if(row.id==='reference'){contain(ctx,reference,left,cellTop,layout.cellWidth,layout.cellHeight);ctx.fillStyle='#172a22';ctx.fillText(`PTS ${actual.toFixed(6)}`,left+5,cellTop+5);}
|
|
152
|
+
else {
|
|
153
|
+
const rig=rigs.find(value=>value.id===row.id);if(!rig)fail('STRIP_MODEL',`missing row ${row.id}`);
|
|
154
|
+
rig.player.draw();if(rig.renderer.backendActive!=='canvas2d')fail('STRIP_RENDERER','unexpected raster backend');
|
|
155
|
+
contain(ctx,rig.renderer.canvas as unknown as Canvas,left,cellTop,layout.cellWidth,layout.cellHeight);
|
|
156
|
+
record.playerTimes[row.id]=rig.player.time;record.frames[row.id]=frameAtTime(rig.clock,actual);
|
|
157
|
+
// The selected basis labels the requested report sample. The pose
|
|
158
|
+
// still uses decoded PTS, preserved separately in the JSON record.
|
|
159
|
+
const label=row.id===group.basis?`F${sample.requestedFrame} | ${sample.time!.toFixed(6)}s`:`F${record.frames[row.id]??'—'} | ${rig.player.time.toFixed(6)}s`;
|
|
160
|
+
ctx.fillStyle='#172a22';ctx.fillText(label,left+5,cellTop+5);
|
|
161
|
+
}
|
|
162
|
+
ctx.strokeStyle='#b7c2b9';ctx.strokeRect(left,cellTop,layout.cellWidth,layout.cellHeight);
|
|
163
|
+
}
|
|
164
|
+
samples.push(record);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
ctx.font='12px monospace';
|
|
168
|
+
ctx.fillStyle='#172a22';lines.forEach((line,index)=>ctx.fillText(line,10,height-footerHeight+8+index*16));
|
|
169
|
+
return {png:await out.encode('png'),width,height,renderer,samples};
|
|
170
|
+
} finally {try{for(const rig of rigs){try{rig.player.dispose();}finally{rig.assets.dispose();}}}finally{try{restore();}finally{release();}}}
|
|
171
|
+
}
|
package/src/strip.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReviewedSource } from './manifest.ts';
|
|
2
|
+
import type { CompareBinding } from './compare.ts';
|
|
3
|
+
import type { ReportSeeds } from './report.ts';
|
|
4
|
+
import type { FrameClock } from './frame-clock.ts';
|
|
5
|
+
|
|
6
|
+
export type StripSide='single'|'a'|'b';
|
|
7
|
+
export interface StripInterval {id:string;feedbackId?:string;basis:StripSide;frameStart:number;frameEnd:number}
|
|
8
|
+
export interface StripRequest {format:'png'|'json';sourceHash:string;animation:string;referenceId?:string;intervals:StripInterval[]}
|
|
9
|
+
export interface StripBinding {sourceHash:string;packages:{id:StripSide;label:string;sourceHash:string;reviewedSource:ReviewedSource}[];reference:{name:string;sha256:string};compare:CompareBinding|null}
|
|
10
|
+
export interface StripRow {id:'reference'|StripSide;label:string}
|
|
11
|
+
export interface StripGroup {id:string;feedbackId:string|null;basis:StripSide;frameStart:number;frameEnd:number;samples:{requestedFrame:number;time:number|null}[]}
|
|
12
|
+
/** layout.footerHeight is a maximum provenance budget; rendering.height records the measured PNG. */
|
|
13
|
+
export interface StripPlan {schema:'rig-review-strip/1';mode:'single'|'compare';animation:string;basis:StripSide|'mixed';rows:StripRow[];groups:StripGroup[];binding:StripBinding;layout:{cellWidth:number;cellHeight:number;labelHeight:number;rowLabelWidth:number;groupGap:number;footerHeight:number}}
|
|
14
|
+
export interface StripRenderInput {plan:StripPlan;referencePath:string;rigs:{id:StripSide;root:string;modelPath:string;animation:string;clock:FrameClock|null}[]}
|
|
15
|
+
export interface StripRenderSample {groupId:string;requestedFrame:number;requestedTime:number;presentedTime:number;delta:number;decodedFrame:number;sampling:'video-frame';playerTimes:Partial<Record<StripSide,number>>;frames:Partial<Record<StripSide,number|null>>}
|
|
16
|
+
export interface StripRenderResult {png:Uint8Array;width:number;height:number;renderer:{name:string;version:string;backend:string};samples:StripRenderSample[]}
|
|
17
|
+
export const stripNoClock='Report has no frameTimes — regenerate with pose 0.30.0 or later';
|
|
18
|
+
function obj(value:unknown,field:string):Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value))throw new Error(`${field}: expected object`);return value as Record<string,unknown>;}
|
|
19
|
+
function text(value:unknown,field:string):string{if(typeof value!=='string'||!value.trim()||value.length>256)throw new Error(`${field}: expected nonempty string <= 256 characters`);return value;}
|
|
20
|
+
function keys(value:Record<string,unknown>,allowed:string[],field:string){for(const key of Object.keys(value))if(!allowed.includes(key))throw new Error(`${field}.${key}: unsupported field`);}
|
|
21
|
+
export function parseStripRequest(input:unknown):StripRequest{
|
|
22
|
+
const v=obj(input,'strip');keys(v,['format','sourceHash','animation','referenceId','intervals'],'strip');
|
|
23
|
+
if(v.format!=='png'&&v.format!=='json')throw new Error('strip.format: expected png | json');
|
|
24
|
+
if(typeof v.sourceHash!=='string'||!/^([a-f0-9]{64})$/.test(v.sourceHash))throw new Error('strip.sourceHash: expected bound SHA-256');
|
|
25
|
+
if(!Array.isArray(v.intervals)||v.intervals.length<1||v.intervals.length>64)throw new Error('strip.intervals: expected 1…64 inclusive intervals');
|
|
26
|
+
const intervals=v.intervals.map((value,index)=>{const f=`strip.intervals[${index}]`,r=obj(value,f);keys(r,['id','feedbackId','basis','frameStart','frameEnd'],f);if(r.basis!=='single'&&r.basis!=='a'&&r.basis!=='b')throw new Error(`${f}.basis: expected single | a | b`);if(!Number.isSafeInteger(r.frameStart)||!Number.isSafeInteger(r.frameEnd)||(r.frameStart as number)<0||(r.frameEnd as number)<(r.frameStart as number))throw new Error(`${f}: expected inclusive safe integers 0 <= frameStart <= frameEnd`);return {id:text(r.id,f+'.id'),...(r.feedbackId===undefined?{}:{feedbackId:text(r.feedbackId,f+'.feedbackId')}),basis:r.basis,frameStart:r.frameStart as number,frameEnd:r.frameEnd as number} as StripInterval;});
|
|
27
|
+
if(new Set(intervals.map(v=>v.id)).size!==intervals.length)throw new Error('strip.intervals.id: expected unique ids');
|
|
28
|
+
if(intervals.reduce((n,v)=>n+v.frameEnd-v.frameStart+1,0)>256)throw new Error('strip.intervals: export exceeds 256 total frames; split the selection');
|
|
29
|
+
return {format:v.format,sourceHash:v.sourceHash,animation:text(v.animation,'strip.animation'),...(v.referenceId===undefined?{}:{referenceId:text(v.referenceId,'strip.referenceId')}),intervals};
|
|
30
|
+
}
|
|
31
|
+
/** Only explicit report frameTimes select export times, including in uniform-clock packages. */
|
|
32
|
+
export function planStrip(request:StripRequest,context:{mode:'single'|'compare';binding:StripBinding;reports:Partial<Record<StripSide,ReportSeeds|null>>}):StripPlan{
|
|
33
|
+
if(request.sourceHash!==context.binding.sourceHash)throw new Error('Strip inputs changed: sourceHash does not match loaded package');
|
|
34
|
+
if(context.mode==='compare'&&request.referenceId!==undefined)throw new Error('strip.referenceId: compare uses its fixed reference');
|
|
35
|
+
const groups=request.intervals.map(interval=>{
|
|
36
|
+
if(context.mode==='single'?interval.basis!=='single':interval.basis==='single')throw new Error('strip.basis: does not match review mode');
|
|
37
|
+
const report=context.reports[interval.basis],clock=report?.clock;
|
|
38
|
+
if(report?.animation&&report.animation!==request.animation)throw new Error('strip.animation: differs from selected report animation');
|
|
39
|
+
const times=clock?.kind==='samples'&&clock.frameTimes.length?clock.frameTimes:null;
|
|
40
|
+
if(times&&report?.animation!==request.animation)throw new Error('strip.animation: explicit report animation required to bind frameTimes');
|
|
41
|
+
if(!times&&request.format==='png')throw new Error(stripNoClock);
|
|
42
|
+
if(times&&interval.frameEnd>=times.length)throw new Error(`strip.frameEnd: ${interval.frameEnd} beyond report last frame ${times.length-1}`);
|
|
43
|
+
return {...interval,feedbackId:interval.feedbackId??null,samples:Array.from({length:interval.frameEnd-interval.frameStart+1},(_,i)=>({requestedFrame:interval.frameStart+i,time:times?.[interval.frameStart+i]??null}))};
|
|
44
|
+
});
|
|
45
|
+
const rows:StripRow[]=[{id:'reference',label:'Reference'},...context.binding.packages.map(p=>({id:p.id,label:p.label}))];
|
|
46
|
+
const layout={cellWidth:220,cellHeight:260,labelHeight:32,rowLabelWidth:120,groupGap:24,footerHeight:180+context.binding.packages.length*320};
|
|
47
|
+
const width=layout.rowLabelWidth+Math.max(...groups.map(g=>g.samples.length))*layout.cellWidth;
|
|
48
|
+
const height=groups.length*(layout.labelHeight*2+rows.length*layout.cellHeight+layout.groupGap)+layout.footerHeight;
|
|
49
|
+
if(width>16384||height>16384||width*height>64000000)throw new Error('strip.layout: export exceeds 64 megapixels or 16384 pixels; split the selection');
|
|
50
|
+
return {schema:'rig-review-strip/1',mode:context.mode,animation:request.animation,basis:new Set(groups.map(group=>group.basis)).size===1?groups[0]!.basis:'mixed',rows,groups,binding:structuredClone(context.binding),layout};
|
|
51
|
+
}
|
package/src/versions.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
function version(text:string,label:string):string {
|
|
6
|
+
const value=JSON.parse(text) as {version?:unknown};
|
|
7
|
+
if(typeof value.version!=='string'||!value.version)throw new Error(`${label}: package version missing`);
|
|
8
|
+
return value.version;
|
|
9
|
+
}
|
|
10
|
+
/** The runtime's manifest wherever the installer put it: nested in a checkout, hoisted beside this package in an install. */
|
|
11
|
+
export function playPackagePath():string {
|
|
12
|
+
let dir=dirname(Bun.resolveSync('@rig-c/play',fileURLToPath(new URL('.',import.meta.url))));
|
|
13
|
+
for(;;){const candidate=join(dir,'package.json');if(existsSync(candidate)&&dirname(candidate).endsWith(join('@rig-c','play')))return candidate;const parent=dirname(dir);if(parent===dir)throw new Error('@rig-c/play: package.json not found from resolved entry');dir=parent;}
|
|
14
|
+
}
|
|
15
|
+
/** Read installed tool versions; never treat a producer report's generic version as one. */
|
|
16
|
+
export async function toolVersions():Promise<{viewerVersion:string;runtimeVersion:string}>{
|
|
17
|
+
const viewerVersion=version(await readFile(new URL('../package.json',import.meta.url),'utf8'),'review');
|
|
18
|
+
const runtimeVersion=version(await readFile(playPackagePath(),'utf8'),'@rig-c/play');
|
|
19
|
+
return {viewerVersion,runtimeVersion};
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Fit-relative inspection cameras. Images and world geometry retain their own units. */
|
|
2
|
+
export interface CameraPoint {x:number;y:number}
|
|
3
|
+
export interface PaneCamera {zoom:number;center:CameraPoint|null}
|
|
4
|
+
export interface CameraFrame {space:'image'|'world';center:CameraPoint;baseScale:number;width:number;height:number}
|
|
5
|
+
export interface CameraSignature {zoom:number;pan:CameraPoint}
|
|
6
|
+
export const clampZoom=(zoom:number):number=>Math.max(.25,Math.min(8,zoom));
|
|
7
|
+
export function cameraFrame(space:CameraFrame['space'],box:{x:number;y:number;width:number;height:number},width:number,height:number,marginX=0,marginY=0):CameraFrame|null {
|
|
8
|
+
if(![box.x,box.y,box.width,box.height,width,height].every(Number.isFinite)||box.width<=0||box.height<=0||width<=0||height<=0)return null;
|
|
9
|
+
return {space,center:{x:box.x+box.width/2,y:box.y+box.height/2},baseScale:Math.max(.001,Math.min((width-marginX)/box.width,(height-marginY)/box.height)),width,height};
|
|
10
|
+
}
|
|
11
|
+
export function cameraSignature(camera:PaneCamera,frame:CameraFrame):CameraSignature {
|
|
12
|
+
const center=camera.center??frame.center,scale=frame.baseScale*camera.zoom;
|
|
13
|
+
return {zoom:camera.zoom,pan:{x:(frame.center.x-center.x)*scale/frame.width,y:(frame.space==='world'?center.y-frame.center.y:frame.center.y-center.y)*scale/frame.height}};
|
|
14
|
+
}
|
|
15
|
+
/** Match the closed numeric observation before a candidate can replace a live camera. */
|
|
16
|
+
export function assertCameraView(camera:PaneCamera,frame:CameraFrame):void {
|
|
17
|
+
const center=camera.center??frame.center,scale=frame.baseScale*camera.zoom,width=frame.width/scale,height=frame.height/scale;
|
|
18
|
+
const coordinate=(value:number)=>Number.isFinite(value)&&Math.abs(value)<=Number.MAX_SAFE_INTEGER;
|
|
19
|
+
if(![center.x,center.y,center.x-width/2,center.y-height/2,(frame.center.x-center.x)*scale,(frame.center.y-center.y)*scale].every(coordinate)||![width,height].every(value=>Number.isFinite(value)&&value>0&&value<=Number.MAX_SAFE_INTEGER)||!Number.isFinite(scale)||scale<=0||!Number.isFinite(camera.zoom)||camera.zoom<.25||camera.zoom>8||![frame.width,frame.height].every(value=>Number.isFinite(value)&&value>0&&value<=65536))throw new Error('VIEW_CAMERA_RANGE: derived camera exceeds numeric source-coordinate limits');
|
|
20
|
+
}
|
|
21
|
+
export function cameraFromSignature(value:CameraSignature,frame:CameraFrame):PaneCamera {
|
|
22
|
+
if(![value.zoom,value.pan.x,value.pan.y].every(Number.isFinite)||value.zoom<.25||value.zoom>8)throw new Error('Camera signature requires finite pan and zoom 0.25..8');
|
|
23
|
+
const scale=frame.baseScale*value.zoom;
|
|
24
|
+
const camera={zoom:value.zoom,center:{x:frame.center.x-value.pan.x*frame.width/scale,y:frame.center.y+(frame.space==='world'?1:-1)*value.pan.y*frame.height/scale}};
|
|
25
|
+
assertCameraView(camera,frame);return camera;
|
|
26
|
+
}
|
|
27
|
+
/** Zoom preserves the normalized location already being inspected. */
|
|
28
|
+
export function zoomSignature(value:CameraSignature,factor:number):CameraSignature {
|
|
29
|
+
const zoom=clampZoom(value.zoom*factor),ratio=zoom/value.zoom;
|
|
30
|
+
return {zoom,pan:{x:value.pan.x*ratio,y:value.pan.y*ratio}};
|
|
31
|
+
}
|
|
32
|
+
export function dragSignature(value:CameraSignature,frame:CameraFrame,dx:number,dy:number):CameraSignature {
|
|
33
|
+
return {zoom:value.zoom,pan:{x:value.pan.x+dx/frame.width,y:value.pan.y+dy/frame.height}};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fit a selected conservative world box using the existing whole-rig camera
|
|
37
|
+
* reference and normalized sync contract. The established zoom range still applies. */
|
|
38
|
+
export function boundsFitSignature(box:{x:number;y:number;width:number;height:number},frame:CameraFrame,marginX=0,marginY=0):CameraSignature {
|
|
39
|
+
if(![box.x,box.y,box.width,box.height].every(Number.isFinite)||box.width<=0||box.height<=0)throw new Error('PART_FIT_UNAVAILABLE: positive finite drawn bounds required');
|
|
40
|
+
const scale=Math.min((frame.width-marginX)/box.width,(frame.height-marginY)/box.height);
|
|
41
|
+
if(!Number.isFinite(scale)||scale<=0)throw new Error('PART_FIT_UNAVAILABLE: viewport has no usable extent');
|
|
42
|
+
const camera={zoom:clampZoom(scale/frame.baseScale),center:{x:box.x+box.width/2,y:box.y+box.height/2}};
|
|
43
|
+
assertCameraView(camera,frame);return cameraSignature(camera,frame);
|
|
44
|
+
}
|