@rig-c/review 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ # Inspection workflows
2
+
3
+ See [workspace panels](PANELS.md) for the controls, or [Start](../README.md#start) for installation.
4
+
5
+ ## Existing output handoff
6
+
7
+ Open the current output folder directly, without changing the producing agent or inserting a manifest:
8
+
9
+ ```sh
10
+ bun run check -- /absolute/path/to/output
11
+ bun run review -- /absolute/path/to/output
12
+ ```
13
+
14
+ The adapter recognizes root `skeleton.model.json` or the fixed rigc build layout `build/skeleton.model.json` (root model takes precedence), root `motion.json`, `report.json`, `skeleton.json`/`.skel`/`.atlas`, source videos (`.mp4`, `.webm`, `.mov`), image filenames containing `strip`, `contact`, `sheet`, `filmstrip` or `띠`, and canonical `*pose*.json` sequences. Model discovery checks only those two named paths; it never recursively searches directories. A `rig-parts build` output root (`out/`: regular `parts.json` and `rig/rig.json`, a real `check/` directory and `check/build/skeleton.model.json`) that has neither model path itself opens as its `check/` package — the same package, identity and session as opening `out/check` — so `rig-review open <out>` works on a fresh build without copying `check/build` anywhere. Model page images resolve relative to the selected model, while motion and references remain relative to the opened folder. It builds the normalized review manifest in memory and reads source files without changing them. Motion/report/skeleton evidence links are read only. A recognized `rig-pose-report/1` also supplies read-only event markers: frame-only events retain their producer interval and explicitly lack seek until a declared clock is present; explicit event seconds can seek. Empty reported categories show none, while missing fields remain missing. Report markers prefill known feedback coordinates and actual bone/slot names without creating a review decision or motion keys. It exposes each matching video/strip separately, without guessing synchronization or source-to-rig bindings.
15
+
16
+ When the report declares `fps` plus `frame0` (seconds offset) and a frame count, or exact `frameTimes[]`, the matching animation also shows a producer-frame readout and frame seek controls. Without a bounded declared clock it shows `rate unknown`; current unchanged v19 has no clock. Sample times come only from the report, and feedback context records the recalculated frame or null.
17
+
18
+ A raw `skeleton.json`/`.skel` can still be inspected as evidence, but cannot play through the compiled-model runtime. Playback requires an upstream gated rigc build's `skeleton.model.json` with portable local page images inside the opened folder. `check` validates the delivery contract and model envelope, not the upstream compiler gate or visual quality.
19
+
20
+ For a curated handoff, [REVIEW_PACKAGE.md](REVIEW_PACKAGE.md) documents optional `review.json` with named checkpoints, references and angle axes:
21
+
22
+ ```sh
23
+ bun run review -- /absolute/path/to/package/review.json --checkpoint silhouette
24
+ ```
25
+
26
+ The server prints the exact link, for example `http://127.0.0.1:4317/?checkpoint=silhouette#cap=…`. The `#cap=` fragment is this server's write capability (see [Managed front and write capability](#managed-front-and-write-capability)). `--port 0` selects a free port. It binds only `127.0.0.1` and never opens a browser automatically. A folder containing `review.json` uses that explicit manifest.
27
+
28
+ ## Compare two outputs
29
+
30
+ Open two compiled packages against one explicitly selected local reference video:
31
+
32
+ ```sh
33
+ bun run review -- compare /absolute/output-a /absolute/output-b --reference /absolute/reference.mp4 --animation dance
34
+ bun run check -- compare /absolute/output-a /absolute/output-b --reference /absolute/reference.mp4 --animation dance
35
+ bun run feedback -- compare /absolute/output-a /absolute/output-b --reference /absolute/reference.mp4 --animation dance
36
+ ```
37
+
38
+ Both models must contain the named animation. The video is a separate read-only CLI input, served only through a fixed endpoint; it is never inserted into either package. A separate compare page uses one video playhead to seek both rigs at the same time. Frame readouts use each side's explicit producer clock; no video FPS or missing clock is inferred.
39
+
40
+ Compare feedback requires side `a` or `b` and carries both package source hashes, both exact rig/motion identities, the shared video hash and animation. A changed input on either side or in the video refuses saved feedback until restart. State stays outside all source inputs. Single-package commands and sessions retain their existing contract. See [the comparison contract](REVIEW_PACKAGE.md#two-package-comparison).
41
+
42
+ ## Feedback ownership
43
+
44
+ Opening leaves status **pending**. The person explicitly saves **approved**, **changes-requested**, or **pending**, checkpoint outcomes/notes, and feedback containing an inclusive frame interval, a bone/slot/rig target, defect/quality category and one-line summary. Frame numbers are entered explicitly; the app does not invent a source FPS. **Write review**, at the top right, opens the review dialog: review status, reviewed source, selected checkpoint, structured feedback and the frame-strip export. Its **Submit review** saves that explicit status and feedback; closing the dialog keeps the unsubmitted draft. The page has no feedback export buttons: `rig-review feedback --format json` and `--format issue` (below) are the export path, and carry the original source binding and reproducible inspection context. Each submission records `reviewedSource`: package revision, actual viewer/runtime versions, and separate exact-byte SHA-256 plus path for the reviewed rig and motion; an absent artifact stays null. An active compiled model is the rig identity; raw skeleton JSON is evidence with playback unavailable.
45
+
46
+ New submissions validate bone/slot names against the exact loaded model; the target field offers those names. A rig target names the package id. Source-only packages cannot validate a bone/slot target and say so explicitly. When a matching animation has a declared producer clock, the inclusive interval cannot end beyond the last declared sample at or before the actual clip end. Unknown clocks keep manual frame intervals without inventing a limit. `context.interval` records the chosen interval; inspection `time`/`frame` remain separate. Older invalid feedback stays readable with per-row warnings and exports its original evidence, but cannot be resubmitted until corrected or removed.
47
+
48
+ Feedback lives under `~/.local/state/rig-c-review/`, separate from delivered files. Identity, revision and a streaming SHA-256 of the normalized manifest plus declared files/model pages bind each saved session. Changed content starts a new pending session, even without a revision bump. Restart the server after changing inputs. Saving or exporting feedback never modifies a model, authors correction keys, writes motion output or publishes an issue automatically.
49
+
50
+ After the person submits, the producing agent retrieves the stored review without needing the hashed state filename:
51
+
52
+ ```sh
53
+ bun run feedback -- /absolute/path/to/output
54
+ # Or the same explicit review.json originally opened:
55
+ bun run feedback -- /absolute/path/to/package/review.json
56
+ ```
57
+
58
+ This read-only command prints the session JSON, including reviewed artifact identity and feedback. The exact current-tool session wins when present. After a viewer/runtime upgrade, if that exact session is absent, the CLI can retrieve the latest saved submission over the same content with its original recorded versions and `retrieval: {kind:"prior-tools", toolsChangedSince:true, matchingSubmissions, currentTools}`. It does not migrate approval into the current UI or change saved bytes. Changed inputs still return a fresh pending session; a prior submission is never borrowed across content, revision or reviewed-artifact changes. The same lookup applies to `feedback compare`, preserving both recorded side bindings, reference identity and animation. See [historical retrieval](REVIEW_PACKAGE.md#historical-feedback-retrieval) for selection and validation rules.
59
+
60
+
61
+ For grouped issue-ready text, use the same input/reference binding with `--format issue`:
62
+
63
+ ```sh
64
+ rig-review feedback /absolute/output --reference /absolute/original.mp4 --format issue
65
+ rig-review feedback /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance --format issue
66
+ ```
67
+
68
+ The header counts feedback records by defect/quality and by build; blocks group by category, then exact target kind/name. Overlapping intervals and A/B sides remain distinct items. Each block retains its id, inclusive interval, one-line summary and selected/full paired provenance. Historical tool identities and validation warnings remain visible; output never migrates approval, writes state or publishes an issue. Default output and `--format json` remain the existing session JSON. Only feedback accepts `--format`, with `json` or `issue` as its value. These two formats are how submitted feedback leaves the tool; the browser page offers no JSON or Markdown download.
69
+
70
+ ## Frame strip export
71
+
72
+ The strip panel exports the explicitly entered inclusive feedback interval, or all current in-memory feedback intervals, as a PNG or JSON download. Batch selections include unsaved annotations; they do not submit or approve a review. Choose a declared reference video in single-package mode and an explicit clock basis A/B in compare mode. Every selected feedback interval must identify the selected animation. Separate intervals remain separate strip groups, including overlaps.
73
+
74
+ The same export is available without a browser:
75
+
76
+ ```sh
77
+ bun src/cli.ts strip /absolute/package/review.json --reference source-video --animation dance --frames 176:184 --out /absolute/external/strip.png
78
+ bun src/cli.ts strip compare /absolute/a /absolute/b --reference /absolute/reference.mp4 --animation dance --basis b --frames 176:184 --out /absolute/external/compare.png
79
+ bun src/cli.ts strip /absolute/package/review.json --reference source-video --animation dance --all-feedback --json
80
+ ```
81
+
82
+ Single `--reference` is a declared video **id**; compare uses the explicitly selected local video path. `--all-feedback` reads only the exact current saved session, and retains each item's side/interval. `--json` produces a selection/provenance plan without rendering; optionally add `--out`. PNG writes an additional `.png.json` provenance sidecar with the actual decoded samples and PNG hash. Destinations must be new files outside every input package, in existing directories without symlink parents. The server exposes download blobs, never a destination-path API.
83
+
84
+ Strip times require the selected report's exact `frameTimes` and matching animation. Missing or uniform-only clocks refuse PNG with `Report has no frameTimes — regenerate with pose 0.30.0 or later`; JSON retains requested frames with `time:null`. The other compare side may lack a clock: it can still be posed at the common seconds, while its frame label stays unknown. No frame rate is inferred from frame count, duration or video metadata.
85
+
86
+ PNG export uses the pinned published `@rig-c/play` canvas player and real Node Canvas2D, with `ffmpeg` (5.1 or later, for `-fps_mode`) and `ffprobe` available on PATH for reference decoding. Missing capabilities fail by name. Each requested sample chooses the nearest actual decoded reference PTS (ties earlier); both rigs seek that actual PTS using the viewer's fixed step. JSON distinguishes requested time, actual PTS, delta, decoded frame index, actual player times and independently known producer frames. Requests outside the common domain refuse instead of clamping. The PNG shows source rows, chronological frame columns, and full source hashes/revisions/tool and renderer versions. Batch groups stack vertically. Exports are bounded to64 intervals,256 total frames,64 megapixels and16384 pixels per dimension; larger selections must be split.
87
+
88
+ The footer fits its measured text while retaining full hashes. RGB/dark-tint features unsupported by the published Canvas2D path refuse with `STRIP_UNSUPPORTED_TINT`; they are not silently omitted.
89
+
90
+ ## One-command owner handoff
91
+
92
+ `open` validates and hashes an existing folder, starts a background loopback
93
+ instance or reuses the exact matching one, prints its hash-bound URL, and exits.
94
+ By default the URL goes through the managed front at `127.0.0.1:4318`; `--direct`
95
+ prints the instance's own port instead (see [Managed front and write capability](#managed-front-and-write-capability)):
96
+
97
+ ```sh
98
+ bun run open -- /absolute/output --reference /absolute/original.mp4
99
+ # Installed executable mapping:
100
+ rig-review open /absolute/output --reference /absolute/original.mp4 --animation dance
101
+ rig-review feedback /absolute/output --reference /absolute/original.mp4
102
+ rig-review open /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance
103
+ rig-review feedback /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance
104
+ # Producer handoff (single review only): the config, parts and inspected rig an Apply rebuilds from
105
+ rig-review open /absolute/output --config /absolute/output/rig/config.json [--parts /absolute/output/rig/out] [--source <png> --full <dir> [--head <dir>]] [--keypoints <file>]
106
+ rig-review feedback /absolute/output --config /absolute/output/rig/config.json
107
+ ```
108
+
109
+ `--config`, `--parts`, `--source`, `--full`, `--head` and `--keypoints` declare the producer
110
+ handoff (see `docs/REVIEW_PACKAGE.md`, *Producer handoff*). They win over the
111
+ review.json `producer` block, which wins over discovery (`config.json`, then
112
+ `rig/config.json` inside the opened folder, then `../config.json` beside an opened
113
+ `out/`, named as discovered outside the folder). For the `check/` package of a
114
+ `rig-parts build` output the next places are the build's own config: `config.json`
115
+ in the build root, else beside it (`<out>/../config.json`, where `rig-parts build
116
+ --config <dir>/config.json --out <dir>/out` leaves it); the parts directory is the
117
+ build root. Declaring `--config` at exactly one of those two files is taken the same
118
+ way, and that file's folder is then the root for the other flags. Every other path must sit inside the
119
+ package root; a path outside it or a symlink that leaves it is refused by name.
120
+ The handoff files are hashed into the reviewed identity, so `feedback` and
121
+ `control` take the same flags to retrieve the same session, and changing the
122
+ config bytes starts a new pending review. `--keypoints` (#111) is the body-18 keypoint
123
+ file a cloth recipe Apply hands to `rig-parts propose`; it is hashed into the identity
124
+ only when declared, so a handoff without it keeps its identity. `--parts`,
125
+ `--source`/`--full`/`--head` and `--keypoints` need `--config`; `--source` and `--full` go together; compare takes none of them.
126
+
127
+ `open` chooses an explicit `--animation` first, then the normalized report's
128
+ `animation` after validating actual model membership, then a unique actual clip
129
+ (or unique common clip in compare). Thus `rig-review open /absolute/output
130
+ --reference /absolute/original.mp4` opens a report-bound `dance` without the flag
131
+ and prints `animation: dance (from report.json)` on stderr. Conflicting A/B reports,
132
+ a stale report name or multiple clips without a report refuse by name; no first
133
+ entry, filename or frame rate supplies a guess. Explicit invalid names never fall
134
+ back. The selected clip actually initializes single playback, while an explicit
135
+ checkpoint link or later manual choice takes priority. Incompatible automatic
136
+ checkpoints do not apply another clip's time or axis. Static/source-only packages
137
+ remain inspectable without claiming clip selection; explicit unvalidatable clips
138
+ refuse. Single review sessions remain package-wide; animation selection does not
139
+ create an animation-specific approval identity. Opening never
140
+ approves a review. The command prints the matching feedback retrieval command on
141
+ stderr; stdout contains the URL alone. A selected external video takes precedence
142
+ at automatic startup; an explicit checkpoint link or manual checkpoint selection
143
+ still chooses that checkpoint's reference.
144
+
145
+ The selected video is a separate CLI capability, not a manifest path escape or a
146
+ file copied into the source package. Its name and streaming byte hash, plus the
147
+ base package hash, appear as `referenceBinding` in single review responses,
148
+ sessions and feedback exports. Use the same `--reference` when retrieving that
149
+ submission; omitting it reads the separate reference-free identity. Changed
150
+ package or reference bytes start pending state. Existing historical retrieval
151
+ preserves earlier recorded tool versions over exactly the same reference content.
152
+
153
+ The single strip panel can export the selected external video using that same
154
+ binding. Headless equivalent (the original declared-reference form stays valid):
155
+
156
+ ```sh
157
+ rig-review strip /absolute/output --reference-video /absolute/original.mp4 --animation dance --frames 176:184 --out /absolute/external/strip.png
158
+ ```
159
+
160
+ Only tool-owned instances with matching content, canonical input locations, tool
161
+ fingerprint and a private capability handshake are reused. The URL hash is
162
+ checked before serving the app; a stale link never selects another instance's
163
+ package. Input hashes are freshly computed, not trusted from mtime caches. Idle
164
+ open-managed workers stop after 30 minutes. Their private registry, requests and
165
+ logs live beside feedback under `~/.local/state/rig-c-review/open/`; nothing is
166
+ written into supplied folders. `RIG_REVIEW_STATE_DIR` selects another state root
167
+ outside all input packages, judged by its canonical location (symlinks in the path
168
+ and its existing ancestors resolved). Existing foreground review commands still work.
169
+
170
+ No browser is opened, no LAN listener is added, and no misleading phone/QR URL is
171
+ printed: every current instance binds only `127.0.0.1`.
172
+
173
+ The printed feedback retrieval command uses POSIX shell quoting for literal path
174
+ arguments. It is not a PowerShell or cmd.exe command string.
175
+
176
+ ### Launcher contract
177
+
178
+ A launcher may start `rig-review open` as a child process and rely on its stdout,
179
+ stderr and exit code alone:
180
+
181
+ 1. On success stdout is exactly one line, the URL. `open` never writes anything
182
+ else to stdout.
183
+ 2. On success stderr carries the human lines in this fixed order: for the `check/`
184
+ package of a `rig-parts build` output, `Package: rig-parts build check package <path>`
185
+ and the handoff line (`Handoff <how>: <config path> sha256 <hash> · parts <dir>`, or
186
+ `Handoff: <refusal>`); the animation diagnostic (when a clip is selected), the control notice (with `--control`),
187
+ then the copyable retrieval line `Review started|reused; feedback: rig-review feedback …`.
188
+ 3. A refusal exits with code 1, writes nothing to stdout, and its first stderr line
189
+ starts with a name, followed by a colon and the explanation:
190
+ - `OPEN_INPUT_MISSING <path>`: the input or `--compare` path does not exist, or is neither a directory nor a regular file.
191
+ - `OPEN_INPUT_UNRECOGNIZED <path>`: the folder holds no recognized model, skeleton, motion, report, reference or pose sequence and is not a `rig-parts build` output root, or the file is not a JSON manifest.
192
+ - `OPEN_REFERENCE_REQUIRED`: `--compare` was given without an explicit `--reference`.
193
+ - `OPEN_ANIMATION_UNKNOWN <name>`: the explicit or report-named clip (JSON-quoted) is not in the compiled model.
194
+ - `OPEN_ANIMATION_AMBIGUOUS`: no flag or report selects one clip, or the A/B reports conflict.
195
+ - `OPEN_ANIMATION_UNVALIDATABLE`: a clip was requested but no compiled model can validate it.
196
+ - `OPEN_OPTION_INVALID <flag>`: an unknown, duplicate, empty or value-less option, a handoff flag without `--config`, `--source` without `--full` (or the reverse), or a handoff flag with `--compare`.
197
+ - `OPEN_STATE_INSIDE_PACKAGE <package | build root | handoff root>`: the state directory (`RIG_REVIEW_STATE_DIR` or the default), resolved through symlinks, lies inside an input package, the `rig-parts build` root a check package belongs to, or the producer handoff's root.
198
+ - `OPEN_HANDOFF_INVALID <flag or path>`: a declared or discovered producer handoff path is refused — `HANDOFF_PATH_OUTSIDE_ROOT`, `HANDOFF_SYMLINK_ESCAPE`, `HANDOFF_INPUT_MISSING`, `HANDOFF_INPUT_INVALID` or `HANDOFF_PATH_INVALID`, named after the colon.
199
+ 4. Exit codes: 0 applied, 1 refused. (`control` keeps its own exit 2 for an
200
+ unapplied command.)
201
+
202
+ Package validation and worker startup refusals keep their existing messages; the
203
+ named refusals above are decided before any state is written.
204
+
205
+ ### Managed front and write capability
206
+
207
+ `open` is **managed by default**. It ensures one detached loopback front at
208
+ `127.0.0.1:4318` (`RIG_REVIEW_FRONT_PORT` overrides) for the state directory and
209
+ prints `http://127.0.0.1:4318/instances/<key>/?input=…&handoff=…#cap=…`. Every
210
+ inspection therefore shares one stable browser origin, so Settings, panel sizes and
211
+ source-bound drafts survive a reopen. The front only routes: it takes the worker
212
+ address from that key's private open record, allows a closed per-method route
213
+ list, bounds bodies (16 KiB control, 1 MiB session/strip) and upstream time,
214
+ limits concurrent upstream streams, re-verifies the worker's nonce on every
215
+ request, and refuses an unknown, expired or replaced instance by name
216
+ (`FRONT_INSTANCE_UNKNOWN`, `FRONT_INSTANCE_EXPIRED`, `FRONT_INSTANCE_REPLACED`).
217
+ Worker error text is not passed through; failures become named `FRONT_*` codes.
218
+ Package assets keep their `sandbox` CSP through the front. It never reads packages.
219
+ Idle fronts exit after 30 minutes once no live instance remains.
220
+
221
+ If another listener holds the front port, `open` fails with
222
+ `FRONT_PORT_BUSY: 127.0.0.1:4318 is held by another listener; use --direct` and
223
+ does not fall back silently. `open --direct` keeps the per-instance port and
224
+ prints that worker's own URL.
225
+
226
+ One origin cannot isolate scripts that run on it, so writes are bound to a
227
+ per-instance capability rather than to the page's origin or the key in its path.
228
+ Each worker mints a 256-bit capability kept only in its private 0600 open record.
229
+ `open` (managed or direct) and the foreground `review`/`review compare` commands
230
+ print it in the `#cap=` fragment, which browsers never send to a server. That
231
+ printed link goes only to the terminal of the command that started the instance.
232
+ Output meant for a log does not carry it: the install smoke (`bun run smoke`, whose output CI publishes) prints the same
233
+ URL as `#cap=<redacted>` and fails if any capability value reaches its stdout. The page
234
+ moves it into memory and that tab's `sessionStorage`, then removes the fragment
235
+ from the address bar. Every mutation sends it as `X-Review-Capability`; the front
236
+ checks it before forwarding and the worker compares it again in constant time
237
+ before its existing same-origin check. No endpoint returns it, so a page opened
238
+ for one instance cannot submit to another. A copied view link (without the
239
+ fragment) opens read-only and says *Read-only view: reopen with rig-review open to
240
+ edit*; run `open` again for a writable link.
241
+
242
+ ## Control the current inspection page
243
+
244
+ Opt in once when opening a fixed package, then keep that page open:
245
+
246
+ ```sh
247
+ rig-review open /absolute/output --reference /absolute/original.mp4 --control
248
+ rig-review control /absolute/output --reference /absolute/original.mp4
249
+ ```
250
+
251
+ The second command reads the connected page's actual inspection state and its
252
+ `pageId`/`revision`; it does not open a browser or start another worker. A page
253
+ learns from `GET /api/access` (`control: true|false`) whether its instance serves
254
+ control; an instance opened without `--control` is never asked for
255
+ `/api/control/config`. To change
256
+ that page, use the observed values and a closed inspection command:
257
+
258
+ ```sh
259
+ rig-review control /absolute/output --reference /absolute/original.mp4 --page '<pageId>' --expected-revision 0 --request-id theme-1 --command '{"type":"theme","value":"light"}'
260
+ ```
261
+
262
+ Use the same reference/compare/animation inputs as the controlled `open`. Multiple
263
+ pages require an explicit `--page`. The URL and source binding stay unchanged;
264
+ existing ordinary servers do not become controllable. Starting the opt-in worker
265
+ is a one-time handoff, not a new link for each parameter change.
266
+
267
+ Commands cover appearance, automatic layout, synchronized or independent pane
268
+ cameras, playback, single animation, time and actual inspection layers. Camera
269
+ pan is measured in viewport CSS pixels (right/down positive). New inspections
270
+ start with camera sync on: zoom relative to fit and pan normalized by each pane's
271
+ viewport move all visible measurable reference/rig panes together. Turn sync off
272
+ with `{"type":"camera-sync","value":false}` to inspect each pane independently;
273
+ turning it off preserves current views. Turning it on aligns the visible panes
274
+ to the last active pane. Camera/fit targets are `rig|reference` in single review
275
+ and `a|b|reference` in compare. For legacy clients, compare `target:"rig"` explicitly
276
+ moves both A/B even when sync is off; its CSS readback aliases A. Comparison retains its
277
+ fixed animation and actual video-master seek path. A single-video time request
278
+ uses the actual decoded timestamp for both reference and rig; the applied
279
+ acknowledgement reports that time, which may differ from the requested seconds.
280
+ Unsupported features, stale
281
+ page/source/revision, unsubmitted feedback drafts and out-of-range values refuse
282
+ by name. No command changes a package/reference, authors motion, or submits or
283
+ approves feedback. Local controls and remote readback use the same inspection
284
+ state. A live view-link/history change supersedes pending remote work, and
285
+ commands refuse while that view is being restored.
286
+
287
+ Remote inspection readback also includes versioned numeric `view` coordinates
288
+ when the page can measure them: rig/world center and zoom, reference/image center
289
+ and zoom, each panel's current CSS viewport size and visible rectangle. World
290
+ coordinates use Y up; reference image coordinates use Y down. Compare reports
291
+ actual A and B rectangles separately; unavailable measurements are null. The
292
+ canonical view also reports `cameraSync` and `activeCamera`. These
293
+ are the same current cameras used by the local controls, rather than a second
294
+ simulated view. Camera commands still use the documented viewport CSS pan.
295
+
296
+ The CLI waits up to10 seconds for the page's actual acknowledgment. `queued` or
297
+ `dispatched` is not success; non-applied commands exit2 and may carry
298
+ `timedOut:true`. Reuse the same `--request-id` and original request to obtain its
299
+ receipt without applying it twice. `--wait-ms 0` returns the queued receipt;
300
+ `--wait-ms` is bounded to30000. A timeout does not mean an already delivered action
301
+ was rolled back. Tokens stay in private tool state, not URLs or command output.
302
+
303
+ ## Keep and share an inspection
304
+
305
+ The view link stores the current numeric inspection in a bounded, versioned
306
+ `#review=` fragment. It includes the exact loaded source/model binding, selected
307
+ animation/time and reference selection, appearance/layers, camera sync and the
308
+ active pane, and each canonical camera center with visible bounds. Reloading a matching link restores a paused view;
309
+ it does not restart an idle loop or submit a review. Coordinates adapt to the
310
+ current viewport instead of replaying another window's CSS pan offsets.
311
+ Historical links without a sync mode restore with sync off to preserve their
312
+ saved views; fresh inspections start with sync on. The page keeps the current
313
+ fragment in the address bar as you inspect, so copying the address copies the
314
+ view; there is no separate copy button. Legacy modeling/motion
315
+ workspace selections are accepted without restoring a top-level workspace switch. A link
316
+ for a different source binding is refused.
317
+
318
+ Ordinary form inputs, selected panels, view state and unsent feedback/checkpoint
319
+ choices are kept in browser local storage for the same source. The draft scope
320
+ includes the source hash (`single:<id>:<sourceHash>`; compare uses both sides'
321
+ id and hash), so two revisions of one package keep separate drafts. Opening a page
322
+ deletes no draft; saving evicts the least-recently-written drafts beyond 32, but
323
+ never one written within the last 24 hours. Browser
324
+ Settings (below) remain, while a source-bound appearance override does not
325
+ carry over. URL view state takes precedence over the locally saved view. Each
326
+ Inspector section's own scroll position is part of this source-bound draft.
327
+
328
+ Local recovery compares the original submitted server-session fingerprint,
329
+ including its update time. It cannot replace a newer server review: conflicting
330
+ or corrupt saved data remains available to download, with an explicit warning;
331
+ recovering a conflicting draft requires choosing a pending review. Storage
332
+ errors never count as a successful save. Nothing is written to the server or
333
+ input package merely by restoring a page. Browser storage is convenient local
334
+ recovery, not a durable backup; submit the review (and retrieve it with
335
+ `rig-review feedback`) when it must be kept.
336
+ Control capabilities are excluded from links and browser persistence.
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.13.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -8,6 +8,7 @@
8
8
  "review": "bun src/cli.ts",
9
9
  "check": "bun src/cli.ts check",
10
10
  "demo": "bun src/cli.ts examples/demo/review.json",
11
+ "check:readme": "bun scripts/check-readme.ts",
11
12
  "typecheck": "tsc --noEmit",
12
13
  "test": "bun test",
13
14
  "build": "bun src/build.ts",
@@ -35,6 +36,8 @@
35
36
  "web",
36
37
  "examples/demo",
37
38
  "docs/REVIEW_PACKAGE.md",
39
+ "docs/WORKFLOWS.md",
40
+ "docs/PANELS.md",
38
41
  "docs/DESIGN.md",
39
42
  "docs/ANIMATION_INSPECTION.md",
40
43
  "docs/LIVE_FIXTURE.md",
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,7 @@
1
+ import {hasStageComment} from './stage-comment.ts';
2
+ import {parseBookmark} from '../web/inspection-persistence.ts';
3
+ import {saveCommentCrops,assertSubmittedComments} from './comment-crops.ts';
4
+ import {sharedTurnDials} from './turn-pad.ts';
1
5
  import { createHash } from 'node:crypto';
2
6
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
7
  import { join } from 'node:path';
@@ -17,10 +21,11 @@ export function parseCompareSession(input:unknown,p:ComparePackage,strict=true):
17
21
  if(value.corrections!==undefined)throw new Error('compare session.corrections: motion authoring proposals are not accepted');
18
22
  if(!Array.isArray(value.feedback)||value.feedback.length>1000)throw new Error('compare session.feedback: expected array <= 1000');
19
23
  const feedbackWarnings:FeedbackWarning[]=[];
20
- 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),side};const read=readPackageFeedback(parsed,p[side],p.response.animation);if(read.warning)feedbackWarnings.push(read.warning);return {...read.feedback,side};});
24
+ const turnDials=sharedTurnDials(p.a.feedbackCatalog.turnDials??[],p.b.feedbackCatalog.turnDials??[]);
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};});
21
26
  if(new Set(feedback.map(f=>f.id)).size!==feedback.length)throw new Error('compare session.feedback: duplicate ids');
