rig-parts 2.9.1 → 2.11.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/docs/AUTHORING.md +97 -15
- package/docs/STABILITY.md +36 -1
- package/package.json +1 -1
- package/src/assemble.ts +71 -9
- package/src/build.ts +4 -3
- package/src/check.ts +7 -7
- package/src/config.ts +26 -4
- package/src/editreceipt.ts +9 -7
- package/src/parts.ts +18 -1
- package/src/requirements.ts +13 -6
- package/src/scene.ts +18 -4
- package/src/takelayers.ts +102 -6
- package/src/takeregion.ts +28 -4
- package/src/takesource.ts +38 -1
package/docs/AUTHORING.md
CHANGED
|
@@ -708,8 +708,8 @@ renaming a supplied image does not establish that it was the donor's input.
|
|
|
708
708
|
The target input is derived from the painting by the existing input model:
|
|
709
709
|
`full` uses the exact centred white `squarePad`, and `head` the configured
|
|
710
710
|
`head_box`. A nonsquare single-run file uses the painting at its original file
|
|
711
|
-
size before the assembler's existing padding.
|
|
712
|
-
have equal dimensions; donor and target input PNGs must also have equal
|
|
711
|
+
size before the assembler's existing padding. Without a declared `crop`, donor
|
|
712
|
+
and target run canvases must have equal dimensions; donor and target input PNGs must also have equal
|
|
713
713
|
dimensions. Incompatible canvases or input sizes are refused, never resized to
|
|
714
714
|
make the comparison pass.
|
|
715
715
|
|
|
@@ -768,6 +768,34 @@ identity an executable check on supplied original inputs instead of trusting
|
|
|
768
768
|
that claim or hand-editing `layers.json`. Seed and painting selection remain
|
|
769
769
|
the author's decisions.
|
|
770
770
|
|
|
771
|
+
|
|
772
|
+
With a whole-layer donor made from a crop of the original painting, add
|
|
773
|
+
`"crop": {"box": [left, top, right, bottom], "resample": "bilinear"}` to that
|
|
774
|
+
entry. The box uses integer source-painting pixels with exclusive right/bottom
|
|
775
|
+
bounds. Its input must be the white square-padded crop produced by the same
|
|
776
|
+
`inputs` geometry. The crop must be inside the painting. Donor RGBA under the
|
|
777
|
+
selected support and its existing margin must equal that reconstructed input.
|
|
778
|
+
Containment covers alpha > 8 support only. That support must fit both the crop
|
|
779
|
+
and the target canvas.
|
|
780
|
+
|
|
781
|
+
The map derives from donor canvas, crop padding/origin and target input geometry.
|
|
782
|
+
Unit scale with integer translation copies straight RGBA exactly. Every other map
|
|
783
|
+
uses one premultiplied float32 bilinear affine pass. The derived map uses pixel
|
|
784
|
+
corners. `warpAffine` samples by pixel index, as `layerToRig` does, with the
|
|
785
|
+
existing cv2-compatible fixed-point sampling and transparent border. The receipt's optional `transport`
|
|
786
|
+
records `crop`, `donor_canvas`, `target_canvas`, `scale`, `translate`, `filter`
|
|
787
|
+
(`exact` or `bilinear`) and `input_rgba_sha256`, `before_rgba_sha256`,
|
|
788
|
+
`after_rgba_sha256`. Hashes cover row-major straight RGBA over the entire named
|
|
789
|
+
canvas, with the donor input dimensions derived from the square-padded crop.
|
|
790
|
+
The production transport verifier checks canvas dimensions and the output hash
|
|
791
|
+
before downstream assembly changes. It refuses a changed canvas as
|
|
792
|
+
`PARTS_TAKEN_LAYERS`.
|
|
793
|
+
Resampling establishes the declared output bytes. It does not establish equality
|
|
794
|
+
between separately generated donor and target art. Omitting `crop` retains the
|
|
795
|
+
existing geometry refusal and its message.
|
|
796
|
+
|
|
797
|
+
Region take entries retain their existing geometry contract and refuse crop donors.
|
|
798
|
+
|
|
771
799
|
### Residual front/back edges (`edge_residuals.json`, issue #999)
|
|
772
800
|
|
|
773
801
|
Under `assemble.project: "visible"`, assemble writes `edge_residuals.json` beside
|
|
@@ -806,19 +834,45 @@ one seed pixel on the target run canvas (integer x right, y down).
|
|
|
806
834
|
"run_dir": "runs/other",
|
|
807
835
|
"st_input": "inputs/other_full.png",
|
|
808
836
|
"part": "body",
|
|
809
|
-
"seed": [120, 240]
|
|
837
|
+
"seed": [120, 240],
|
|
838
|
+
"reference": { "painting": "inputs/painting.png", "sha256": "<SHA-256 of PNG bytes>" }
|
|
810
839
|
}]
|
|
811
840
|
```
|
|
812
841
|
|
|
813
842
|
The region is derived from base alpha < 128, donor alpha >= 128, and the
|
|
814
843
|
maximum RGB channel difference after rounding each pixel source-over white.
|
|
815
844
|
The tolerance is exactly `--requirements`' `setup_painting.rgb_tolerance`.
|
|
816
|
-
A missing declaration is refused. The
|
|
817
|
-
|
|
845
|
+
A missing tolerance declaration is refused. The optional `reference` declares
|
|
846
|
+
exactly one of `"source": true` (the stage's `--source`) or
|
|
847
|
+
`"painting": "path/to/reference.png"` (config-relative), and `sha256`, the
|
|
848
|
+
lowercase SHA-256 of that PNG's file bytes. An absent `reference` preserves the
|
|
849
|
+
shipped `source: true` semantics and prints one
|
|
850
|
+
`ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT` warning per build. It reads the same
|
|
851
|
+
stage painting for the predicate. A declared but unreadable reference, or a
|
|
852
|
+
hash or painting-dimension mismatch, is refused by name. Only an explicitly
|
|
853
|
+
declared painting reference keeps the selected mask fixed when `--source`
|
|
854
|
+
changes for projection. Its input is derived as for whole-layer take above.
|
|
855
|
+
Both run canvases must agree, and the supplied original donor input
|
|
818
856
|
must match the entire target input in RGBA and dimensions, establishing the same
|
|
819
857
|
crop. Different crops await #973. Painting samples use the existing Lanczos3
|
|
820
858
|
resize onto the run canvas. Only the 8-connected qualifying component containing
|
|
821
859
|
the seed is copied. The base layer's depth and draw order stay in use.
|
|
860
|
+
For a declared reference, its hash, binding and painting-to-run transform are logged. For
|
|
861
|
+
`scale` and `origin`, run coordinates are `(painting - origin) * scale`.
|
|
862
|
+
The origin is the head crop's top-left or the negative full-input padding.
|
|
863
|
+
The recorded filter is Lanczos3.
|
|
864
|
+
|
|
865
|
+
A region take evaluates the pre-take assembly once as a counterfactual, retaining
|
|
866
|
+
whole-layer takes. The target set includes cut pieces, limb segments and hybrid
|
|
867
|
+
bands consuming a target, transitively. The same set permits empty pieces only
|
|
868
|
+
in the internal counterfactual. Patches consume the painting directly and have
|
|
869
|
+
no source-part relationship. Every non-target part keeps that assembly's complete image
|
|
870
|
+
and record. This contains its changes to zero everywhere, including beyond the
|
|
871
|
+
mapped region plus bicubic's 2 px support, through subsequent seam operations.
|
|
872
|
+
Final recomposite and error measurements use those retained parts together with
|
|
873
|
+
the taken targets. The counterfactual is never written. A take adds this extra
|
|
874
|
+
assembly evaluation. Builds without region takes follow the existing single
|
|
875
|
+
assembly path. Target filter halos are reported separately and have no bar.
|
|
822
876
|
|
|
823
877
|
No polygon, rectangle or brush input is accepted. A drawn bound is scissors.
|
|
824
878
|
The log reports the derived pixel count, run-grid `[left, top, width, height]`
|
|
@@ -832,6 +886,20 @@ every donor author/hidden image requires a valid receipt before pixels are
|
|
|
832
886
|
copied. A receipted author-layer target is accepted. The plan proposal command
|
|
833
887
|
refuses region entries because their part names depend on an existing plan.
|
|
834
888
|
|
|
889
|
+
### Scene plate editor receipts (issue #966)
|
|
890
|
+
|
|
891
|
+
Optional `scene.plate.editor_receipts` uses the assemble ledger shape
|
|
892
|
+
`{receipts: [{file, sha256, from_sha256, editor}], required?}`. Paths resolve
|
|
893
|
+
relative to the scene file. Every declared receipt is verified, and under
|
|
894
|
+
`required: true` the plate must match a verified receipt by content before
|
|
895
|
+
compose writes anything. The shared `trace_parts` boolean also requires a
|
|
896
|
+
matching receipt when true, as in assemble. `from_sha256` and `editor` remain
|
|
897
|
+
author declarations held to form and differing hashes. `provenance` remains
|
|
898
|
+
required and describes pixel origin (`observed`, `generated`, `unknown`).
|
|
899
|
+
A receipt verifies file identity and records an edit declaration. Neither
|
|
900
|
+
field judges pixel content. Omitting the ledger preserves output bytes and
|
|
901
|
+
accepted exit codes.
|
|
902
|
+
|
|
835
903
|
### Editor receipts (`assemble.editor_receipts`, issue #946)
|
|
836
904
|
|
|
837
905
|
An image edited in code — a layer re-segmented by a script, a patch
|
|
@@ -4536,7 +4604,8 @@ Limb source intersections use the final segmentation ownership map separately
|
|
|
4536
4604
|
for each adjacent source; another limb's island is excluded before capture.
|
|
4537
4605
|
|
|
4538
4606
|
To enforce a bend, add `cut_material` to the existing
|
|
4539
|
-
`spine-parts-requirements/1` file
|
|
4607
|
+
`spine-parts-requirements/1` file. `requirements` remains required. `fps` is
|
|
4608
|
+
required only when a declared measurement reads the file's sampling rate.
|
|
4540
4609
|
|
|
4541
4610
|
```json
|
|
4542
4611
|
{
|
|
@@ -5122,6 +5191,10 @@ those settings. Changing a config seed alone cannot change a no-config run.
|
|
|
5122
5191
|
| <a id="ASSEMBLE_TAKE_DEPTH_AMBIGUOUS"></a>`ASSEMBLE_TAKE_DEPTH_AMBIGUOUS` | different original runs have equal or null depths | use a run with distinct numeric depths; do not invent an inter-run tie order |
|
|
5123
5192
|
| <a id="ASSEMBLE_TAKE_PLAN_ORDER"></a>`ASSEMBLE_TAKE_PLAN_ORDER` | supplied plan reverses an overlapping mixed-run pair: two-run full-layer head roles first, scalar depth within a role band (head-run and single-run rows use scalar depth); names the parts and support intersection | use the order drafted by `assemble --propose-plan`; fallback headwear follows the body and precedes the face, front hair follows the face; disjoint supports impose no plan-order constraint |
|
|
5124
5193
|
| <a id="EDGE_PAIR_RESIDUAL"></a>`EDGE_PAIR_RESIDUAL` | report-only front/back residual candidates after visible edge composition, with pixel spans, boxes and coverage | read measured candidates and explicit skipped/unavailable reasons in `edge_residuals.json`. No pass/fail threshold is applied |
|
|
5194
|
+
| <a id="ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT"></a>`ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT` | warning, `reference` is absent and the predicate uses `--source` | declare a painting reference and its PNG hash to keep the mask independent of projection-source changes |
|
|
5195
|
+
| <a id="ASSEMBLE_REGION_TAKE_REFERENCE"></a>`ASSEMBLE_REGION_TAKE_REFERENCE` | a declared region reference is unreadable | supply the declared PNG, with `source: true` or `painting` and its byte hash |
|
|
5196
|
+
| <a id="ASSEMBLE_REGION_TAKE_REFERENCE_MATCH"></a>`ASSEMBLE_REGION_TAKE_REFERENCE_MATCH` | a reference hash or painting dimensions differ | supply the declared PNG and preserve the source geometry |
|
|
5197
|
+
| <a id="ASSEMBLE_REGION_TAKE_CONTAINMENT"></a>`ASSEMBLE_REGION_TAKE_CONTAINMENT` | a non-target part has no pre-take counterpart | retain the same neighbour part population before and after the take |
|
|
5125
5198
|
| <a id="ASSEMBLE_REGION_TAKE_TOLERANCE"></a>`ASSEMBLE_REGION_TAKE_TOLERANCE` | a region take has no authored `setup_painting.rgb_tolerance` | supply the same requirements file to assemble/build and check |
|
|
5126
5199
|
| <a id="ASSEMBLE_REGION_TAKE_GEOMETRY"></a>`ASSEMBLE_REGION_TAKE_GEOMETRY` | donor and target canvases or original input RGBA differ, or the target head run is absent | supply the same crop and original input PNG. Cropped donors await #973 |
|
|
5127
5200
|
| <a id="ASSEMBLE_REGION_TAKE_PART"></a>`ASSEMBLE_REGION_TAKE_PART` | the named plan part/run or its base/donor tag is absent, or the target is taken twice | name an existing plan part and one donor holding its tag |
|
|
@@ -5129,7 +5202,8 @@ those settings. Changing a config seed alone cannot change a no-config run.
|
|
|
5129
5202
|
| <a id="ASSEMBLE_REGION_TAKE_SEED"></a>`ASSEMBLE_REGION_TAKE_SEED` | the seed touches no qualifying component | name an integer pixel in the derived region on the target run canvas |
|
|
5130
5203
|
| <a id="ASSEMBLE_REGION_TAKE_BINDING"></a>`ASSEMBLE_REGION_TAKE_BINDING` | active scalar depth or hidden companions have no region binding | supply a part without those companions |
|
|
5131
5204
|
| <a id="ASSEMBLE_REGION_TAKE_PLAN"></a>`ASSEMBLE_REGION_TAKE_PLAN` | a region entry is present during plan proposal | draft the plan before declaring region takes, then use assemble/build with requirements |
|
|
5132
|
-
| <a id="
|
|
5205
|
+
| <a id="ASSEMBLE_TAKE_CROP"></a>`ASSEMBLE_TAKE_CROP` | declared crop is outside the source, its square-padded input dimensions differ, or taken support would fall outside the crop or target | supply the original inside integer crop and its exact square-padded input, with explicit `resample: "bilinear"` |
|
|
5206
|
+
| <a id="ASSEMBLE_TAKE_GEOMETRY"></a>`ASSEMBLE_TAKE_GEOMETRY` | missing target head, unequal run canvases or unequal original input dimensions | supply matching geometry, or declare the whole-layer donor crop and resample explicitly |
|
|
5133
5207
|
| <a id="ASSEMBLE_TAKE_META"></a>`ASSEMBLE_TAKE_META` | donor `meta.json` lacks a readable prompt ID or seed | retain the original run metadata |
|
|
5134
5208
|
| <a id="ASSEMBLE_TAKE_INPUT"></a>`ASSEMBLE_TAKE_INPUT` | declared donor input PNG cannot be read | name its original PNG relative to the config |
|
|
5135
5209
|
| <a id="ASSEMBLE_TAKE_DEPTH_SHEET"></a>`ASSEMBLE_TAKE_DEPTH_SHEET` | taking layers with an active depth opt-in or bound scalar sheets has no declared mixed-run binding | keep scalar depth inactive; numeric ordering depth is retained, and a target sheet is never attached silently |
|
|
@@ -5557,7 +5631,7 @@ documents do not ship). Each is an input this package built; none is an author's
|
|
|
5557
5631
|
| <a id="BUILD_REQUIREMENTS_CONFLICT"></a>`BUILD_REQUIREMENTS_CONFLICT` | `build --requirements <file>` names a file with other bytes than the one `config.requirements` declares (issue #556): the detail names both, each resolved (the flag against the working directory, the declaration against the config's) with its sha256 — or `no such file`. Refused before assemble writes anything. The same file by both, or a copy of its bytes, is not refused: the declaration is read and recorded | drop the flag to build from the config's file, or point `config.requirements` at the flag's |
|
|
5558
5632
|
| <a id="BUILD_STAGE_REQUEST"></a>`BUILD_STAGE_REQUEST` | an internal build worker was given a missing or malformed request, an unknown stage, or incomplete runner, input, output or scratch paths; the worker refuses before running a stage. This is the internal process protocol, not a public command or option | invoke `rig-parts build` with its documented flags; if that command produces this refusal, report the named request defect rather than constructing or modifying a worker request |
|
|
5559
5633
|
| <a id="REQUIREMENTS_FILE"></a>`REQUIREMENTS_FILE` | `--requirements` names no file, or one that does not parse as a JSON object. Refused before anything is built (by `build`, before assemble) | the path of a `spine-parts-requirements/1` file |
|
|
5560
|
-
| <a id="REQUIREMENTS_FIELD"></a>`REQUIREMENTS_FIELD` | a field of the file is missing, of the wrong type, or not one the format reads: `spec`, `fps` (no default), `requirements`, each requirement's `name` (letters, digits, underscores; once each), `kind`, `animation` and its kind's fields — every bar among them (`within_px`, `fraction`, `tolerance`, `least_drive` above 0, `within_degrees` from 0 to 180, `lo_degrees` ≤ `hi_degrees`, `within_ratio` ≥ 1) — and each target's `bone`, `animation` and exactly one of `at` and `keys` (times strictly increasing), and `not_planted`, `not_swinging`, `rises` and `rests` when given (each a non-empty list of part names, each once; no part both in `not_swinging` and in `rises` or `rests`), and `may_fold` when given (a non-empty list of `{ "slot", "animation", "why" }`, every one a non-blank string, each pair once; issue #320), and `pinholes` when given (an object whose one field, `required`, is `true` or `false`; issue #944), `seam_over_motion` when given (the same shape; issue #941), and `boundary_colour` when given (an object whose one field, `max_step_excess`, is an integer from 0 through 255; issue #942). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing, as in the config | write the field; the bar is yours. For a pair read only by `seam_over_motion.required`, declare `config.motion_seams` `[{part, neighbour}]` and omit a seam requirement. See the [carrier contract](#CONFIG_MOTION_SEAM) and [background over every frame](#background-over-every-frame-issue-941). |
|
|
5634
|
+
| <a id="REQUIREMENTS_FIELD"></a>`REQUIREMENTS_FIELD` | a field of the file is missing, of the wrong type, or not one the format reads: `spec`, `fps` (a positive number when supplied, required only when a declared measurement reads the file's sampling rate, with no default), `requirements`, each requirement's `name` (letters, digits, underscores; once each), `kind`, `animation` and its kind's fields — every bar among them (`within_px`, `fraction`, `tolerance`, `least_drive` above 0, `within_degrees` from 0 to 180, `lo_degrees` ≤ `hi_degrees`, `within_ratio` ≥ 1) — and each target's `bone`, `animation` and exactly one of `at` and `keys` (times strictly increasing), and `not_planted`, `not_swinging`, `rises` and `rests` when given (each a non-empty list of part names, each once; no part both in `not_swinging` and in `rises` or `rests`), and `may_fold` when given (a non-empty list of `{ "slot", "animation", "why" }`, every one a non-blank string, each pair once; issue #320), and `pinholes` when given (an object whose one field, `required`, is `true` or `false`; issue #944), `seam_over_motion` when given (the same shape; issue #941), and `boundary_colour` when given (an object whose one field, `max_step_excess`, is an integer from 0 through 255; issue #942). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing, as in the config | write the field; the bar is yours. For a pair read only by `seam_over_motion.required`, declare `config.motion_seams` `[{part, neighbour}]` and omit a seam requirement. See the [carrier contract](#CONFIG_MOTION_SEAM) and [background over every frame](#background-over-every-frame-issue-941). |
|
|
5561
5635
|
| <a id="REQUIREMENTS_RESOLVES"></a>`REQUIREMENTS_RESOLVES` | a bone, an `ik` or `transform` constraint (by name and `constraint_type`, as rig-c resolves it), a slot, a mesh attachment or an animation the file names is not in `rig.json` or `motion.json`, or a seam's part or a `not_planted`, `not_swinging`, `rises` or `rests` name is not in `parts.json` — the detail lists the ones that are; or an animation whose name cannot be a directory. Refused before anything is built | the name as the rig spells it |
|
|
5562
5636
|
| <a id="REQUIREMENTS_CONSTRAINT_DRIVES"></a>`REQUIREMENTS_CONSTRAINT_DRIVES` | a `follow` names a bone its constraint does not constrain, or a property it does not drive: an `ik` drives `rotate` only; a `transform` drives `rotate` when a `to` names `rotate`, `translate` when one names `x` or `y` | the constraint's own bone and property |
|
|
5563
5637
|
| <a id="REQUIREMENTS_TARGET_PARENT"></a>`REQUIREMENTS_TARGET_PARENT` | a scene target's bone is the root or is not parented to it, or the root is not at rest at the origin, or the animation keys the root — a stage point is a bone's position only under a root that stands still at the origin | a bone under the root for the scene to place |
|
|
@@ -5672,6 +5746,10 @@ and stay absent).
|
|
|
5672
5746
|
| --- | --- | --- |
|
|
5673
5747
|
| <a id="SCENE_FILE"></a><a id="SCENE_SPEC"></a><a id="SCENE_FIELD_PRESENT"></a><a id="SCENE_FIELD_TYPE"></a><a id="SCENE_KEY_KNOWN"></a>`SCENE_FILE`, `SCENE_SPEC`, `SCENE_FIELD_PRESENT`, `SCENE_FIELD_TYPE`, `SCENE_KEY_KNOWN` | the scene file is missing, not a JSON object, not `spine-parts-scene/1`, or a field is missing, of the wrong type or not one the format reads (`canvas` two positive integers; `characters` at least one, each `id`, `build`, `offset` — an offset is never assumed (0, 0) — and an optional `scale`, a positive finite number: 0, a negative number or a non-number is refused at `characters[i].scale`; `order` a list of strings). `note`, `*_note` (strings) and `x-…` (any value) are read by nothing | the field |
|
|
5674
5748
|
| <a id="SCENE_ID_UNIQUE"></a><a id="SCENE_ID_FORM"></a>`SCENE_ID_UNIQUE`, `SCENE_ID_FORM` | an `id` repeated, empty, or holding `:` — the separator of a prefixed name — or `/`, `\` or a leading `.`, because an id begins every image file of its character | another id |
|
|
5749
|
+
| <a id="SCENE_EDITOR_RECEIPT"></a>`SCENE_EDITOR_RECEIPT` | `scene.plate.editor_receipts` has a malformed ledger or receipt (issue #966) | use `{receipts: [{file, sha256, from_sha256, editor}], required?}` with the same shape rules as assemble editor receipts |
|
|
5750
|
+
| <a id="SCENE_EDITOR_RECEIPT_FILE"></a>`SCENE_EDITOR_RECEIPT_FILE` | a plate receipt names a missing file | supply the edited image relative to the scene file |
|
|
5751
|
+
| <a id="SCENE_EDITOR_RECEIPT_SHA256"></a>`SCENE_EDITOR_RECEIPT_SHA256` | a plate receipt's file hashes differently from its declared `sha256` | record the current edited file's sha256 |
|
|
5752
|
+
| <a id="SCENE_EDITOR_RECEIPT_MISSING"></a>`SCENE_EDITOR_RECEIPT_MISSING` | `scene.plate.editor_receipts.required` is true and no verified receipt matches the plate's bytes | edit the plate in an image editor and record a matching receipt |
|
|
5675
5753
|
| <a id="SCENE_PLATE_PROVENANCE"></a><a id="SCENE_PLATE_PRESENT"></a><a id="SCENE_PLATE_SIZE"></a>`SCENE_PLATE_PROVENANCE`, `SCENE_PLATE_PRESENT`, `SCENE_PLATE_SIZE` | the plate's `provenance` is not `observed`, `generated` or `unknown`; its image is missing or not a PNG; it is not the canvas's size (it is drawn at canvas (0, 0) and never scaled) | the plate |
|
|
5676
5754
|
| <a id="SCENE_BUILD_PRESENT"></a>`SCENE_BUILD_PRESENT` | a build directory, or a file `build` writes in it (`parts.json`, `rig/rig.json`, `rig/motion.json` with an `idle`, an image its skin names, `check/check.json`), is missing | run `build` for that character |
|
|
5677
5755
|
| <a id="SCENE_BUILD_GREEN"></a>`SCENE_BUILD_GREEN` | the build's own `check/check.json` does not say `PASS` and `gate_spine_html_green`; under an accept list (`--accept`, `characters[].accept`), it failed a bar the list does not name, its gate is red, a declared requirement is not PASS, or it names no failing bar — the bars not met and the ones accepted are listed | finish that character first: compose binds finished characters. To look at the scene meanwhile, name the failing bars (§7, *Composing several characters*) |
|
|
@@ -6415,7 +6493,7 @@ single-character path (§4, steps 0–9) on its own, already isolated input to a
|
|
|
6415
6493
|
| field | what it is |
|
|
6416
6494
|
| --- | --- |
|
|
6417
6495
|
| `canvas` | the shared stage, canvas px, y down. The composed rig's stage is the canvas, `root` at its bottom centre, as a single character's is its own |
|
|
6418
|
-
| `plate` | optional. `image` (relative to the scene file) is a PNG of exactly the canvas's size; `provenance` is `observed` (the painting's own pixels), `generated` or `unknown` (a residual after the people were removed is `unknown` where they stood, and the author says so); `note` is free text.
|
|
6496
|
+
| `plate` | optional. `image` (relative to the scene file) is a PNG of exactly the canvas's size; `provenance` is `observed` (the painting's own pixels), `generated` or `unknown` (a residual after the people were removed is `unknown` where they stood, and the author says so); `note` is free text. `provenance` and `note` are copied into `scene.json` and **judged by nothing**. Optional `editor_receipts` uses the [editor receipt ledger](#scene-plate-editor-receipts-issue-966), with paths relative to the scene file and verified file hashes; `required: true` demands a matching plate receipt |
|
|
6419
6497
|
| `characters[].id` | the prefix: every bone, slot, attachment (skin entry), constraint (its name, and every bone and path slot it names), `stops` entry (its `chain`, `links` and `against`, issue #431), `invariants.detached` bone, track, group and easing of that character is renamed `<id>:<name>`. `root` is the one shared root. A name resolves to exactly one character, and nothing resolves across characters without its prefix |
|
|
6420
6498
|
| `characters[].build` | a `build --out` directory (relative to the scene file) whose own `check/check.json` is green |
|
|
6421
6499
|
| `characters[].offset` | `[x, y]`, a translation from the build's rig px to canvas px. Every character is built at one `assemble.rig_scale`, so its rig px are canvas px at scale 1 |
|
|
@@ -6628,8 +6706,10 @@ and an entry whose slot does not fold in its animation fails the line
|
|
|
6628
6706
|
(`CHECK_ANIMATIONS_DO_NOT_FOLD`, `declared_not_folding`) — an exception that excepts nothing
|
|
6629
6707
|
is taken out of the file, as rig-c refuses one for `invariants.deformMayFold`.
|
|
6630
6708
|
|
|
6631
|
-
`
|
|
6632
|
-
|
|
6709
|
+
`requirements` is required. `fps` is required only when a declared measurement
|
|
6710
|
+
reads the file's sampling rate. Instruments using their own rate do not require it.
|
|
6711
|
+
A file that declares only exceptions
|
|
6712
|
+
is `{ "spec": "spine-parts-requirements/1", "requirements": [], "not_planted": ["shoes"], "not_swinging": ["under"], "rises": ["arm"], "rests": ["hem"] }`.
|
|
6633
6713
|
|
|
6634
6714
|
**Four outcomes, never merged** (#87):
|
|
6635
6715
|
|
|
@@ -6717,8 +6797,9 @@ render each requirement on that animation reads: the rig under test's own build,
|
|
|
6717
6797
|
copy with its scene targets placed), and, per follow, `requirements/released/<name>/`
|
|
6718
6798
|
and `requirements/full/<name>/` — each a `rigc render` frame set with its
|
|
6719
6799
|
`geometry.json`. The copies' specs and builds are scratch, removed before the stage
|
|
6720
|
-
returns. `check.json` gains `requirements` —
|
|
6721
|
-
— before `PASS`, and `PASS` needs every
|
|
6800
|
+
returns. `check.json` gains `requirements` — the summary and each line by name,
|
|
6801
|
+
with `fps` only when the file supplies it — before `PASS`, and `PASS` needs every
|
|
6802
|
+
declared requirement to PASS.
|
|
6722
6803
|
|
|
6723
6804
|
The selftest holds each kind and outcome to a hand value on a generated rig
|
|
6724
6805
|
(`fixtures/reqrig.ts`): a two-bone ik that reaches its scene target holds its contact
|
|
@@ -6780,8 +6861,9 @@ Distance **greater than** the declared limit fails; equality passes. Without a
|
|
|
6780
6861
|
limit, the block and every joint are **SKIP**, with the measured distances and
|
|
6781
6862
|
`within_px: null`; this line is excluded from measured bars and contributes no
|
|
6782
6863
|
PASS. Other independent checks may still pass. With no `fixed_skeleton` block,
|
|
6783
|
-
this measurement adds no output or render call. `
|
|
6784
|
-
|
|
6864
|
+
this measurement adds no output or render call. `requirements` remains required
|
|
6865
|
+
by the enclosing file. `fps` is required only when a declared measurement reads
|
|
6866
|
+
the file's sampling rate. Neither field changes this setup measurement.
|
|
6785
6867
|
|
|
6786
6868
|
| rule | change |
|
|
6787
6869
|
| --- | --- |
|
package/docs/STABILITY.md
CHANGED
|
@@ -156,7 +156,9 @@ part name, `[i]` an entry of a list):
|
|
|
156
156
|
- **assemble.alternates[i]** — required: `name`; optional: `source`, `full`, `head`, `parts`, `hides`, `head_box`, `cuts`, `skeleton`, `anchor`, `base_anchor`, `drawn`, `tips`, `front`, `layers`, `focus` (issues #454, #465, #543, #652, #664, #666, #719, #780). `focus` is an authored finite `[x, y]` in painting pixels, within its canvas (edges included), refused as CONFIG_SET_FOCUS before assemble output when invalid; only when present, `parts.json` records it with focus_source_size and `sets.json` emits it in setup-world coordinates through the actual rig resize and `src/coords.ts`. Absent focus emits no field or inferred value; changing/removing it requires reassembly
|
|
157
157
|
- **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
|
|
158
158
|
- **assemble.extend_painting[i]** — required: `part`, `band`, `max_distance`; optional: `min_held_alpha`, `order` (issues #848, #854, #855; explicit final layer-derived part, half-open rig band and positive four-neighbour path bound; base painting only). The optional integer floor 1–255 admits exact original alpha-over coverage strictly below it; absent or 1 blocks every nonzero holder. Only selected target samples below the floor may take painting RGBA; target samples at/above the floor and every non-target holder remain exact. By default overlapping reachable claims refuse. With `order` on every entry (unique nonnegative safe integers), lower values claim first and their gains block later candidates and paths; array order never breaks ties or supplies missing precedence
|
|
159
|
-
- **assemble.take[i]** — required: `run`, `run_dir`, `st_input
|
|
159
|
+
- **assemble.take[i]** — required: `run`, `run_dir`, `st_input`; optional: `tags`, `part`, `seed`, `reference`, `crop` (issues #866, #973, #1000, #1031). Whole-layer entries require `tags`; region entries require `part` and `seed`. An absent `reference` preserves the shipped `source: true` predicate and emits one named implicit-reference warning; only an explicitly declared painting reference makes the mask invariant to changes in `--source`
|
|
160
|
+
- **assemble.take[i].crop** — required when present: `box`, `resample` (issue #973, additive, whole-layer entries only). `box` is an inside nonempty integer source-painting `[left, top, right, bottom]` with exclusive right/bottom. `resample: "bilinear"` is required explicitly. Donor input must match the square-padded crop under taken support plus its margin. Unit scale and integer translation copy RGBA exactly; other maps use the declared premultiplied float32 affine filter. Absent, the shipped geometry predicate and refusal message remain unchanged. Region entries reject this field
|
|
161
|
+
- **assemble.take[i].reference** — required: `sha256`; optional: `source`, `painting` (issue #1031). Exactly one of `source: true` or a config-relative `painting` PNG is required
|
|
160
162
|
- **assemble.editor_receipts** — required: `receipts`; optional: `required` (issue #946, additive), `trace_parts` (issue #947, additive)
|
|
161
163
|
- **assemble.editor_receipts.receipts[i]** — required: `file`, `sha256`, `from_sha256`, `editor` (issue #946)
|
|
162
164
|
- **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
|
|
@@ -615,6 +617,20 @@ stale evidence rather than reconstructing original art from emitted siblings.
|
|
|
615
617
|
Without the block the API reading is SKIP; CLI stdout and emitted bytes remain unchanged.
|
|
616
618
|
See [Source before scissors](AUTHORING.md#source-before-scissors).
|
|
617
619
|
|
|
620
|
+
### Scene plate editor receipts
|
|
621
|
+
|
|
622
|
+
`scene.plate.editor_receipts` (issue #966, additive) is optional and uses the
|
|
623
|
+
assemble receipt ledger shape, including `required` and `trace_parts`.
|
|
624
|
+
Receipt file paths resolve against the scene file. Declared receipts are
|
|
625
|
+
verified even without `required`; when required, the plate needs a verified
|
|
626
|
+
receipt matching its bytes. Receipt refusals occur before compose writes new
|
|
627
|
+
artifacts. As before, compose creates `--out` and clears its owned outputs,
|
|
628
|
+
including an earlier `rig/` and `scene.json`, before input validation, so a
|
|
629
|
+
refused re-run can remove earlier artifacts. Omitting the field preserves
|
|
630
|
+
exit codes and `scene.json` bytes. The existing required
|
|
631
|
+
`provenance` field is preserved. It declares pixel origin, while the receipt
|
|
632
|
+
verifies file identity and records the author's edit declaration.
|
|
633
|
+
|
|
618
634
|
### Editor receipts
|
|
619
635
|
|
|
620
636
|
The optional `assemble.editor_receipts` block (issue #946, additive) records
|
|
@@ -657,3 +673,22 @@ changing a calibration requires preparing its recipe again. Compare the same
|
|
|
657
673
|
provider maps and preserve their provenance. Each report row names the part,
|
|
658
674
|
declared relief, group `zScale` and clipping over original alpha>=128 support;
|
|
659
675
|
it remains a measurement rather than a shape-quality gate.
|
|
676
|
+
|
|
677
|
+
The requirements file's `fps` becomes optional when no declared measurement
|
|
678
|
+
reads that rate (issue #965, additive for readers). Measurements in
|
|
679
|
+
`requirements` still require a positive `fps`, with no default. Declarations
|
|
680
|
+
whose instruments use their own rate keep that rate.
|
|
681
|
+
The output `check.json` omits `requirements.fps` when the file supplies no `fps`.
|
|
682
|
+
|
|
683
|
+
### Crop donor transport receipts (issue #973)
|
|
684
|
+
|
|
685
|
+
Whole-layer `parts[].taken_layers[]` optionally adds `transport` when its config
|
|
686
|
+
entry declares `crop`. The ordered fields are `crop`, `donor_canvas`,
|
|
687
|
+
`target_canvas`, `scale`, `translate`, `filter`, `input_rgba_sha256`,
|
|
688
|
+
`before_rgba_sha256` and `after_rgba_sha256`. Hashes bind whole-canvas row-major
|
|
689
|
+
straight RGBA before and after transport, and the donor input reconstructed from
|
|
690
|
+
the declared crop. `filter: "exact"` requires unit scale and integer translation.
|
|
691
|
+
Other maps carry `"bilinear"`. The strict reader refuses incomplete or malformed
|
|
692
|
+
transport records. Absent, the existing receipt fields and bytes are unchanged.
|
|
693
|
+
The output hash describes transport before downstream assembly, independently of
|
|
694
|
+
final part geometry. Existing no-take inputs emit no transport metadata.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/assemble.ts
CHANGED
|
@@ -300,6 +300,8 @@ export function singleRunPadRecord(single: boolean, sourceW: number, sourceH: nu
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
export interface AssembleInput {
|
|
303
|
+
/** Counterfactual before seeded takes, with whole-layer takes already applied. */
|
|
304
|
+
regionTake?: { full: LayerSet; head: LayerSet; targets: string[] };
|
|
303
305
|
/** Turn context for the exact part/carrier declarations, never an exemption flag. */
|
|
304
306
|
turn?: TurnSpec;
|
|
305
307
|
regions?: Record<string, RegionSpec>;
|
|
@@ -4052,6 +4054,7 @@ function cutLimbs(
|
|
|
4052
4054
|
readHidden: (layer: string, by: string) => void = () => undefined,
|
|
4053
4055
|
tips?: Record<string, [number, number]>,
|
|
4054
4056
|
sourceGroups: ReadonlyMap<string, string> = new Map(),
|
|
4057
|
+
emptyTargets?: ReadonlySet<string>,
|
|
4055
4058
|
): { parts: LimbPart[]; pieces: Built[]; counts: LimbCount[]; capFrom: Array<CapFrom | null>; material: MaterialRow[]; sources: MaterialSource[] } {
|
|
4056
4059
|
const logical = (source: string): string => sourceGroups.get(source) ?? source;
|
|
4057
4060
|
const names = Object.keys(limbs);
|
|
@@ -4110,7 +4113,7 @@ function cutLimbs(
|
|
|
4110
4113
|
const on = names.filter((l) => limbs[l].segments.some((sg) => logical(sg.from) === logical(src)));
|
|
4111
4114
|
const own = new Int32Array(W * H).fill(-1);
|
|
4112
4115
|
const before = opaqueOf(b.r);
|
|
4113
|
-
if (before === 0) {
|
|
4116
|
+
if (before === 0 && !emptyTargets?.has(src)) {
|
|
4114
4117
|
problems.push({ code: 'ASSEMBLE_LIMB_TAKES', object: `part "${src}" (config.limbs.${on[0]})`, detail: 'has no opaque pixel to cut; a part with art is required — the limb takes its pixels' });
|
|
4115
4118
|
continue;
|
|
4116
4119
|
}
|
|
@@ -4211,7 +4214,7 @@ function cutLimbs(
|
|
|
4211
4214
|
for (const { q, i } of shares) {
|
|
4212
4215
|
const writers = b.writers?.slice();
|
|
4213
4216
|
pieces[i] = { ...b, writers, r: rasters[i], region: regions[i], merged: mergedIn(b, regions[i]) };
|
|
4214
|
-
if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0) {
|
|
4217
|
+
if (count(regions[i], regions[i]) === 0 && opaqueOf(rasters[i]) === 0 && !emptyTargets?.has(q.part)) {
|
|
4215
4218
|
problems.push({
|
|
4216
4219
|
code: 'ASSEMBLE_LIMB_PIXELS',
|
|
4217
4220
|
object: `config.limbs.${q.limb}.segments[${q.segment}] (part "${q.part}")`,
|
|
@@ -4251,6 +4254,7 @@ function cutLimbs(
|
|
|
4251
4254
|
const hasCoverSplit = names.some((other) => other !== l && limbs[other].split !== undefined && limbs[other].segments.some((osg) => osg.from === upFrom || osg.from === sg.from));
|
|
4252
4255
|
const object = `config.limbs.${l}.segments[${k}] (joint "${String(sg.joint)}", part "${sg.part}")`;
|
|
4253
4256
|
const run = limbRun(line, art, [0, 0, W - 1, H - 1]) ?? (hasCoverSplit ? limbRun(line, sharedArt, [0, 0, W - 1, H - 1]) : null);
|
|
4257
|
+
if (run === null && emptyTargets?.has(sg.part)) return;
|
|
4254
4258
|
if (run === null) {
|
|
4255
4259
|
problems.push({
|
|
4256
4260
|
code: 'ASSEMBLE_LIMB_JOINT_ON_ART',
|
|
@@ -4383,7 +4387,7 @@ function cutLimbs(
|
|
|
4383
4387
|
}
|
|
4384
4388
|
}
|
|
4385
4389
|
}
|
|
4386
|
-
if (img !== null && imagePx + lowerPx === 0 && hidden === null) {
|
|
4390
|
+
if (img !== null && imagePx + lowerPx === 0 && hidden === null && !emptyTargets?.has(sg.part)) {
|
|
4387
4391
|
problems.push({
|
|
4388
4392
|
code: 'ASSEMBLE_LIMB_CAP_EMPTY',
|
|
4389
4393
|
object: `${object}.cap`,
|
|
@@ -4477,6 +4481,7 @@ function cutLimbs(
|
|
|
4477
4481
|
const li = names.indexOf(l), own = owners.get(sg.from) as Int32Array;
|
|
4478
4482
|
const art = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < W && y < H && own[y * W + x] === li && source.r.data[(y * W + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE;
|
|
4479
4483
|
const run = limbRun(line, art, [0, 0, W - 1, H - 1]);
|
|
4484
|
+
if (run === null && emptyTargets?.has(sg.hybrid.part)) return;
|
|
4480
4485
|
const width = sg.hybrid.width ?? (run === null ? 0 : 2 * halfWidthAbout(run));
|
|
4481
4486
|
const upper = places.get(segs[k - 1].bone) as BonePlace;
|
|
4482
4487
|
const lower = places.get(sg.bone) as BonePlace;
|
|
@@ -4799,12 +4804,56 @@ export function checkSingleRun(full: LayerSet, plan: readonly PlanEntry[] | null
|
|
|
4799
4804
|
refuseIfAny(problems);
|
|
4800
4805
|
}
|
|
4801
4806
|
|
|
4807
|
+
/** All output pieces that consume a taken source, including cut-to-limb paths.
|
|
4808
|
+
* Painting patches have no source-part edge in the supported Patch schema. */
|
|
4809
|
+
export function regionTakeTargets(names: readonly string[], cuts: readonly Cut[], limbs: Readonly<Record<string, Limb>> = {}): ReadonlySet<string> {
|
|
4810
|
+
const targets = new Set(names);
|
|
4811
|
+
const edges = cuts.map(c => ({ from: c.from, into: c.into }));
|
|
4812
|
+
for (const limb of Object.values(limbs)) for (const [index, segment] of limb.segments.entries()) {
|
|
4813
|
+
edges.push({ from: segment.from, into: segment.part });
|
|
4814
|
+
// A lower cap can copy the upper segment's source art.
|
|
4815
|
+
if (index > 0) edges.push({ from: limb.segments[index - 1].from, into: segment.part });
|
|
4816
|
+
if (segment.hybrid !== undefined) edges.push({ from: segment.from, into: segment.hybrid.part });
|
|
4817
|
+
}
|
|
4818
|
+
// Set iteration visits newly added descendants and terminates on cycles.
|
|
4819
|
+
for (const source of targets) for (const edge of edges) if (edge.from === source) targets.add(edge.into);
|
|
4820
|
+
return targets;
|
|
4821
|
+
}
|
|
4822
|
+
|
|
4823
|
+
/** Retain neighbour pixels and their matching records after every coupled edit. */
|
|
4824
|
+
export function retainRegionNeighbours(placed: PlacedPart[], before: readonly PlacedPart[], targets: ReadonlySet<string>): void {
|
|
4825
|
+
const originals = new Map(before.map(p => [p.record.name, p]));
|
|
4826
|
+
for (let i = 0; i < placed.length; i++) {
|
|
4827
|
+
if (targets.has(placed[i].record.name)) continue;
|
|
4828
|
+
const original = originals.get(placed[i].record.name);
|
|
4829
|
+
if (original === undefined) throw new PartsError([{
|
|
4830
|
+
code: 'ASSEMBLE_REGION_TAKE_CONTAINMENT', object: `part "${placed[i].record.name}"`,
|
|
4831
|
+
detail: 'has no pre-take counterpart; unchanged neighbour pixels and their measurements are required',
|
|
4832
|
+
}]);
|
|
4833
|
+
placed[i] = original;
|
|
4834
|
+
}
|
|
4835
|
+
if (before.some(p => !targets.has(p.record.name) && !placed.some(q => q.record.name === p.record.name))) throw new PartsError([{
|
|
4836
|
+
code: 'ASSEMBLE_REGION_TAKE_CONTAINMENT', object: 'config.assemble.take',
|
|
4837
|
+
detail: 'removed a non-target part; unchanged neighbour pixels and their measurements are required',
|
|
4838
|
+
}]);
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4802
4841
|
/**
|
|
4803
4842
|
* Run the stage in memory. Everything that can be refused is refused before
|
|
4804
4843
|
* this returns; nothing here writes a file, so a caller that writes only
|
|
4805
4844
|
* after this returned writes only after green.
|
|
4806
4845
|
*/
|
|
4807
4846
|
export function assemble(input: AssembleInput): AssembleResult {
|
|
4847
|
+
if (input.regionTake === undefined) return assembleParts(input);
|
|
4848
|
+
const targets = regionTakeTargets(input.regionTake.targets, input.cuts, input.limbs);
|
|
4849
|
+
const before = assembleParts({ ...input, full: input.regionTake.full, head: input.regionTake.head, regionTake: undefined }, targets);
|
|
4850
|
+
return assembleParts(input, targets, before);
|
|
4851
|
+
}
|
|
4852
|
+
|
|
4853
|
+
/** The counterfactual is internal and is never emitted. Empty taken targets
|
|
4854
|
+
* may be recovered by a take, so only that counterfactual permits them. */
|
|
4855
|
+
function assembleParts(input: AssembleInput, targets?: ReadonlySet<string>, beforeTake: AssembleResult | null = null): AssembleResult {
|
|
4856
|
+
const emptyTargets = beforeTake === null ? targets : undefined;
|
|
4808
4857
|
const { source, extend, patches, seamRule, projectRule } = input;
|
|
4809
4858
|
const authoredDepths = input.depth === undefined ? new Map<string, AuthoredUnitDepth>() : authoredUnitDepths(input);
|
|
4810
4859
|
if (input.paintingExtension !== undefined) refuseIfAny(paintingExtensionProblems(input.paintingExtension));
|
|
@@ -5130,7 +5179,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5130
5179
|
merged = mergeBelowCrop(r, extras, front, srcr, frame.headBottom, trace, (p, stage) => { writers[p] = AW[stage]; });
|
|
5131
5180
|
}
|
|
5132
5181
|
const { x1, opaque } = measureBox(r);
|
|
5133
|
-
if (opaque === 0) {
|
|
5182
|
+
if (opaque === 0 && !emptyTargets?.has(name)) {
|
|
5134
5183
|
empty.push({
|
|
5135
5184
|
code: 'ASSEMBLE_PART_OPAQUE',
|
|
5136
5185
|
object: `part "${name}" (${key}, config.assemble.plan[${pi}])`,
|
|
@@ -5238,7 +5287,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5238
5287
|
if (whole !== parts) empty.push({ code: 'ASSEMBLE_COUNTS_ADD_UP', object, detail: `${what}: ${whole} before its cuts, ${parts} over the pieces; equal is required (a bug in the assembler, not in the inputs)` });
|
|
5239
5288
|
}
|
|
5240
5289
|
mine.forEach((i, k) => {
|
|
5241
|
-
if (taken[k] === 0) {
|
|
5290
|
+
if (taken[k] === 0 && !emptyTargets?.has(cuts[i].into)) {
|
|
5242
5291
|
empty.push({
|
|
5243
5292
|
code: 'ASSEMBLE_CUT_PIXELS',
|
|
5244
5293
|
object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`,
|
|
@@ -5246,7 +5295,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5246
5295
|
});
|
|
5247
5296
|
}
|
|
5248
5297
|
});
|
|
5249
|
-
if (kept === 0) {
|
|
5298
|
+
if (kept === 0 && !emptyTargets?.has(name)) {
|
|
5250
5299
|
empty.push({
|
|
5251
5300
|
code: 'ASSEMBLE_CUT_PIXELS',
|
|
5252
5301
|
object,
|
|
@@ -5271,7 +5320,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5271
5320
|
for (let p = 0; p < W * H; p++) if (cutMasks[i].data[p] === 1 && band.data[p] === 0 && (pieces[i] as Built).r.data[p * 4 + 3] > 0 && b.r.data[p * 4 + 3] > 0) { (b.writers as Int32Array)[p] = cutIds[i]; if (b.r.data[p * 4 + 3] > OPAQUE_ALPHA_ABOVE) opaqueFilled++; }
|
|
5272
5321
|
// A hidden-art layer (issue #359) is the whole layer's covered art, read by every cut of its part: a cut it holds nothing
|
|
5273
5322
|
// under fills 0, which the cut line counts, and is not refused as a file made for this one cut would be.
|
|
5274
|
-
if (f.filled === 0 && input.cuts[i].under !== undefined) {
|
|
5323
|
+
if (f.filled === 0 && input.cuts[i].under !== undefined && !emptyTargets?.has(cuts[i].into)) {
|
|
5275
5324
|
empty.push({
|
|
5276
5325
|
code: 'ASSEMBLE_UNDER_EMPTY',
|
|
5277
5326
|
object: `config.assemble.cuts[${i}].under (part "${cuts[i].into}")`,
|
|
@@ -5300,7 +5349,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5300
5349
|
if (source === null) return;
|
|
5301
5350
|
const m = cutMasks[i], r = newRaster(W, H);
|
|
5302
5351
|
for (let p = 0; p < W * H; p++) if (m.data[p] === 1) r.data.set(source.r.data.subarray(p * 4, p * 4 + 4), p * 4);
|
|
5303
|
-
if (opaqueOf(r) === 0) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
|
|
5352
|
+
if (opaqueOf(r) === 0 && !emptyTargets?.has(cuts[i].into)) empty.push({ code: 'ASSEMBLE_CUT_PIXELS', object: `config.assemble.cuts[${i}] (part "${cuts[i].into}")`, detail: `takes 0 opaque pixels from selected ${source.key}; draw its polygon over that layer's art` });
|
|
5304
5353
|
const writers = (source.writers as Int32Array).slice();
|
|
5305
5354
|
pieces[i] = { ...source, r, writers, region: m, merged: mergedIn(source, m) };
|
|
5306
5355
|
for (let j = materialSources.length - 1; j >= 0; j--) if (materialSources[j].part === cuts[i].into) materialSources.splice(j, 1);
|
|
@@ -5328,7 +5377,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5328
5377
|
const ci = cuts.findIndex((q) => q.into === name);
|
|
5329
5378
|
return ci >= 0 ? pieces[ci] : null;
|
|
5330
5379
|
};
|
|
5331
|
-
const lim = cutLimbs(input.limbs ?? {}, input.bones ?? [], input.caps ?? new Map(), W, H, limbSource, opaqueOf, mergedIn, count, (name) => hiddenArt.get(name) ?? null, (layer, by) => hiddenUses.get(layer)?.push(by), input.boneTips, input.limbSourceGroups);
|
|
5380
|
+
const lim = cutLimbs(input.limbs ?? {}, input.bones ?? [], input.caps ?? new Map(), W, H, limbSource, opaqueOf, mergedIn, count, (name) => hiddenArt.get(name) ?? null, (layer, by) => hiddenUses.get(layer)?.push(by), input.boneTips, input.limbSourceGroups, emptyTargets);
|
|
5332
5381
|
// Issue #564: and the cap a run's own layer fills above its upper joint, read once the limbs are cut.
|
|
5333
5382
|
if (bands !== null) refuseIfAny(limbBands(cuts, pieces, input.limbs ?? {}, input.bones ?? [], W, H, (l, k) => lim.parts.find((q) => q.limb === l && q.segment === k)?.record.cap, input.boneTips).problems);
|
|
5334
5383
|
const limbPieces = lim.pieces;
|
|
@@ -5736,6 +5785,18 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5736
5785
|
if (edged.get(name)?.data[p] !== 1 && paintingGains.get(name)?.data[p] !== 1 && retakeByName.get(name)?.data[p] !== 1 && yields.get(name)?.mask.data[p] !== 1 && trace.ring.data[p] === 0 && own.takenMask.data[p] === 1 && (region === null || region.data[p] === 1)) projected.data[p] = 1;
|
|
5737
5786
|
}
|
|
5738
5787
|
const { x0, y0, x1, y1, opaque } = measureBox(r);
|
|
5788
|
+
if (opaque === 0 && emptyTargets?.has(name)) {
|
|
5789
|
+
set({
|
|
5790
|
+
record: {
|
|
5791
|
+
name, from: key, x: 0, y: 0, w: 1, h: 1,
|
|
5792
|
+
opaque_px: 0, visible_px: 0, occluded_px: 0, projected_core_px: 0,
|
|
5793
|
+
source_px_taken: 0, visible_not_projected_px: 0, refused_drift_px: 0,
|
|
5794
|
+
merged_px: 0, seam_override_px: 0,
|
|
5795
|
+
},
|
|
5796
|
+
image: newRaster(1, 1),
|
|
5797
|
+
});
|
|
5798
|
+
return;
|
|
5799
|
+
}
|
|
5739
5800
|
if (opaque === 0) {
|
|
5740
5801
|
const pushed = pushedHere.reduce((a, q) => a + q.px, 0);
|
|
5741
5802
|
empty.push({
|
|
@@ -5817,6 +5878,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5817
5878
|
const depth = authoredDepths.get(p.record.name);
|
|
5818
5879
|
if (depth !== undefined) p.record.provenance.depth = depth;
|
|
5819
5880
|
});
|
|
5881
|
+
if (beforeTake !== null) retainRegionNeighbours(placed, beforeTake.images, targets!);
|
|
5820
5882
|
// Provenance is now numbers/labels only. No later raster/figure operation
|
|
5821
5883
|
// reads report buffers; release all aliases before recomposite/error work.
|
|
5822
5884
|
valueBaselines.clear();
|
package/src/build.ts
CHANGED
|
@@ -351,12 +351,13 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
351
351
|
// Issue #947: under `trace_parts`, a shape drawn in the config through a part's pixels is refused with them.
|
|
352
352
|
refuseIfAny([...receipts.problems, ...missingReceipts(receipts, [...editedRunImages(read), ...editedConfigImages(input.config, cfg.assemble.cuts ?? [], cfg.limbs)]), ...untracedPartSources(cfg)]);
|
|
353
353
|
const takenRuns = applyTakenLayers(read, src, cfg, input.config);
|
|
354
|
-
const runs = applyTakenRegions(takenRuns, src, cfg, input.config, requirementsFile?.setup_painting, receipts);
|
|
355
|
-
for (const note of runs.notes) log(`note: ${note}`);
|
|
354
|
+
const runs = applyTakenRegions(takenRuns, src, cfg, input.config, requirementsFile?.setup_painting, receipts, input.source);
|
|
355
|
+
for (const note of runs.notes) log(note.startsWith('WARN ') ? note : `note: ${note}`);
|
|
356
356
|
const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
|
|
357
357
|
const under = readUnders(input.config, fields.cuts);
|
|
358
358
|
const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
|
|
359
|
-
const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under,
|
|
359
|
+
const result = assemble({ source: src, full: runs.full, head: runs.head, depth: cfg.depth, depthBase: dirname(resolve(input.config)), ...fields, under,
|
|
360
|
+
...((cfg.assemble.take ?? []).some(t => 'part' in t) ? { regionTake: { full: takenRuns.full, head: takenRuns.head, targets: (cfg.assemble.take ?? []).flatMap(t => 'part' in t ? [t.part] : []) } } : {}), ...(caps === undefined ? {} : { caps }), seamRule: seam, projectRule: project });
|
|
360
361
|
|
|
361
362
|
// Issue #454: alternate painting sets, assembled before any file is written.
|
|
362
363
|
const configDir = dirname(resolve(input.config));
|
package/src/check.ts
CHANGED
|
@@ -149,6 +149,7 @@ import { TURN_BONE, turnConstraintName, turnTwin } from './turn.ts';
|
|
|
149
149
|
import { ROOT_BONE, loadConfigWithBytes, type Point } from './config.ts';
|
|
150
150
|
import { influences, meshLimits, type Segment, type Influence, type InfluenceLimits } from './weights.ts';
|
|
151
151
|
import {
|
|
152
|
+
requirementAnimations,
|
|
152
153
|
aimLine,
|
|
153
154
|
contactLine,
|
|
154
155
|
declareConsumerDriven,
|
|
@@ -2767,7 +2768,7 @@ export interface SetCheckFigures {
|
|
|
2767
2768
|
/** `check.json`'s `requirements` block: which file was read, the fps every named animation was sampled at, the summary, and each line by its name, in the file's order. */
|
|
2768
2769
|
export interface RequirementsBlock {
|
|
2769
2770
|
file: RequirementsFileRecord;
|
|
2770
|
-
fps
|
|
2771
|
+
fps?: number;
|
|
2771
2772
|
summary: RequirementsSummary;
|
|
2772
2773
|
lines: Record<string, RequirementLine>;
|
|
2773
2774
|
}
|
|
@@ -7489,12 +7490,11 @@ export function measureRequirements(inp: CheckInputs, file: RequirementsFile, bu
|
|
|
7489
7490
|
return p;
|
|
7490
7491
|
};
|
|
7491
7492
|
const declared = new Map<string, { poses: Poses; geo: IdleGeometry; path: string }>();
|
|
7492
|
-
for (const
|
|
7493
|
-
|
|
7494
|
-
const
|
|
7495
|
-
const
|
|
7496
|
-
|
|
7497
|
-
declared.set(r.animation, posesOf(candidate, r.animation, join(reqDir, 'as-declared', r.animation), label));
|
|
7493
|
+
for (const animation of requirementAnimations(file.requirements)) {
|
|
7494
|
+
const scene = file.targets.some((t) => t.animation === animation);
|
|
7495
|
+
const label = scene ? `as declared, the scene targets of "${animation}" placed` : 'as declared';
|
|
7496
|
+
const candidate = scene ? green(buildCopy(placedFor(animation).rig, placedFor(animation).motion), `the rig ${label}`) : buildDir;
|
|
7497
|
+
declared.set(animation, posesOf(candidate, animation, join(reqDir, 'as-declared', animation), label));
|
|
7498
7498
|
}
|
|
7499
7499
|
for (const r of file.requirements) {
|
|
7500
7500
|
const d = declared.get(r.animation) as { poses: Poses; geo: IdleGeometry; path: string };
|
package/src/config.ts
CHANGED
|
@@ -2500,12 +2500,20 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
|
|
|
2500
2500
|
const p = `${at}[${i}]`;
|
|
2501
2501
|
const region = isPlainObject(v) && ('part' in v || 'seed' in v);
|
|
2502
2502
|
const o = region
|
|
2503
|
-
? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], [])
|
|
2504
|
-
: c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
|
|
2503
|
+
? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], ['reference'])
|
|
2504
|
+
: c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], ['crop']);
|
|
2505
2505
|
if (o === null) return;
|
|
2506
2506
|
if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
|
|
2507
2507
|
for (const k of ['run_dir', 'st_input']) c.string(`${p}.${k}`, o[k]);
|
|
2508
2508
|
if (region) {
|
|
2509
|
+
if (o.reference !== undefined) {
|
|
2510
|
+
const r = c.object(`${p}.reference`, o.reference, ['sha256'], ['source', 'painting']);
|
|
2511
|
+
if (r !== null) {
|
|
2512
|
+
if (typeof r.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.sha256)) c.fail('CONFIG_FIELD_TYPE', `${p}.reference.sha256`, 'a lowercase SHA-256 of the reference PNG bytes is required');
|
|
2513
|
+
if ((r.source === true) === (typeof r.painting === 'string') || ('source' in r && r.source !== true)) c.fail('CONFIG_FIELD_TYPE', `${p}.reference`, 'exactly one of source: true or painting is required');
|
|
2514
|
+
if ('painting' in r) c.string(`${p}.reference.painting`, r.painting);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2509
2517
|
if (c.string(`${p}.part`, o.part) && plan !== null && !plan.includes(o.part)) {
|
|
2510
2518
|
c.fail('CONFIG_NAME_RESOLVES', `${p}.part`, `names "${o.part}"; a part named in assemble.plan is required`);
|
|
2511
2519
|
}
|
|
@@ -2518,6 +2526,14 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
|
|
|
2518
2526
|
}
|
|
2519
2527
|
return;
|
|
2520
2528
|
}
|
|
2529
|
+
if ('crop' in o) {
|
|
2530
|
+
const crop = c.object(`${p}.crop`, o.crop, ['box', 'resample'], []);
|
|
2531
|
+
if (crop !== null) {
|
|
2532
|
+
const box = crop.box;
|
|
2533
|
+
if (!(Array.isArray(box) && box.length === 4 && box.every(n => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0) && box[2] > box[0] && box[3] > box[1])) c.fail('CONFIG_FIELD_TYPE', `${p}.crop.box`, 'requires [left, top, right, bottom], nonnegative safe integers with positive width/height in source-painting pixels');
|
|
2534
|
+
if (crop.resample !== 'bilinear') c.fail('CONFIG_FIELD_TYPE', `${p}.crop.resample`, '"bilinear" is required explicitly');
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2521
2537
|
if (!c.array(p + '.tags', o.tags, true)) return;
|
|
2522
2538
|
(o.tags as Json[]).forEach((tag, j) => {
|
|
2523
2539
|
const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
|
|
@@ -2529,6 +2545,13 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
|
|
|
2529
2545
|
});
|
|
2530
2546
|
}
|
|
2531
2547
|
|
|
2548
|
+
/** Shared receipt shape validation for config and scene input doors. */
|
|
2549
|
+
export function editorReceiptProblems(value: unknown, at: string): Problem[] {
|
|
2550
|
+
const c = new Check();
|
|
2551
|
+
checkEditorReceipts(c, value, at);
|
|
2552
|
+
return c.problems;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2532
2555
|
/**
|
|
2533
2556
|
* `config.assemble.editor_receipts` (issue #946): `{receipts, required?, trace_parts?}` (`trace_parts`, issue #947).
|
|
2534
2557
|
* Shape refusals are the loader's own; a receipt whose `from_sha256` equals
|
|
@@ -2536,8 +2559,7 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
|
|
|
2536
2559
|
* Whether the file exists and hashes to `sha256` is read at assemble
|
|
2537
2560
|
* (`src/editreceipt.ts`), where the file is.
|
|
2538
2561
|
*/
|
|
2539
|
-
function checkEditorReceipts(c: Check, value: Json): void {
|
|
2540
|
-
const at = 'config.assemble.editor_receipts';
|
|
2562
|
+
function checkEditorReceipts(c: Check, value: Json, at = 'config.assemble.editor_receipts'): void {
|
|
2541
2563
|
const o = c.object(at, value, ['receipts'], ['required', 'trace_parts']);
|
|
2542
2564
|
if (o === null) return;
|
|
2543
2565
|
for (const k of ['required', 'trace_parts'] as const) {
|
package/src/editreceipt.ts
CHANGED
|
@@ -89,6 +89,8 @@ export interface VerifiedReceipt extends EditorReceipt {
|
|
|
89
89
|
/** The receipts read for one build: those that verified, keyed by sha256, and the refusals of those that did not. */
|
|
90
90
|
export interface ReceiptLedger {
|
|
91
91
|
required: boolean;
|
|
92
|
+
/** Input location, defaulting to the assemble ledger. */
|
|
93
|
+
at?: string;
|
|
92
94
|
/** The field that made receipts required, as a refusal names it: `required`, else `trace_parts` (issue #947). */
|
|
93
95
|
by: 'required' | 'trace_parts';
|
|
94
96
|
bySha: ReadonlyMap<string, VerifiedReceipt>;
|
|
@@ -101,17 +103,17 @@ export interface ReceiptLedger {
|
|
|
101
103
|
* `file`). A missing file is `ASSEMBLE_EDITOR_RECEIPT_FILE`; bytes that hash
|
|
102
104
|
* to another value are `ASSEMBLE_EDITOR_RECEIPT_SHA256`, naming both values.
|
|
103
105
|
*/
|
|
104
|
-
export function readEditorReceipts(spec: EditorReceipts | undefined, configPath: string): ReceiptLedger {
|
|
106
|
+
export function readEditorReceipts(spec: EditorReceipts | undefined, configPath: string, at = AT, relativeTo: 'config' | 'scene file' = 'config'): ReceiptLedger {
|
|
105
107
|
const problems: Problem[] = [];
|
|
106
108
|
const bySha = new Map<string, VerifiedReceipt>();
|
|
107
109
|
if (spec === undefined) return { required: false, by: 'required', bySha, problems };
|
|
108
110
|
const base = dirname(resolve(configPath));
|
|
109
111
|
spec.receipts.forEach((r, i) => {
|
|
110
|
-
const
|
|
112
|
+
const receiptAt = `${at}.receipts[${i}]`;
|
|
111
113
|
const path = resolve(base, r.file);
|
|
112
|
-
const object = `${
|
|
114
|
+
const object = `${receiptAt} (file "${r.file}")`;
|
|
113
115
|
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
114
|
-
problems.push({ code: 'ASSEMBLE_EDITOR_RECEIPT_FILE', object, detail: `names ${path}, which is no such file; the edited image, its path relative to the
|
|
116
|
+
problems.push({ code: 'ASSEMBLE_EDITOR_RECEIPT_FILE', object, detail: `names ${path}, which is no such file; the edited image, its path relative to the ${relativeTo}'s directory, is required` });
|
|
115
117
|
return;
|
|
116
118
|
}
|
|
117
119
|
const found = fileSha256(path);
|
|
@@ -119,10 +121,10 @@ export function readEditorReceipts(spec: EditorReceipts | undefined, configPath:
|
|
|
119
121
|
problems.push({ code: 'ASSEMBLE_EDITOR_RECEIPT_SHA256', object, detail: `the file's bytes hash to sha256 ${found}; the receipt's sha256 ${r.sha256} is required — the file changed after its receipt was written, or the receipt names another file` });
|
|
120
122
|
return;
|
|
121
123
|
}
|
|
122
|
-
if (!bySha.has(found)) bySha.set(found, { ...r, at, path });
|
|
124
|
+
if (!bySha.has(found)) bySha.set(found, { ...r, at: receiptAt, path });
|
|
123
125
|
});
|
|
124
126
|
const by = spec.required === true || spec.trace_parts !== true ? 'required' : 'trace_parts';
|
|
125
|
-
return { required: spec.required === true || spec.trace_parts === true, by, bySha, problems };
|
|
127
|
+
return { required: spec.required === true || spec.trace_parts === true, by, bySha, problems, at };
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
/** An image the input itself says was made or edited outside See-through. */
|
|
@@ -181,7 +183,7 @@ export function missingReceipts(ledger: ReceiptLedger, images: readonly EditedIm
|
|
|
181
183
|
problems.push({
|
|
182
184
|
code: 'ASSEMBLE_EDITOR_RECEIPT_MISSING',
|
|
183
185
|
object: image.object,
|
|
184
|
-
detail: `${image.path} (sha256 ${found}) carries no editor receipt; ${AT}.${ledger.by} is true, so a receipt {file, sha256, from_sha256, editor} whose sha256 is this file's is required — edit the image in an image editor and record it, never generate or patch it in code`,
|
|
186
|
+
detail: `${image.path} (sha256 ${found}) carries no editor receipt; ${ledger.at ?? AT}.${ledger.by} is true, so a receipt {file, sha256, from_sha256, editor} whose sha256 is this file's is required — edit the image in an image editor and record it, never generate or patch it in code`,
|
|
185
187
|
});
|
|
186
188
|
}
|
|
187
189
|
return problems;
|
package/src/parts.ts
CHANGED
|
@@ -507,9 +507,26 @@ function orderedProvenance(p: PixelProvenance): PixelProvenance {
|
|
|
507
507
|
...(p.depth === undefined ? {} : { depth: { kind: p.depth.kind, values: p.depth.values.map(v => ({ group: v.group, ...(v.bone === undefined ? {} : { bone: v.bone }), value: v.value })) } }) };
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
function orderedTakenSource(source: TakenLayerSource): TakenLayerSource {
|
|
511
|
+
const transport = source.transport;
|
|
512
|
+
return {
|
|
513
|
+
run: source.run, tag: source.tag, run_dir: source.run_dir, st_input: source.st_input,
|
|
514
|
+
prompt_id: source.prompt_id, seed: source.seed, support_px: source.support_px,
|
|
515
|
+
checked_px: source.checked_px, differing_px: source.differing_px,
|
|
516
|
+
...(transport === undefined ? {} : { transport: {
|
|
517
|
+
crop: transport.crop, donor_canvas: transport.donor_canvas,
|
|
518
|
+
target_canvas: transport.target_canvas, scale: transport.scale,
|
|
519
|
+
translate: transport.translate, filter: transport.filter,
|
|
520
|
+
input_rgba_sha256: transport.input_rgba_sha256,
|
|
521
|
+
before_rgba_sha256: transport.before_rgba_sha256,
|
|
522
|
+
after_rgba_sha256: transport.after_rgba_sha256,
|
|
523
|
+
} }),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
510
527
|
function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
|
|
511
528
|
return parts.map((p) =>
|
|
512
|
-
Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : k === 'taken_layers' ? p.taken_layers?.map(
|
|
529
|
+
Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : k === 'taken_layers' ? p.taken_layers?.map(orderedTakenSource) : k === 'provenance' ? orderedProvenance(p.provenance as PixelProvenance) : p[k]])),
|
|
513
530
|
);
|
|
514
531
|
}
|
|
515
532
|
|
package/src/requirements.ts
CHANGED
|
@@ -133,6 +133,11 @@ export interface SeamRequirement extends RequirementCommon {
|
|
|
133
133
|
|
|
134
134
|
export type Requirement = ContactRequirement | FollowRequirement | AimRequirement | RangeRequirement | StretchRequirement | SeamRequirement;
|
|
135
135
|
|
|
136
|
+
/** Animations whose measurements consume the requirements file's sampling rate. */
|
|
137
|
+
export function requirementAnimations(requirements: readonly { animation: string }[]): string[] {
|
|
138
|
+
return [...new Set(requirements.map((requirement) => requirement.animation))];
|
|
139
|
+
}
|
|
140
|
+
|
|
136
141
|
/** One stage point of a scene target, at a time in seconds (null for a fixed point). */
|
|
137
142
|
export interface TargetPoint {
|
|
138
143
|
t: number | null;
|
|
@@ -149,7 +154,7 @@ export interface SceneTarget {
|
|
|
149
154
|
|
|
150
155
|
export interface RequirementsFile {
|
|
151
156
|
path: string;
|
|
152
|
-
fps
|
|
157
|
+
fps?: number;
|
|
153
158
|
targets: SceneTarget[];
|
|
154
159
|
requirements: Requirement[];
|
|
155
160
|
/**
|
|
@@ -314,8 +319,8 @@ const EXCEPTION_ABSENT: Record<ExceptionList, string> = {
|
|
|
314
319
|
|
|
315
320
|
/**
|
|
316
321
|
* Read and shape-check a requirements file. Every problem is collected before
|
|
317
|
-
* one refusal: the file, `spec`, `fps` (
|
|
318
|
-
* default), `targets` (optional), `requirements` (required, possibly empty)
|
|
322
|
+
* one refusal: the file, `spec`, `fps` (a positive number required when a measurement reads
|
|
323
|
+
* the file's sampling rate, with no default), `targets` (optional), `requirements` (required, possibly empty)
|
|
319
324
|
* and `not_planted`, `not_swinging`, `rises` and `rests` (each optional,
|
|
320
325
|
* issues #231 and #245: a non-empty list of distinct part names, no part both
|
|
321
326
|
* in `not_swinging` and in `rises` or `rests`;
|
|
@@ -355,9 +360,6 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
355
360
|
if ('setup_painting' in raw) problems.push(...setupPaintingProblems(raw.setup_painting, `${at} setup_painting`));
|
|
356
361
|
if ('cut_material' in raw) problems.push(...jointMaterialProblems(raw.cut_material, `${at} cut_material`));
|
|
357
362
|
if ('fixed_skeleton' in raw) problems.push(...fixedSkeletonProblems(raw.fixed_skeleton, `${at} fixed_skeleton`));
|
|
358
|
-
let fps = NaN;
|
|
359
|
-
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
360
|
-
else fps = raw.fps;
|
|
361
363
|
|
|
362
364
|
const requirements: Requirement[] = [];
|
|
363
365
|
if (!Array.isArray(raw.requirements)) field(`${at} field "requirements"`, `is ${JSON.stringify(raw.requirements)}; an array of requirements is required (an empty one declares none)`);
|
|
@@ -537,6 +539,11 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
537
539
|
});
|
|
538
540
|
}
|
|
539
541
|
}
|
|
542
|
+
const samplesAnimation = requirementAnimations(requirements).length > 0;
|
|
543
|
+
if ((raw.fps !== undefined || samplesAnimation) && !(finite(raw.fps) && raw.fps > 0)) {
|
|
544
|
+
field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
545
|
+
}
|
|
546
|
+
const fps = finite(raw.fps) ? raw.fps : undefined;
|
|
540
547
|
refuseIfAny(problems);
|
|
541
548
|
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold, ...(raw.fixed_skeleton === undefined ? {} : { fixed_skeleton: raw.fixed_skeleton as FixedSkeleton }), ...(raw.cut_material === undefined ? {} : { cut_material: raw.cut_material as JointMaterial[] }), ...(raw.setup_painting === undefined ? {} : { setup_painting: raw.setup_painting as SetupPainting }), ...(raw.pinholes === undefined ? {} : { pinholes: { required: (raw.pinholes as PinholesDeclaration).required } }), ...(raw.seam_over_motion === undefined ? {} : { seam_over_motion: { required: (raw.seam_over_motion as SeamOverMotionDeclaration).required } }), ...(raw.boundary_colour === undefined ? {} : { boundary_colour: { max_step_excess: (raw.boundary_colour as BoundaryColour).max_step_excess } }) };
|
|
542
549
|
}
|
package/src/scene.ts
CHANGED
|
@@ -96,9 +96,10 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
96
96
|
import type { RigStop } from '@rig-c/core/rig';
|
|
97
97
|
import { checkStage, gateThroughRigc, type Log } from './build.ts';
|
|
98
98
|
import { ACCEPTABLE_BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
|
|
99
|
-
import { CONSTRAINT_BONE_FIELDS, ROOT_BONE } from './config.ts';
|
|
99
|
+
import { CONSTRAINT_BONE_FIELDS, editorReceiptProblems, ROOT_BONE } from './config.ts';
|
|
100
100
|
import { cropToSpineY } from './coords.ts';
|
|
101
101
|
import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
102
|
+
import { missingReceipts, readEditorReceipts, type EditorReceipts } from './editreceipt.ts';
|
|
102
103
|
import type { MotionSpec } from './motion.ts';
|
|
103
104
|
import { readPng } from './raster/png.ts';
|
|
104
105
|
import { readRequirements, type RequirementsFile } from './requirements.ts';
|
|
@@ -161,6 +162,7 @@ export interface ScenePlate {
|
|
|
161
162
|
image: string;
|
|
162
163
|
imagePath: string;
|
|
163
164
|
provenance: Provenance;
|
|
165
|
+
editor_receipts?: EditorReceipts;
|
|
164
166
|
note: string | null;
|
|
165
167
|
}
|
|
166
168
|
|
|
@@ -221,7 +223,8 @@ export function acceptProblems(names: readonly string[], where: string): Problem
|
|
|
221
223
|
/**
|
|
222
224
|
* Read and shape-check a scene file. Every problem is collected before one
|
|
223
225
|
* refusal: the file, `spec`, `canvas` (two positive integers), `plate`
|
|
224
|
-
* (optional
|
|
226
|
+
* (optional, with required `image` and `provenance` and optional `note`
|
|
227
|
+
* and `editor_receipts` with the shared ledger shape), `characters` (at
|
|
225
228
|
* least one; each `id`, `build`, `offset`), `order` (a list of strings), and
|
|
226
229
|
* any key the format does not read. Resolving the ids and slots `order`
|
|
227
230
|
* names is {@link composeScene}'s, which has the builds.
|
|
@@ -270,7 +273,10 @@ export function loadScene(path: string): SceneFile {
|
|
|
270
273
|
const p = o.plate;
|
|
271
274
|
if (!isRecord(p)) fail('SCENE_FIELD_TYPE', 'scene.plate', `is ${show(p)}; {"image": "<png>", "provenance": ${PROVENANCES.map((x) => `"${x}"`).join(' | ')}} is required when the key is present`);
|
|
272
275
|
else {
|
|
273
|
-
known(p, 'scene.plate', ['image', 'provenance', 'note']);
|
|
276
|
+
known(p, 'scene.plate', ['image', 'provenance', 'note', 'editor_receipts']);
|
|
277
|
+
if ('editor_receipts' in p) {
|
|
278
|
+
problems.push(...editorReceiptProblems(p.editor_receipts, 'scene.plate.editor_receipts').map((problem) => ({ ...problem, code: 'SCENE_EDITOR_RECEIPT' })));
|
|
279
|
+
}
|
|
274
280
|
const image = p.image;
|
|
275
281
|
const prov = p.provenance;
|
|
276
282
|
if (!('image' in p)) fail('SCENE_FIELD_PRESENT', 'scene.plate.image', 'is absent and required; the plate PNG, relative to the scene file');
|
|
@@ -278,7 +284,7 @@ export function loadScene(path: string): SceneFile {
|
|
|
278
284
|
if (!('provenance' in p)) fail('SCENE_FIELD_PRESENT', 'scene.plate.provenance', `is absent and required; one of ${PROVENANCES.join(', ')} — what the plate's pixels are, which the author states and nothing judges`);
|
|
279
285
|
else if (typeof prov !== 'string' || !(PROVENANCES as readonly string[]).includes(prov)) fail('SCENE_PLATE_PROVENANCE', 'scene.plate.provenance', `is ${show(prov)}; one of ${PROVENANCES.join(', ')} is required`);
|
|
280
286
|
if (typeof image === 'string' && image !== '' && typeof prov === 'string' && (PROVENANCES as readonly string[]).includes(prov)) {
|
|
281
|
-
plate = { image, imagePath: resolve(base, image), provenance: prov as Provenance, note: typeof p.note === 'string' ? p.note : null };
|
|
287
|
+
plate = { image, imagePath: resolve(base, image), provenance: prov as Provenance, ...('editor_receipts' in p ? { editor_receipts: p.editor_receipts as EditorReceipts } : {}), note: typeof p.note === 'string' ? p.note : null };
|
|
282
288
|
}
|
|
283
289
|
}
|
|
284
290
|
}
|
|
@@ -1375,6 +1381,14 @@ export function composeFromFiles(scenePath: string, requirementsPath?: string, m
|
|
|
1375
1381
|
const builds = scene.characters.map((c) => readCharacterBuild(c, problems, accept));
|
|
1376
1382
|
let plate: PlateImage | null = null;
|
|
1377
1383
|
if (scene.plate !== null) {
|
|
1384
|
+
const ledger = readEditorReceipts(scene.plate.editor_receipts, scene.path, 'scene.plate.editor_receipts', 'scene file');
|
|
1385
|
+
const receiptCodes: Readonly<Record<string, string>> = {
|
|
1386
|
+
ASSEMBLE_EDITOR_RECEIPT_FILE: 'SCENE_EDITOR_RECEIPT_FILE',
|
|
1387
|
+
ASSEMBLE_EDITOR_RECEIPT_SHA256: 'SCENE_EDITOR_RECEIPT_SHA256',
|
|
1388
|
+
ASSEMBLE_EDITOR_RECEIPT_MISSING: 'SCENE_EDITOR_RECEIPT_MISSING',
|
|
1389
|
+
};
|
|
1390
|
+
const receiptProblems = [...ledger.problems, ...missingReceipts(ledger, [{ object: 'scene.plate.image', path: scene.plate.imagePath }])];
|
|
1391
|
+
problems.push(...receiptProblems.map((problem) => ({ ...problem, code: receiptCodes[problem.code] ?? problem.code })));
|
|
1378
1392
|
const where = `scene.plate.image (${scene.plate.image})`;
|
|
1379
1393
|
if (!existsSync(scene.plate.imagePath)) problems.push({ code: 'SCENE_PLATE_PRESENT', object: where, detail: `${scene.plate.imagePath} does not exist; a PNG of the canvas's size is required` });
|
|
1380
1394
|
else {
|
package/src/takelayers.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/** Verified replacement in an existing full/head run; never writes or forges a run manifest. */
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
4
5
|
import type { EarlyConfig, PlanEntry, Run } from './config.ts';
|
|
5
6
|
import { CORE_KERNEL, fullPlanBand } from './assemble.ts';
|
|
6
|
-
import { makeInputs } from './inputs.ts';
|
|
7
|
+
import { makeInputs, squarePad } from './inputs.ts';
|
|
7
8
|
import { OPAQUE_ALPHA_ABOVE, readLayers, type Layer, type LayerSet } from './layers.ts';
|
|
8
9
|
import { PartsError, refuseIfAny, type Problem } from './errors.ts';
|
|
9
10
|
import { dilate } from './raster/morph.ts';
|
|
10
|
-
import { newMask, type Raster } from './raster/types.ts';
|
|
11
|
+
import { newMask, newRaster, newFloatImage, type Raster } from './raster/types.ts';
|
|
11
12
|
import { readPng } from './raster/png.ts';
|
|
12
13
|
import type { TakeDeclaration } from './takeregion.ts';
|
|
13
|
-
import
|
|
14
|
+
import { crop, pad } from './raster/composite.ts';
|
|
15
|
+
import { warpAffine, type ScaleTranslate } from './raster/warp.ts';
|
|
16
|
+
import { takenTransportProblems, type TakenLayerSource, type TakenLayerTransport } from './takesource.ts';
|
|
14
17
|
|
|
15
18
|
export interface TakeLayers {
|
|
16
19
|
run: Run;
|
|
@@ -18,6 +21,8 @@ export interface TakeLayers {
|
|
|
18
21
|
/** The exact PNG supplied to See-through, resolved from the config directory. */
|
|
19
22
|
st_input: string;
|
|
20
23
|
tags: string[];
|
|
24
|
+
/** Original painting pixels, right/bottom exclusive; donor input is squarePad(crop). */
|
|
25
|
+
crop?: { box: [number, number, number, number]; resample: 'bilinear' };
|
|
21
26
|
}
|
|
22
27
|
function refuse(code: string, object: string, detail: string): never { throw new PartsError([{ code, object, detail }]); }
|
|
23
28
|
function metadata(path: string): { prompt_id: string; seed: number } {
|
|
@@ -49,6 +54,92 @@ function identity(layer: Layer, canvas: LayerSet['canvas'], donor: Raster, targe
|
|
|
49
54
|
if (differing > 0) return refuse('ASSEMBLE_TAKE_INPUT_IDENTITY', object, `${differing} differing pixel(s), bbox [${x0}, ${y0}, ${x1}, ${y1}] in st_input pixels (right/bottom exclusive); exact RGBA identity under alpha > ${OPAQUE_ALPHA_ABOVE}, grown by ${CORE_KERNEL}x${CORE_KERNEL}, is required`);
|
|
50
55
|
return { support, checked };
|
|
51
56
|
}
|
|
57
|
+
/** Hash straight RGBA, with the dimensions carried separately in the transport receipt. */
|
|
58
|
+
function rgbaHash(image: Raster): string {
|
|
59
|
+
return createHash('sha256').update(image.data).digest('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cropGeometry(d: TakeLayers, painting: Raster, target: Raster, donor: LayerSet, set: LayerSet, inputs: ReturnType<typeof makeInputs>, at: string): { expected: Raster; map: ScaleTranslate; exact: boolean } {
|
|
63
|
+
const declaration = d.crop!;
|
|
64
|
+
const [x0, y0, x1, y1] = declaration.box;
|
|
65
|
+
if (declaration.resample !== 'bilinear' || !declaration.box.every(Number.isSafeInteger) || x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0 || x1 > painting.width || y1 > painting.height) {
|
|
66
|
+
return refuse('ASSEMBLE_TAKE_CROP', at, `crop [${declaration.box.join(', ')}] on painting ${painting.width}x${painting.height}; an inside nonempty integer box and resample "bilinear" are required`);
|
|
67
|
+
}
|
|
68
|
+
const w = x1 - x0, h = y1 - y0;
|
|
69
|
+
const donorPad = squarePad(w, h);
|
|
70
|
+
const expected = pad(crop(painting, x0, y0, w, h), donorPad.left, donorPad.top, donorPad.side - w - donorPad.left, donorPad.side - h - donorPad.top, [255, 255, 255, 255]);
|
|
71
|
+
const origin = target === painting ? [0, 0] : d.run === 'head' ? [-inputs.headBox![0], -inputs.headBox![1]] : [inputs.padLeft, inputs.padTop];
|
|
72
|
+
const scaleXUnit = BigInt(donorPad.side) * BigInt(set.canvas.w) === BigInt(donor.canvas.w) * BigInt(target.width);
|
|
73
|
+
const scaleYUnit = BigInt(donorPad.side) * BigInt(set.canvas.h) === BigInt(donor.canvas.h) * BigInt(target.height);
|
|
74
|
+
const offsetX = BigInt(x0 - donorPad.left + origin[0]) * BigInt(set.canvas.w);
|
|
75
|
+
const offsetY = BigInt(y0 - donorPad.top + origin[1]) * BigInt(set.canvas.h);
|
|
76
|
+
const integerX = offsetX % BigInt(target.width) === 0n;
|
|
77
|
+
const integerY = offsetY % BigInt(target.height) === 0n;
|
|
78
|
+
return { expected, exact: scaleXUnit && scaleYUnit && integerX && integerY, map: {
|
|
79
|
+
sx: scaleXUnit ? 1 : donorPad.side / donor.canvas.w * set.canvas.w / target.width,
|
|
80
|
+
sy: scaleYUnit ? 1 : donorPad.side / donor.canvas.h * set.canvas.h / target.height,
|
|
81
|
+
tx: integerX ? Number(offsetX / BigInt(target.width)) : (x0 - donorPad.left + origin[0]) * set.canvas.w / target.width,
|
|
82
|
+
ty: integerY ? Number(offsetY / BigInt(target.height)) : (y0 - donorPad.top + origin[1]) * set.canvas.h / target.height,
|
|
83
|
+
} };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One full-canvas affine pass, using the same premultiplied float32 convention as layerToRig. */
|
|
87
|
+
function transportLayer(layer: Layer, donor: LayerSet, set: LayerSet, image: Raster, d: TakeLayers, map: ScaleTranslate, exact: boolean, at: string): { layer: Layer; transport: TakenLayerTransport } {
|
|
88
|
+
const canvas = newRaster(donor.canvas.w, donor.canvas.h);
|
|
89
|
+
for (let y = 0; y < layer.pixels.height; y++) {
|
|
90
|
+
canvas.data.set(layer.pixels.data.subarray(y * layer.pixels.width * 4, (y + 1) * layer.pixels.width * 4), ((y + layer.top) * canvas.width + layer.left) * 4);
|
|
91
|
+
}
|
|
92
|
+
const [x0, y0, x1, y1] = d.crop!.box;
|
|
93
|
+
const donorPad = squarePad(x1 - x0, y1 - y0);
|
|
94
|
+
for (let y = layer.top; y < layer.bottom; y++) for (let x = layer.left; x < layer.right; x++) {
|
|
95
|
+
if (canvas.data[(y * canvas.width + x) * 4 + 3] <= OPAQUE_ALPHA_ABOVE) continue;
|
|
96
|
+
const dx = x * donorPad.side / canvas.width - donorPad.left;
|
|
97
|
+
const dy = y * donorPad.side / canvas.height - donorPad.top;
|
|
98
|
+
const tx = map.sx * x + map.tx, ty = map.sy * y + map.ty;
|
|
99
|
+
if (dx < 0 || dy < 0 || (x + 1) * donorPad.side / canvas.width - donorPad.left > x1 - x0 || (y + 1) * donorPad.side / canvas.height - donorPad.top > y1 - y0 || tx < 0 || ty < 0 || tx + map.sx > set.canvas.w || ty + map.sy > set.canvas.h) {
|
|
100
|
+
return refuse('ASSEMBLE_TAKE_CROP', at, `supported donor cell [${x}, ${y}] falls outside the declared painting crop or target canvas; the whole taken support must be retained`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const out = newRaster(set.canvas.w, set.canvas.h);
|
|
104
|
+
if (exact) {
|
|
105
|
+
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
|
|
106
|
+
const tx = x + map.tx, ty = y + map.ty;
|
|
107
|
+
if (tx >= 0 && ty >= 0 && tx < out.width && ty < out.height) out.data.set(canvas.data.subarray((y * canvas.width + x) * 4, (y * canvas.width + x + 1) * 4), (ty * out.width + tx) * 4);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
const pre = newFloatImage(canvas.width, canvas.height, 4);
|
|
111
|
+
for (let p = 0; p < canvas.width * canvas.height; p++) {
|
|
112
|
+
const a = canvas.data[p * 4 + 3];
|
|
113
|
+
for (let c = 0; c < 3; c++) pre.data[p * 4 + c] = Math.fround(canvas.data[p * 4 + c] * Math.fround(a / 255));
|
|
114
|
+
pre.data[p * 4 + 3] = a;
|
|
115
|
+
}
|
|
116
|
+
const warped = warpAffine(pre, map, out.width, out.height, 'bilinear');
|
|
117
|
+
const clip = (v: number): number => Math.max(0, Math.min(255, v));
|
|
118
|
+
for (let p = 0; p < out.width * out.height; p++) {
|
|
119
|
+
const a = Math.fround(clip(warped.data[p * 4 + 3]));
|
|
120
|
+
for (let c = 0; c < 3; c++) {
|
|
121
|
+
const value = Math.fround(clip(warped.data[p * 4 + c]));
|
|
122
|
+
const straight = Math.fround(Math.fround(value * 255) / Math.max(a, Math.fround(1e-3)));
|
|
123
|
+
out.data[p * 4 + c] = a > 0 ? Math.trunc(clip(straight)) : 0;
|
|
124
|
+
}
|
|
125
|
+
out.data[p * 4 + 3] = Math.trunc(a);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let opaquePx = 0;
|
|
129
|
+
for (let p = 3; p < out.data.length; p += 4) if (out.data[p] > OPAQUE_ALPHA_ABOVE) opaquePx++;
|
|
130
|
+
if (opaquePx === 0) return refuse('ASSEMBLE_TAKE_SUPPORT', at, `has 0 pixels of alpha above ${OPAQUE_ALPHA_ABOVE} after the declared transport; a nonempty taken layer is required`);
|
|
131
|
+
const transport: TakenLayerTransport = {
|
|
132
|
+
crop: [...d.crop!.box], donor_canvas: [canvas.width, canvas.height], target_canvas: [out.width, out.height],
|
|
133
|
+
scale: [map.sx, map.sy], translate: [map.tx, map.ty], filter: exact ? 'exact' : 'bilinear',
|
|
134
|
+
input_rgba_sha256: rgbaHash(image), before_rgba_sha256: rgbaHash(canvas), after_rgba_sha256: rgbaHash(out),
|
|
135
|
+
};
|
|
136
|
+
refuseIfAny(takenTransportProblems(transport, out, at));
|
|
137
|
+
return {
|
|
138
|
+
layer: { ...layer, left: 0, top: 0, right: out.width, bottom: out.height, pixels: out, opaquePx },
|
|
139
|
+
transport,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
52
143
|
export function takeLayerNote(source: TakenLayerSource): string {
|
|
53
144
|
return `take ${source.run}:${source.tag} from ${source.run_dir}; prompt_id ${source.prompt_id}; seed ${source.seed}; st_input ${source.st_input}; support ${source.support_px} run px; checked ${source.checked_px} input px; differing ${source.differing_px}; alpha > ${OPAQUE_ALPHA_ABOVE}, ${CORE_KERNEL}x${CORE_KERNEL} margin`;
|
|
54
145
|
}
|
|
@@ -81,7 +172,10 @@ export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single:
|
|
|
81
172
|
const meta = metadata(resolve(base, d.run_dir, 'meta.json'));
|
|
82
173
|
let image: Raster;
|
|
83
174
|
try { image = readPng(resolve(base, d.st_input)); } catch { return refuse('ASSEMBLE_TAKE_INPUT', at, `st_input "${d.st_input}" must name the original readable PNG supplied to the donor run`); }
|
|
84
|
-
if (target === null || donor.canvas.w !== set.canvas.w || donor.canvas.h !== set.canvas.h || image.width !== target.width || image.height !== target.height) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, `donor canvas ${donor.canvas.w}x${donor.canvas.h}, target canvas ${set.canvas.w}x${set.canvas.h}, donor st_input ${image.width}x${image.height}, target st_input ${target?.width ?? 0}x${target?.height ?? 0}; equal canvases and equal input dimensions are required (full uses squarePad; head uses head_box)`);
|
|
175
|
+
if (d.crop === undefined && (target === null || donor.canvas.w !== set.canvas.w || donor.canvas.h !== set.canvas.h || image.width !== target.width || image.height !== target.height)) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, `donor canvas ${donor.canvas.w}x${donor.canvas.h}, target canvas ${set.canvas.w}x${set.canvas.h}, donor st_input ${image.width}x${image.height}, target st_input ${target?.width ?? 0}x${target?.height ?? 0}; equal canvases and equal input dimensions are required (full uses squarePad; head uses head_box)`);
|
|
176
|
+
if (target === null) return refuse('ASSEMBLE_TAKE_CROP', at, 'a target input is required for a declared crop');
|
|
177
|
+
const geometry = d.crop === undefined ? undefined : cropGeometry(d, painting, target, donor, set, inputs, at);
|
|
178
|
+
if (geometry !== undefined && (image.width !== geometry.expected.width || image.height !== geometry.expected.height)) return refuse('ASSEMBLE_TAKE_CROP', at, `donor st_input ${image.width}x${image.height}; declared square-padded crop requires ${geometry.expected.width}x${geometry.expected.height}`);
|
|
85
179
|
const union = new Uint8Array(image.width * image.height);
|
|
86
180
|
for (const tag of d.tags) {
|
|
87
181
|
const key = `${d.run}:${tag}`;
|
|
@@ -89,10 +183,12 @@ export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single:
|
|
|
89
183
|
seen.add(key);
|
|
90
184
|
const layer = donor.layers.find(l => l.name === tag);
|
|
91
185
|
if (layer === undefined) return refuse('ASSEMBLE_TAKE_TAG_IN_RUN', at, `tag "${tag}" is absent; a named See-through tag held by that donor is required`);
|
|
92
|
-
const checked = identity(layer, donor.canvas, image, target, `${at} tag "${tag}"`);
|
|
186
|
+
const checked = identity(layer, donor.canvas, image, geometry?.expected ?? target, `${at} tag "${tag}"`);
|
|
93
187
|
for (let p = 0; p < union.length; p++) union[p] |= checked.checked[p];
|
|
94
188
|
const source: TakenLayerSource = { run: d.run, tag, run_dir: d.run_dir, st_input: d.st_input, prompt_id: meta.prompt_id, seed: meta.seed, support_px: checked.support, checked_px: checked.checked.reduce((a, b) => a + b, 0), differing_px: 0 };
|
|
95
|
-
const
|
|
189
|
+
const transported = geometry === undefined ? undefined : transportLayer(layer, donor, set, image, d, geometry.map, geometry.exact, `${at} tag "${tag}"`);
|
|
190
|
+
if (transported !== undefined) source.transport = transported.transport;
|
|
191
|
+
const replacement: Layer = { ...(transported?.layer ?? layer), takenLayers: [source] };
|
|
96
192
|
lists[d.run] = [...lists[d.run].filter(l => l.name !== tag), replacement];
|
|
97
193
|
origins.set(replacement, resolve(base, d.run_dir));
|
|
98
194
|
notes.push(takeLayerNote(source));
|
package/src/takeregion.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
import type { AssembleConfig, Run } from './config.ts';
|
|
4
4
|
import { PartsError, refuseIfAny } from './errors.ts';
|
|
5
|
-
import { editedRunImages, missingReceipts, type ReceiptLedger } from './editreceipt.ts';
|
|
5
|
+
import { editedRunImages, fileSha256, missingReceipts, type ReceiptLedger } from './editreceipt.ts';
|
|
6
6
|
import { makeInputs } from './inputs.ts';
|
|
7
7
|
import { OPAQUE_ALPHA_ABOVE, readLayers, type Layer, type LayerSet } from './layers.ts';
|
|
8
8
|
import { connectedComponents } from './raster/components.ts';
|
|
@@ -19,6 +19,8 @@ export interface TakeRegion {
|
|
|
19
19
|
part: string;
|
|
20
20
|
/** One integer pixel on the target run canvas, x right and y down. */
|
|
21
21
|
seed: [number, number];
|
|
22
|
+
/** PNG bytes, explicitly bound to the stage source or a separate painting. */
|
|
23
|
+
reference?: { source: true; sha256: string } | { painting: string; sha256: string };
|
|
22
24
|
}
|
|
23
25
|
export type TakeDeclaration = TakeLayers | TakeRegion;
|
|
24
26
|
export interface RegionReading {
|
|
@@ -31,6 +33,8 @@ export interface RegionReading {
|
|
|
31
33
|
painting_match_share: number;
|
|
32
34
|
rgb_tolerance: number;
|
|
33
35
|
}
|
|
36
|
+
const IMPLICIT_REFERENCE_WARNING = 'ASSEMBLE_REGION_TAKE_REFERENCE_IMPLICIT';
|
|
37
|
+
|
|
34
38
|
type Runs = { full: LayerSet; head: LayerSet; single: boolean; notes: string[] };
|
|
35
39
|
|
|
36
40
|
function refuse(code: string, object: string, detail: string): never {
|
|
@@ -98,7 +102,7 @@ export function deriveTakenRegion(base: Layer, donor: Layer, painting: Raster, s
|
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/** Called beside whole-layer take, before assembly or any output emission. */
|
|
101
|
-
export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleConfig, configPath: string, setup: SetupPainting | undefined, receipts: ReceiptLedger): Runs {
|
|
105
|
+
export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleConfig, configPath: string, setup: SetupPainting | undefined, receipts: ReceiptLedger, sourcePath?: string): Runs {
|
|
102
106
|
const declarations = (cfg.assemble.take ?? []).filter((item): item is TakeRegion => 'part' in item);
|
|
103
107
|
if (declarations.length === 0) return runs;
|
|
104
108
|
const tolerance = setup?.rgb_tolerance;
|
|
@@ -108,9 +112,11 @@ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleCon
|
|
|
108
112
|
if (cfg.depth !== undefined || [...runs.full.layers, ...runs.head.layers].some(layer => layer.depthSheet !== undefined)) {
|
|
109
113
|
return refuse('ASSEMBLE_REGION_TAKE_BINDING', 'config.assemble.take', 'active scalar depth sheets have no region donor binding; a run without active scalar depth is required');
|
|
110
114
|
}
|
|
111
|
-
const inputs = makeInputs(painting, cfg, 'region take painting');
|
|
112
115
|
const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
|
|
113
116
|
const notes = [...runs.notes];
|
|
117
|
+
if (declarations.some(item => item.reference === undefined)) {
|
|
118
|
+
notes.push(`WARN ${IMPLICIT_REFERENCE_WARNING}: config.assemble.take — reference is absent; uses --source for the colour predicate; declare reference to bind its PNG sha256`);
|
|
119
|
+
}
|
|
114
120
|
const seen = new Set<string>();
|
|
115
121
|
for (const declaration of declarations) {
|
|
116
122
|
const at = `config.assemble.take part "${declaration.part}" from "${declaration.run_dir}"`;
|
|
@@ -121,13 +127,28 @@ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleCon
|
|
|
121
127
|
return refuse('ASSEMBLE_REGION_TAKE_PART', at, `target ${key} is taken more than once; one region donor or whole-layer donor per target is required`);
|
|
122
128
|
}
|
|
123
129
|
seen.add(key);
|
|
130
|
+
const reference = declaration.reference;
|
|
131
|
+
let referencePainting = painting;
|
|
132
|
+
let referenceHash: string | undefined;
|
|
133
|
+
if (reference !== undefined) {
|
|
134
|
+
const referencePath = 'source' in reference ? sourcePath : resolve(dirname(resolve(configPath)), reference.painting);
|
|
135
|
+
try {
|
|
136
|
+
if (referencePath === undefined) throw new Error('source path is absent');
|
|
137
|
+
referenceHash = fileSha256(referencePath);
|
|
138
|
+
referencePainting = readPng(referencePath);
|
|
139
|
+
} catch { return refuse('ASSEMBLE_REGION_TAKE_REFERENCE', at, 'reference must name a readable PNG with its declared sha256; a source binding requires the stage source path'); }
|
|
140
|
+
if (referenceHash !== reference.sha256 || referencePainting.width !== painting.width || referencePainting.height !== painting.height) {
|
|
141
|
+
return refuse('ASSEMBLE_REGION_TAKE_REFERENCE_MATCH', at, `reference sha256 ${referenceHash}, expected ${reference.sha256}; reference ${referencePainting.width}x${referencePainting.height}, source ${painting.width}x${painting.height}; matching hash and painting dimensions are required`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const inputs = makeInputs(referencePainting, cfg, reference === undefined ? 'region take painting' : 'region take reference');
|
|
124
145
|
const set = runs[declaration.run];
|
|
125
146
|
const donor = readLayers(resolve(dirname(resolve(configPath)), declaration.run_dir));
|
|
126
147
|
refuseIfAny(missingReceipts(receipts, editedRunImages(
|
|
127
148
|
{ full: donor, head: donor, single: true },
|
|
128
149
|
`region take "${declaration.run}:${declaration.part}" donor "${declaration.run_dir}" `,
|
|
129
150
|
)));
|
|
130
|
-
const target = declaration.run === 'head' ? inputs.head : runs.single && set.canvas.w !== set.canvas.h ?
|
|
151
|
+
const target = declaration.run === 'head' ? inputs.head : runs.single && set.canvas.w !== set.canvas.h ? referencePainting : inputs.full;
|
|
131
152
|
let supplied: Raster;
|
|
132
153
|
try { supplied = readPng(resolve(dirname(resolve(configPath)), declaration.st_input)); }
|
|
133
154
|
catch { return refuse('ASSEMBLE_REGION_TAKE_GEOMETRY', at, `st_input "${declaration.st_input}" must name the original readable donor input PNG so its geometry can be checked`); }
|
|
@@ -144,6 +165,9 @@ export function applyTakenRegions(runs: Runs, painting: Raster, cfg: AssembleCon
|
|
|
144
165
|
const derived = deriveTakenRegion(base, material, source, declaration.seed, tolerance, at);
|
|
145
166
|
lists[declaration.run] = lists[declaration.run].map(layer => layer === base ? derived.layer : layer);
|
|
146
167
|
const reading: RegionReading = { part: declaration.part, run: declaration.run, run_dir: declaration.run_dir, ...derived.reading };
|
|
168
|
+
const pad = target === referencePainting ? [0, 0] : declaration.run === 'head' ? inputs.headBox! : [-inputs.padLeft, -inputs.padTop];
|
|
169
|
+
const transform = { scale: [set.canvas.w / target.width, set.canvas.h / target.height], origin: [pad[0], pad[1]], filter: 'lanczos3' };
|
|
170
|
+
if (reference !== undefined) notes.push(`region reference ${reading.run}:${reading.part}: sha256 ${referenceHash}; binding ${'source' in reference ? 'source' : 'painting'}; painting-to-run ${JSON.stringify(transform)}`);
|
|
147
171
|
notes.push(`region take ${reading.run}:${reading.part} from ${reading.run_dir}: ${reading.pixels} run px; box [${reading.box.join(', ')}] (left, top, width, height); painting-match share ${reading.painting_match_share}; RGB tolerance ${reading.rgb_tolerance}; seed [${declaration.seed.join(', ')}], 8-connected`);
|
|
148
172
|
}
|
|
149
173
|
const full = { ...runs.full, layers: lists.full };
|
package/src/takesource.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
/** Layer identity receipts, retained independently of part names and derived geometry. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import type { Raster } from './raster/types.ts';
|
|
2
4
|
import type { Problem } from './errors.ts';
|
|
3
5
|
import { readLayerTag } from './tags.ts';
|
|
6
|
+
export interface TakenLayerTransport {
|
|
7
|
+
crop: [number, number, number, number];
|
|
8
|
+
donor_canvas: [number, number];
|
|
9
|
+
target_canvas: [number, number];
|
|
10
|
+
scale: [number, number];
|
|
11
|
+
translate: [number, number];
|
|
12
|
+
filter: 'exact' | 'bilinear';
|
|
13
|
+
input_rgba_sha256: string;
|
|
14
|
+
before_rgba_sha256: string;
|
|
15
|
+
after_rgba_sha256: string;
|
|
16
|
+
}
|
|
17
|
+
/** Verify a transported run canvas against its sealed receipt before downstream assembly. */
|
|
18
|
+
export function takenTransportProblems(transport: TakenLayerTransport, image: Raster, at: string): Problem[] {
|
|
19
|
+
const actual = createHash('sha256').update(image.data).digest('hex');
|
|
20
|
+
if (image.width !== transport.target_canvas[0] || image.height !== transport.target_canvas[1] || actual !== transport.after_rgba_sha256) {
|
|
21
|
+
return [{ code: 'PARTS_TAKEN_LAYERS', object: at,
|
|
22
|
+
detail: `transported canvas ${image.width}x${image.height}, RGBA sha256 ${actual}; receipt requires ${transport.target_canvas.join('x')}, RGBA sha256 ${transport.after_rgba_sha256}` }];
|
|
23
|
+
}
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
4
26
|
export interface TakenLayerSource {
|
|
5
27
|
run: 'full' | 'head';
|
|
6
28
|
tag: string;
|
|
@@ -11,6 +33,7 @@ export interface TakenLayerSource {
|
|
|
11
33
|
support_px: number;
|
|
12
34
|
checked_px: number;
|
|
13
35
|
differing_px: 0;
|
|
36
|
+
transport?: TakenLayerTransport;
|
|
14
37
|
}
|
|
15
38
|
export function unionTakenSources(lists: readonly (readonly TakenLayerSource[] | undefined)[]): TakenLayerSource[] {
|
|
16
39
|
const out: TakenLayerSource[] = [];
|
|
@@ -28,13 +51,27 @@ export function takenSourceProblems(value: unknown, at: string): Problem[] {
|
|
|
28
51
|
if (typeof item !== 'object' || item === null || Array.isArray(item)) { fail('each source must be a complete identity receipt'); continue; }
|
|
29
52
|
const o = item as Record<string, unknown>;
|
|
30
53
|
const tag = typeof o.tag === 'string' ? readLayerTag(o.tag) : null;
|
|
31
|
-
if (Object.keys(o).some(k => !keys.includes(k)) || keys.some(k => !(k in o)) ||
|
|
54
|
+
if (Object.keys(o).some(k => !keys.includes(k) && k !== 'transport') || keys.some(k => !(k in o)) ||
|
|
32
55
|
(o.run !== 'full' && o.run !== 'head') || tag === null || tag.group === 'author' ||
|
|
33
56
|
['run_dir', 'st_input', 'prompt_id'].some(k => typeof o[k] !== 'string' || !(o[k] as string).trim()) ||
|
|
34
57
|
!Number.isSafeInteger(o.seed) || (o.seed as number) < 0 ||
|
|
35
58
|
['support_px', 'checked_px'].some(k => !Number.isSafeInteger(o[k]) || (o[k] as number) <= 0) || o.differing_px !== 0) {
|
|
36
59
|
fail('each source requires exactly run, See-through tag, run_dir, st_input, prompt_id, nonnegative safe integer seed, positive support_px/checked_px and differing_px 0'); continue;
|
|
37
60
|
}
|
|
61
|
+
if (o.transport !== undefined) {
|
|
62
|
+
const t = o.transport as Record<string, unknown> | null;
|
|
63
|
+
const fields = ['crop', 'donor_canvas', 'target_canvas', 'scale', 'translate', 'filter', 'input_rgba_sha256', 'before_rgba_sha256', 'after_rgba_sha256'];
|
|
64
|
+
const pair = (v: unknown, positive: boolean, integer: boolean): v is number[] => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n) && (!positive || n > 0) && (!integer || Number.isSafeInteger(n)));
|
|
65
|
+
if (typeof t !== 'object' || t === null || Array.isArray(t) || Object.keys(t).length !== fields.length || fields.some(k => !(k in t)) ||
|
|
66
|
+
!Array.isArray(t.crop) || t.crop.length !== 4 || !t.crop.every(n => Number.isSafeInteger(n) && n >= 0) || t.crop[2] <= t.crop[0] || t.crop[3] <= t.crop[1] ||
|
|
67
|
+
!pair(t.donor_canvas, true, true) || !pair(t.target_canvas, true, true) || !pair(t.scale, true, false) || !pair(t.translate, false, false) ||
|
|
68
|
+
(t.filter !== 'exact' && t.filter !== 'bilinear') ||
|
|
69
|
+
['input_rgba_sha256', 'before_rgba_sha256', 'after_rgba_sha256'].some(k => typeof t[k] !== 'string' || !/^[0-9a-f]{64}$/.test(t[k] as string))) {
|
|
70
|
+
fail('transport requires exactly crop, donor_canvas, target_canvas, scale, translate, filter and three lowercase RGBA SHA-256 hashes');
|
|
71
|
+
} else if ((t.filter === 'exact') !== (t.scale[0] === 1 && t.scale[1] === 1 && t.translate.every(Number.isSafeInteger))) {
|
|
72
|
+
fail('transport filter exact requires unit scale and integer translation; every other map requires bilinear');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
38
75
|
const key = JSON.stringify([o.run, o.tag, o.run_dir]);
|
|
39
76
|
if (seen.has(key)) fail(`duplicate source ${key}; each source must appear once`);
|
|
40
77
|
seen.add(key);
|