@rig-c/review 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -705
- package/docs/DESIGN.md +12 -1
- package/docs/PANELS.md +412 -0
- package/docs/REVIEW_PACKAGE.md +42 -1
- package/docs/WORKFLOWS.md +343 -0
- package/package.json +4 -1
- package/src/cli.ts +5 -3
- package/src/comment-crops.ts +30 -0
- package/src/compare-session.ts +7 -2
- package/src/feedback-export.ts +24 -6
- package/src/feedback-validation.ts +11 -0
- package/src/manifest.ts +4 -3
- package/src/server.ts +1 -1
- package/src/session.ts +44 -10
- package/src/stage-comment.ts +89 -0
- package/src/turn-pad.ts +60 -0
- package/web/compare.css +13 -0
- package/web/compare.html +1 -1
- package/web/compare.ts +45 -12
- package/web/index.html +1 -1
- package/web/inspection-persistence.ts +9 -3
- package/web/main.ts +72 -26
- package/web/stage-comments.ts +107 -0
- package/web/styles.css +13 -0
- package/web/turn-pad.ts +26 -0
- package/web/viewport-capture.ts +73 -0
|
@@ -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.
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/compare-session.ts
CHANGED
|
@@ -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
|
|
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;}
|
package/src/feedback-export.ts
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
/**
|