22
27
  return {...fresh,status:choice(value.status,['pending','approved','changes-requested'] as const,'compare session.status'),feedback,feedbackWarnings,updatedAt:new Date().toISOString()};
23
28
  }
24
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');}
25
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;}}
26
- 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), '',
@@ -1,3 +1,4 @@
1
+ import type {TurnDial} from './turn-pad.ts';
1
2
  import { lastFrame, timeAtFrame, type FrameClock } from './frame-clock.ts';
2
3
 
3
4
  /** Null names mean unavailable metadata; an empty list means known empty. */
@@ -5,6 +6,7 @@ export interface FeedbackCatalog {
5
6
  rig:string;bones:string[]|null;slots:string[]|null;
6
7
  animations:{name:string;duration:number}[]|null;
7
8
  clock:FrameClock|null;clockAnimation:string|null;
9
+ turnDials?:TurnDial[];
8
10
  }
9
11
  export type FeedbackTargetKind='bone'|'slot'|'rig';
10
12
  export interface FeedbackSelection {frameStart:number;frameEnd:number;target:{kind:FeedbackTargetKind;name:string};animation:string|null}
@@ -36,3 +38,12 @@ export function validateFeedbackSelection(catalog:FeedbackCatalog,value:Feedback
36
38
  const last=feedbackLastFrame(catalog,value.animation);
37
39
  if(last!==null&&value.frameEnd>last)throw new Error(`feedback.frameEnd: ${value.frameEnd} beyond clip last frame ${last}`);
38
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
@@ -1,3 +1,4 @@
1
+ import {discoverTurnDials} from './turn-pad.ts';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { toolVersions } from './versions.ts';
3
4
  import type { FeedbackCatalog } from './feedback-validation.ts';
@@ -21,7 +22,7 @@ export interface ReviewManifest {
21
22
  producer?: HandoffDeclaration;
22
23
  }
23
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 }
24
- 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;
25
26
  /** The resolved producer handoff (#101) and, when there is none, the refusal line naming where it was looked for. */
26
27
  handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags;
27
28
  /** #187: the rig-parts build root ({@link isBuildRoot}) whose `check/` this package is, however it was opened; review state stays outside it. */
@@ -114,8 +115,8 @@ export async function loadReview(input: string, options: { handoff?: HandoffFlag
114
115
  // #192: the root is `check/` whether the folder or its review.json was opened, so the build root is kept either way.
115
116
  const buildRoot=basename(root)==='check'&&await isBuildRoot(dirname(root))?dirname(root):undefined;
116
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}:{})};
117
- 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};
118
- return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{}),...(buildRoot?{buildRoot}:{})};
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 {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}:{})};
119
120
  }
120
121
 
121
122
  /**