@rig-c/review 0.8.0 → 0.9.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 +21 -11
- package/docs/DESIGN.md +1 -1
- package/docs/REVIEW_PACKAGE.md +13 -7
- package/package.json +1 -1
- package/src/baseline-reach.ts +177 -0
- package/src/baseline.ts +14 -9
- package/src/cli.ts +2 -2
- package/src/compare-server.ts +1 -1
- package/src/frame-clock.ts +30 -0
- package/src/front.ts +3 -0
- package/src/handoff.ts +59 -7
- package/src/manifest.ts +13 -3
- package/src/open-protocol.ts +4 -3
- package/src/open.ts +12 -3
- package/src/rebuild.ts +198 -21
- package/src/server.ts +8 -1
- package/web/compare.ts +7 -5
- package/web/index.html +1 -1
- package/web/label-placement.ts +46 -0
- package/web/main.ts +73 -10
- package/web/rebuild-client.ts +37 -7
- package/web/rig-overlays.ts +24 -9
- package/web/styles.css +1 -0
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ bun run check -- /absolute/path/to/output
|
|
|
39
39
|
bun run review -- /absolute/path/to/output
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
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. 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.
|
|
42
|
+
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.
|
|
43
43
|
|
|
44
44
|
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.
|
|
45
45
|
|
|
@@ -115,7 +115,7 @@ bun run smoke
|
|
|
115
115
|
|
|
116
116
|
`smoke` packs this tree, installs the tarball into an empty project, and runs the installed bin: `--help`, `open --help`, `check` on the bundled demo, and a served page whose browser bundle is built from the installed files. A file missing from `files` in `package.json` fails there, not after publishing.
|
|
117
117
|
|
|
118
|
-
The server bundles `web/main.ts` at startup using the pinned installed runtime, with no CDN. `build` verifies that browser bundle in memory. CI checks types, focused path/state/API tests, bundling and the demo package. Follow AGENTS.md for contributor verification policy. [DESIGN.md](docs/DESIGN.md) distinguishes the runnable foundation from future modeling touch-up and preserves the illustrative screen concept.
|
|
118
|
+
The server bundles `web/main.ts` at startup using the pinned installed runtime, with no CDN. `build` verifies that browser bundle in memory. CI checks types, focused path/state/API tests, bundling and the demo package. Follow AGENTS.md for contributor verification policy. For a live run against the installed producers, [docs/LIVE_FIXTURE.md](docs/LIVE_FIXTURE.md) gives the public package, the producer bench and the open line that reaches `baseline: reproduced`. [DESIGN.md](docs/DESIGN.md) distinguishes the runnable foundation from future modeling touch-up and preserves the illustrative screen concept.
|
|
119
119
|
|
|
120
120
|
### Releasing
|
|
121
121
|
|
|
@@ -164,7 +164,12 @@ rig-review feedback /absolute/output --config /absolute/output/rig/config.json
|
|
|
164
164
|
handoff (see `docs/REVIEW_PACKAGE.md`, *Producer handoff*). They win over the
|
|
165
165
|
review.json `producer` block, which wins over discovery (`config.json`, then
|
|
166
166
|
`rig/config.json` inside the opened folder, then `../config.json` beside an opened
|
|
167
|
-
`out/`, named as discovered outside the folder).
|
|
167
|
+
`out/`, named as discovered outside the folder). For the `check/` package of a
|
|
168
|
+
`rig-parts build` output the next places are the build's own config: `config.json`
|
|
169
|
+
in the build root, else beside it (`<out>/../config.json`, where `rig-parts build
|
|
170
|
+
--config <dir>/config.json --out <dir>/out` leaves it); the parts directory is the
|
|
171
|
+
build root. Declaring `--config` at exactly one of those two files is taken the same
|
|
172
|
+
way, and that file's folder is then the root for the other flags. Every other path must sit inside the
|
|
168
173
|
package root; a path outside it or a symlink that leaves it is refused by name.
|
|
169
174
|
The handoff files are hashed into the reviewed identity, so `feedback` and
|
|
170
175
|
`control` take the same flags to retrieve the same session, and changing the
|
|
@@ -228,13 +233,15 @@ stderr and exit code alone:
|
|
|
228
233
|
|
|
229
234
|
1. On success stdout is exactly one line, the URL. `open` never writes anything
|
|
230
235
|
else to stdout.
|
|
231
|
-
2. On success stderr carries the human lines in this fixed order: the
|
|
232
|
-
|
|
236
|
+
2. On success stderr carries the human lines in this fixed order: for the `check/`
|
|
237
|
+
package of a `rig-parts build` output, `Package: rig-parts build check package <path>`
|
|
238
|
+
and the handoff line (`Handoff <how>: <config path> sha256 <hash> · parts <dir>`, or
|
|
239
|
+
`Handoff: <refusal>`); the animation diagnostic (when a clip is selected), the control notice (with `--control`),
|
|
233
240
|
then the copyable retrieval line `Review started|reused; feedback: rig-review feedback …`.
|
|
234
241
|
3. A refusal exits with code 1, writes nothing to stdout, and its first stderr line
|
|
235
242
|
starts with a name, followed by a colon and the explanation:
|
|
236
243
|
- `OPEN_INPUT_MISSING <path>`: the input or `--compare` path does not exist, or is neither a directory nor a regular file.
|
|
237
|
-
- `OPEN_INPUT_UNRECOGNIZED <path>`: the folder holds no recognized model, skeleton, motion, report, reference or pose sequence, or the file is not a JSON manifest.
|
|
244
|
+
- `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.
|
|
238
245
|
- `OPEN_REFERENCE_REQUIRED`: `--compare` was given without an explicit `--reference`.
|
|
239
246
|
- `OPEN_ANIMATION_UNKNOWN <name>`: the explicit or report-named clip (JSON-quoted) is not in the compiled model.
|
|
240
247
|
- `OPEN_ANIMATION_AMBIGUOUS`: no flag or report selects one clip, or the A/B reports conflict.
|
|
@@ -294,7 +301,10 @@ rig-review control /absolute/output --reference /absolute/original.mp4
|
|
|
294
301
|
```
|
|
295
302
|
|
|
296
303
|
The second command reads the connected page's actual inspection state and its
|
|
297
|
-
`pageId`/`revision`; it does not open a browser or start another worker.
|
|
304
|
+
`pageId`/`revision`; it does not open a browser or start another worker. A page
|
|
305
|
+
learns from `GET /api/access` (`control: true|false`) whether its instance serves
|
|
306
|
+
control; an instance opened without `--control` is never asked for
|
|
307
|
+
`/api/control/config`. To change
|
|
298
308
|
that page, use the observed values and a closed inspection command:
|
|
299
309
|
|
|
300
310
|
```sh
|
|
@@ -595,9 +605,9 @@ Rig pixels are the whole figure's frame, which is the crop frame the model's sta
|
|
|
595
605
|
|
|
596
606
|
- **Producer.** `@rig-c/parts`' `rig-parts`, resolved the way `rigc review` resolves `rig-review`: `node_modules/@rig-c/parts/package.json` in the nearest directory walking up from the package, then from the directory `rig-review open` was run in; its `bin` entry is read from that manifest, never from `PATH`. The compiler is the `rig-c` that `rig-parts` itself finds walking up from its own package. Absent: `MODELING_PRODUCER_NOT_INSTALLED: … — bun add -d @rig-c/parts`; older than the first release with `remesh` (1.40.0): `MODELING_PRODUCER_TOO_OLD <found> 1.40.0`. Review never installs anything.
|
|
597
607
|
- **Inputs.** The producer handoff (`docs/REVIEW_PACKAGE.md`, *Producer handoff*): `open --config` (and `--parts`), else the review.json `producer` block, else `config.json`, then `rig/config.json` inside the opened folder, then `../config.json` beside an opened `out/`. The parts directory defaults to `out/` beside the config and holds `parts.json`, `parts/` and the inspected `rig/`. The editor bar shows, before any Apply, how the config was found (declared, discovered, or discovered outside the opened folder), its path and SHA-256, and the baseline. Without a handoff the bar shows `MODELING_INPUT_UNAVAILABLE: config.json — looked at …` and Apply refuses `MODELING_HANDOFF_MISSING`, naming `--config`. The request carries only `{part, regions, configHash}`; the server reads the config, replaces `meshes.<part>.contour.regions` with the designated regions (every other field as written) and runs `rig-parts remesh --rig <out>/rig --config <config> --parts <out> --part <part> --mesh mesh.json --out revision`, then `rigc build --profile rig-play --pack` with the revision's own `--page-edges` / `--pack-shape`, both with the scratch directory as working directory. A region row with equal semi-axes and no clip is sent as the config's circle, any other as the 32-sample polygon. An untouched editor starts from the config's own circle regions and contour numbers.
|
|
598
|
-
- **Baseline.** Once per session, when the handoff and the producer both resolve, review runs `rig-parts rig --config <config> --parts <parts> --out <state>/baseline/<key>/baseline` and compares every file it writes (rig.json, motion.json, mesh_report.json, sets.json, images/*) with the inspected rig directory: `baseline: reproduced — rig.json <hash> = inspected <hash>`, or `baseline: not reproduced — <first differing path>`. The key is the package hash, the handoff record and the producer versions (no clock), so a reopen reuses the result. Ops that rebuild the whole rig refuse `EDIT_BASELINE_MISMATCH <path>` unless the baseline reproduced; `remesh` keeps its own `REMESH_BASELINE_MISMATCH`. Once it reproduces, `rig-parts check` runs on the baseline output in the background (into `<state>/baseline/<key>/inspected-check/`, with Apply's default `--page-edges free --pack-shape polygon`) and its summary is stored beside the baseline record as `inspected-check.json`; that is the physics readout's BEFORE for the inspected build, and a reopen reads it instead of re-running.
|
|
599
|
-
- **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused inside the package), named from the package, the config bytes, the part, the entry and both tool versions — no clock. The same designation again reuses its build and says so; a new one replaces the previous scratch build. The inspected package's files are only read.
|
|
600
|
-
- **Watching.** The producer's lines stream into the editor bar (the latest line and a short log); a refusal shows the producer's first named line verbatim (for example `FAIL REMESH_REQUEST_HELD: …`) and the stage is unchanged. On success the compiled scratch build (served read-only under `/rebuild/<id>/`, its model and pages hashed) replaces the drawn rig in place with the same camera, animation, time and part set; playback resumes with physics when it was running. **Reset** returns the stage to the inspected build.
|
|
608
|
+
- **Baseline.** Once per session, when the handoff and the producer both resolve, review runs `rig-parts rig --config <config> --parts <parts> --out <state>/baseline/<key>/baseline` and compares every file it writes (rig.json, motion.json, mesh_report.json, sets.json, images/*) with the inspected rig directory: `baseline: reproduced — rig.json <hash> = inspected <hash>`, or `baseline: not reproduced — <first differing path> · <what Apply can still do here>`. The key is the package hash, the handoff record and the producer versions (no clock), so a reopen reuses the result. Ops that rebuild the whole rig refuse `EDIT_BASELINE_MISMATCH <path>` unless the baseline reproduced; `remesh` keeps its own `REMESH_BASELINE_MISMATCH`. A build the config does not reproduce — a hand repair — is not open to every Apply (#148): retune carries a **mesh** repair over but holds every image, so a repair that redrew an image refuses every physics and idle edit (`RETUNE_REQUEST_HELD: images/<part>.png`); remesh from a config entry refuses the repaired parts and re-fits the others; a mesh edit is held to the inspected mesh, not the config; and remesh, its mesh edit and retune all refuse an inspected `rig.json` (or `mesh_report.json`, `motion.json`) that is not the writer's text (`…_INPUT_WRITTEN` — re-serialise it as rig writes it, JSON with one space of indent and a closing newline, in a copy). The baseline line and `EDIT_BASELINE_MISMATCH` name which of these hold here, and a producer refusal review can explain carries `review: …` after the producer's own line (docs/REVIEW_PACKAGE.md, *Baseline reach*). Once it reproduces, `rig-parts check` runs on the baseline output in the background (into `<state>/baseline/<key>/inspected-check/`, with Apply's default `--page-edges free --pack-shape polygon`) and its summary is stored beside the baseline record as `inspected-check.json`; that is the physics readout's BEFORE for the inspected build, and a reopen reads it instead of re-running.
|
|
609
|
+
- **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused inside the package), named from the package, the config bytes, the part, the entry and both tool versions — no clock. The same designation again reuses its build and says so; a new one replaces the previous scratch build on the stage (the earlier applied records' builds are kept beside the scratch root for undo, at most eight per package). The inspected package's files are only read.
|
|
610
|
+
- **Watching.** The producer's lines stream into the editor bar (the latest line and a short log); a refusal shows the producer's first named line verbatim (for example `FAIL REMESH_REQUEST_HELD: …`) and the stage is unchanged. On success the compiled scratch build (served read-only under `/rebuild/<id>/`, its model and pages hashed) replaces the drawn rig in place with the same camera, animation, time and part set; playback resumes with physics when it was running. **Reset** returns the stage to the inspected build. **Undo last Apply** (the *Applied records* list at the top of the Inspector, its depth in the label) returns the stage to the previous applied build, or to the inspected build when none is left; a predecessor whose build was evicted re-runs its recorded step and must come back byte for byte, else `UNDO_PREDECESSOR_CHANGED: <what>` (docs/REVIEW_PACKAGE.md, *Undo across Applies*).
|
|
601
611
|
- **Record.** The last applied state (`rig-review-applied/1`: part, regions, for a Landmarks Apply also `bust` — the entry as sent, with `regions` its two derived circles — config hash, entry SHA-256, producer and compiler versions, the producer's line and the scratch model's SHA-256) stays in the inspection draft, and feedback added while that build is on the stage carries it as `context.applied`. The review's `reviewedSource` still names the inspected build.
|
|
602
612
|
- **Config patch.** The same loop takes any config edit as data: `POST /api/rebuild {patch}` with a `rig-review-config-patch/1` document (JSON Pointer ops over the handoff config, refused `EDIT_PATCH_STALE` when made against other config bytes) rebuilds the whole rig (`rig`) or one part (`remesh`) into scratch, compiles it and runs `rig-parts check` on it; the page's `window.rigReviewEditor.applyPatch(patch)` streams, reloads and resets like Apply, and the draft and `context.applied` carry `rig-review-applied/2` (the patch, its hash, the producer's line, check's summary and the diff of every document the producer writes, per file, with the images by hash). Schema and refusals: `docs/REVIEW_PACKAGE.md`, *Config patch and Apply*.
|
|
603
613
|
|
|
@@ -695,7 +705,7 @@ In **Animate**, the **Retarget** block (#114) puts a pose sequence the package d
|
|
|
695
705
|
- **Clean** — clean first (default; a sequence that already carries clean's record starts with it off), `--loop`, `--legs`, `--rest`, the stillness hold and `--hips-inferred`. With clean off the sequence goes to retarget as the package holds it, and an uncleaned one is refused by the producer (`POSE_NOT_CLEANED`).
|
|
696
706
|
- **Retarget** — the new animation's name (`--animation`), `--flip`, `--short`, `--min-score`, `--bends refuse|clamp`, `--bend-tolerance`, `--leg-scale min,max`, `--plant-feet`, `--stance`, `--raised-by-head`, `--carry-idle`, and the person when the sequence holds more than one.
|
|
697
707
|
|
|
698
|
-
**Apply · Retarget** sends `POST /api/rebuild {retarget: {sequence, map, flags, configHash}}` — a reference id, the map by content and typed flags; the server maps the flags to argv. Into a scratch directory under the review state directory it writes the sequence (`pose.json`) and the map (`map.json`), runs `rig-pose clean --pose pose.json --out pose.clean.json …` and `rig-pose retarget --rig <handoff rig> --map map.json --sequence pose.clean.json --out motion.json …`, copies the handoff rig's files beside the new `motion.json` as `rig/`, and finishes with the shared tail: the per-file diff against the inspected rig and `rigc build` with Apply's default pack flags, using the `rigc` installed beside `@rig-c/pose`. The package's `motion.json` is never written; review writes no keys. Every `FAIL` line of a refusal is shown verbatim — `POSE_NOT_CLEANED`, `POSE_BEND_ONE_WAY`, `RAISED_HEAD`, `MOTION_NAME_FREE`, `MAP_RESOLVES` / `MAP_BONE_EXISTS` — and the stage keeps its model. A built job loads in place, the new animation is chosen, the sequence is shown as the reference beside the rig, and the run's FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines are listed; each line seeks to the first frame it names, at the motion's own key time for that frame (the run's report `frameTimes`). `context.applied` becomes `rig-review-applied/4`, with the `rig-review-retarget/1` record (sequence by reference, path and hash; map by content and hash; flags and their argv), the producer's line, what the scratch motion holds against the inspected rig's, the per-file diff and the listed lines. **Reset** returns to the inspected build. `rig-pose compare --sheet` is not run: it needs a detector pass over the render, which review does not run.
|
|
708
|
+
**Apply · Retarget** sends `POST /api/rebuild {retarget: {sequence, map, flags, configHash}}` — a reference id, the map by content and typed flags; the server maps the flags to argv. Into a scratch directory under the review state directory it writes the sequence (`pose.json`) and the map (`map.json`), runs `rig-pose clean --pose pose.json --out pose.clean.json …` and `rig-pose retarget --rig <handoff rig> --map map.json --sequence pose.clean.json --out motion.json …`, copies the handoff rig's files beside the new `motion.json` as `rig/`, and finishes with the shared tail: the per-file diff against the inspected rig and `rigc build` with Apply's default pack flags, using the `rigc` installed beside `@rig-c/pose`. The package's `motion.json` is never written; review writes no keys. Every `FAIL` line of a refusal is shown verbatim — `POSE_NOT_CLEANED`, `POSE_BEND_ONE_WAY`, `RAISED_HEAD`, `MOTION_NAME_FREE`, `MAP_RESOLVES` / `MAP_BONE_EXISTS` — and the stage keeps its model. A built job loads in place, the new animation is chosen, the sequence is shown as the reference beside the rig, and the run's FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines are listed; each line seeks to the first frame it names, at the motion's own key time for that frame (the run's report `frameTimes`). While that build is on the stage, its report `frameTimes` are the new animation's clock: the transport counter reads `F<n> / <last>` (`frameTimes · … · this build's report`) instead of `rate unknown`, and the rig pane's header names the animation, frame and time the drawn pose is at (`Generated rig · walk · F14 · 0.583 s`) — the same clock a seek sets. Reset or another rebuild drops that clock. `context.applied` becomes `rig-review-applied/4`, with the `rig-review-retarget/1` record (sequence by reference, path and hash; map by content and hash; flags and their argv), the producer's line, what the scratch motion holds against the inspected rig's, the per-file diff and the listed lines. **Reset** returns to the inspected build. `rig-pose compare --sheet` is not run: it needs a detector pass over the render, which review does not run.
|
|
699
709
|
|
|
700
710
|
To reproduce an Apply outside review, run the same producer with the record's flags in an empty directory holding the sequence as `pose.json` and the record's map as `map.json`: `rig-pose clean --pose pose.json --out pose.clean.json <op.clean…>` then `rig-pose retarget --rig <handoff rig> --map map.json --sequence pose.clean.json --out motion.json <op.args…>`. Its `motion.json` is byte-equal to the scratch build's `rig/motion.json`.
|
|
701
711
|
|
package/docs/DESIGN.md
CHANGED
|
@@ -24,7 +24,7 @@ Animate's Retarget block edits what a pose retarget is made from — the `rig-po
|
|
|
24
24
|
|
|
25
25
|
The chest-region editor and Edit Mesh exist for detail that cannot be said in words. Issue #99 states the loop: **point → rebuild → watch → repeat → submit**. Review builds no mesh itself; **Apply · Rebuild runs the installed producer as a child process** on the user's own designation, into a scratch build beside the inspected one, and the viewer reloads that build in place. Every applied state goes into the draft and the submitted feedback (`context.applied`), so the AI receiving the review gets the exact last applied state, not a description of it. Edit Mesh's delta has no producer operation yet and refuses by name.
|
|
26
26
|
|
|
27
|
-
Every Apply needs a binding to what the inspected rig was built from (issue #101): the **producer handoff**, declared (`open --config`, or the review.json `producer` block) or found at fixed places (`config.json`, `rig/config.json`, and last `../config.json` beside an opened `out/`, which the bar names as outside the folder). Its files are hashed into the reviewed identity beside the package, and once per session a **baseline** rebuilds the whole rig from them and says whether it reproduces the inspected one. An op that rebuilds the whole rig runs only on a reproduced baseline (`EDIT_BASELINE_MISMATCH` names the first differing path); `remesh` checks its own part (`REMESH_BASELINE_MISMATCH`). A patch that touches only what `rig-parts retune` splices — constraints, the idle's tracks and duration, the blink's numbers — runs retune instead (parts 1.44.0+, #136), which holds every other byte of the inspected rig itself, so a
|
|
27
|
+
Every Apply needs a binding to what the inspected rig was built from (issue #101): the **producer handoff**, declared (`open --config`, or the review.json `producer` block) or found at fixed places (`config.json`, `rig/config.json`, and last `../config.json` beside an opened `out/`, which the bar names as outside the folder). Its files are hashed into the reviewed identity beside the package, and once per session a **baseline** rebuilds the whole rig from them and says whether it reproduces the inspected one. An op that rebuilds the whole rig runs only on a reproduced baseline (`EDIT_BASELINE_MISMATCH` names the first differing path); `remesh` checks its own part (`REMESH_BASELINE_MISMATCH`). A patch that touches only what `rig-parts retune` splices — constraints, the idle's tracks and duration, the blink's numbers — runs retune instead (parts 1.44.0+, #136), which holds every other byte of the inspected rig itself, so a build whose repair is a mesh the config does not reproduce can still take a physics or idle edit — but not one whose repair redrew an image, which retune holds too, nor one whose rig.json is not the writer's text (#148; the baseline line says which); bones, stops, regions, turn and the blink's groups and still keep the whole-rig path and its guard.
|
|
28
28
|
|
|
29
29
|
Edits reach the producer as data (issue #102): every edit card states its edit as one **config patch** (`rig-review-config-patch/1`: the config hash it was made against, JSON Pointer ops, the producer and the op), and one generic runner applies it to a scratch copy of the handoff config, runs the op (`rig` for the whole rig, `remesh` for one part's entry), compiles and runs `check` on the result. A stale patch is refused (`EDIT_PATCH_STALE`) rather than rebased. The page's client exposes `applyPatch(patch)` with #99's stream, in-place reload and Reset; the physics tuning panel (#103) is the first editing UI that produces them. What was applied — the patch, its hash, the producer's line, check's summary and the rig.json diff — rides in `context.applied`, so the AI receiving the review can rebuild exactly that state.
|
|
30
30
|
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -9,7 +9,7 @@ order. Adapter means downstream consumer integration, not a newly introduced or
|
|
|
9
9
|
unreleased adapter contract. Existing producer formats and playback dependencies
|
|
10
10
|
remain authoritative.
|
|
11
11
|
|
|
12
|
-
The CLI also accepts an existing output directory. It reads a root `review.json` when present. Otherwise it constructs this contract in memory: optional root `skeleton.model.json`, otherwise the fixed rigc build path `build/skeleton.model.json`; read-only `skeleton.*`, `motion.json` and `report.json` links; root videos (`mp4`, `webm`, `mov`); image filenames containing strip/contact/sheet/filmstrip/띠; and canonical `*pose*.json` references. The root compiled model takes precedence over the build path. These are the only two automatically recognized model paths; discovery never searches recursively. The opened directory remains the package root, so root motion/references accompany the build model and its model-relative pages. An existing selected model with an invalid type, dangling symlink, escaping symlink or invalid model envelope is refused rather than skipped. Each reference is shown separately; no source relationship, FPS or video synchronization is inferred. It never inserts or rewrites a manifest or motion file in the source folder. Raw Spine JSON/skel remains read-only evidence with playback unavailable. The folder revision is `folder-adapter/1`; actual changed file bytes are distinguished by the source hash.
|
|
12
|
+
The CLI also accepts an existing output directory. It reads a root `review.json` when present. Otherwise it constructs this contract in memory: optional root `skeleton.model.json`, otherwise the fixed rigc build path `build/skeleton.model.json`; read-only `skeleton.*`, `motion.json` and `report.json` links; root videos (`mp4`, `webm`, `mov`); image filenames containing strip/contact/sheet/filmstrip/띠; and canonical `*pose*.json` references. The root compiled model takes precedence over the build path. These are the only two automatically recognized model paths; discovery never searches recursively. One fixed layout redirects instead (#149): a folder with neither model path that is a `rig-parts build` output root — regular `parts.json` and `rig/rig.json`, a real (not symlinked) `check/` directory, and an existing `check/build/skeleton.model.json` entry — opens as its `check/` package. The package root is then `check/`, exactly as if `check/` had been opened: the same manifest, source hash, `reviewedSource`, session and open key. A folder missing any of those files is not a build root and is refused or adapted as before; a build root that also holds a model of its own opens as itself. The opened directory remains the package root, so root motion/references accompany the build model and its model-relative pages. An existing selected model with an invalid type, dangling symlink, escaping symlink or invalid model envelope is refused rather than skipped. Each reference is shown separately; no source relationship, FPS or video synchronization is inferred. It never inserts or rewrites a manifest or motion file in the source folder. Raw Spine JSON/skel remains read-only evidence with playback unavailable. The folder revision is `folder-adapter/1`; actual changed file bytes are distinguished by the source hash.
|
|
13
13
|
|
|
14
14
|
`review.json` is UTF-8 JSON. Its directory is the package root. Required keys are `schema: "rig-review/1"`, `id`, `title`, and `revision`. `id` and every entry ID match `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`. Titles and revision strings are nonempty. Optional collections normalize to empty arrays.
|
|
15
15
|
|
|
@@ -126,9 +126,11 @@ An Apply rebuilds through the installed producer from the files the inspected ri
|
|
|
126
126
|
1. **Declared on the command line**: `rig-review open <dir> --config <path> [--parts <dir>] [--source <png> --full <dir> [--head <dir>]] [--keypoints <file>]`. `feedback` and `control` take the same flags so retrieval binds the same identity. Paths are resolved from the working directory and canonicalized; the worker and every reload read exactly those files.
|
|
127
127
|
2. **Declared in review.json**: the `producer` block, package-relative literal portable paths (no `..`, no absolute path, no encoding).
|
|
128
128
|
3. **Discovered inside the opened folder**: `config.json`, then `rig/config.json`.
|
|
129
|
-
4. **Discovered beside an opened `out
|
|
129
|
+
4. **Discovered beside an opened `out/`**: `../config.json`. It is outside the opened folder, so the record says `"how": "discovered", "outside": true` and the editor bar says *discovered outside the opened folder*, with its path and SHA-256. This step runs only when the opened folder is named `out`, and only for a regular file there (a symlink is refused).
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
5. **The build's own config, for the `check/` package of a `rig-parts build` output root** (#149): `../config.json` (in the build root), then `../../config.json` (beside it). A `rig-parts build` records no config path or hash, so "the producer's own config for that build" is defined by place, bounded by the build's own files: the opened package root is named `check` and its parent is a build root as recognized above, and the config is the regular file named `config.json` directly in that build root or directly in the folder holding it — the two places `rig-parts build --config <dir>/config.json --out <dir>/out` leaves it. No other parent and no other file name is looked at; a symlink there is refused (`HANDOFF_SYMLINK_ESCAPE`), never followed. The root for this step is the config's folder, the parts directory is the build root, and the record says `"how": "discovered", "outside": true` with the path (`../../config.json`) and SHA-256. Place is not proof that the config built the rig: the session baseline below is that check (`reproduced` or `not-reproduced`, by name). Declaring `--config` at exactly one of those two files (as written, its parent chain canonicalized) is taken the same way with `"how": "declared"`; that file's folder is then the root for `--parts`, `--source`, `--full`, `--head` and `--keypoints`. The build records none of those, so they stay declared-only. When nothing is at either place the refusal lists them: `MODELING_INPUT_UNAVAILABLE: config.json — looked at config.json, rig/config.json, ../config.json (in the build root), ../../config.json (beside the build root); declare it with …`.
|
|
132
|
+
|
|
133
|
+
Without `--parts` the parts directory is `out/` beside the config (for step 4, the opened `out/` itself; for step 5, the build root). A declared or discovered path that is missing, outside its root (the package root; for step 4 the folder holding `out/`; for step 5 the config's folder), or a symlink that leaves it is refused by name: `HANDOFF_PATH_OUTSIDE_ROOT`, `HANDOFF_SYMLINK_ESCAPE`, `HANDOFF_INPUT_MISSING`, `HANDOFF_INPUT_INVALID`, `HANDOFF_PATH_INVALID` (`open` prefixes `OPEN_HANDOFF_INVALID <flag or path>`). Nothing found is not a load error: `/api/rebuild` reports `MODELING_INPUT_UNAVAILABLE: config.json — looked at config.json, rig/config.json, ../config.json (beside an opened out/); declare it with rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block`, and Apply refuses `MODELING_HANDOFF_MISSING`.
|
|
132
134
|
|
|
133
135
|
**Hashed files.** The config; the files the config names that the producer resolves against the config's directory (`motion.animations_from`, `generation.control.skeleton`; each must stay inside the root, a missing one is listed in `missing`); `parts.json` and every regular file directly in `parts/`; every regular file directly in the inspected `rig/` (`rig.json`, `motion.json`, `mesh_report.json`, `sets.json`, …) and `rig/images/`; with `--source`/`--full`/`--head`, that PNG and the regular files directly in those directories; with `--keypoints` (#111; the review.json block's `keypoints`), that file — the same path rules as `--source` (literal, inside the root, no symlink escape).
|
|
134
136
|
|
|
@@ -140,9 +142,9 @@ Without `--parts` the parts directory is `out/` beside the config (for step 4, t
|
|
|
140
142
|
"files":[{"path":"rig/config.json","sha256":"…"},{"path":"rig/out/parts.json","sha256":"…"}],"missing":[]}
|
|
141
143
|
```
|
|
142
144
|
|
|
143
|
-
`keypoints` (#111) is `{path, sha256}` and is present only when the keypoint file is declared: a handoff without it has no `keypoints` key and hashes no keypoint file, so its record and identity are what they were before. `how` is `declared` or `discovered`, `via` is `open --config`, `review.json producer` or `discovery`, and paths are relative to the package root (`../` only for
|
|
145
|
+
`keypoints` (#111) is `{path, sha256}` and is present only when the keypoint file is declared: a handoff without it has no `keypoints` key and hashes no keypoint file, so its record and identity are what they were before. `how` is `declared` or `discovered`, `via` is `open --config`, `review.json producer` or `discovery`, and paths are relative to the package root (`../` only for steps 4 and 5). The record is part of `reviewedSource`, so it reaches `/api/review`, the session, the feedback export and the session file name: changing any handoff file's bytes starts a new pending session while `sourceHash` (the package bytes) is unchanged, and the open-managed key changes with it. The same inputs give the same record, key and URL.
|
|
144
146
|
|
|
145
|
-
**Baseline.** See README, *Chest regions*: once per session `rig-parts rig --config --parts --out <state>/baseline/<key>/baseline`, compared file by file with the inspected rig; `/api/rebuild` returns `baseline` (`status` `running`, `reproduced`, `not-reproduced`, `refused` or `failed`, `firstDifference`, both rig.json and tree hashes, `wallMs`). Nothing is written into the package. Once the baseline reproduces, `rig-parts check --rig baseline --out inspected-check --parts <parts> --page-edges free --pack-shape polygon` runs in the background in the same directory, and `/api/rebuild` returns `inspectedCheck` (`{schema:"rig-review-inspected-check/1", key, status:"running"|"checked"|"refused"|"failed", lines[], check (the check summary, or null), line ("check running…", check's `check: …` line, or its named refusal verbatim), exitCode, wallMs}`, else `null`). The finished record is stored as `<state>/baseline/<key>/inspected-check.json`, so a reopen reads it instead of re-running; it is the physics readout's BEFORE for the inspected build.
|
|
147
|
+
**Baseline.** See README, *Chest regions*: once per session `rig-parts rig --config --parts --out <state>/baseline/<key>/baseline`, compared file by file with the inspected rig; `/api/rebuild` returns `baseline` (`status` `running`, `reproduced`, `not-reproduced`, `refused` or `failed`, `firstDifference`, both rig.json and tree hashes, `wallMs`, and since #148 `reach` when not reproduced — *Baseline reach*, below). Nothing is written into the package. Once the baseline reproduces, `rig-parts check --rig baseline --out inspected-check --parts <parts> --page-edges free --pack-shape polygon` runs in the background in the same directory, and `/api/rebuild` returns `inspectedCheck` (`{schema:"rig-review-inspected-check/1", key, status:"running"|"checked"|"refused"|"failed", lines[], check (the check summary, or null), line ("check running…", check's `check: …` line, or its named refusal verbatim), exitCode, wallMs}`, else `null`). The finished record is stored as `<state>/baseline/<key>/inspected-check.json`, so a reopen reads it instead of re-running; it is the physics readout's BEFORE for the inspected build.
|
|
146
148
|
|
|
147
149
|
## Config patch and Apply (`rig-review-config-patch/1`)
|
|
148
150
|
|
|
@@ -158,7 +160,9 @@ Every edit card states its edit as one config patch; review applies it to a scra
|
|
|
158
160
|
|
|
159
161
|
- **Document.** Exactly these five keys. `ops` holds 1–256 operations, each `replace` or `add` (`{op, path, value}`, value any JSON) or `remove` (`{op, path}`), each with an optional `name` (#103): the `name` the list entry `/<list>/<index>` holds in the config the patch was made against (a physics edit records `{"op":"replace","path":"/constraints/1/damping","value":0.3,"name":"fringe_r1_physics"}`), applied in order with RFC 6902 semantics; `path` is an RFC 6901 JSON Pointer into the config (`~1` is `/`, `~0` is `~`; an array index is a decimal without leading zeros, and `add` may use `-` to append). `""` (the whole config) is refused. `producer` is the producer the patch was made for. `op.cmd` is `rig` (rebuild the whole rig from the patched config) or `remesh` (re-fit one part from its patched `meshes.<part>` entry; `args` must hold `--part <name>`). `op.args` may also hold `--idle-keys ctl|direct`, `--page-edges pot|free` and `--pack-shape rect|polygon`, each once; nothing else, never a path.
|
|
160
162
|
- **Refusals before anything runs.** Malformed (HTTP 400): `EDIT_PATCH_INVALID`, `EDIT_PATCH_OP_UNKNOWN`, `EDIT_PATCH_POINTER_INVALID`, `EDIT_PATCH_CMD_UNKNOWN`, `EDIT_PATCH_ARG_UNKNOWN`. Outcomes (HTTP 200, `{status:"refused", error, producerLine:null}`): `EDIT_PATCH_STALE` when `config_sha256` is not the handoff config's hash; `EDIT_PATCH_PATH_MISSING` when a pointer does not resolve in that config; `EDIT_PATCH_NAME_MISMATCH` when an op's `name` is not the name its index holds there; `EDIT_PATCH_PATH_HELD` when the patch changes a file name the config resolves against its directory (`motion.animations_from`, `generation.control.skeleton` — handoff files); `EDIT_PATCH_OUT_OF_SCOPE` when a `remesh` patch edits outside `/meshes/<part>`; `EDIT_PRODUCER_MISMATCH` when the installed producer is not the patch's; for a `rig` patch the runner runs as the whole-rig `rig` (*Routing*, below), `EDIT_BASELINE_MISMATCH: <first differing path>` unless the session's baseline reproduced the inspected rig (*Producer handoff*, Baseline); and the producer-resolution refusals of an Apply.
|
|
161
|
-
- **Routing (#136).** A `rig` patch runs `rig-parts retune` instead of the whole-rig `rig` when the installed producer is `@rig-c/parts` 1.44.0 or later (`RETUNE_SINCE`, the CHANGELOG's first release with retune) and **every** op's pointer lies at or under one of `/constraints`, `/motion/duration`, `/motion/tracks`, `/motion/blink/t`, `/motion/blink/squash`, `/motion/blink/brow_drop` (`src/retune-route.ts`). The scope is retune's own help: it splices the config's constraints (rig.json `constraints` and `invariants.detached`) and idle (motion.json `animations.idle`) onto the gated inspected rig and holds stops, bones, slots, skeleton, images, every other invariant and every other byte of motion.json (`RETUNE_REQUEST_HELD`). So `/stops`, `/bones`, `/regions`, `/turn`, `/meshes`, `/motion/animations_from`, the blink's `eyes` and `brows` (its motion.json groups) and `still` (a `<part>_still` slot), and a patch mixing any of them with retune's pointers, keep the whole-rig `rig` behind the baseline guard; so does every patch on an older producer. Retune needs no baseline: it holds the
|
|
163
|
+
- **Routing (#136).** A `rig` patch runs `rig-parts retune` instead of the whole-rig `rig` when the installed producer is `@rig-c/parts` 1.44.0 or later (`RETUNE_SINCE`, the CHANGELOG's first release with retune) and **every** op's pointer lies at or under one of `/constraints`, `/motion/duration`, `/motion/tracks`, `/motion/blink/t`, `/motion/blink/squash`, `/motion/blink/brow_drop` (`src/retune-route.ts`). The scope is retune's own help: it splices the config's constraints (rig.json `constraints` and `invariants.detached`) and idle (motion.json `animations.idle`) onto the gated inspected rig and holds stops, bones, slots, skeleton, images, every other invariant and every other byte of motion.json (`RETUNE_REQUEST_HELD`). So `/stops`, `/bones`, `/regions`, `/turn`, `/meshes`, `/motion/animations_from`, the blink's `eyes` and `brows` (its motion.json groups) and `still` (a `<part>_still` slot), and a patch mixing any of them with retune's pointers, keep the whole-rig `rig` behind the baseline guard; so does every patch on an older producer. Retune needs no baseline, but it does not carry every repair (#148, `@rig-c/parts` 1.49.1 AUTHORING §retune and `src/retune.ts`): it carries every attachment and every `mesh_report.json` row over as bytes, so a **mesh** repair the config does not reproduce stays; it holds every image file (with stops, bones, slots, skeleton, every invariant but `detached` and motion.json outside the idle) to the config's rebuild, so on a build whose repair redrew an image every retune refuses `RETUNE_REQUEST_HELD: images/<part>.png — the config changes it`; and it splices `rig.json` and `motion.json` as text, so an input that is not the writer's text refuses `RETUNE_INPUT_WRITTEN`. Measured on parts-examples `family/roa` (a hand shoulder repair that redrew `topwear.png`, parts 1.49.1): as committed, retune refuses `RETUNE_INPUT_WRITTEN` (its `rig.json` is not the writer's text); re-serialised in the writer's format, it refuses `RETUNE_REQUEST_HELD: images/topwear.png`. The baseline line says which applies (*Baseline reach*, below). What it judges per value (a track that newly keys a mesh-bound bone, which gains a `<bone>_ctl` under `--idle-keys ctl`) comes back as its own `RETUNE_REQUEST_HELD` line verbatim, as do `RETUNE_CONFIG_REFUSED` and its other refusals.
|
|
164
|
+
- **Baseline reach (#148).** When the baseline does not reproduce, `baseline.reach` (`src/baseline-reach.ts`) reads both rig directories and states what each producer op will still take, from the producer's own holds: `{schema:"rig-review-baseline-reach/1", notWriterText[], images[], retuneHeld, remeshRefused[], meshParts[], bonesDiffer, ops:{retune, delta, version}, text}`. `notWriterText`: the inspected `rig.json`, `motion.json` and `mesh_report.json` that are not `JSON.stringify(value, null, 1)` and a closing newline (the writer, parts `rigJsonText`) — remesh and its mesh edit refuse `rig.json` / `mesh_report.json` (`REMESH_INPUT_WRITTEN`), retune `rig.json` / `motion.json` (`RETUNE_INPUT_WRITTEN`). `images`: the images the config draws differently. `retuneHeld`: retune's first held difference in its own order (stops, bones, slots, skeleton, images, invariants but `detached`, each image, motion.json outside `animations.idle`), or null when a physics or idle edit Applies. `remeshRefused`: the meshed parts (`skins.default.<part>.<part>` of type `mesh`) whose attachment, image or report row (or an `auto` row's motion) the config does not reproduce — remesh from a config entry refuses them `REMESH_BASELINE_MISMATCH` and re-fits the rest; `bonesDiffer` refuses every part. A mesh edit (`remesh --delta`) builds no config entry, so the baseline does not hold it. `ops` is the installed producer's: below 1.44.0 a physics or idle edit is whole-rig. `text` is appended to the baseline line (`baseline: not reproduced — <first differing path> · <text>`) and to `EDIT_BASELINE_MISMATCH: <path> — <text>`. It is review's reading of the producer's rules; the producer's line on Apply stays the judgment. For family/roa as committed: `out/rig/rig.json, out/rig/mesh_report.json are not the text rig writes for their values (reformatted, or written by another tool), and an op that splices a revision as text refuses such a file — use the files as rig wrote them, or re-serialise them in a copy of the package exactly as rig writes them (JSON with one space of indent and a closing newline) and open the copy; whole-rig edits refuse (EDIT_BASELINE_MISMATCH); physics and idle edits (retune) refuse out/rig/rig.json (RETUNE_INPUT_WRITTEN), and past that the config draws out/rig/images/topwear.png differently and retune holds every image (RETUNE_REQUEST_HELD); remesh and a mesh edit refuse out/rig/rig.json, out/rig/mesh_report.json (REMESH_INPUT_WRITTEN); past that, remesh: bottomwear, topwear, hair_back, hair_front are refused (REMESH_BASELINE_MISMATCH: …); handwear_r, neck, earring can be re-fit`.
|
|
165
|
+
- **Producer refusal hint (#148).** When the producer refuses with a line review can explain — `REMESH_INPUT_WRITTEN` / `RETUNE_INPUT_WRITTEN` (a file that is not the writer's text) or `RETUNE_REQUEST_HELD: images/<name>` (an image the config draws differently) — the job keeps `producerLine` as printed and adds `hint` (absent otherwise), also pushed to `lines` as `review: <hint>`; the status line reads `<producer line> · review: <hint>`. For `RETUNE_INPUT_WRITTEN` on `rig.json`: `rig.json in the inspected rig is not the text rig writes for its value (reformatted, or written by another tool); retune splices a revision as text, so it refuses the file — use the files as rig wrote them, or re-serialise them in a copy of the package exactly as rig writes them (JSON with one space of indent and a closing newline) and open the copy`.
|
|
162
166
|
- **Build (#115).** A `rig` patch with any op at or under `/assemble` or `/limbs` — or under `/bones` on a config that declares `limbs`, whose joints are their bones' origins — runs `rig-parts build` (op `build`): those sections are read by the producer's assemble stage, which `rig --config --parts` never runs (rig-parts AUTHORING §3, the `assemble.cuts`, `assemble.patches` and `limbs` rows). It needs the handoff's painting and full run (`--source <png> --full <dir> [--head <dir>]`, or the review.json block); without them it refuses before anything runs: `MODELING_INPUT_UNAVAILABLE: source painting and See-through runs — rig-parts build reassembles the parts from the painting (--source) and the runs (--full, --head) before the rig, and the handoff declares none; declare them with …`. It is a whole-rig op behind the reproduced baseline (`EDIT_BASELINE_MISMATCH`). The files assemble reads by the config's own naming (each cut's `under`, each limb segment's `cap`, each alternate's `source`, `full`, `head` and `skeleton`) are resolved against the handoff config's directory, must stay inside the package root (`BUILD_INPUT_OUTSIDE_ROOT`, `BUILD_INPUT_MISSING`) and are hashed into the build id; a patch may not name a new one (`EDIT_PATCH_PATH_HELD` — it may keep, move or drop one the config names). `config.json` is the patched config with those paths and the two above made absolute, then `rig-parts build --config config.json --source <handoff source> --full <handoff full> [--head <handoff head>] --out out --seam-pairs [args]`; the rig spec is `out/rig`, and the shared tail diffs and compiles it (no second check: build's own check ran on the parts it assembled). A stage that refuses leaves no scratch, and its FAIL lines come back verbatim (the stage prefix `[assemble]`, `[rig]`, `[check]` stripped); a build that stopped at check with bars not met is kept, check's FAIL lines beside it. The job adds `assembled: {inputs:{source, full, head, named:[{field, path, sha256|null}]}, refusals[], checkFails[], lines[] (the cut:, limb cut:, limb cap: and uncovered-hole lines), seam[], seamPairs[] (the base set's `seam pair` lines), recomposite:{line, before, after, errorMap:{url, sha256}|null}, parts:{changed[], unchanged, partsJson:{count, paths}}|null}` (`before` is the handoff's `parts.json` recomposite block, `after` the scratch one; `errorMap` serves `out/recomposite_error_rig.png` read-only at `/rebuild/<id>/recomposite/recomposite_error_rig.png`), and `wallMs.op` is the build's own wall clock (assemble → rig → check).
|
|
163
167
|
- **Cloth (#111).** A `rig` patch whose ops all lie under `/cloth` (the Setup · Cloth block: one recipe per tag the installed reader accepts — read from its own `CLOTH_TARGETS` — with the reader's fields) runs op `cloth`; a patch mixing `/cloth` ops with others is refused before anything runs (`CLOTH_PATCH_MIXED`). Refused before anything runs: `CLOTH_KEYPOINTS_UNDECLARED` when the handoff declares no `--keypoints` (without them the draft moves the body bones the config states — Decision 1), `MODELING_PRODUCER_TOO_OLD <found> 1.49.1` (the first `@rig-c/parts` whose `--write-config` splices `bones`, `constraints` and `motion.tracks` entry by entry, parts#552), `MODELING_INPUT_UNAVAILABLE: source painting …` without `--source`, and, as a whole-rig op, `EDIT_BASELINE_MISMATCH`. `cloth.json` is the patched config written as `rig` writes its scratch config, with the assemble-read paths made absolute as for `build`; then `rig-parts propose --parts <handoff parts> --source <handoff source> --keypoints <handoff keypoints> --out propose --cloth cloth.json --write-config config.json` (no `--wrist-snap` or `--grid-rule`: the handoff states neither). Its refusals come back verbatim, every FAIL line in `cloth.refusals` — the reader's `CONFIG_CLOTH_*`, `PROPOSE_CLOTH_DRAFTED` (a recipe that drafts nothing), `PROPOSE_CLOTH_PART`, and the write's `PROPOSE_WRITE_CONFIG_FRESH`, `PROPOSE_WRITE_CONFIG_CONFLICT` (an entry the recipe re-drafts or removes that equals neither the draft with the recipe nor the draft without it, rig-parts AUTHORING *The config written*) and `PROPOSE_WRITE_CONFIG_SPLICE`. Review reads only the written `config.json`, never the producer's draft file: it lists every JSON Pointer at which the written config differs from `cloth.json` and refuses to build one that differs outside the sections propose splices (`bones`, `constraints`, `meshes`, `regions`, `limbs`, `motion/tracks`, `turn`, `proposed_rig_scale`): `CLOTH_WRITTEN_OUTSIDE_DRAFTED`. The written config is then built (Decision 2) as `rig-parts build --config config.json …` exactly as a `build` patch when the config declares `limbs` (the job adds `assembled`), else `rig-parts rig --config config.json --parts <handoff parts> --out rig`, and goes through the shared tail. The id adds `cloth`, the keypoint file's hash and (for the build route) the build inputs' hash. The job adds `cloth: {route:"build"|"rig", inputs:{source, keypoints:{path, sha256}}, refusals[], line (propose's `wrote config.json: …` line), written:{count, paths[≤200], outside[≤200], sections[]}|null, links[] (per tag: its parts and the written config's chains, the link preview), wallMs:{propose, build}}`. `GET /api/rebuild` adds `cloth: {targets, targetsRead, recipes, tags[] (per tag: its parts by See-through tag or by the recipe's `part`/`parts`, and the chains their `meshes.<part>.segments` name with `points` and `tip`), keypoints, route, reason}`.
|
|
164
168
|
- **Run.** Scratch is `<state>/rebuild/<package source hash>/<id>/`, `id = sha256([rig-review-rebuild/1, "patch", source hash, config SHA-256, the patch's canonical bytes, producer@version, compiler@version])` — no clock; the same patch again reuses its build. `rig`: `config.json` there is the patched config written as the producer's own scratch configs are (2-space JSON, `motion.animations_from` and `generation.control.skeleton` made absolute when they name a file beside the handoff config), then `rig-parts rig --config config.json --parts <handoff parts> --out rig [args]`, or for a retune-routed patch `rig-parts retune --rig <handoff rig> --config config.json --parts <handoff parts> --out rig [args]` (the same config; retune reads the inspected rig and writes only `rig/`, with `retune.json`). `remesh`: `mesh.json` is the patched `meshes.<part>` entry and `rig-parts remesh --rig <handoff rig> --config <handoff config> --parts <handoff parts> --part <part> --mesh mesh.json --out rig [args]` (remesh reads the config as written). Then `rigc build --rig rig/rig.json --motion rig/motion.json --out build --profile rig-play --pack --page-edges … --pack-shape …` (the args' values, else `free` / `polygon`; for remesh, its `remesh.json` request; for retune, its `retune.json` request) and `rig-parts check --rig rig --out check --parts <handoff parts>` with the same pack flags. A producer or compiler refusal is the first named line verbatim and leaves no scratch; a check `FAIL` is check's judgment, kept beside the build.
|
|
@@ -219,6 +223,8 @@ The issue export adds an `Applied:` line for a valid record (`patch <hash>… (r
|
|
|
219
223
|
|
|
220
224
|
**Rig fields (#110).** `GET /api/rebuild` also returns `fields`: the handoff config's `regions`, `motion.blink` (as `{motion:{blink}}`), `stops`, `bones` and `turn` as written, an absent section left out (or `null` without a handoff). The Rig fields block's edits are ordinary config patch ops, each at its field's pointer (rig-parts AUTHORING §3): `{op:"replace", path:"/regions/<part>", value:<bone>}`; `{op:"add", path:"/motion/blink/still/<part>", value:{row, bone}}` (preceded by `{op:"add", path:"/motion/blink/still", value:{}}` when the config states no `still`) or `{op:"remove", path:"/motion/blink/still/<part>"}`; `{op:"replace", path:"/stops/<i>", value:<the entry with inward / outward changed>, name:<its chain>}`; `{op:"add"|"remove", path:"/bones/<i>/inherit", value:<mode>, name}` or `path:"/bones/<i>/link_inherit", value:{<link>: mode}`; `{op:"replace", path:"/turn/pivot"|"/turn/depths/<part>", value:<number>}`. A `stops` entry is named by its `chain` and `EDIT_PATCH_NAME_MISMATCH` checks it as it checks a `bones` entry. The page refuses anything else before sending (`RIG_FIELDS_OP_OUT_OF_SCOPE`; `turn.deform` is out of scope) and sends the ops as one `rig` patch (no flag), so it needs the reproduced baseline: none of its pointers is in retune's scope (*Routing*). The producer judges every value and its refusal comes back verbatim as the job's `producerLine`. The turn dial is play's `adjust` on the drawn build's `turn` bone; it is not a patch op and nothing records it. Nothing is written into the package.
|
|
221
225
|
|
|
226
|
+
**Undo across Applies (#166).** The session's applied records are a stack, in order, each with its build id; the last is on the stage. `GET /api/rebuild/history` (single review) answers `{schema:"rig-review-applied-history/1", depth, records:[{id, kind:"regions"|"patch"|"delta"|"retarget", op, what, modelHash, kept}]}` (`kept`: the record's build is still on disk). `POST /api/rebuild/undo` (same-origin, capability, 1 KiB) takes exactly `{from}`, the build id the page shows on the stage; it removes the last record and returns the stage to its predecessor's build, or to the inspected build when none is left: `{status:"built", undone, job, history}` (the predecessor's job, its model served at `/rebuild/<id>/` again), `{status:"running", undone, job}` (an evicted predecessor re-running; poll `/api/rebuild/<id>`), or `{status:"inspected", undone, history}`. Refusals (HTTP 200 `{status:"refused", error}`): `UNDO_NOTHING_APPLIED`, `UNDO_STALE` (`from` is not the last record), `REBUILD_BUSY`, and `UNDO_PREDECESSOR_CHANGED: <what>`; malformed: HTTP 400 `REBUILD_REQUEST_INVALID`. The scratch root `<state>/rebuild/<package hash>/` still holds only the build on the stage; the other records' builds wait in `<state>/rebuild-kept/<package hash>/<id>/`, at most `MAX_KEPT_BUILDS` (8) builds per package on disk counting the stage's, the oldest record's evicted first, and a build no record names is removed. A kept predecessor is moved back and re-hashed (every file the model names) against both its result.json and its record; an evicted one re-runs its recorded request through the same runner, refused before anything runs when the handoff config's sha256 or the producer or compiler version is not the record's (`UNDO_PREDECESSOR_CHANGED: <config path> sha256 a… → b…`, `UNDO_PREDECESSOR_CHANGED: @rig-c/parts 1.49.1 → @rig-c/parts 1.50.0; …`), or when its build id hashes differently (`UNDO_PREDECESSOR_CHANGED: build id a… → b… — …`), and refused after the run when the build is not byte-identical, by the first differing file (`UNDO_PREDECESSOR_CHANGED: skeleton.model.json — the record's build has sha256 a…, the re-run wrote b…; …`) — never guessed; a refusal leaves the stage and the stack on the last Apply. The undone record's build is removed (no redo). Undo writes nothing into the package. Feedback written after an undo carries the post-undo record as `context.applied` (or `null` on the inspected build), never the reverted one. The stack lives in the review process; a restarted review starts with none.
|
|
227
|
+
|
|
222
228
|
## Mesh edit export
|
|
223
229
|
|
|
224
230
|
Edit Mesh's **Download JSON** (#80, setup space since #100) saves two files; neither is written into the package and neither enters the review session.
|
|
@@ -295,7 +301,7 @@ The uniform example is an explicitly authored test clock; it is not a timing cla
|
|
|
295
301
|
|
|
296
302
|
The current frame is the greatest sample index whose timestamp is at or before the inspection time, only within the closed interval from the first sample to the last. Time before or after that domain is unknown; the last frame has no invented trailing duration. A one-frame clock is known only at its single timestamp, and an empty clock is unknown. Frame seeking accepts only a nonnegative integer through the declared last index and returns that sample's exact time. Broad epsilon snapping does not advance a genuinely earlier time to the next frame.
|
|
297
303
|
|
|
298
|
-
With a model player, the transport uses this clock only while the active animation exactly matches the report's named `animation`; other animations or a missing binding keep frame lookup unknown. A source-only inspection can use its explicit report clock without inventing an animation identity. Unbounded or absent timing keeps frame lookup unknown. The seconds transport remains available independently. Bounded clocks show `F<n> / <last>`; outside their domain the frame is unknown. The unchanged v19 report displays `rate unknown`. An explicit 24fps, zero-offset test fixture shows F100 at `100/24`, displayed as 4.1667s; offset and irregular fixtures exercise the different declared times.
|
|
304
|
+
With a model player, the transport uses this clock only while the active animation exactly matches the report's named `animation`; other animations or a missing binding keep frame lookup unknown. The one other clock is a scratch build's own: after a Retarget Apply, the run's report `frameTimes` bind to the new animation and to that build's model hash, and the transport uses them for that animation when the package report has no clock for it. A source-only inspection can use its explicit report clock without inventing an animation identity. Unbounded or absent timing keeps frame lookup unknown. The seconds transport remains available independently. Bounded clocks show `F<n> / <last>`; outside their domain the frame is unknown. The unchanged v19 report displays `rate unknown`. An explicit 24fps, zero-offset test fixture shows F100 at `100/24`, displayed as 4.1667s; offset and irregular fixtures exercise the different declared times.
|
|
299
305
|
|
|
300
306
|
When feedback is added, `context.frame` is recalculated from the active clock and current inspection time, or recorded as null. Marker frame labels do not override that calculation. Existing inclusive feedback intervals remain producer annotations and are not inferred from the transport. No clock operation retimes or writes motion, source report or models.
|
|
301
307
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Apply can still do on a build whose baseline did not reproduce (#148). The baseline (`src/baseline.ts`) rebuilds
|
|
3
|
+
* the whole rig from the handoff config; when that differs from the inspected rig, the whole-rig ops refuse
|
|
4
|
+
* (`EDIT_BASELINE_MISMATCH`), and the producer's own revising ops hold their own parts of it. This module reads both
|
|
5
|
+
* rig directories and states, per op, what the installed producer will hold — read off `@rig-c/parts`' AUTHORING
|
|
6
|
+
* (§remesh and §retune, the `REMESH_*` and `RETUNE_*` rows) and `src/remesh.ts` / `src/retune.ts`:
|
|
7
|
+
*
|
|
8
|
+
* - **The writer's text.** `remesh` (and its `--delta` mesh edit) splices `rig.json` and `mesh_report.json` as text,
|
|
9
|
+
* `retune` splices `rig.json` and `motion.json`; each refuses an input file that is not the text the writer prints
|
|
10
|
+
* for its value (`REMESH_INPUT_WRITTEN`, `RETUNE_INPUT_WRITTEN`: "reformatted, or written by something else"). The
|
|
11
|
+
* writer is `rigJsonText`: `JSON.stringify(value, null, 1)` and a closing newline.
|
|
12
|
+
* - **retune** carries every attachment and every `mesh_report.json` row over as bytes, so a mesh repair stays; it
|
|
13
|
+
* holds `stops`, `bones`, `slots`, `skeleton`, `images`, every invariant but `detached`, every image file and
|
|
14
|
+
* `motion.json` outside `animations.idle` to the config's rebuild (`RETUNE_REQUEST_HELD`). An image the config draws
|
|
15
|
+
* differently — a repaired texture — is therefore held, and no constraint or idle edit can Apply over it.
|
|
16
|
+
* - **remesh** holds its target part (its attachment, its image, its `mesh_report.json` row, and for an `auto` row the
|
|
17
|
+
* motion) and the bones to the config's rebuild (`REMESH_BASELINE_MISMATCH`); every other part is carried over. The
|
|
18
|
+
* mesh edit (`--delta`) builds no config entry: its baseline is the inspected mesh itself.
|
|
19
|
+
*
|
|
20
|
+
* The baseline is the config as written, so this is what an op whose patch the producer judges no further meets. It is
|
|
21
|
+
* a reading, not the producer's verdict: the producer's own line on Apply stays the judgment, shown verbatim.
|
|
22
|
+
*/
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { PRODUCER_IMAGES_DIR } from './producer-files.ts';
|
|
26
|
+
import { REMESH_DELTA_SINCE, versionAtLeast } from './producer.ts';
|
|
27
|
+
import { RETUNE_SINCE } from './retune-route.ts';
|
|
28
|
+
|
|
29
|
+
/** Which revising ops the installed producer has: `retune` since 1.44.0, `remesh --delta` since 1.43.0. Unknown version: both. */
|
|
30
|
+
export interface ReachOps { retune: boolean; delta: boolean; version: string | null }
|
|
31
|
+
export const reachOps = (version: string | null | undefined): ReachOps => ({ retune: !version || versionAtLeast(version, RETUNE_SINCE), delta: !version || versionAtLeast(version, REMESH_DELTA_SINCE), version: version || null });
|
|
32
|
+
|
|
33
|
+
/** `@rig-c/parts`' writer for its rig documents (`src/rig.ts` `rigJsonText`): one space of indent and a closing newline. */
|
|
34
|
+
export const writerText = (value: unknown): string => `${JSON.stringify(value, null, 1)}\n`;
|
|
35
|
+
/** True when the text is exactly what the writer prints for the value it parses to. */
|
|
36
|
+
export function isWriterText(text: string): boolean {
|
|
37
|
+
try { return writerText(JSON.parse(text)) === text; } catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const BASELINE_REACH_SCHEMA = 'rig-review-baseline-reach/1';
|
|
41
|
+
export interface BaselineReach {
|
|
42
|
+
schema: typeof BASELINE_REACH_SCHEMA;
|
|
43
|
+
/** Inspected documents (package-relative) that are not the writer's text, in `rig.json`, `motion.json`, `mesh_report.json` order. */
|
|
44
|
+
notWriterText: string[];
|
|
45
|
+
/** Images (package-relative) the config draws differently from the inspected rig, or that one side lacks. */
|
|
46
|
+
images: string[];
|
|
47
|
+
/** retune (physics and idle edits): null when it holds nothing the config changes, else the first held difference. */
|
|
48
|
+
retuneHeld: string | null;
|
|
49
|
+
/** remesh from a config entry: the meshed parts whose attachment, image or report row the config does not reproduce. */
|
|
50
|
+
remeshRefused: string[];
|
|
51
|
+
/** remesh: every meshed part of the inspected rig, in the order the rig lists its skins. */
|
|
52
|
+
meshParts: string[];
|
|
53
|
+
/** remesh: true when the config moves a bone, which refuses every part. */
|
|
54
|
+
bonesDiffer: boolean;
|
|
55
|
+
/** The producer's revising ops this reading assumed (`reachOps`). */
|
|
56
|
+
ops: ReachOps;
|
|
57
|
+
/** The sentence the baseline line and `EDIT_BASELINE_MISMATCH` carry. */
|
|
58
|
+
text: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Json = unknown;
|
|
62
|
+
const isObject = (v: Json): v is Record<string, Json> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
63
|
+
/** Structural equality of two parsed JSON values (object key order ignored). */
|
|
64
|
+
export function jsonEqual(a: Json, b: Json): boolean {
|
|
65
|
+
if (a === b) return true;
|
|
66
|
+
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
|
|
67
|
+
if (!isObject(a) || !isObject(b)) return false;
|
|
68
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
69
|
+
return ka.length === kb.length && ka.every(k => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]));
|
|
70
|
+
}
|
|
71
|
+
const withoutKey = (v: Json, key: string): Json => isObject(v) ? Object.fromEntries(Object.entries(v).filter(([k]) => k !== key)) : v;
|
|
72
|
+
/** motion.json outside `animations.idle`, as retune holds it. */
|
|
73
|
+
const outsideIdle = (m: Json): Json => isObject(m) ? { ...m, animations: withoutKey(m.animations, 'idle') } : m;
|
|
74
|
+
/** The rig's mesh attachments named as `remesh --part` names them: `skins.default.<part>.<part>` of type `mesh`. */
|
|
75
|
+
function meshAttachments(rig: Json): Map<string, Json> {
|
|
76
|
+
const out = new Map<string, Json>();
|
|
77
|
+
const skins = isObject(rig) ? rig.skins : undefined, skin = isObject(skins) ? skins.default : undefined;
|
|
78
|
+
if (!isObject(skin)) return out;
|
|
79
|
+
for (const [slot, atts] of Object.entries(skin)) if (isObject(atts) && isObject(atts[slot]) && (atts[slot] as Record<string, Json>).type === 'mesh') out.set(slot, atts[slot]);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
const reportRow = (report: Json, part: string): Json => Array.isArray(report) ? report.find(r => isObject(r) && r.part === part) : undefined;
|
|
83
|
+
|
|
84
|
+
export interface RigDocuments { rig: Json; motion: Json; report: Json; texts: Record<string, string | null> }
|
|
85
|
+
const DOCUMENTS = ['rig.json', 'motion.json', 'mesh_report.json'] as const;
|
|
86
|
+
async function documents(dir: string): Promise<RigDocuments> {
|
|
87
|
+
const texts: Record<string, string | null> = {}, parsed: Record<string, Json> = {};
|
|
88
|
+
for (const name of DOCUMENTS) {
|
|
89
|
+
let text: string | null = null;
|
|
90
|
+
try { text = await readFile(join(dir, name), 'utf8'); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
|
91
|
+
texts[name] = text;
|
|
92
|
+
try { parsed[name] = text === null ? undefined : JSON.parse(text); } catch { parsed[name] = undefined; }
|
|
93
|
+
}
|
|
94
|
+
return { rig: parsed['rig.json'], motion: parsed['motion.json'], report: parsed['mesh_report.json'], texts };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The reach, from both rig directories' documents and their `{rig-relative path: sha256}` trees (`rigTree`).
|
|
99
|
+
* `show` turns a rig-relative path into the package-relative one the line names.
|
|
100
|
+
*/
|
|
101
|
+
export function reachOf(baseline: RigDocuments, inspected: RigDocuments, baselineTree: Record<string, string>, inspectedTree: Record<string, string>, show: (rel: string) => string, ops: ReachOps = reachOps(null)): BaselineReach {
|
|
102
|
+
const notWritten = DOCUMENTS.filter(name => inspected.texts[name] !== null && !isWriterText(inspected.texts[name]!));
|
|
103
|
+
const imageKeys = [...new Set([...Object.keys(baselineTree), ...Object.keys(inspectedTree)])].filter(k => k.startsWith(`${PRODUCER_IMAGES_DIR}/`)).sort();
|
|
104
|
+
const images = imageKeys.filter(k => baselineTree[k] !== inspectedTree[k]);
|
|
105
|
+
const rigB = isObject(baseline.rig) ? baseline.rig : {}, rigI = isObject(inspected.rig) ? inspected.rig : {};
|
|
106
|
+
// retune's hold, in its own order: stops, then bones, slots, skeleton, images, the invariants but detached, each image, motion outside idle.
|
|
107
|
+
let retuneHeld: string | null = null;
|
|
108
|
+
for (const key of ['stops', 'bones', 'slots', 'skeleton', 'images']) if (!retuneHeld && !jsonEqual(rigI[key], rigB[key])) retuneHeld = `rig.json ${key}`;
|
|
109
|
+
if (!retuneHeld && !jsonEqual(withoutKey(rigI.invariants, 'detached'), withoutKey(rigB.invariants, 'detached'))) retuneHeld = 'rig.json invariants';
|
|
110
|
+
if (!retuneHeld && images.length) retuneHeld = images[0]!;
|
|
111
|
+
if (!retuneHeld && !jsonEqual(outsideIdle(inspected.motion), outsideIdle(baseline.motion))) retuneHeld = 'motion.json outside animations.idle';
|
|
112
|
+
// remesh's per-part baseline: the attachment, the image, the report row (and an auto row's motion), and the bones.
|
|
113
|
+
const attsI = meshAttachments(inspected.rig), attsB = meshAttachments(baseline.rig), meshParts = [...attsI.keys()];
|
|
114
|
+
const bonesDiffer = !jsonEqual(rigI.bones, rigB.bones), motionDiffers = !jsonEqual(inspected.motion, baseline.motion);
|
|
115
|
+
const remeshRefused = meshParts.filter(part => {
|
|
116
|
+
const row = reportRow(inspected.report, part);
|
|
117
|
+
return !jsonEqual(attsI.get(part), attsB.get(part)) || images.includes(`${PRODUCER_IMAGES_DIR}/${part}.png`) || !jsonEqual(row, reportRow(baseline.report, part)) || (isObject(row) && row.mode === 'auto' && motionDiffers);
|
|
118
|
+
});
|
|
119
|
+
const reach: BaselineReach = { schema: BASELINE_REACH_SCHEMA, notWriterText: notWritten.map(show), images: images.map(show), retuneHeld: retuneHeld === null ? null : retuneHeld.startsWith(`${PRODUCER_IMAGES_DIR}/`) ? show(retuneHeld) : retuneHeld, remeshRefused, meshParts, bonesDiffer, ops, text: '' };
|
|
120
|
+
reach.text = reachText(reach, show);
|
|
121
|
+
return reach;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Reads both directories and returns the reach (`reachOf`). */
|
|
125
|
+
export async function baselineReach(baselineDir: string, inspectedDir: string, baselineTree: Record<string, string>, inspectedTree: Record<string, string>, show: (rel: string) => string, ops: ReachOps = reachOps(null)): Promise<BaselineReach> {
|
|
126
|
+
return reachOf(await documents(baselineDir), await documents(inspectedDir), baselineTree, inspectedTree, show, ops);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const list = (items: readonly string[], max = 4) => items.length <= max ? items.join(', ') : `${items.slice(0, max).join(', ')} and ${items.length - max} more`;
|
|
130
|
+
/** The re-serialisation advice for a document that is not the writer's text (the producer's own row says: use the files as rig wrote them). */
|
|
131
|
+
export const WRITER_TEXT_ADVICE = 'use the files as rig wrote them, or re-serialise them in a copy of the package exactly as rig writes them (JSON with one space of indent and a closing newline) and open the copy';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One sentence per op, the cause first:
|
|
135
|
+
* whole-rig edits refuse (EDIT_BASELINE_MISMATCH); physics and idle edits (retune) …; remesh …; a mesh edit ….
|
|
136
|
+
*/
|
|
137
|
+
export function reachText(r: Omit<BaselineReach, 'text'>, show: (rel: string) => string = rel => rel): string {
|
|
138
|
+
const parts: string[] = [];
|
|
139
|
+
const written = (names: readonly string[]) => r.notWriterText.filter(p => names.some(n => p === show(n)));
|
|
140
|
+
const remeshWritten = written(['rig.json', 'mesh_report.json']), retuneWritten = written(['rig.json', 'motion.json']);
|
|
141
|
+
if (r.notWriterText.length) parts.push(`${list(r.notWriterText)} ${r.notWriterText.length === 1 ? 'is' : 'are'} not the text rig writes for ${r.notWriterText.length === 1 ? 'its value' : 'their values'} (reformatted, or written by another tool), and an op that splices a revision as text refuses such a file — ${WRITER_TEXT_ADVICE}`);
|
|
142
|
+
parts.push('whole-rig edits refuse (EDIT_BASELINE_MISMATCH)');
|
|
143
|
+
// retune: physics and the idle.
|
|
144
|
+
const retune = !r.ops.retune ? `physics and idle edits rebuild the whole rig on ${r.ops.version} (retune is ${RETUNE_SINCE}+), so they refuse too`
|
|
145
|
+
: retuneWritten.length ? `physics and idle edits (retune) refuse ${list(retuneWritten)} (RETUNE_INPUT_WRITTEN)${r.retuneHeld ? `, and past that ${retuneHeldText(r.retuneHeld)}` : ''}`
|
|
146
|
+
: r.retuneHeld ? `physics and idle edits (retune) refuse: ${retuneHeldText(r.retuneHeld)}`
|
|
147
|
+
: 'physics and idle edits Apply through retune, which carries every inspected mesh over';
|
|
148
|
+
parts.push(retune);
|
|
149
|
+
// remesh from the config's entry (Mesh panel, chest regions).
|
|
150
|
+
const fits = r.meshParts.filter(p => !r.remeshRefused.includes(p));
|
|
151
|
+
const remeshHeld = r.bonesDiffer ? 'the config moves a bone, so every part is refused (REMESH_BASELINE_MISMATCH)'
|
|
152
|
+
: r.remeshRefused.length ? `${list(r.remeshRefused)} ${r.remeshRefused.length === 1 ? 'is' : 'are'} refused (REMESH_BASELINE_MISMATCH: the config does not reproduce ${r.remeshRefused.length === 1 ? 'its' : 'their'} attachment, image or report row)${fits.length ? `; ${list(fits, 8)} can be re-fit` : ''}`
|
|
153
|
+
: r.meshParts.length ? 'every meshed part can be re-fit' : 'the rig has no meshed part';
|
|
154
|
+
parts.push(remeshWritten.length ? `remesh${r.ops.delta ? ' and a mesh edit refuse' : ' refuses'} ${list(remeshWritten)} (REMESH_INPUT_WRITTEN); past that, remesh: ${remeshHeld}` : `remesh: ${remeshHeld}`);
|
|
155
|
+
if (r.ops.delta && !remeshWritten.length && r.meshParts.length) parts.push('a mesh edit (Edit Mesh) is held to the inspected mesh itself, not to the config, so the baseline does not refuse it');
|
|
156
|
+
return parts.join('; ');
|
|
157
|
+
}
|
|
158
|
+
function retuneHeldText(held: string): string {
|
|
159
|
+
if (held.includes(`${PRODUCER_IMAGES_DIR}/`) && !held.startsWith('rig.json')) return `the config draws ${held} differently and retune holds every image (RETUNE_REQUEST_HELD)`;
|
|
160
|
+
return `the config changes ${held}, which retune holds (RETUNE_REQUEST_HELD)`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A review line beside a producer refusal it can explain (#148). The producer's line stays as printed; this names the
|
|
165
|
+
* cause and the way out in review's terms. Null for any other line.
|
|
166
|
+
*/
|
|
167
|
+
export function producerHint(line: string | null): string | null {
|
|
168
|
+
if (!line) return null;
|
|
169
|
+
const written = /\b(REMESH|RETUNE)_INPUT_WRITTEN:\s+(.+?)\s+—\s/.exec(line);
|
|
170
|
+
if (written) {
|
|
171
|
+
const file = written[2]!.split(/[\\/]/).pop()!, op = written[1] === 'REMESH' ? 'remesh' : 'retune';
|
|
172
|
+
return `${file} in the inspected rig is not the text rig writes for its value (reformatted, or written by another tool); ${op} splices a revision as text, so it refuses the file — ${WRITER_TEXT_ADVICE}`;
|
|
173
|
+
}
|
|
174
|
+
const image = /\bRETUNE_REQUEST_HELD:\s+(images\/\S+)\s+—/.exec(line);
|
|
175
|
+
if (image) return `the inspected ${image[1]} is not the image the config draws (a repaired or redrawn image); retune holds every image, so no physics or idle edit Applies on this build — the baseline line says what still does`;
|
|
176
|
+
return null;
|
|
177
|
+
}
|
package/src/baseline.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { isProducerFile, PRODUCER_DOCUMENTS, PRODUCER_IMAGES_DIR } from './produ
|
|
|
13
13
|
import { firstRefusal, runTool, type InstalledTool } from './producer.ts';
|
|
14
14
|
import { checkSummary, MAX_DIFF_PATHS, type CheckSummary, type FilesDiff } from './applied-context.ts';
|
|
15
15
|
import { jsonDiff } from './config-patch.ts';
|
|
16
|
+
import { baselineReach, reachOps, type BaselineReach } from './baseline-reach.ts';
|
|
16
17
|
|
|
17
18
|
export const BASELINE_SCHEMA = 'rig-review-baseline/1';
|
|
18
19
|
export type BaselineStatus = 'running' | 'reproduced' | 'not-reproduced' | 'refused' | 'failed';
|
|
@@ -23,6 +24,8 @@ export interface BaselineResult {
|
|
|
23
24
|
firstDifference: string | null;
|
|
24
25
|
/** Both sides: the rig.json SHA-256 and the SHA-256 of the sorted {path: sha256} table of every compared file. */
|
|
25
26
|
hashes: { baseline: { rigJson: string | null; tree: string }; inspected: { rigJson: string | null; tree: string } } | null;
|
|
27
|
+
/** #148: what Apply can still do when not reproduced (`src/baseline-reach.ts`); null otherwise. */
|
|
28
|
+
reach?: BaselineReach | null;
|
|
26
29
|
line: string; exitCode: number | null; wallMs: number | null;
|
|
27
30
|
}
|
|
28
31
|
const sha256 = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
|
|
@@ -73,9 +76,9 @@ export async function producerDiff(inspectedDir: string, scratchDir: string): Pr
|
|
|
73
76
|
return { documents, images: { changed: changed.length, unchanged: names.length - changed.length, names: changed.slice(0, MAX_DIFF_PATHS) } };
|
|
74
77
|
}
|
|
75
78
|
/** The editor bar's line. */
|
|
76
|
-
export function baselineLine(r: Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'line'>): string {
|
|
79
|
+
export function baselineLine(r: Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'line'> & { reach?: BaselineReach | null }): string {
|
|
77
80
|
if (r.status === 'reproduced' && r.hashes) return `baseline: reproduced — rig.json ${r.hashes.baseline.rigJson?.slice(0, 12)}… = inspected ${r.hashes.inspected.rigJson?.slice(0, 12)}…`;
|
|
78
|
-
if (r.status === 'not-reproduced') return `baseline: not reproduced — ${r.firstDifference}`;
|
|
81
|
+
if (r.status === 'not-reproduced') return `baseline: not reproduced — ${r.firstDifference}${r.reach ? ` · ${r.reach.text}` : ''}`;
|
|
79
82
|
return r.line;
|
|
80
83
|
}
|
|
81
84
|
/**
|
|
@@ -86,7 +89,7 @@ export class BaselineError extends Error { constructor(readonly code: string, de
|
|
|
86
89
|
export function assertWholeRigBaseline(result: BaselineResult | null): void {
|
|
87
90
|
if (!result) throw new BaselineError('EDIT_BASELINE_MISMATCH', 'no baseline was run: the handoff or the producer did not resolve');
|
|
88
91
|
if (result.status === 'running') throw new BaselineError('EDIT_BASELINE_PENDING', 'the baseline rebuild is still running');
|
|
89
|
-
if (result.status !== 'reproduced') throw new BaselineError('EDIT_BASELINE_MISMATCH', result.firstDifference
|
|
92
|
+
if (result.status !== 'reproduced') throw new BaselineError('EDIT_BASELINE_MISMATCH', result.firstDifference ? `${result.firstDifference}${result.reach ? ` — ${result.reach.text}` : ''}` : result.line);
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/** Deterministic: the package, the handoff record and the producer. No clock. */
|
|
@@ -94,9 +97,11 @@ export const baselineKey = (sourceHash: string, handoff: Handoff, producer: Inst
|
|
|
94
97
|
sha256(JSON.stringify([BASELINE_SCHEMA, sourceHash, handoff.record, `${producer.package}@${producer.version}`, `${compiler.package}@${compiler.version}`]));
|
|
95
98
|
|
|
96
99
|
/** Compare a finished `<dir>/baseline` with the inspected rig; `show` turns a rig-relative path into the package-relative one. */
|
|
97
|
-
export async function compareBaseline(out: string, inspectedRig: string, show: (rel: string) => string): Promise<Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes'>> {
|
|
100
|
+
export async function compareBaseline(out: string, inspectedRig: string, show: (rel: string) => string, producerVersion: string | null = null): Promise<Pick<BaselineResult, 'status' | 'firstDifference' | 'hashes' | 'reach'>> {
|
|
98
101
|
const baseline = await rigTree(out), inspected = await rigTree(inspectedRig), first = firstDifference(baseline, inspected);
|
|
99
|
-
|
|
102
|
+
// #148: a build the config does not reproduce says, per op, what the producer will still take.
|
|
103
|
+
const reach = first === null ? null : await baselineReach(out, inspectedRig, baseline, inspected, show, reachOps(producerVersion));
|
|
104
|
+
return { status: first === null ? 'reproduced' : 'not-reproduced', firstDifference: first === null ? null : show(first), hashes: { baseline: { rigJson: baseline['rig.json'] ?? null, tree: treeHash(baseline) }, inspected: { rigJson: inspected['rig.json'] ?? null, tree: treeHash(inspected) } }, reach };
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
/**
|
|
@@ -106,11 +111,11 @@ export async function compareBaseline(out: string, inspectedRig: string, show: (
|
|
|
106
111
|
export async function runBaseline(dir: string, key: string, handoff: Handoff, producer: InstalledTool, show: (rel: string) => string): Promise<BaselineResult> {
|
|
107
112
|
const base = { schema: BASELINE_SCHEMA, key, producer: { package: producer.package, version: producer.version } } as const;
|
|
108
113
|
const out = join(dir, 'baseline'), resultFile = join(dir, 'result.json');
|
|
109
|
-
if (!handoff.parts || !handoff.rig) return { ...base, status: 'refused', firstDifference: null, hashes: null, line: `baseline: unavailable — MODELING_INPUT_UNAVAILABLE: ${handoff.record.missing.join(', ')}`, exitCode: null, wallMs: null };
|
|
114
|
+
if (!handoff.parts || !handoff.rig) return { ...base, status: 'refused', firstDifference: null, hashes: null, reach: null, line: `baseline: unavailable — MODELING_INPUT_UNAVAILABLE: ${handoff.record.missing.join(', ')}`, exitCode: null, wallMs: null };
|
|
110
115
|
try {
|
|
111
116
|
const stored = JSON.parse(await readFile(resultFile, 'utf8')) as BaselineResult;
|
|
112
117
|
if (stored.schema === BASELINE_SCHEMA && stored.key === key && (stored.status === 'reproduced' || stored.status === 'not-reproduced')) {
|
|
113
|
-
const now = await compareBaseline(out, handoff.rig, show);
|
|
118
|
+
const now = await compareBaseline(out, handoff.rig, show, producer.version);
|
|
114
119
|
if (now.hashes?.baseline.tree === stored.hashes?.baseline.tree) return { ...stored, ...now, line: baselineLine({ ...stored, ...now }) };
|
|
115
120
|
}
|
|
116
121
|
} catch { /* no reusable result */ }
|
|
@@ -119,9 +124,9 @@ export async function runBaseline(dir: string, key: string, handoff: Handoff, pr
|
|
|
119
124
|
const run = await runTool(producer.bin, ['rig', '--config', handoff.config, '--parts', handoff.parts, '--out', out], { cwd: dir });
|
|
120
125
|
if (run.exitCode !== 0) {
|
|
121
126
|
const named = firstRefusal(run.lines) ?? `rig-parts rig exited ${run.exitCode} without a named refusal`;
|
|
122
|
-
return { ...base, status: 'refused', firstDifference: null, hashes: null, line: `baseline: not run — ${named}`, exitCode: run.exitCode, wallMs: run.wallMs };
|
|
127
|
+
return { ...base, status: 'refused', firstDifference: null, hashes: null, reach: null, line: `baseline: not run — ${named}`, exitCode: run.exitCode, wallMs: run.wallMs };
|
|
123
128
|
}
|
|
124
|
-
const compared = await compareBaseline(out, handoff.rig, show);
|
|
129
|
+
const compared = await compareBaseline(out, handoff.rig, show, producer.version);
|
|
125
130
|
const result: BaselineResult = { ...base, ...compared, line: '', exitCode: 0, wallMs: run.wallMs };
|
|
126
131
|
result.line = baselineLine(result);
|
|
127
132
|
await writeFile(resultFile, JSON.stringify(result, null, 1) + '\n', { mode: 0o600 });
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { formatFeedbackIssues } from './feedback-export.ts';
|
|
|
6
6
|
import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
|
|
7
7
|
import {animationDiagnostic,selectAnimation} from './animation-selection.ts';
|
|
8
8
|
import {shellWord} from './open-shell.ts';
|
|
9
|
-
import {openReview,controlReview,loadOpenPackage,defaultStateDir,handoffFlags,type OpenInputs} from './open.ts';
|
|
9
|
+
import {openReview,buildOpenDiagnostic,controlReview,loadOpenPackage,defaultStateDir,handoffFlags,type OpenInputs} from './open.ts';
|
|
10
10
|
import {checkHandoffFlags,HANDOFF_FLAGS} from './handoff.ts';
|
|
11
11
|
import { readFeedbackSession, readFeedbackCompareSession } from './session-history.ts';
|
|
12
12
|
import { startServer } from './server.ts';
|
|
@@ -94,7 +94,7 @@ export async function main(args:string[],options:{stateDir?:string}={}){
|
|
|
94
94
|
options={stateDir:options.stateDir??defaultStateDir()};if(args[0]==='--')args.shift();
|
|
95
95
|
if(args[0]==='control')return controlCommand(args.slice(1),options.stateDir);
|
|
96
96
|
if(args[0]==='open'){const openArgs=args.slice(1);if(commandHelpRequested(openArgs,'open')){console.log(usage);return;}const inputs=parseOpenInputs(openArgs);const result=await openReview(inputs,options);// Launcher contract (README): stdout is the URL line alone; stderr is diagnostic, control notice, retrieval, in that order.
|
|
97
|
-
console.log(result.url);if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[]),...handoffWords(result.inputs)].map(shellWord).join(' ')}`);return;}
|
|
97
|
+
console.log(result.url);if(result.build)console.error(buildOpenDiagnostic(result.build));if(result.selection)console.error(animationDiagnostic(result.selection));if(inputs.control)console.error('Inspection control enabled; query the current connected page with rig-review control and the same inputs.');console.error(`Review ${result.reused?'reused':'started'}; feedback: rig-review feedback ${[result.inputs.input,...(result.inputs.compare?['--compare',result.inputs.compare]:[]),...(result.inputs.reference?['--reference',result.inputs.reference]:[]),...(result.inputs.animation?['--animation',result.inputs.animation]:[]),...handoffWords(result.inputs)].map(shellWord).join(' ')}`);return;}
|
|
98
98
|
if(args[0]==='strip'){args.shift();if(args.includes('--help')){console.log(usage+'\nStrip: strip <package> --reference <declared-video-id> --animation <name> (--frames FIRST:LAST | --all-feedback) [--json] [--out path]\n strip compare <a> <b> --reference <video> --animation <name> --basis a|b (--frames FIRST:LAST | --all-feedback) [--json] [--out path]');return;}return stripCommand(args,options);}let command:'review'|'check'|'feedback'='review';if(args[0]==='check'||args[0]==='feedback'){command=args.shift() as 'check'|'feedback';}
|
|
99
99
|
if(args[0]==='--help'||args[0]==='-h'){console.log(usage);return;}
|
|
100
100
|
let format:'json'|'issue'='json';if(command==='feedback'){const parsed=feedbackFormat(args);args=parsed.args;format=parsed.format;}
|
package/src/compare-server.ts
CHANGED
|
@@ -36,7 +36,7 @@ export async function startCompareServer(p:ComparePackage,options:{port?:number;
|
|
|
36
36
|
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try{
|
|
37
37
|
const url=new URL(req.url),origin=`http://127.0.0.1:${port}`;
|
|
38
38
|
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
39
|
-
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
|
|
39
|
+
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
|
|
40
40
|
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
41
41
|
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await assertCompareUnchanged(p);}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
42
42
|
if(req.method==='PUT'&&url.pathname==='/api/compare/session'){
|