@rig-c/review 0.14.0 → 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 CHANGED
@@ -64,6 +64,7 @@ http://127.0.0.1:4318/instances/<key>/?input=…&handoff=…#cap=…
64
64
  | <a id="cuts-cut-polygons-patch-boxes-limb-joints--rig-parts-build"></a>Designate cuts, patches and limb joints | Cuts | [Cuts — PANELS](docs/PANELS.md#cuts-cut-polygons-patch-boxes-limb-joints--rig-parts-build) |
65
65
  | <a id="mesh-edit-typed-delta-preview-only"></a>Preview a setup-space delta and rebuild | Edit Mesh | [Mesh edit — PANELS](docs/PANELS.md#mesh-edit-typed-delta-preview-only) |
66
66
  | <a id="retarget-map-flags--rig-pose-watch"></a>Retarget a declared pose sequence | Retarget | [Retarget — PANELS](docs/PANELS.md#retarget-map-flags--rig-pose-watch) |
67
+ | Mark a point or region on the current view | Comment · C | [Stage comments — PANELS](docs/PANELS.md#stage-comments) |
67
68
  | **Control and share** | | |
68
69
  | <a id="control-the-current-inspection-page"></a>Control an opted-in inspection | `control` | [Inspection control — WORKFLOWS](docs/WORKFLOWS.md#control-the-current-inspection-page) |
69
70
  | <a id="keep-and-share-an-inspection"></a>Restore a view link or local draft | View link, draft | [Keep and share — WORKFLOWS](docs/WORKFLOWS.md#keep-and-share-an-inspection) |
package/docs/DESIGN.md CHANGED
@@ -12,7 +12,7 @@ The CLI opens `review.json` or an existing output directory. The directory adapt
12
12
 
13
13
  Inspection supports local references, checkpoint context, runtime playback when a real compatible model exists, available angle/audio facilities and explicit review state. The canonical `rig-pose-sequence/1` format documented by `@rig-c/pose` preserves frame times, person IDs, joint states/coordinates/confidence and optional camera metadata. The demo pose is an authored synthetic transport example, not extracted from footage.
14
14
 
15
- Feedback has an inclusive integer frame interval, target `{kind: bone|slot|rig, name}`, category `defect|quality`, and one-line summary. Optional context records the checkpoint, time, reference/person and camera/axis state. The person can export data and issue-ready Markdown; this app neither posts issues automatically nor writes proposed motion keys. Notes must be useful to the producing agent without interpreting a free-form key-edit JSON blob.
15
+ Manual feedback has an inclusive integer frame interval; a clockless single stage comment explicitly records seconds with frame/interval unavailable (see REVIEW_PACKAGE). Feedback has a target `{kind: bone|slot|rig, name}`, category `defect|quality`, and one-line summary. Optional context records the checkpoint, time, reference/person and camera/axis state. The person can export data and issue-ready Markdown; this app neither posts issues automatically nor writes proposed motion keys. Notes must be useful to the producing agent without interpreting a free-form key-edit JSON blob.
16
16
 
17
17
  Identity, revision and content hash bind every decision. A submission also records separate exact rig/motion hashes and paths, packageRevision, viewerVersion and installed runtimeVersion; absent source stays null. The producing agent can retrieve the saved JSON with the read-only feedback CLI after the UI closes. Opening never means reviewed. The person explicitly approves, requests changes or returns to pending, independently of checkpoint outcomes. Changed inputs do not inherit prior approval. Feedback writes remain outside source, are bounded and atomic, and require the app's exact Origin.
18
18
 
package/docs/PANELS.md CHANGED
@@ -398,3 +398,15 @@ separately validated `context.turn`; submitted feedback retains the immutable
398
398
  package/model/motion identity. It does not record pending pointer travel, author
399
399
  keys, write `motion.json` or change source files. The Rig fields `turn` x preview
400
400
  remains independent, temporary and unrecorded.
401
+
402
+ ## Stage comments
403
+
404
+ The visible **Comment · C** stage button is present on the rig, compare A/B and reference video. Toggle it, drag a rectangle (or click a point), type, and press Enter. Playback pauses, turn is held, and the inline composer receives focus; Escape cancels. Text is the only required input. An active Edit Mesh keeps its Create shortcut C; leave the editor before using Comment. With Comment off, selection, turn and camera gestures keep their normal behavior.
405
+
406
+ **Comments** lists numbered marks with their placement mode. Setup and Animate marks appear only in that mode; the compare page always records Compare. Returning to a mode shows its marks again at their captured pose. A row restores its captured mode before its exact frame/time, camera and turn. Placement mode is captured when the gesture begins, before asynchronous crop/save; changing mode cancels the pending drag or composer and never rebinds it. Edit or delete an unsent comment here; submitted marks are read-only. Pins and rectangles use world coordinates (reference pixels, Y down, for the video), follow zoom and pan, and appear only at the captured frame/time and dial state. Reference letterboxing is included in its camera mapping. The list is also a way back to a mark hidden in another mode or pose. JSON exports index every canonical feedback comment by mode; Markdown places the full comments under their mode headings. Missing mode refuses `COMMENT_MODE_MISSING`, unknown mode refuses `COMMENT_MODE_UNKNOWN`, and a mode incompatible with the saved page/inspector refuses `COMMENT_MODE_MISMATCH`; no missing-mode guess or migration is made. Ordinary feedback and legacy text context are unchanged.
407
+
408
+ Each mark is an ordinary feedback row, category defect, with its available single-frame interval, or explicit time-only evidence when a single review has no producer frame, and topmost intersecting drawn slot (or the rig for empty regions and reference views). Picking intersects posed triangles with the held clipping polygon and uses nearest-first draw order; it is geometric coverage, not texture alpha picking. The crop is a bounded, resampled PNG of the current pane background and shown layers, with the mark burned in; it is an agent reading aid, not a pixel-identical measurement. It copies displayed rig rasters when they match the held public frame, plus the actual overlay canvases; a whole live canvas is used directly when available. The fallback uses the held public frame and current backend, and Images off omits its image tier. Video crops retain the actual displayed content and letterbox. Compare can use its existing indexed decoded reference when producer timing is missing. A clockless single rig or reference view saves a `rig-review-time-only/1` seconds basis and an explicit frame-unavailable reason. The mark frame, row frameStart/frameEnd, context.frame and inclusive interval are null; the list and exports state the time and why the frame is unavailable. No numeric sentinel, inferred FPS or callback-count frame is used. A frame strip refuses this row by name (`COMMENT_CLOCK_UNAVAILABLE`); the comment itself remains savable, editable before submission and restorable at its exact saved seconds/camera/turn.
409
+
410
+ Comment is unavailable while an unapplied authoring preview or designation is on stage. The disabled control and adjacent reason name `COMMENT_UNAPPLIED_PREVIEW` and the active tool (Idle tracks, Rig fields dial/handles, Physics live overrides, a loaded density-strip candidate, or Chest/Bones/Cuts/Cloth/Mesh editing geometry/handles). Pending form values with no stage effect remain allowed. Stop that preview explicitly; Comment never clears or applies it. An applied scratch build retains its refusal (`COMMENT_APPLIED_BUILD_UNSUPPORTED`), and a model replacement names `COMMENT_SOURCE_BUSY`. Enable-before-preview and asynchronous pause/crop transitions are rechecked before capture/save. Standalone Turn pad, world/reference inspection and source mesh/bone/selection/weights inspection stay available. Retarget has no unapplied live pose path; Apply is a rebuild.
411
+
412
+ A reference-basis comment cannot become a producer-frame strip: CLI and browser batch export refuse `COMMENT_REFERENCE_STRIP_UNAVAILABLE` for reference-frame comments on any viewport, including compare A/B fallback. No frame-rate/time mapping is attempted. Ordinary manual and producer-frame intervals still export; time-only comments retain `COMMENT_CLOCK_UNAVAILABLE`. Comments and their JSON/issue reports remain savable/readable with their original reference timing.
@@ -87,7 +87,7 @@ Audio clips contain `id`, local `path` and optional `label`. Cues require `id`,
87
87
  }
88
88
  ```
89
89
 
90
- `status` is `pending`, `approved`, or `changes-requested`; checkpoint `outcome` is `pending`, `pass`, or `fail`. Feedback requires an inclusive integer interval `0 <= frameStart <= frameEnd`, a target `{kind: "bone"|"slot"|"rig", name}`, `category: "defect"|"quality"`, a unique nonempty `id`, and a nonempty one-line `summary` of at most 1000 characters. `context` optionally carries inert inspection JSON (checkpoint, reference/person, time, camera/axis). Frame numbers are explicit user input; there is no guessed FPS. Unknown checkpoint IDs, mismatched content or reviewed-source bindings, duplicate feedback IDs and the old `corrections` authoring-proposal field are refused. Motion is read-only: annotations never author keys, retime output or write `motion.json`. The save does not require all checkpoints to pass: it records the person's explicit decision without manufacturing one.
90
+ `status` is `pending`, `approved`, or `changes-requested`; checkpoint `outcome` is `pending`, `pass`, or `fail`. Manual feedback requires an inclusive integer interval `0 <= frameStart <= frameEnd` (the strictly validated time-only stage-comment exception is described under Stage region comments), a target `{kind: "bone"|"slot"|"rig", name}`, `category: "defect"|"quality"`, a unique nonempty `id`, and a nonempty one-line `summary` of at most 1000 characters. `context` optionally carries inert inspection JSON (checkpoint, reference/person, time, camera/axis). Frame numbers are explicit user input; there is no guessed FPS. Unknown checkpoint IDs, mismatched content or reviewed-source bindings, duplicate feedback IDs and the old `corrections` authoring-proposal field are refused. Motion is read-only: annotations never author keys, retime output or write `motion.json`. The save does not require all checkpoints to pass: it records the person's explicit decision without manufacturing one.
91
91
 
92
92
  ### Feedback catalog and validation
93
93
 
@@ -797,3 +797,21 @@ The live selected slot follows animation changes and each successful draw refres
797
797
  The right list orders every slot by the public snapshot's exact held z, including hidden/null slots. Solo filters a copied full held posed frame after clip resolution; it never sets player skin, set, slots, clock or track. Context/Whole preserve full drawing. An amber diagnostic selection box uses conservative world bounds intersected with a normal clip's AABB; inverse clips remain conservative. It is not alpha coverage or an exact clip polygon. Selection/Fit centers those bounds within the established0.25–8× camera limit. SyncON sends the same fit-relative zoom/normalized pan signature to visible panes; OFF affects only the selected side. Missing/nonpositive bounds report `PART_FIT_UNAVAILABLE`. Fitting never reseeks or authors motion.
798
798
 
799
799
  Selection, links, controls and export never approve a review. Producer-backed optimization must use a separately owned producer returning a new source-bound package revision/hash and provenance; Review does not generate rig, motion or mesh bytes.
800
+
801
+ ## Stage region comments
802
+
803
+ An optional `feedback[].context.comment` (`rig-review-comment/1`) records required placement `mode` (`setup|animate|compare`), `viewport` (`rig|a|b|reference`), `frame:{index,basis:producer|reference}` or null with the named time-only basis below, exact held `time`, `animation`, selected `sourceHash`, nullable `modelHash`, `referenceHash` and `referenceId`, canonical numeric `camera`, `screen` and `world` rectangles (`x,y,width,height`), nearest-first `slots:[{slot,z}]`, `crop:{path,sha256,png,width,height}`, and the source-bound inspection `bookmark`. A point has zero width/height. World space is Y up for a rig and image pixels Y down for a reference. Screen space is local CSS pixels of that viewport; both rectangles must agree through its recorded camera.
804
+
805
+ When a frame is available, the row's inclusive interval is that one frame. A decoded-reference basis is explicitly distinct from a producer frame; the existing raw `context.frame` stays null when producer timing is unknown. No video FPS is inferred. The target is the first drawn slot intersecting the marked posed/clipped triangles, or the rig for an empty or reference mark. The normal session source binding, same-origin JSON mutation, capability and 1 MiB body limit apply unchanged. Saves validate the comment source/model binding, viewport side, bounded numeric evidence and PNG signature, dimensions, chunk CRCs, bounded RGB/RGBA scanline decode and exact hash. A changed source follows the existing HTTP 409/restart refusal.
806
+
807
+ The inline PNG is bounded to 150 KiB (200000 data-URL characters), each dimension at most 1024 pixels; the browser captures at most 768 pixels per dimension. Submission materializes only the digest-derived `comment-crops/<sha256>.png` beneath session state, never a client-chosen path. Existing nonregular or mismatched crop files refuse. CLI feedback exports resolve crop paths for local reading and omit `crop.png` only in the derived report; the submitted session and draft retain the original inline PNG; no HTTP filesystem endpoint is added. The PNG composes the actual computed pane backgrounds and visible layers. A whole live canvas is copied when available; otherwise unambiguous displayed image/canvas rasters are matched by z to the held public frame and copied with their actual transforms, opacity and clips. If that safe raster mapping is unavailable, the published CanvasRenderer uses the held frame and actual backend; an unsupported Canvas2D RGB/dark tint refuses `COMMENT_CAPTURE_UNAVAILABLE`. Images off omits model pixels, and mesh/bone/selection overlays copy the existing displayed overlay canvases. Reference crops retain the displayed media background/letterbox. Unsupported background syntax refuses `COMMENT_BACKGROUND_UNAVAILABLE`. No HTML/foreignObject rasterization or DOM screenshot mechanism is used. These are resampled inspection pixels for agent reading, not pixel-identical measurements or proof of upstream compiler validation.
808
+
809
+ Marks share the ordinary feedback array, source-bound drafts and submission. List restoration validates the bookmark's loaded source binding before applying frame, per-pane cameras and turn. Existing sessions and no-comment JSON/issue output retain their prior shape. Submitted marks are read-only in the stage list and server saves refuse their edit or deletion (`COMMENT_SUBMITTED`); a new comment starts a new unsent row. No source, model or motion file changes.
810
+
811
+ Single reference marks validate `referenceId` against the selected bookmark reference and `referenceHash` against the exact file hash the package loader already computed, or the CLI-selected reference binding. `/api/review.referenceHashes` exposes these existing source-bound hashes by declared ID; it adds no asset path or decoder endpoint. Single review exposes no decoded reference index. Without an explicit producer frame at the saved time, rig and reference marks save `frame:null` plus `timeBasis:{schema:"rig-review-time-only/1",basis:"seconds",reason:"frame is unavailable: no explicit producer frame index at this time (COMMENT_FRAME_UNAVAILABLE)"}`. The row frameStart/frameEnd, context.frame and context.interval must all be null, while exact finite nonnegative `time` seconds, animation, target, source/model/reference bindings, turn, camera, region and crop remain validated. This named shape is allowed only on single structured comments; an ordinary null interval, a mixed null/numeric range, a numeric interval falsely claiming a time-only frame, or a time-only claim when a producer frame is available is refused (`COMMENT_TIME_ONLY_INVALID`). A comparison cannot use this exception and retains its existing producer/reference frame basis. JSON and issue exports explicitly say that the frame is unavailable and why; strip export refuses `COMMENT_CLOCK_UNAVAILABLE` instead of coercing null to zero. Seconds, video callback counts and inferred FPS are never called frame indices.
812
+
813
+ The comment mode is captured once at gesture start; switching mode cancels an unfinished gesture/composer. Single Setup binds `mode:setup` to bookmark `selection.inspector:rig`; Animate binds `mode:animate` to `playback`. Compare always records `mode:compare` with a compare page binding, regardless of its Inspector section. Pins/rectangles require the current mode as well as the captured pose; list restoration applies the captured mode before showing the pin. Missing mode refuses `COMMENT_MODE_MISSING`, an unknown enum refuses `COMMENT_MODE_UNKNOWN`, and a page/viewport/inspector mismatch refuses `COMMENT_MODE_MISMATCH`. Structured comments without mode are deliberately refused rather than guessed; ordinary feedback and legacy text `context.comment` remain unchanged. Derived CLI JSON retains the complete canonical `feedback` array and adds `commentGroups:{schema:"rig-review-comment-groups/1",canonical:"feedback",modes:[{mode,feedbackIds}]}` only when structured comments exist. Each ID resolves to that array's full evidence exactly once. Markdown puts the actual comments under `Comments · setup`, `Comments · animate` or `Comments · compare` headings; ordinary feedback retains its category/target sections. Neither export adds a second persistence system. No-comment JSON and Markdown bytes keep the existing format.
814
+
815
+ Stage comment capture refuses unrecorded authoring effects, by `COMMENT_UNAPPLIED_PREVIEW` plus tool name: actual idle/dial adjust functions, live physics parameter patches, a pending loaded density-strip model, visible authoring geometry (Chest/Bones/Cuts/Cloth/Mesh), and Idle/Rig fields stage handles. Unapplied form operations alone do not trigger this refusal. The Comment control displays the reason and is disabled; active capture is cancelled if a preview begins, and capture/save recheck after asynchronous pause/crop. No preview is automatically cleared/applied or omitted from a crop. Applied scratch builds retain their refusal (`COMMENT_APPLIED_BUILD_UNSUPPORTED`); rebuild/model swaps refuse `COMMENT_SOURCE_BUSY`. Turn pad, world/reference and source inspection remain inspection, not authored previews.
816
+
817
+ `frame.basis:reference` remains valid comment evidence and is retained in JSON/issue reports, but it is not a producer frame interval. `COMMENT_REFERENCE_STRIP_UNAVAILABLE` refuses its CLI and browser batch strip exports before planning/render, independently of viewport side. No reference→producer time/index mapping occurs. Numeric producer/manual ranges remain unchanged; unavailable time-only ranges still refuse `COMMENT_CLOCK_UNAVAILABLE`.
package/docs/WORKFLOWS.md CHANGED
@@ -335,3 +335,9 @@ recovery, not a durable backup; submit the review (and retrieve it with
335
335
  `rig-review feedback`) when it must be kept.
336
336
  Control capabilities are excluded from links and browser persistence.
337
337
 
338
+
339
+ ## Comment on a detail
340
+
341
+ On the view you are inspecting, choose **Comment · C**, drag over the detail, type, and press Enter. Use **Comments** to restore, edit or delete before submitting through the existing review flow. No target, interval or category form is required. [Panel behavior](PANELS.md#stage-comments) explains pins and frame availability.
342
+
343
+ `rig-review feedback <input> --format json` and `--format issue` retain these comments in the existing feedback report. Compare uses the same A/B/reference/animation arguments as its open command. Each comment includes source hashes, viewport, frame basis, animation, `context.turn`, camera, screen/world region, nearest-first drawn slots, and crop path/SHA-256/dimensions. Derived CLI reports omit the inline PNG; the private draft and submitted session retain it. The CLI resolves the PNG's derived crop path under the review state directory so an agent can open it without a browser. Crop files are created on submission, outside the input package. Unsubmitted UI drafts stay local. No comment field or output byte is added to a review without comments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bun
2
+ import {requireFeedbackFrames} from './stage-comment.ts';
3
+ import {commentCropPaths} from './comment-crops.ts';
2
4
  import {randomBytes} from 'node:crypto';
3
5
  import {parseControlCommand} from './control.ts';
4
6
  import { loadReview } from './manifest.ts';
5
- import { formatFeedbackIssues } from './feedback-export.ts';
7
+ import { formatFeedbackIssues,feedbackWithCommentGroups } from './feedback-export.ts';
6
8
  import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
7
9
  import {animationDiagnostic,selectAnimation} from './animation-selection.ts';
8
10
  import {shellWord} from './open-shell.ts';
@@ -29,7 +31,7 @@ async function stripCommand(args:string[],options:{stateDir?:string}){
29
31
  let intervals:StripInterval[]=[];
30
32
  if(range){const match=/^(\d+):(\d+)$/.exec(range);if(!match)throw new Error('strip --frames: expected inclusive FIRST:LAST');if(compare&&!basis)throw new Error('strip --basis: explicitly select a | b');intervals=[{id:'selection',basis:compare?basis!:'single',frameStart:Number(match[1]),frameEnd:Number(match[2])}];}
31
33
  const request=(hash:string):StripRequest=>({format:jsonOnly?'json':'png',sourceHash:hash,animation:animation!,...(compare?{}:{referenceId:reference}),intervals});
32
- const fromFeedback=(feedback:(Feedback&{side?:'a'|'b'})[])=>feedback.map(f=>{const context=f.context;if(!context||typeof context!=='object'||Array.isArray(context)||context.animation!==animation)throw new Error('strip --all-feedback: every interval must identify the selected animation');return {id:f.id,feedbackId:f.id,basis:compare?f.side!:'single' as const,frameStart:f.frameStart,frameEnd:f.frameEnd};});
34
+ const fromFeedback=(feedback:(Feedback&{side?:'a'|'b'})[])=>feedback.map(f=>{const context=f.context;if(!context||typeof context!=='object'||Array.isArray(context)||context.animation!==animation)throw new Error('strip --all-feedback: every interval must identify the selected animation');return {id:f.id,feedbackId:f.id,basis:compare?f.side!:'single' as const,...requireFeedbackFrames(f)};});
33
35
  let result,roots:string[];
34
36
  if(compare){if(referenceVideo)throw new Error('strip compare: use --reference local-video');const p=await loadCompare({a,b:b!,reference:reference!,animation});roots=[p.a.root,p.b.root];if(all)intervals=fromFeedback((await readCompareSession(p,options.stateDir)).feedback);if(output){await validateStripOutput(output,roots);if(!jsonOnly)await validateStripOutput(output+'.json',roots);}result=await exportCompareStrip(p,request(p.response.sourceHash));}
35
37
  else {if(basis)throw new Error('strip --basis: single package uses single basis');const p=await loadReferencedReview(a,referenceVideo);if(!reference)reference=selectedReference(p)?.binding.reference.id;roots=[p.root];if(all)intervals=fromFeedback((await readSession(p,options.stateDir)).feedback);if(output){await validateStripOutput(output,roots);if(!jsonOnly)await validateStripOutput(output+'.json',roots);}result=await exportReviewStrip(p,request(p.sourceHash));}
@@ -100,7 +102,7 @@ export async function main(args:string[],options:{stateDir?:string}={}){
100
102
  let format:'json'|'issue'='json';if(command==='feedback'){const parsed=feedbackFormat(args);args=parsed.args;format=parsed.format;}
101
103
  // Feedback formatting assigns --format/value ownership before the help pass.
102
104
  if(commandHelpRequested(args,command)){console.log(usage);return;}
103
- const printFeedback=(session:Parameters<typeof formatFeedbackIssues>[0])=>console.log(format==='issue'?formatFeedbackIssues(session).trimEnd():JSON.stringify(session,null,2));
105
+ const printFeedback=(session:Parameters<typeof formatFeedbackIssues>[0])=>{const exported=commentCropPaths(session,options.stateDir);console.log(format==='issue'?formatFeedbackIssues(exported).trimEnd():JSON.stringify(feedbackWithCommentGroups(exported),null,2));};
104
106
  if(args[0]==='compare'){
105
107
  args.shift();const a=args.shift(),b=args.shift();if(!a||!b)throw new Error(usage);let reference:string|undefined,animation:string|undefined,port=4317;
106
108
  while(args.length){const flag=args.shift(),value=args.shift();if(!value)throw new Error(`Missing value for ${flag}`);if(flag==='--reference')reference=value;else if(flag==='--animation')animation=value;else if(flag==='--port'){port=Number(value);if(!Number.isInteger(port)||port<0||port>65535)throw new Error('--port: expected integer 0…65535');}else throw new Error(`Unknown compare option ${flag}`);}
@@ -0,0 +1,30 @@
1
+ import {inflateSync} from 'node:zlib';
2
+ /** Derived PNGs live beside the session. A client never selects a filesystem path. */
3
+ import {createHash} from 'node:crypto';
4
+ import {mkdir,lstat,readFile,writeFile} from 'node:fs/promises';
5
+ import {join,resolve} from 'node:path';
6
+ import {isDeepStrictEqual} from 'node:util';
7
+ import {homedir} from 'node:os';
8
+ import {parseStageComment,hasStageComment} from './stage-comment.ts';
9
+ import type {Feedback} from './session.ts';
10
+ export function validateCommentCrop(value:unknown):Buffer {
11
+ const c=parseStageComment(value),bytes=Buffer.from(c.crop.png.slice('data:image/png;base64,'.length),'base64');
12
+ if(bytes.length<33||bytes.subarray(0,8).toString('hex')!=='89504e470d0a1a0a'||bytes.toString('ascii',12,16)!=='IHDR'||bytes.readUInt32BE(16)!==c.crop.width||bytes.readUInt32BE(20)!==c.crop.height||createHash('sha256').update(bytes).digest('hex')!==c.crop.sha256)throw new Error('COMMENT_CROP_INVALID: PNG signature, dimensions or SHA-256 differ');
13
+ const data:Buffer[]=[];let offset=8,ended=false,hasData=false;const color=bytes[25],channels=color===6?4:color===2?3:0;if(bytes[24]!==8||!channels||bytes[26]!==0||bytes[27]!==0||bytes[28]!==0)throw new Error('COMMENT_CROP_INVALID: expected noninterlaced 8-bit RGB/RGBA');while(offset<bytes.length){if(offset+12>bytes.length)throw new Error('COMMENT_CROP_INVALID: truncated PNG chunk');const length=bytes.readUInt32BE(offset),end=offset+12+length;if(end>bytes.length)throw new Error('COMMENT_CROP_INVALID: PNG chunk length');const type=bytes.toString('ascii',offset+4,offset+8);let crc=0xffffffff;for(const n of bytes.subarray(offset+4,end-4)){crc^=n;for(let bit=0;bit<8;bit++)crc=(crc>>>1)^((crc&1)?0xedb88320:0);}if(((crc^0xffffffff)>>>0)!==bytes.readUInt32BE(end-4))throw new Error('COMMENT_CROP_INVALID: PNG chunk CRC');if(type==='IDAT'){hasData=true;data.push(bytes.subarray(offset+8,end-4));}if(type==='acTL')throw new Error('COMMENT_CROP_INVALID: static PNG required');if(type==='IEND'){if(length!==0||end!==bytes.length)throw new Error('COMMENT_CROP_INVALID: PNG end');ended=true;}offset=end;}if(!ended||!hasData)throw new Error('COMMENT_CROP_INVALID: PNG data/end missing');const stride=c.crop.width*channels+1;let inflated:Buffer;try{inflated=inflateSync(Buffer.concat(data),{maxOutputLength:stride*c.crop.height});}catch{throw new Error('COMMENT_CROP_INVALID: bounded PNG data decode');}if(inflated.length!==stride*c.crop.height||Array.from({length:c.crop.height},(_,i)=>inflated[i*stride]).some(n=>n===undefined||n>4))throw new Error('COMMENT_CROP_INVALID: PNG scanlines');return bytes;
14
+ }
15
+ const stateRoot=(state?:string)=>state??join(homedir(),'.local','state','rig-c-review');
16
+ export async function saveCommentCrops(feedback:Feedback[],state?:string):Promise<void>{
17
+ for(const f of feedback){const context=f.context as Record<string,unknown>|undefined;if(!hasStageComment(context?.comment))continue;const c=parseStageComment(context?.comment),bytes=validateCommentCrop(c),folder=join(stateRoot(state),'comment-crops');await mkdir(folder,{recursive:true,mode:0o700});if(!(await lstat(folder)).isDirectory())throw new Error('COMMENT_CROP_PATH: regular state directory required');const path=join(folder,c.crop.sha256+'.png');
18
+ try{await writeFile(path,bytes,{mode:0o600,flag:'wx'});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;const info=await lstat(path);if(!info.isFile()||createHash('sha256').update(await readFile(path)).digest('hex')!==c.crop.sha256)throw new Error('COMMENT_CROP_PATH: existing crop is not the bound regular PNG');}
19
+ }
20
+ }
21
+ /** Only comment-bearing exports acquire resolved derived-evidence paths; old/no-comment bytes stay unchanged. */
22
+ export function commentCropPaths<T extends {feedback:Feedback[]}>(session:T,state?:string):T {
23
+ if(!session.feedback.some(f=>hasStageComment((f.context as any)?.comment)))return session;
24
+ return {...session,feedback:session.feedback.map(f=>{const raw=(f.context as any)?.comment;if(!hasStageComment(raw))return f;let c;try{c=parseStageComment(raw);}catch{return f;}const {png:_privatePng,...metadata}=c.crop;return {...f,context:{...f.context as any,comment:{...c,crop:{...metadata,path:resolve(stateRoot(state),c.crop.path)}}}};})};
25
+ }
26
+
27
+ /** Once submitted, comment evidence can be extended only by new IDs. Ordinary feedback keeps its existing rules. */
28
+ export function assertSubmittedComments(previous:Feedback[],next:Feedback[]):void {
29
+ for(const old of previous){if(!hasStageComment((old.context as any)?.comment))continue;const candidate=next.find(f=>f.id===old.id);if(!candidate||!isDeepStrictEqual(candidate,old))throw new Error('COMMENT_SUBMITTED: submitted comments cannot be edited or deleted; add a new comment');}
30
+ }
@@ -1,3 +1,6 @@
1
+ import {hasStageComment} from './stage-comment.ts';
2
+ import {parseBookmark} from '../web/inspection-persistence.ts';
3
+ import {saveCommentCrops,assertSubmittedComments} from './comment-crops.ts';
1
4
  import {sharedTurnDials} from './turn-pad.ts';
2
5
  import { createHash } from 'node:crypto';
3
6
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
@@ -19,10 +22,10 @@ export function parseCompareSession(input:unknown,p:ComparePackage,strict=true):
19
22
  if(!Array.isArray(value.feedback)||value.feedback.length>1000)throw new Error('compare session.feedback: expected array <= 1000');
20
23
  const feedbackWarnings:FeedbackWarning[]=[];
21
24
  const turnDials=sharedTurnDials(p.a.feedbackCatalog.turnDials??[],p.b.feedbackCatalog.turnDials??[]);
22
- const feedback:CompareFeedback[]=value.feedback.map((item:unknown)=>{const side=choice(object(item,'feedback').side,['a','b'] as const,'feedback.side'),parsed=parseFeedback(item,strict);if(strict)return {...validatePackageFeedback(parsed,p[side],p.response.animation,turnDials),side};const read=readPackageFeedback(parsed,p[side],p.response.animation,turnDials);if(read.warning)feedbackWarnings.push(read.warning);return {...read.feedback,side};});
25
+ const feedback:CompareFeedback[]=value.feedback.map((item:unknown)=>{const side=choice(object(item,'feedback').side,['a','b'] as const,'feedback.side'),parsed=parseFeedback(item,strict);if(strict){const raw=(parsed.context as any)?.comment,c=hasStageComment(raw)?raw:null;if(c)parseBookmark(c.bookmark,{mode:'compare',sourceHash:p.response.sourceHash,rigs:[p.a.reviewedSource.rig?.sha256??null,p.b.reviewedSource.rig?.sha256??null]});if(c&&(c.viewport!=='reference'&&c.viewport!==side||c.referenceHash!==p.response.reference.sha256))throw new Error('COMMENT_CONTEXT_MISMATCH: viewport side or reference binding');}if(strict)return {...validatePackageFeedback(parsed,p[side],p.response.animation,turnDials),side};const read=readPackageFeedback(parsed,p[side],p.response.animation,turnDials);if(read.warning)feedbackWarnings.push(read.warning);return {...read.feedback,side};});
23
26
  if(new Set(feedback.map(f=>f.id)).size!==feedback.length)throw new Error('compare session.feedback: duplicate ids');
24
27
  return {...fresh,status:choice(value.status,['pending','approved','changes-requested'] as const,'compare session.status'),feedback,feedbackWarnings,updatedAt:new Date().toISOString()};
25
28
  }
26
29
  export function compareSessionFile(p:ComparePackage,stateDir=join(homedir(),'.local','state','rig-c-review')):string {const key=createHash('sha256').update(JSON.stringify(['compare',p.response.sourceHash,p.response.binding])).digest('hex');return join(stateDir,key+'.json');}
27
30
  export async function readCompareSession(p:ComparePackage,stateDir?:string):Promise<CompareSession>{await assertCompareUnchanged(p);try{const value=JSON.parse(await readFile(compareSessionFile(p,stateDir),'utf8'));return {...parseCompareSession(value,p,false),updatedAt:value.updatedAt};}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return freshCompareSession(p);throw e;}}
28
- export async function saveCompareSession(p:ComparePackage,input:unknown,stateDir?:string):Promise<CompareSession>{const value=parseCompareSession(input,p);await assertCompareUnchanged(p);const file=compareSessionFile(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;}
31
+ export async function saveCompareSession(p:ComparePackage,input:unknown,stateDir?:string):Promise<CompareSession>{const value=parseCompareSession(input,p);await assertCompareUnchanged(p);assertSubmittedComments((await readCompareSession(p,stateDir)).feedback,value.feedback);await saveCommentCrops(value.feedback,stateDir);const file=compareSessionFile(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;}
@@ -1,3 +1,4 @@
1
+ import {feedbackTimingLabel,hasStageComment,stageCommentMode,commentModes,type CommentMode} from './stage-comment.ts';
1
2
  import type { Json, ReviewedSource } from './manifest.ts';
2
3
  import type { Feedback, FeedbackWarning, ReviewSession } from './session.ts';
3
4
  import type { CompareBinding } from './compare.ts';
@@ -21,8 +22,8 @@ interface BuildCount {
21
22
  interface ExportItem {
22
23
  id: string;
23
24
  side: Side;
24
- frameStart: number;
25
- frameEnd: number;
25
+ frameStart: number|null;
26
+ frameEnd: number|null;
26
27
  target: Feedback['target'];
27
28
  category: Category;
28
29
  summary: string;
@@ -52,6 +53,7 @@ export interface FeedbackIssueExport {
52
53
  counts: Counts;
53
54
  builds: BuildCount[];
54
55
  categories: CategoryGroup[];
56
+ commentModes?: {mode:CommentMode;items:ExportItem[]}[];
55
57
  pairedBinding: CompareBinding | null;
56
58
  referenceBinding: ReferenceBinding | null;
57
59
  feedbackWarnings: FeedbackWarning[];
@@ -62,6 +64,11 @@ const categories: readonly Category[] = ['defect', 'quality'];
62
64
  const sideOrder: Record<Side, number> = { single: 0, a: 1, b: 2 };
63
65
  const compareText = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
64
66
  const emptyCounts = (): Counts => ({ defect: 0, quality: 0, total: 0 });
67
+ /** A derived index into canonical feedback, never a second persisted store. */
68
+ export function feedbackWithCommentGroups<T extends {feedback:Feedback[]}>(session:T):T&{commentGroups?:{schema:'rig-review-comment-groups/1';canonical:'feedback';modes:{mode:CommentMode;feedbackIds:string[]}[]}} {
69
+ const groups=commentModes.map(mode=>({mode,feedbackIds:session.feedback.filter(f=>hasStageComment((f.context as any)?.comment)&&stageCommentMode((f.context as any).comment)===mode).map(f=>f.id)})).filter(g=>g.feedbackIds.length);
70
+ return groups.length?{...session,commentGroups:{schema:'rig-review-comment-groups/1',canonical:'feedback',modes:groups}}:session;
71
+ }
65
72
  function cloneSource(source: ReviewedSource): ReviewedSource {
66
73
  return { ...source, rig: source.rig ? { ...source.rig } : null, motion: source.motion ? { ...source.motion } : null };
67
74
  }
@@ -126,10 +133,11 @@ export function groupFeedbackIssues(input: ParsedFeedbackSession): FeedbackIssue
126
133
  for (const target of targets) target.items.sort((a, b) => sideOrder[a.side] - sideOrder[b.side] || compareText(a.id, b.id));
127
134
  return { category, count: counts[category], targets };
128
135
  });
136
+ const comments=commentModes.map(mode=>({mode,items:categoryGroups.flatMap(c=>c.targets.flatMap(t=>t.items)).filter(f=>hasStageComment((f.context as any)?.comment)&&stageCommentMode((f.context as any).comment)===mode)})).filter(g=>g.items.length);
129
137
  return {
130
138
  schema: 'rig-review-issue-export/1',
131
139
  session: { schema: input.schema, id: input.id, revision: input.revision, sourceHash: input.sourceHash, status: input.status, updatedAt: input.updatedAt },
132
- counts, builds, categories: categoryGroups, pairedBinding, referenceBinding,
140
+ counts, builds, categories: categoryGroups, ...(comments.length?{commentModes:comments}:{}), pairedBinding, referenceBinding,
133
141
  feedbackWarnings: input.feedbackWarnings.map(warning => ({ ...warning })).sort((a, b) => compareText(a.id, b.id) || compareText(a.error, b.error)),
134
142
  ...(input.retrieval ? { retrieval: { ...input.retrieval, currentTools: 'a' in input.retrieval.currentTools ? { a: { ...input.retrieval.currentTools.a }, b: { ...input.retrieval.currentTools.b } } : { ...input.retrieval.currentTools } } } : {}),
135
143
  historyWarnings: (input.historyWarnings ?? []).map(warning => ({ ...warning })).sort((a, b) => compareText(a.file, b.file) || compareText(a.error, b.error)),
@@ -202,8 +210,17 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
202
210
  // Keep the complete warning list, including unmatched ids, without duplicating
203
211
  // issue items or quietly hiding evidence supplied by the parsed reader.
204
212
  if (model.feedbackWarnings.length) lines.push('', '## Feedback validation warnings', '', jsonBlock(model.feedbackWarnings));
205
- for (const category of model.categories) {
206
- lines.push('', `## ${category.category} (${category.count})`);
213
+ const sections=model.categories.map(category=>{
214
+ const targets=category.targets.map(t=>({...t,items:t.items.filter(f=>!hasStageComment((f.context as any)?.comment))})).filter(t=>t.items.length);
215
+ return {heading:`${category.category} (${targets.reduce((n,t)=>n+t.items.length,0)})`,targets};
216
+ });
217
+ for(const group of model.commentModes??[]){
218
+ const targets:TargetGroup[]=[];
219
+ for(const item of group.items){let target=targets.find(t=>t.target.kind===item.target.kind&&t.target.name===item.target.name);if(!target){target={target:item.target,items:[]};targets.push(target);}target.items.push(item);}
220
+ sections.push({heading:`Comments · ${group.mode} (${group.items.length})`,targets});
221
+ }
222
+ for (const category of sections) {
223
+ lines.push('', `## ${category.heading}`);
207
224
  if (!category.targets.length) lines.push('', 'No feedback items.');
208
225
  for (const group of category.targets) {
209
226
  lines.push('', `### ${md(group.target.kind)}: ${md(group.target.name)}`);
@@ -211,9 +228,10 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
211
228
  lines.push('', `#### Feedback ${md(item.id)}`, '',
212
229
  `- Category: ${item.category}`,
213
230
  `- Target: ${md(item.target.kind)}: ${md(item.target.name)}`,
214
- `- Inclusive frames: ${item.frameStart}–${item.frameEnd}`,
231
+ item.frameStart===null?`- Time-only comment: ${md(feedbackTimingLabel(item))}`:`- Inclusive frames: ${item.frameStart}–${item.frameEnd}`,
215
232
  `- Side: ${item.side}`,
216
233
  `- Summary: ${md(item.summary)}`,
234
+ ...((item.context as any)?.comment?.schema==='rig-review-comment/1'&&typeof (item.context as any)?.comment?.crop?.path==='string' ? [`- Comment crop: ${md((item.context as any).comment.crop.path)}`,`- Viewport: ${md((item.context as any).comment.viewport)} · ${(item.context as any).comment.frame===null?'time basis':'frame basis'}: ${md(((item.context as any).comment.frame?.basis??(item.context as any).comment.timeBasis?.basis))}`] : []),
217
235
  ...environmentLine(item.context),
218
236
  ...appliedLine(item.context),
219
237
  ...requirementsLine(item.context), ...scheduleLine(item.context), '',
@@ -38,3 +38,12 @@ export function validateFeedbackSelection(catalog:FeedbackCatalog,value:Feedback
38
38
  const last=feedbackLastFrame(catalog,value.animation);
39
39
  if(last!==null&&value.frameEnd>last)throw new Error(`feedback.frameEnd: ${value.frameEnd} beyond clip last frame ${last}`);
40
40
  }
41
+ /** Target/animation validation for a seconds-only comment; no fabricated frame is passed to the integer validator. */
42
+ export function validateTimedFeedbackSelection(catalog:FeedbackCatalog,value:{target:FeedbackSelection['target'];animation:string|null;time:number}):void {
43
+ if(!value.target||!['bone','slot','rig'].includes(value.target.kind))throw new Error('feedback.target.kind: expected bone | slot | rig');
44
+ const names=feedbackTargetNames(catalog,value.target.kind);if(names===null)throw new Error(`feedback.target.name: ${value.target.kind} validation unavailable without model names`);
45
+ if(!names.includes(value.target.name))throw new Error('feedback.target.name: not in inspected model/package');
46
+ if(value.animation!==null&&typeof value.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
47
+ const clip=catalog.animations?.find(c=>c.name===value.animation);if(value.animation!==null&&catalog.animations!==null&&!clip)throw new Error('feedback.context.animation: not in model');
48
+ if(!Number.isFinite(value.time)||value.time<0||value.time>Number.MAX_SAFE_INTEGER||clip&&value.time>clip.duration)throw new Error('COMMENT_TIME_ONLY_INVALID: seconds outside inspected animation');
49
+ }
package/src/manifest.ts CHANGED
@@ -22,7 +22,7 @@ export interface ReviewManifest {
22
22
  producer?: HandoffDeclaration;
23
23
  }
24
24
  export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null; readonly handoff?:HandoffRecord }
25
- export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog;
25
+ export interface ReviewPackage { referenceHashes?:Record<string,string>; root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog;
26
26
  /** The resolved producer handoff (#101) and, when there is none, the refusal line naming where it was looked for. */
27
27
  handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags;
28
28
  /** #187: the rig-parts build root ({@link isBuildRoot}) whose `check/` this package is, however it was opened; review state stays outside it. */
@@ -116,7 +116,7 @@ export async function loadReview(input: string, options: { handoff?: HandoffFlag
116
116
  const buildRoot=basename(root)==='check'&&await isBuildRoot(dirname(root))?dirname(root):undefined;
117
117
  const reviewedSource:ReviewedSource={packageRevision:manifest.revision,...await toolVersions(),rig:rigPath?{path:rigPath,sha256:fileHashes.get(rigPath)!}:null,motion:motionPath?{path:motionPath,sha256:fileHashes.get(motionPath)!}:null,...(handoff?{handoff:handoff.record}:{})};
118
118
  const feedbackCatalog:FeedbackCatalog={rig:manifest.id,bones:modelNames(modelMetadata,'bones'),slots:modelNames(modelMetadata,'slots'),animations:modelAnimations(modelMetadata),clock:report?.clock??null,clockAnimation:report?.animation??null,...(discoverTurnDials(modelMetadata).length?{turnDials:discoverTurnDials(modelMetadata)}:{})};
119
- return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{}),...(buildRoot?{buildRoot}:{})};
119
+ return {referenceHashes:Object.fromEntries(manifest.references.map(r=>[r.id,fileHashes.get(r.path)!])),root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{}),...(buildRoot?{buildRoot}:{})};
120
120
  }
121
121
 
122
122
  /**
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,3 +1,7 @@
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';
1
5
  import {TurnState,type TurnDial} from './turn-pad.ts';
2
6
  import { createHash } from 'node:crypto';
3
7
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
@@ -5,29 +9,33 @@ import { join } from 'node:path';
5
9
  import { isDeepStrictEqual } from 'node:util';
6
10
  import { homedir } from 'node:os';
7
11
  import {referenceBinding,type ReferenceBinding} from './reference.ts';
8
- import { validateFeedbackSelection } from './feedback-validation.ts';
12
+ import { validateFeedbackSelection,validateTimedFeedbackSelection } from './feedback-validation.ts';
9
13
  import { parseEnvironmentContext } from './environment.ts';
10
14
  import { appliedCheckRefusal, parseAppliedContext } from './applied-context.ts';
11
15
  import { parseReadingsContext } from './readings.ts';
12
16
  import { parseRequirementsContext } from './requirements.ts';
13
17
  import { parseScheduleContext } from './environment-schedule.ts';
14
18
  import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
15
- 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}
16
20
  export function parseFeedback(item:unknown,strict=true):Feedback {
17
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);
18
23
  // Older releases admitted integer values beyond the safe range. Retain those
19
24
  // as readable evidence; strict saves and validatePackageFeedback refuse them.
20
25
  const integer=strict?Number.isSafeInteger:Number.isInteger;
21
- 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');
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');
22
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');
23
- 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})};
24
30
  }
25
31
  export interface FeedbackWarning {id:string;error:string}
26
32
 
27
33
  /** Derive the annotated interval, retaining inspection context as separate data. */
28
34
  export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string,turnDials:readonly TurnDial[]=p.feedbackCatalog.turnDials??[]):Feedback {
29
35
  const context=feedback.context===undefined?{}:object(feedback.context,'feedback.context');
30
- const interval={frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,inclusive:true};
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};
31
39
  if(context.interval!==undefined&&!isDeepStrictEqual(context.interval,interval))throw new Error('feedback.context.interval: must match chosen inclusive frameStart/frameEnd');
32
40
  if(context.animation!==undefined&&context.animation!==null&&typeof context.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
33
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');
@@ -42,8 +50,32 @@ export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animat
42
50
  // The exported environment schedule (#112): absent or null stays valid; a present record carries its bytes and their sha256, recomputed here.
43
51
  if(context.environmentSchedule!==undefined)parseScheduleContext(context.environmentSchedule,text=>createHash('sha256').update(text).digest('hex'));
44
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
+ }
45
76
  const selected=animation??(typeof context.animation==='string'?context.animation:null);
46
- validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
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});
47
79
  return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
48
80
  }
49
81
  export function readPackageFeedback(feedback:Feedback,p:ReviewPackage,animation?:string,turnDials?:readonly TurnDial[]):{feedback:Feedback;warning?:FeedbackWarning} {
@@ -72,4 +104,4 @@ export function parseSession(input:unknown,p:ReviewPackage,strict=true):ReviewSe
72
104
  }
73
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'); }
74
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;} }
75
- 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/web/compare.css CHANGED
@@ -131,3 +131,6 @@ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-templa
131
131
  .turn-pad output{font-variant-numeric:tabular-nums;color:var(--fg-strong)}
132
132
  .turn-pad button[aria-pressed=true]{border-color:var(--pass);color:var(--pass)}
133
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)}