@rig-c/review 0.10.1 → 0.10.3
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 +5 -3
- package/docs/LIVE_FIXTURE.md +93 -0
- package/docs/REVIEW_PACKAGE.md +8 -3
- package/package.json +2 -1
- package/src/animation-selection.ts +2 -0
- package/src/applied-blocks.ts +14 -0
- package/src/baseline-reach.ts +12 -1
- package/src/bones-check.ts +3 -5
- package/src/cloth-recipe.ts +12 -8
- package/src/environment-check.ts +3 -5
- package/src/environment-schedule.ts +3 -1
- package/src/manifest.ts +6 -2
- package/src/open.ts +5 -3
- package/src/readings-run.ts +3 -5
- package/src/readings.ts +8 -0
- package/src/rebuild.ts +126 -72
- package/src/requirements-run.ts +3 -5
- package/src/requirements.ts +6 -2
- package/src/state-dir.ts +48 -0
- package/web/environment-world.ts +15 -0
- package/web/label-placement.ts +4 -0
- package/web/main.ts +51 -27
- package/web/rebuild-client.ts +27 -0
- package/web/retarget-panel.ts +7 -2
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ The server bundles `web/main.ts` at startup using the pinned installed runtime,
|
|
|
119
119
|
|
|
120
120
|
### Releasing
|
|
121
121
|
|
|
122
|
-
Releases are cut by `.github/workflows/release.yml`. On every push to main, release-please keeps a `release: vX.Y.Z` pull request whose version comes from the conventional-commit subjects since the last release. Merging it tags the version, creates the GitHub release, runs typecheck and the install smoke on the tagged tree, and publishes to npm through trusted publishing (OIDC) with provenance. No npm token is stored. Nothing is published until a release PR is merged.
|
|
122
|
+
Releases are cut by `.github/workflows/release.yml`. On every push to main, release-please keeps a `release: vX.Y.Z` pull request whose version comes from the conventional-commit subjects since the last release. Merging it tags the version, creates the GitHub release, runs typecheck and the install smoke on the tagged tree, and publishes to npm through trusted publishing (OIDC) with provenance. No npm token is stored. Nothing is published until a release PR is merged. A manual dispatch only re-publishes an existing tag whose GitHub release release-please created, whose commit is on main, and whose version the registry does not serve yet. It must be dispatched on the tag itself (`gh workflow run release.yml --ref vX.Y.Z -f tag=vX.Y.Z`): npm provenance names the run's commit, so a run whose commit is not the tag's is refused `PROVENANCE_SHA_MISMATCH`. Only a tag cut after that check carries it.
|
|
123
123
|
|
|
124
124
|
## Frame strip export
|
|
125
125
|
|
|
@@ -218,7 +218,8 @@ package. Input hashes are freshly computed, not trusted from mtime caches. Idle
|
|
|
218
218
|
open-managed workers stop after 30 minutes. Their private registry, requests and
|
|
219
219
|
logs live beside feedback under `~/.local/state/rig-c-review/open/`; nothing is
|
|
220
220
|
written into supplied folders. `RIG_REVIEW_STATE_DIR` selects another state root
|
|
221
|
-
outside all input packages
|
|
221
|
+
outside all input packages, judged by its canonical location (symlinks in the path
|
|
222
|
+
and its existing ancestors resolved). Existing foreground review commands still work.
|
|
222
223
|
|
|
223
224
|
No browser is opened, no LAN listener is added, and no misleading phone/QR URL is
|
|
224
225
|
printed: every current instance binds only `127.0.0.1`.
|
|
@@ -247,6 +248,7 @@ stderr and exit code alone:
|
|
|
247
248
|
- `OPEN_ANIMATION_AMBIGUOUS`: no flag or report selects one clip, or the A/B reports conflict.
|
|
248
249
|
- `OPEN_ANIMATION_UNVALIDATABLE`: a clip was requested but no compiled model can validate it.
|
|
249
250
|
- `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`.
|
|
251
|
+
- `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.
|
|
250
252
|
- `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.
|
|
251
253
|
4. Exit codes: 0 applied, 1 refused. (`control` keeps its own exit 2 for an
|
|
252
254
|
unapplied command.)
|
|
@@ -606,7 +608,7 @@ Rig pixels are the whole figure's frame, which is the crop frame the model's sta
|
|
|
606
608
|
- **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.
|
|
607
609
|
- **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.
|
|
608
610
|
- **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.
|
|
611
|
+
- **Scratch.** `<state>/rebuild/<package source hash>/<designation hash>/` under `RIG_REVIEW_STATE_DIR` (refused, by its canonical location, inside the package, its build root or the handoff root), 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
612
|
- **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*). A change to the handoff config ends the session's undo stack (reopen): Undo is then refused `FRONT_INSTANCE_EXPIRED`. The Cuts and Cloth blocks mark a pending op applied only while the record on the stage holds it, so after an undo they show the predecessor's ops as applied, and none on the inspected build. The mark shows what the build on the stage carries, not every op applied so far: while a cloth build is on the stage, the Cuts block marks nothing.
|
|
611
613
|
- **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.
|
|
612
614
|
- **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*.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Live fixture
|
|
2
|
+
|
|
3
|
+
Unit tests use fake producers. A card that changes what an Apply runs also needs one **live** run against the installed producers. This page is the package and the setup that run uses, so a session does not have to find them again.
|
|
4
|
+
|
|
5
|
+
## The package: parts `examples/wind`
|
|
6
|
+
|
|
7
|
+
The public [`examples/wind`](https://github.com/rig-c/parts/tree/v1.49.1/examples/wind) in rig-c/parts fits every editor block:
|
|
8
|
+
|
|
9
|
+
- cloth recipes for three tags (bottomwear, back hair, front hair), for the Cloth block;
|
|
10
|
+
- limbs on arms and legs, for the Cuts block and the leg lines of Retarget (FEET, STANCE, LEG-SCALE, REACH);
|
|
11
|
+
- `keypoints.json`, for the `--keypoints` handoff input;
|
|
12
|
+
- a painting and the `full` and `head` See-through layer sets, for `--source`, `--full` and `--head`;
|
|
13
|
+
- `expected/`, the files a build must reproduce.
|
|
14
|
+
|
|
15
|
+
It is public, so a PR may name it. Pin it to a parts tag; `examples/wind` is unchanged from v1.49.1 to v1.49.2.
|
|
16
|
+
|
|
17
|
+
## 1. The producer bench
|
|
18
|
+
|
|
19
|
+
The blocks below run in order in one shell. Start in an empty folder; every later path hangs off `BASE`, so a block works wherever the previous one left the shell.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
BASE="$(pwd)"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Review runs the installed producers and never installs them. Keep one folder that holds the versions a card needs, and start review from it (`rig-review open` resolves the producers from its working directory):
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
mkdir "$BASE/review-producer" && cd "$BASE/review-producer"
|
|
29
|
+
npm init -y
|
|
30
|
+
npm i -D @rig-c/review@0.8.0 @rig-c/parts@1.49.1 rig-c@2.64.1 @rig-c/pose@0.37.0
|
|
31
|
+
cd "$BASE"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Bump a version here when a card needs a newer producer. Review refuses an older one by name where a flag needs it, for example `MODELING_PRODUCER_TOO_OLD` for `check --environment`.
|
|
35
|
+
|
|
36
|
+
## 2. The package, built in scratch
|
|
37
|
+
|
|
38
|
+
The example's heavy inputs live in rig-c/parts-examples. A parts clone copies them in with `bun run fetch-examples`:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
git clone --branch v1.49.1 https://github.com/rig-c/parts.git "$BASE/parts"
|
|
42
|
+
cd "$BASE/parts" && bun install && bun run fetch-examples
|
|
43
|
+
cd "$BASE"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Copy the example into a scratch folder, because review refuses handoff paths outside the folder it opens. `pose.json` is needed: the config names it as `generation.control.skeleton`, and `assemble` refuses `CONFIG_FILE_PRESENT` without it.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
W="$BASE/parts/examples/wind"
|
|
50
|
+
P="$BASE/scratch/wind"
|
|
51
|
+
mkdir -p "$P"
|
|
52
|
+
cp "$W/config.json" "$W/keypoints.json" "$W/pose.json" "$P/"
|
|
53
|
+
cp -R "$W/inputs" "$P/inputs"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Build it from the bench, then put the checked compiled model where the folder adapter looks (`build/skeleton.model.json`):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
cd "$BASE/review-producer"
|
|
60
|
+
npx rig-parts build --config "$P/config.json" --source "$P/inputs/painting.png" \
|
|
61
|
+
--full "$P/inputs/layers/full" --head "$P/inputs/layers/head" --out "$P/out"
|
|
62
|
+
cp -R "$P/out/check/build" "$P/build"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The build takes about two minutes and ends with `build: PASS`. `out/parts.json`, `out/rig/motion.json` and `out/rig/mesh_report.json` are byte-equal to `expected/`.
|
|
66
|
+
|
|
67
|
+
## 3. Open it with the handoff
|
|
68
|
+
|
|
69
|
+
From the bench folder:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
cd "$BASE/review-producer"
|
|
73
|
+
npx rig-review open "$P" --config "$P/config.json" --parts "$P/out" \
|
|
74
|
+
--source "$P/inputs/painting.png" --full "$P/inputs/layers/full" --head "$P/inputs/layers/head" \
|
|
75
|
+
--keypoints "$P/keypoints.json" --animation idle
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Inside a review clone, `bun src/cli.ts open …` is the same command. Once the session baseline settles (about two minutes), the editor bar reads:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
baseline: reproduced — rig.json d19892a32cec… = inspected d19892a32cec…
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`--parts` is the build's output root (`out/`), not `out/parts`.
|
|
85
|
+
|
|
86
|
+
**Shorter form (#149).** `rig-review open "$P/out"` opens the build root directly, without copying `out/check/build` to `build/`. Review opens `out/check` and discovers the build's own `config.json` beside `out/`, and the open line prints its path and sha256. `--source`, `--full`, `--head` and `--keypoints` are not recorded by the build, so declare them when a card needs them, as in step 3 (`--source "$P/inputs/painting.png"`, and so on).
|
|
87
|
+
|
|
88
|
+
## Routes worth knowing
|
|
89
|
+
|
|
90
|
+
- **Whole rig.** On a reproduced baseline, a physics patch runs `rig --config` (#136).
|
|
91
|
+
- **Retune.** To exercise retune (#136, #139), change one number in `$P/out/rig/rig.json` (a +0.001 nudge is enough), so the baseline is not reproduced. Then a patch inside retune's scope routes to `rig-parts retune`. Keep the file the writer's text (edit the number in place, or re-serialise with `JSON.stringify(value, null, 1)` and a closing newline): any other text refuses `RETUNE_INPUT_WRITTEN` (#148). A nudge in an image, not in rig.json, refuses every retune `RETUNE_REQUEST_HELD` instead.
|
|
92
|
+
- **Density strip (#173).** Select `bottomwear` (a `grid` 31 entry) in the Mesh panel and build grid 40 → 120 with N 5: grid 120 refuses `REMESH_CHECK_HELD` (TIP_OVER_ROOT), the other four build. Each candidate is a whole remesh (its own baseline rig, input check and revision check), so a strip of five costs about five Applies of CPU; on a shared machine its wall clock follows the free cores.
|
|
93
|
+
- **Build.** A patch under `/assemble`, `/limbs`, or `/bones` on a config with `limbs` runs `rig-parts build` (#115). So does a cloth Apply (#111).
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -163,7 +163,7 @@ Every edit card states its edit as one config patch; review applies it to a scra
|
|
|
163
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
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
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`.
|
|
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).
|
|
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 named directory outside the declared runs by its contents (every regular file under it, at most 8 levels deep and 4096 files, each held inside the package root, in path order; over the bound refuses `BUILD_INPUT_TOO_LARGE`), a declared run through the handoff record (#185); 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).
|
|
167
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}`.
|
|
168
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.
|
|
169
169
|
- **A retune's check (#139).** A retune-routed Apply does not run `rig-parts check` a second time: retune already gated and checked the revision it wrote (`checkStage` over that revision, the same `--parts` and pack flags review passes), and from `@rig-c/parts` 1.49.0 (`RETUNE_CHECK_SINCE`, parts#541) `retune.json`'s `check.revision` carries `line` — check's own last line, byte for byte — and `status` — every block's status as that tree's `check.json` records it, in the file's order. Review first binds it to the files: every `revision` hash row's file under `rig/` must hash to its row, none missing, and `rig/` holds no file but those rows and `retune.json`; otherwise the Apply is refused `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>; retune's check is not this revision's, so no build is recorded` (`absent` on the side without the file) and leaves no scratch. Bound, the side becomes the same check summary a separate check gives (below): `pass` ← `pass`; `exitCode` ← 0 when it passes, else 1 (check's own exit); `line` ← `line`; `bars` ← every upper-case `status` entry (`gate`, `loop` and `seam` are read off figures and are not check.json bars); `gate` ← `status.gate` is `PASS`; plus `source:"retune"`. The job's `wallMs.check` is `null` and its lines say `review: check not run again — retune.json carries retune's check of this revision, bound to it by N revision hash row(s): <line>`. Check still runs on the revision, as before, when `retune.json` carries no `line` / `status` (an older producer; the line says so — not a refusal) or when the Apply checks under an exported world (#112; retune's check read the default one). Whole-rig `rig`, `build` and cloth keep running check.
|
|
@@ -458,8 +458,13 @@ Registry/request/log/claim files are private tool-state files; a per-binding cla
|
|
|
458
458
|
serializes startup, stale records are not reused, and no unrelated process is
|
|
459
459
|
terminated. Workers expire after 30 minutes without requests and clean only their
|
|
460
460
|
own nonce's registration. Startup waiting is bounded. `RIG_REVIEW_STATE_DIR`
|
|
461
|
-
changes the tool state root but cannot place it inside a source package,
|
|
462
|
-
|
|
461
|
+
changes the tool state root but cannot place it inside a source package, the
|
|
462
|
+
`rig-parts build` root a check package belongs to, or the producer handoff's root,
|
|
463
|
+
including through a symlink alias of the directory or an ancestor
|
|
464
|
+
(`OPEN_STATE_INSIDE_PACKAGE <package | build root | handoff root>`). Every runner that
|
|
465
|
+
writes state (rebuild, strip, bones check, readings, requirements, environment check)
|
|
466
|
+
applies the same canonical check and refuses with its own `*_STATE_INSIDE_PACKAGE`
|
|
467
|
+
code naming the folder. No package manifest, source video, motion or compiled
|
|
463
468
|
model is written by open. There is no browser launch or new LAN exposure.
|
|
464
469
|
|
|
465
470
|
The printed feedback retrieval command uses POSIX shell quoting for literal path
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/review",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local human rig inspection and structured feedback export",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"examples/demo",
|
|
36
36
|
"docs/REVIEW_PACKAGE.md",
|
|
37
37
|
"docs/DESIGN.md",
|
|
38
|
+
"docs/LIVE_FIXTURE.md",
|
|
38
39
|
"README.md",
|
|
39
40
|
"LICENSE"
|
|
40
41
|
],
|
|
@@ -31,3 +31,5 @@ export function initialPlayerAnimation(names:readonly string[],selected:string|u
|
|
|
31
31
|
const name=checkpoint?.animation??selected;if(name!==undefined){if(!names.includes(name))throw new Error(`initial.animation: model has no animation ${JSON.stringify(name)}`);return name;}
|
|
32
32
|
return names[0]??null;
|
|
33
33
|
}
|
|
34
|
+
/** The animation a model swap keeps (#186): the staged one when the new model has it, else none — never a removed clip's name. */
|
|
35
|
+
export function keptAnimation(staged:string,names:readonly string[]):string {return staged&&names.includes(staged)?staged:'';}
|
package/src/applied-blocks.ts
CHANGED
|
@@ -46,3 +46,17 @@ export function itemApplied(itemOps: readonly PatchOp[], applied: readonly Patch
|
|
|
46
46
|
const keys = new Set(applied.map(opKey));
|
|
47
47
|
return itemOps.every(o => keys.has(opKey(o)));
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** The stage's identity for a readout: the applied record's build id, or null on the inspected build. */
|
|
51
|
+
const stageId = (record: unknown): string | null => isObject(record) && typeof record.buildId === 'string' ? record.buildId : null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* #186: the blocks whose result readout goes stale when the stage changes from `previous` to `next` — every block the
|
|
55
|
+
* new stage record does not belong to (a Cuts readout after a Cloth Apply, both after a Reset or another kind of Apply).
|
|
56
|
+
* Nothing while the stage is unchanged, so a refused Apply's readout stays; the owning block shows its own new job.
|
|
57
|
+
*/
|
|
58
|
+
export function staleReadouts(previous: unknown, next: unknown): AppliedBlock[] {
|
|
59
|
+
if (stageId(previous) === stageId(next)) return [];
|
|
60
|
+
const owner = recordBlock(next);
|
|
61
|
+
return APPLIED_BLOCKS.filter(b => b !== owner);
|
|
62
|
+
}
|
package/src/baseline-reach.ts
CHANGED
|
@@ -79,6 +79,15 @@ function meshAttachments(rig: Json): Map<string, Json> {
|
|
|
79
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
80
|
return out;
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The rig-relative image a part's mesh attachment draws: the rig's `images` directory and the attachment's `image`
|
|
84
|
+
* (`images/shirt.png` for `topwear` drawing `shirt.png`); `images/<part>.png` only when the attachment names none.
|
|
85
|
+
*/
|
|
86
|
+
function attachmentImage(rig: Json, att: Json, part: string): string {
|
|
87
|
+
const dir = isObject(rig) && typeof rig.images === 'string' && rig.images ? rig.images.replace(/\/+$/, '') : PRODUCER_IMAGES_DIR;
|
|
88
|
+
const image = isObject(att) && typeof att.image === 'string' && att.image ? att.image : `${part}.png`;
|
|
89
|
+
return `${dir}/${image}`;
|
|
90
|
+
}
|
|
82
91
|
const reportRow = (report: Json, part: string): Json => Array.isArray(report) ? report.find(r => isObject(r) && r.part === part) : undefined;
|
|
83
92
|
|
|
84
93
|
export interface RigDocuments { rig: Json; motion: Json; report: Json; texts: Record<string, string | null> }
|
|
@@ -114,7 +123,9 @@ export function reachOf(baseline: RigDocuments, inspected: RigDocuments, baselin
|
|
|
114
123
|
const bonesDiffer = !jsonEqual(rigI.bones, rigB.bones), motionDiffers = !jsonEqual(inspected.motion, baseline.motion);
|
|
115
124
|
const remeshRefused = meshParts.filter(part => {
|
|
116
125
|
const row = reportRow(inspected.report, part);
|
|
117
|
-
|
|
126
|
+
// The image each side's attachment draws (#185), compared in the trees, so a renamed image is not missed.
|
|
127
|
+
const imageDiffers = [attachmentImage(inspected.rig, attsI.get(part), part), attachmentImage(baseline.rig, attsB.get(part), part)].some(k => baselineTree[k] !== inspectedTree[k]);
|
|
128
|
+
return !jsonEqual(attsI.get(part), attsB.get(part)) || imageDiffers || !jsonEqual(row, reportRow(baseline.report, part)) || (isObject(row) && row.mode === 'auto' && motionDiffers);
|
|
118
129
|
});
|
|
119
130
|
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
131
|
reach.text = reachText(reach, show);
|
package/src/bones-check.ts
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
* scratch directory is removed after the run.
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
-
import {
|
|
12
|
+
import { join } from 'node:path';
|
|
13
13
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import type { ReviewPackage } from './manifest.ts';
|
|
15
15
|
import { firstRefusal, resolveProducer, runTool, type InstalledTool } from './producer.ts';
|
|
16
16
|
import { applyConfigPatch, assertOpNames, assertPatchFresh, ConfigPatchError, CONFIG_PATCH_SCHEMA, parseConfigPatch, type JsonValue, type PatchOp } from './config-patch.ts';
|
|
17
17
|
import { assertNamedFilesHeld, producerInputs, RebuildError, scratchConfigText } from './rebuild.ts';
|
|
18
18
|
import { assertBoneOps, BoneAnchorError, compareOrigins, proposeLines, type OriginRow } from './bone-anchors.ts';
|
|
19
|
+
import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
|
|
19
20
|
|
|
20
21
|
export const BONES_CHECK_SCHEMA = 'rig-review-bones-check/1';
|
|
21
22
|
export const BONES_CHECK_LIMIT = 64 * 1024;
|
|
@@ -60,10 +61,7 @@ export function createBonesCheck(p: ReviewPackage, options: { stateDir: string;
|
|
|
60
61
|
let running = false;
|
|
61
62
|
async function run(input: unknown): Promise<BonesCheckResult> {
|
|
62
63
|
const { ops, configHash } = parseBonesCheck(input);
|
|
63
|
-
|
|
64
|
-
const inside = relative(root, options.stateDir);
|
|
65
|
-
if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
|
|
66
|
-
}
|
|
64
|
+
assertStateDirOutside(options.stateDir, p, message => new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', message));
|
|
67
65
|
await options.unchanged();
|
|
68
66
|
const inputs = await producerInputs(p.handoff, p.handoffReason);
|
|
69
67
|
const original = JSON.parse(inputs.configText) as JsonValue;
|
package/src/cloth-recipe.ts
CHANGED
|
@@ -129,19 +129,23 @@ export function recipeOps(config: unknown, tag: string, next: Record<string, Jso
|
|
|
129
129
|
if (!known.has(key)) continue;
|
|
130
130
|
const was = current[key], now = next[key];
|
|
131
131
|
if (same(was, now)) continue;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
for (const sub of [...new Set([...Object.keys(was), ...Object.keys(
|
|
137
|
-
if (same(was[sub],
|
|
132
|
+
// An object field (split, strength, swing, influence) is edited key by key, so its other keys keep their place;
|
|
133
|
+
// cleared to nothing (no field left, no unknown key to keep), the object itself is removed rather than left `{}`.
|
|
134
|
+
if (isObject(was) && (now === undefined || isObject(now))) {
|
|
135
|
+
const to: Record<string, JsonValue> = now ?? {}, left: Record<string, unknown> = { ...was }, subOps: PatchOp[] = [];
|
|
136
|
+
for (const sub of [...new Set([...Object.keys(was), ...Object.keys(to)])]) {
|
|
137
|
+
if (same(was[sub], to[sub])) continue;
|
|
138
138
|
const known2 = RECIPE_FIELDS.some(f => f.path[0] === key && f.path[1] === sub);
|
|
139
139
|
if (!known2) continue;
|
|
140
|
-
if (
|
|
141
|
-
else
|
|
140
|
+
if (to[sub] === undefined) { subOps.push({ op: 'remove', path: at(key, sub) }); delete left[sub]; }
|
|
141
|
+
else { subOps.push({ op: was[sub] === undefined ? 'add' : 'replace', path: at(key, sub), value: to[sub] as JsonValue }); left[sub] = to[sub] as JsonValue; }
|
|
142
142
|
}
|
|
143
|
+
if (Object.keys(left).length === 0) ops.push({ op: 'remove', path: at(key) });
|
|
144
|
+
else ops.push(...subOps);
|
|
143
145
|
continue;
|
|
144
146
|
}
|
|
147
|
+
if (now === undefined) { ops.push({ op: 'remove', path: at(key) }); continue; }
|
|
148
|
+
if (was === undefined) { ops.push({ op: 'add', path: at(key), value: now }); continue; }
|
|
145
149
|
ops.push({ op: 'replace', path: at(key), value: now });
|
|
146
150
|
}
|
|
147
151
|
return ops;
|
package/src/environment-check.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* back. The client sends `{build, schedule}` only — never a path or a flag. Nothing is written into the package.
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
|
-
import {
|
|
14
|
+
import { join } from 'node:path';
|
|
15
15
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
16
16
|
import type { ReviewPackage } from './manifest.ts';
|
|
17
17
|
import { firstRefusal, modelingProducerTooOld, resolveProducer, runTool, versionAtLeast } from './producer.ts';
|
|
@@ -19,6 +19,7 @@ import { producerInputs, RebuildError } from './rebuild.ts';
|
|
|
19
19
|
import { checkSummary, type CheckSummary } from './applied-context.ts';
|
|
20
20
|
import { barLine, READOUT_BARS } from './physics-tuning.ts';
|
|
21
21
|
import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
|
|
22
|
+
import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
|
|
22
23
|
|
|
23
24
|
export const ENVIRONMENT_CHECK_SCHEMA = 'rig-review-environment-check/1';
|
|
24
25
|
export const ENVIRONMENT_CHECK_LIMIT = 64 * 1024;
|
|
@@ -78,10 +79,7 @@ export function createEnvironmentCheck(p: ReviewPackage, options: EnvironmentChe
|
|
|
78
79
|
let running: string | null = null;
|
|
79
80
|
const snapshot = (job: EnvironmentCheckJob): EnvironmentCheckJob => JSON.parse(JSON.stringify(job)) as EnvironmentCheckJob;
|
|
80
81
|
function assertStateOutside(): void {
|
|
81
|
-
|
|
82
|
-
const inside = relative(root, options.stateDir);
|
|
83
|
-
if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
|
|
84
|
-
}
|
|
82
|
+
assertStateDirOutside(options.stateDir, p, message => new EnvironmentCheckError('ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE', message));
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
async function execute(job: EnvironmentCheckJob, dir: string, bin: string, rig: string, parts: string, text: string): Promise<void> {
|
|
@@ -65,7 +65,9 @@ export function windSchedule(s: WindSnapshot, gravity: number, duration: number,
|
|
|
65
65
|
const g: [number, number] = [0, gravity];
|
|
66
66
|
if (!windVaries(s)) return { schedule: standingSchedule({ wind: [s.base, 0], gravity: g }), form: 'step', tolerance: 0, fps, duration };
|
|
67
67
|
if (!(duration > 0) || !Number.isFinite(duration)) throw new ScheduleError('ENVIRONMENT_SCHEDULE_NO_CLIP', 'a breeze or a repeating gust is sampled over a clip; no clip with a duration is drawn');
|
|
68
|
-
|
|
68
|
+
// The last frame inside the clip: floor, so an off-grid clip (0.8 s = 9.6 frames) ends on frame 9, not a step after it
|
|
69
|
+
// (rigc refuses that); the epsilon keeps an on-grid duration whose product lands a hair under the integer on its frame.
|
|
70
|
+
const last = Math.floor(duration * fps + 1e-9);
|
|
69
71
|
if (last + 1 > MAX_SCHEDULE_STEPS) throw new ScheduleError('ENVIRONMENT_SCHEDULE_TOO_LONG', `${duration} s at ${fps} fps is ${last + 1} steps; at most ${MAX_SCHEDULE_STEPS}`);
|
|
70
72
|
const steps: EnvironmentStep[] = [];
|
|
71
73
|
let previous = NaN;
|
package/src/manifest.ts
CHANGED
|
@@ -23,7 +23,9 @@ export interface ReviewManifest {
|
|
|
23
23
|
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
24
|
export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog;
|
|
25
25
|
/** The resolved producer handoff (#101) and, when there is none, the refusal line naming where it was looked for. */
|
|
26
|
-
handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags
|
|
26
|
+
handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags;
|
|
27
|
+
/** #187: the rig-parts build root ({@link isBuildRoot}) whose `check/` this package is, however it was opened; review state stays outside it. */
|
|
28
|
+
buildRoot?: string }
|
|
27
29
|
function fail(field: string, expected: string): never { throw new Error(`${field}: expected ${expected}`); }
|
|
28
30
|
export function object(value: unknown, field: string): Record<string, unknown> { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(field, 'object'); return value as Record<string, unknown>; }
|
|
29
31
|
export function string(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim() || value.length > 10000) fail(field, 'nonempty string (at most 10000 characters)'); return value; }
|
|
@@ -109,9 +111,11 @@ export async function loadReview(input: string, options: { handoff?: HandoffFlag
|
|
|
109
111
|
}
|
|
110
112
|
// The handoff files are hashed beside the package: their record joins the reviewed identity (and so the session file).
|
|
111
113
|
const {handoff,reason:handoffReason}=await resolveHandoff(root,manifest.producer,options.handoff);
|
|
114
|
+
// #192: the root is `check/` whether the folder or its review.json was opened, so the build root is kept either way.
|
|
115
|
+
const buildRoot=basename(root)==='check'&&await isBuildRoot(dirname(root))?dirname(root):undefined;
|
|
112
116
|
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}:{})};
|
|
113
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};
|
|
114
|
-
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{})};
|
|
118
|
+
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{}),...(buildRoot?{buildRoot}:{})};
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/**
|
package/src/open.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {spawn} from 'node:child_process';
|
|
|
5
5
|
import {homedir} from 'node:os';
|
|
6
6
|
import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,stat,open as openFile} from 'node:fs/promises';
|
|
7
7
|
import {constants} from 'node:fs';
|
|
8
|
-
import {basename,dirname,join,
|
|
8
|
+
import {basename,dirname,join,resolve,isAbsolute} from 'node:path';
|
|
9
9
|
import {fileURLToPath} from 'node:url';
|
|
10
10
|
import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
|
|
11
11
|
import {loadReview,UnrecognizedOutputError,type ReviewPackage} from './manifest.ts';
|
|
@@ -17,6 +17,7 @@ import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-proto
|
|
|
17
17
|
import {ensureFront,frontPort} from './front.ts';
|
|
18
18
|
import {playPackagePath} from './versions.ts';
|
|
19
19
|
import {HandoffError,HANDOFF_FLAGS,isBuildRoot,type HandoffFlags,type HandoffRecord} from './handoff.ts';
|
|
20
|
+
import {stateDirInside,stateRoots} from './state-dir.ts';
|
|
20
21
|
/** #149: an opened rig-parts build check package — its canonical path and the handoff record (or the refusal line). */
|
|
21
22
|
export interface BuildOpen {package:string;handoff:HandoffRecord|null;reason:string|null}
|
|
22
23
|
/** The stderr diagnostic for {@link BuildOpen}: the package opened, then how the config was found, its package-relative path and full SHA-256. */
|
|
@@ -110,8 +111,9 @@ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idl
|
|
|
110
111
|
if(inputs.compare!==undefined&&!inputs.reference)throw new Error('OPEN_REFERENCE_REQUIRED: compare: explicit --reference required');
|
|
111
112
|
let p:OpenPackage;try{p=await loadOpenPackage(inputs);}catch(error){throw await openRefusal(error,inputs);}
|
|
112
113
|
const fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
// #187: canonical (symlinks and ancestors resolved) and outside every supplied folder: the package, the build root a check package belongs to, the handoff root.
|
|
115
|
+
const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?stateRoots(p.p):[...stateRoots(p.p.a),...stateRoots(p.p.b)];
|
|
116
|
+
const inside=stateDirInside(stateDir,roots);if(inside)throw new Error(`OPEN_STATE_INSIDE_PACKAGE ${inside.kind}: the review state directory must be outside input packages; it is inside the ${inside.kind}; set RIG_REVIEW_STATE_DIR outside it`);
|
|
115
117
|
const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');
|
|
116
118
|
const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
|
|
117
119
|
const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
|
package/src/readings-run.ts
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { copyFile, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
-
import { dirname,
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
11
|
import { safeFile, type ReviewPackage } from './manifest.ts';
|
|
12
12
|
import { resolveCompiler, runTool, type InstalledTool } from './producer.ts';
|
|
13
13
|
import { scheduleText, type EnvironmentSchedule } from './environment-schedule.ts';
|
|
14
14
|
import { environmentSchedule, helpHasEnvironment, parseReadingsRequest, READING_FILE, readingLabel, readingResult, READINGS_SCHEMA, READINGS_SINCE, ReadingsError, renderArgs, type ReadingBuild, type ReadingRequest, type ReadingResult, type ReadingsRequest } from './readings.ts';
|
|
15
|
+
import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
|
|
15
16
|
|
|
16
17
|
const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
|
|
17
18
|
async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; throw e; } }
|
|
@@ -67,10 +68,7 @@ export function createReadings(p: ReviewPackage, options: ReadingsOptions) {
|
|
|
67
68
|
let running: string | null = null;
|
|
68
69
|
const snapshot = (job: ReadingsJob): ReadingsJob => JSON.parse(JSON.stringify(job)) as ReadingsJob;
|
|
69
70
|
function assertStateOutside(): void {
|
|
70
|
-
|
|
71
|
-
const inside = relative(root, options.stateDir);
|
|
72
|
-
if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new ReadingsError('READINGS_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
|
|
73
|
-
}
|
|
71
|
+
assertStateDirOutside(options.stateDir, p, message => new ReadingsError('READINGS_STATE_INSIDE_PACKAGE', message));
|
|
74
72
|
}
|
|
75
73
|
/** Whether this rigc's `render --help` lists `--environment`, asked once per compiler. */
|
|
76
74
|
async function environmentFlag(compiler: InstalledTool): Promise<boolean> {
|
package/src/readings.ts
CHANGED
|
@@ -214,6 +214,14 @@ export function readingTicks(runs: readonly { build: ReadingBuild; results: read
|
|
|
214
214
|
|
|
215
215
|
/** rigc samples the last frame at frame / fps; the runtime's clip duration can round a hair below it. Within this, the tick is the clip's end. */
|
|
216
216
|
export const TICK_EDGE = 1e-4;
|
|
217
|
+
/**
|
|
218
|
+
* A reading's seek time on its clip (#186): within TICK_EDGE past the clip's end, the clip's end. `clipDuration` is the
|
|
219
|
+
* duration of the clip the reading names (the one the seek switches to), never the clip drawn before the switch; a time
|
|
220
|
+
* further past is returned unchanged for the seek to refuse by name.
|
|
221
|
+
*/
|
|
222
|
+
export function readingSeekTime(exact: number, clipDuration: number): number {
|
|
223
|
+
return clipDuration > 0 && exact > clipDuration && exact - clipDuration <= TICK_EDGE ? clipDuration : exact;
|
|
224
|
+
}
|
|
217
225
|
/** The ticks the scrub draws: the job's animation is the one drawn, each with a time inside the clip (the last sample kept). */
|
|
218
226
|
export function ticksOnClip(ticks: readonly ReadingTick[], jobAnimation: string, animation: string, duration: number): ReadingTick[] {
|
|
219
227
|
if (jobAnimation !== animation || !(duration > 0)) return [];
|