rig-parts 2.10.0 → 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 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. Donor and target run canvases must
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
@@ -858,6 +886,20 @@ every donor author/hidden image requires a valid receipt before pixels are
858
886
  copied. A receipted author-layer target is accepted. The plan proposal command
859
887
  refuses region entries because their part names depend on an existing plan.
860
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
+
861
903
  ### Editor receipts (`assemble.editor_receipts`, issue #946)
862
904
 
863
905
  An image edited in code — a layer re-segmented by a script, a patch
@@ -5160,7 +5202,8 @@ those settings. Changing a config seed alone cannot change a no-config run.
5160
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 |
5161
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 |
5162
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 |
5163
- | <a id="ASSEMBLE_TAKE_GEOMETRY"></a>`ASSEMBLE_TAKE_GEOMETRY` | missing target head, unequal run canvases or unequal original input dimensions | supply matching geometry; the check does not resize inputs |
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 |
5164
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 |
5165
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 |
5166
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 |
@@ -5703,6 +5746,10 @@ and stay absent).
5703
5746
  | --- | --- | --- |
5704
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 |
5705
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 |
5706
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 |
5707
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 |
5708
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*) |
@@ -6446,7 +6493,7 @@ single-character path (§4, steps 0–9) on its own, already isolated input to a
6446
6493
  | field | what it is |
6447
6494
  | --- | --- |
6448
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 |
6449
- | `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. Both are copied into `scene.json` and **judged by nothing** |
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 |
6450
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 |
6451
6498
  | `characters[].build` | a `build --out` directory (relative to the scene file) whose own `check/check.json` is green |
6452
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 |
package/docs/STABILITY.md CHANGED
@@ -156,7 +156,8 @@ 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`; optional: `tags`, `part`, `seed`, `reference` (issues #866, #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`
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
160
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
161
162
  - **assemble.editor_receipts** — required: `receipts`; optional: `required` (issue #946, additive), `trace_parts` (issue #947, additive)
162
163
  - **assemble.editor_receipts.receipts[i]** — required: `file`, `sha256`, `from_sha256`, `editor` (issue #946)
@@ -616,6 +617,20 @@ stale evidence rather than reconstructing original art from emitted siblings.
616
617
  Without the block the API reading is SKIP; CLI stdout and emitted bytes remain unchanged.
617
618
  See [Source before scissors](AUTHORING.md#source-before-scissors).
618
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
+
619
634
  ### Editor receipts
620
635
 
621
636
  The optional `assemble.editor_receipts` block (issue #946, additive) records
@@ -664,3 +679,16 @@ reads that rate (issue #965, additive for readers). Measurements in
664
679
  `requirements` still require a positive `fps`, with no default. Declarations
665
680
  whose instruments use their own rate keep that rate.
666
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.10.0",
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/config.ts CHANGED
@@ -2501,7 +2501,7 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
2501
2501
  const region = isPlainObject(v) && ('part' in v || 'seed' in v);
2502
2502
  const o = region
2503
2503
  ? c.object(p, v, ['run', 'run_dir', 'st_input', 'part', 'seed'], ['reference'])
2504
- : c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
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]);
@@ -2526,6 +2526,14 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
2526
2526
  }
2527
2527
  return;
2528
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
+ }
2529
2537
  if (!c.array(p + '.tags', o.tags, true)) return;
2530
2538
  (o.tags as Json[]).forEach((tag, j) => {
2531
2539
  const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
@@ -2537,6 +2545,13 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
2537
2545
  });
2538
2546
  }
2539
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
+
2540
2555
  /**
2541
2556
  * `config.assemble.editor_receipts` (issue #946): `{receipts, required?, trace_parts?}` (`trace_parts`, issue #947).
2542
2557
  * Shape refusals are the loader's own; a receipt whose `from_sha256` equals
@@ -2544,8 +2559,7 @@ function checkTakeLayers(c: Check, value: Json, plan: string[] | null = null): v
2544
2559
  * Whether the file exists and hashes to `sha256` is read at assemble
2545
2560
  * (`src/editreceipt.ts`), where the file is.
2546
2561
  */
2547
- function checkEditorReceipts(c: Check, value: Json): void {
2548
- const at = 'config.assemble.editor_receipts';
2562
+ function checkEditorReceipts(c: Check, value: Json, at = 'config.assemble.editor_receipts'): void {
2549
2563
  const o = c.object(at, value, ['receipts'], ['required', 'trace_parts']);
2550
2564
  if (o === null) return;
2551
2565
  for (const k of ['required', 'trace_parts'] as const) {
@@ -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 at = `${AT}.receipts[${i}]`;
112
+ const receiptAt = `${at}.receipts[${i}]`;
111
113
  const path = resolve(base, r.file);
112
- const object = `${at} (file "${r.file}")`;
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 config's directory, is required` });
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(t => ({ run: t.run, tag: t.tag, run_dir: t.run_dir, st_input: t.st_input, prompt_id: t.prompt_id, seed: t.seed, support_px: t.support_px, checked_px: t.checked_px, differing_px: t.differing_px })) : k === 'provenance' ? orderedProvenance(p.provenance as PixelProvenance) : p[k]])),
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/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: `image`, `provenance`, an optional `note`), `characters` (at
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 type { TakenLayerSource } from './takesource.ts';
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 replacement: Layer = { ...layer, takenLayers: [source] };
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/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);