rig-parts 1.82.2 → 1.83.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/cli.ts +5 -2
- package/docs/AUTHORING.md +95 -0
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/assemble.ts +20 -0
- package/src/build.ts +3 -1
- package/src/config.ts +28 -3
- package/src/layers.ts +3 -0
- package/src/parts.ts +8 -3
- package/src/takelayers.ts +110 -0
- package/src/takesource.ts +43 -0
package/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { applyTakenLayers } from './src/takelayers.ts';
|
|
2
3
|
/**
|
|
3
4
|
* rig-parts — one painting and its See-through layers in, Spine-ready parts
|
|
4
5
|
* and rig specs out, verified through rig-c.
|
|
@@ -1722,10 +1723,12 @@ function cmdAssemble(args: string[]): number {
|
|
|
1722
1723
|
if (propose) {
|
|
1723
1724
|
const target = writePath === undefined ? null : readConfigWriteTarget(writePath);
|
|
1724
1725
|
const src = readSource(source);
|
|
1725
|
-
const
|
|
1726
|
-
const
|
|
1726
|
+
const cfg = loadEarlyConfig(config, 'layers');
|
|
1727
|
+
const runs = applyTakenLayers(readRuns(full, head), src, { ...cfg, assemble: { ...cfg.assemble, plan: undefined } }, config);
|
|
1728
|
+
const g = proposeFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
|
|
1727
1729
|
const geometry = { sourceW: src.width, sourceH: src.height, ...g };
|
|
1728
1730
|
const proposal = runs.single ? proposeSinglePlan(runs.full, geometry) : proposePlan(runs.full, runs.head, geometry);
|
|
1731
|
+
proposal.notes.unshift(...runs.notes);
|
|
1729
1732
|
if (target !== null) {
|
|
1730
1733
|
staged = stageConfigWrite(target, { kind: 'assemble-plan', plan: proposal.plan, extend_below_crop: proposal.extend_below_crop });
|
|
1731
1734
|
staged.commit();
|
package/docs/AUTHORING.md
CHANGED
|
@@ -615,6 +615,88 @@ remain separate authoring decisions; a shifted shoe can still require work on
|
|
|
615
615
|
its neighbouring shin. A walk built from eight generated keyframes motivated
|
|
616
616
|
the opt-in placement; it does not change the default for existing inputs.
|
|
617
617
|
|
|
618
|
+
### Taking named layers from another run (`assemble.take`, issue #866)
|
|
619
|
+
|
|
620
|
+
A run of the same painting, or a different painting with the same pixels under
|
|
621
|
+
some layers, can supply those tags without editing either run directory:
|
|
622
|
+
|
|
623
|
+
```json
|
|
624
|
+
"take": [{
|
|
625
|
+
"run": "full",
|
|
626
|
+
"run_dir": "runs/other",
|
|
627
|
+
"st_input": "inputs/other_full.png",
|
|
628
|
+
"tags": ["front hair", "mouth"]
|
|
629
|
+
}]
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
This array belongs in `assemble`. Every entry requires all four fields. `run`
|
|
633
|
+
is the **target** `full` or `head` run; `head` requires `--head`. `run_dir` and
|
|
634
|
+
`st_input` resolve relative to the config, for assemble, build and
|
|
635
|
+
`assemble --propose-plan` alike. Each target run/tag may be selected once. Empty
|
|
636
|
+
`take` and absent `take` leave the existing output unchanged. This selects base
|
|
637
|
+
run layers; alternate painting sets keep their own existing declarations.
|
|
638
|
+
|
|
639
|
+
`st_input` is the author's explicit declaration of the original PNG fed to the
|
|
640
|
+
donor. The tool reads `run_dir/layers.json`
|
|
641
|
+
and `run_dir/meta.json`, requiring a nonempty `prompt_id` and a nonnegative safe
|
|
642
|
+
integer `seed`; it never infers either from the target config. Existing metadata
|
|
643
|
+
does **not** bind that PNG's bytes to the prompt or layers. This checks the
|
|
644
|
+
supplied evidence's pixel identity, not cryptographic provenance of a remote
|
|
645
|
+
execution. Keep the original run, its metadata and original input together;
|
|
646
|
+
renaming a supplied image does not establish that it was the donor's input.
|
|
647
|
+
|
|
648
|
+
The target input is derived from the painting by the existing input model:
|
|
649
|
+
`full` uses the exact centred white `squarePad`, and `head` the configured
|
|
650
|
+
`head_box`. A nonsquare single-run file uses the painting at its original file
|
|
651
|
+
size before the assembler's existing padding. Donor and target run canvases must
|
|
652
|
+
have equal dimensions; donor and target input PNGs must also have equal
|
|
653
|
+
dimensions. Incompatible canvases or input sizes are refused, never resized to
|
|
654
|
+
make the comparison pass.
|
|
655
|
+
|
|
656
|
+
For each selected layer, support means its **original** alpha > 8, including
|
|
657
|
+
specks before ghost cleanup. Grow that support by the existing projection-core
|
|
658
|
+
`CORE_KERNEL` (5 x 5, radius 2 run pixels), clipped to the run canvas. Map each
|
|
659
|
+
covered run cell to **every intersecting** original input pixel cell. Compare
|
|
660
|
+
all four RGBA bytes exactly, with no filtering or tolerance. Checking each
|
|
661
|
+
layer's grown support checks their union too; the note reports both per-layer
|
|
662
|
+
and union counts. A mismatch names the donor run, tag, number of differing
|
|
663
|
+
pixels and `[left, top, right, bottom]` bbox in input pixels, right/bottom
|
|
664
|
+
exclusive. A tag absent from the donor, or with no alpha > 8 support, is refused.
|
|
665
|
+
An input difference outside that union is permitted.
|
|
666
|
+
|
|
667
|
+
Taken layers replace the target's tag, keeping the donor's tag and
|
|
668
|
+
`depth_median`, so existing hair/blink classification continues to read `from`.
|
|
669
|
+
Larger depth draws behind smaller depth. Numeric ties keep manifest order only
|
|
670
|
+
inside one original run. Equal or null depths **across** runs are ambiguous
|
|
671
|
+
and refused, even if the plan gives an order. A supplied plan reversing the
|
|
672
|
+
mixed-run depth order is also refused; `--propose-plan` drafts the depth order.
|
|
673
|
+
Replacing a layer with hidden companion art is refused because independent
|
|
674
|
+
hidden-art identity has not been declared. Active scalar depth sheets (`depth`
|
|
675
|
+
opt-in or already bound sheets) are also refused: selecting donor art does not
|
|
676
|
+
rebind a target atlas or invent donor scalar units. Inactive `depth_filename`
|
|
677
|
+
hints remain inactive; numeric `depth_median` ordering is preserved.
|
|
678
|
+
|
|
679
|
+
The proposal's `notes` and assemble/build log name every taken layer's run
|
|
680
|
+
directory, prompt ID, seed and input path, plus support/checked/differing counts.
|
|
681
|
+
`parts.json` adds `taken_layers` only to affected parts: each entry records
|
|
682
|
+
`run`, `tag`, `run_dir`, `st_input`, `prompt_id`, `seed`, `support_px` (run cells),
|
|
683
|
+
`checked_px` (original input cells), and `differing_px` (zero). Cuts, generated
|
|
684
|
+
arm layers, limb segments and copied extension sources retain these receipts.
|
|
685
|
+
The strict parts reader refuses partial, unknown, duplicate or nonzero-difference
|
|
686
|
+
receipts. These source identities are separate from pixel-writer counts.
|
|
687
|
+
|
|
688
|
+
Motivation, from the recorded 2026-10-30 decomposition measurements: a side-view
|
|
689
|
+
control held 62,627 front-hair, 36,643 headwear and 595 mouth pixels at
|
|
690
|
+
**alpha >= 250**. Three body repaints reported zero pixels at that threshold
|
|
691
|
+
for front hair and mouth at seed 42; their alpha > 8 counts were nonzero.
|
|
692
|
+
Six further runs at seeds 43, 101 and 2027 reported no run retaining both front
|
|
693
|
+
hair and headwear. These are recorded measurements, not a new pixel audit:
|
|
694
|
+
the retained run receipts contain no original layer PNGs. The records report
|
|
695
|
+
identical head input pixels and body-dependent limb results; this feature makes
|
|
696
|
+
identity an executable check on supplied original inputs instead of trusting
|
|
697
|
+
that claim or hand-editing `layers.json`. Seed and painting selection remain
|
|
698
|
+
the author's decisions.
|
|
699
|
+
|
|
618
700
|
### Alternate part layers (`assemble.alternates[i].layers`, issue #666)
|
|
619
701
|
|
|
620
702
|
A gesture run may hold a raised arm only in a layer the author added, while
|
|
@@ -4365,6 +4447,19 @@ those settings. Changing a config seed alone cannot change a no-config run.
|
|
|
4365
4447
|
|
|
4366
4448
|
| rule | means | change |
|
|
4367
4449
|
| --- | --- | --- |
|
|
4450
|
+
| <a id="ASSEMBLE_TAKE_INPUT_IDENTITY"></a>`ASSEMBLE_TAKE_INPUT_IDENTITY` | original input RGBA differs under a taken tag's grown support; detail gives run/tag, count and exclusive input bbox | select a compatible donor and its actual original `st_input`; no tolerance or repaint is applied |
|
|
4451
|
+
| <a id="ASSEMBLE_TAKE_TAG_IN_RUN"></a>`ASSEMBLE_TAKE_TAG_IN_RUN` | selected donor does not hold the tag | choose a tag present in that donor |
|
|
4452
|
+
| <a id="ASSEMBLE_TAKE_SUPPORT"></a>`ASSEMBLE_TAKE_SUPPORT` | selected layer has zero alpha > 8 pixels | choose a nonempty donor layer |
|
|
4453
|
+
| <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 |
|
|
4454
|
+
| <a id="ASSEMBLE_TAKE_PLAN_ORDER"></a>`ASSEMBLE_TAKE_PLAN_ORDER` | supplied plan reverses mixed-run depth order | use the depth order drafted by `assemble --propose-plan` |
|
|
4455
|
+
| <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 |
|
|
4456
|
+
| <a id="ASSEMBLE_TAKE_META"></a>`ASSEMBLE_TAKE_META` | donor `meta.json` lacks a readable prompt ID or seed | retain the original run metadata |
|
|
4457
|
+
| <a id="ASSEMBLE_TAKE_INPUT"></a>`ASSEMBLE_TAKE_INPUT` | declared donor input PNG cannot be read | name its original PNG relative to the config |
|
|
4458
|
+
| <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 |
|
|
4459
|
+
| <a id="ASSEMBLE_TAKE_HIDDEN"></a>`ASSEMBLE_TAKE_HIDDEN` | a replaced tag has hidden companion art | independent hidden-art identity is undeclared; choose a run without that companion |
|
|
4460
|
+
| <a id="ASSEMBLE_TAKE_UNIQUE"></a>`ASSEMBLE_TAKE_UNIQUE` | repeated donor for one target tag at runtime | declare each target run/tag once |
|
|
4461
|
+
| <a id="CONFIG_TAKE_UNIQUE"></a>`CONFIG_TAKE_UNIQUE` | config selects the same target run/tag more than once | retain one donor per target tag |
|
|
4462
|
+
| <a id="PARTS_TAKEN_LAYERS"></a>`PARTS_TAKEN_LAYERS` | a taken-layer receipt is partial, unknown, duplicated, malformed or claims differing pixels | regenerate parts with assemble; never patch source receipts by hand |
|
|
4368
4463
|
| <a id="ASSEMBLE_FIELD_PRESENT"></a>`ASSEMBLE_FIELD_PRESENT` | `seethrough.head_box` is missing | `propose --head-box` proposes it (§3) |
|
|
4369
4464
|
| <a id="ASSEMBLE_RIG_SIZE"></a>`ASSEMBLE_RIG_SIZE` | `rig_scale` makes an empty rig | `assemble.rig_scale` |
|
|
4370
4465
|
| <a id="ASSEMBLE_HEAD_BOX_INSIDE"></a>`ASSEMBLE_HEAD_BOX_INSIDE` | the head box is outside the painting | `head_box` — `propose --head-box` holds it inside |
|
package/docs/STABILITY.md
CHANGED
|
@@ -129,10 +129,11 @@ part name, `[i]` an entry of a list):
|
|
|
129
129
|
- **generation.control.skeleton**, the pose file it names when it is no built-in — required: `width`, `height`, `points`
|
|
130
130
|
- **seethrough** — required: `resolution`, `steps`, `seed`, `offload`; optional: `head_box`, `arm_split`
|
|
131
131
|
- **seethrough.arm_split** — required: `order`, and one of `x` or `line` (issue #439)
|
|
132
|
-
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`
|
|
132
|
+
- **assemble** — required: `rig_scale`, `plan` (from plain `assemble` on); optional: `extend_below_crop`, `patches`, `cuts`, `alternates`, `head_face_underfill`, `yield_refused`, `keep_under_occluders`, `extend_painting`, `take`
|
|
133
133
|
- **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
|
|
134
134
|
- **assemble.extend_below_crop[i]** — required: `part`, `run`, `tag`
|
|
135
135
|
- **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
|
|
136
|
+
- **assemble.take[i]** — required: `run`, `run_dir`, `st_input`, `tags` (issue #866)
|
|
136
137
|
- **assemble.patches[i]** — required: `name`, `box`, `alpha`, `draw`
|
|
137
138
|
- **assemble.patches[i].draw**, when an object — required: `before`
|
|
138
139
|
- **assemble.cuts[i]** — required: `from`, `into`, `polygon`, `draw`, `overlap`; optional: `under`, `under_fill`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unionTakenSources } from './takesource.ts';
|
|
1
2
|
import { paintingExtensionMasks, paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
|
|
2
3
|
import { AUTOMATIC_WRITERS as AW, compactValueBaseline, pixelProvenance, rgbaEqual, type ValueBaseline } from './provenance.ts';
|
|
3
4
|
import { bindDepthRun, copyDepthMaterial, registerDepthRun, transportPartDepth, type DepthConfig, type RegisteredDepth } from './depthsheets.ts';
|
|
@@ -1494,6 +1495,7 @@ export function applyArmSplit(set: LayerSet, resolution: number, armSplit: ArmSp
|
|
|
1494
1495
|
name,
|
|
1495
1496
|
tag: readLayerTag(name) as LayerTagReading,
|
|
1496
1497
|
file: refLayer.file,
|
|
1498
|
+
takenLayers: unionTakenSources(handLayers.map(l => l.takenLayers)),
|
|
1497
1499
|
pixels: crop(canvas, m.x0, m.y0, m.x1 - m.x0 + 1, m.y1 - m.y0 + 1),
|
|
1498
1500
|
left: m.x0,
|
|
1499
1501
|
top: m.y0,
|
|
@@ -1803,6 +1805,7 @@ export function splitMergedArmRunsWithNotes(
|
|
|
1803
1805
|
name,
|
|
1804
1806
|
tag: readLayerTag(name) as LayerTagReading,
|
|
1805
1807
|
file: hwLayer.file,
|
|
1808
|
+
...(hwLayer.takenLayers === undefined ? {} : { takenLayers: hwLayer.takenLayers }),
|
|
1806
1809
|
pixels: crop(canvas, x0, y0, x1 - x0 + 1, y1 - y0 + 1),
|
|
1807
1810
|
left: x0,
|
|
1808
1811
|
top: y0,
|
|
@@ -2098,6 +2101,7 @@ function prepareHeadFace(
|
|
|
2098
2101
|
const postStats = measureCoverage(comp);
|
|
2099
2102
|
const replacedLayer: Layer = {
|
|
2100
2103
|
...headFaceLayer,
|
|
2104
|
+
takenLayers: unionTakenSources([headFaceLayer.takenLayers, fullFaceLayer.takenLayers]),
|
|
2101
2105
|
pixels: crop(comp, x0, y0, x1 - x0 + 1, y1 - y0 + 1),
|
|
2102
2106
|
left: x0,
|
|
2103
2107
|
top: y0,
|
|
@@ -5522,6 +5526,13 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5522
5526
|
merged_px: merged,
|
|
5523
5527
|
seam_override_px: 0,
|
|
5524
5528
|
};
|
|
5529
|
+
const sourceKeys = new Set([key, ...extendKeys.filter((_, i) => trace.from.some(v => v === i))]);
|
|
5530
|
+
const takenLayers = unionTakenSources([...sourceKeys].map(k => {
|
|
5531
|
+
const colon = k.indexOf(':');
|
|
5532
|
+
const run = k.slice(0, colon) as Run;
|
|
5533
|
+
return (run === 'full' ? full : head).layers.find(l => l.name === k.slice(colon + 1))?.takenLayers;
|
|
5534
|
+
}));
|
|
5535
|
+
if (takenLayers.length > 0) record.taken_layers = takenLayers;
|
|
5525
5536
|
const paintingGain = paintingGains.get(name);
|
|
5526
5537
|
if (paintingGain !== undefined) record.painting_extension_px = paintingGain.data.reduce((sum, value) => sum + value, 0);
|
|
5527
5538
|
if (pushedHere.length > 0) record.fringe_pushed_back = pushedHere;
|
|
@@ -5542,6 +5553,15 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
5542
5553
|
},
|
|
5543
5554
|
...((limb?.cap?.image_px ?? 0) > 0 || (limb?.cap?.lower_px ?? 0) > 0 ? { unsupported: `cap copies ${(limb?.cap?.image_px ?? 0) + (limb?.cap?.lower_px ?? 0)} pixels from a separate cap image whose scalar grid is undeclared` } : {}),
|
|
5544
5555
|
});
|
|
5556
|
+
if (capSource !== null && upperIndex >= 0 && capSource.codes.some(code => code === CAP_FROM_UPPER)) {
|
|
5557
|
+
const upper = limbPieces[upperIndex];
|
|
5558
|
+
const keys = [upper.key, ...upper.extendKeys.filter((_, i) => upper.trace.from.some(v => v === i))];
|
|
5559
|
+
const sources = unionTakenSources([record.taken_layers, ...keys.map(k => {
|
|
5560
|
+
const colon = k.indexOf(':');
|
|
5561
|
+
return (k.slice(0, colon) === 'full' ? full : head).layers.find(l => l.name === k.slice(colon + 1))?.takenLayers;
|
|
5562
|
+
})]);
|
|
5563
|
+
if (sources.length > 0) record.taken_layers = sources;
|
|
5564
|
+
}
|
|
5545
5565
|
if (scalar !== undefined) record.depth = scalar.reference;
|
|
5546
5566
|
if (scalar !== undefined && scalar.clampedSamples > 0) depthClamps.push({ part: name, samples: scalar.clampedSamples });
|
|
5547
5567
|
set({ record, image, ...(scalar === undefined ? {} : { depthImage: scalar.image }) });
|
package/src/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyTakenLayers } from './takelayers.ts';
|
|
1
2
|
import { ownershipLines, provenanceLines } from './provenance.ts';
|
|
2
3
|
import { readSourceDepth } from './depthsheets.ts';
|
|
3
4
|
import { orderedBaseAnchor } from './baseanchor.ts';
|
|
@@ -320,7 +321,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
320
321
|
// regions and motion, so it requires only what it reads (issue #20).
|
|
321
322
|
const cfg = loadEarlyConfig(input.config, 'assemble');
|
|
322
323
|
const src = readSource(input.source);
|
|
323
|
-
const runs = readRuns(input.full, input.head);
|
|
324
|
+
const runs = applyTakenLayers(readRuns(input.full, input.head), src, cfg, input.config);
|
|
325
|
+
for (const note of runs.notes) log(`note: ${note}`);
|
|
324
326
|
const fields = stageFields(cfg, runs.single ? { w: src.width, h: src.height } : null);
|
|
325
327
|
const under = readUnders(input.config, fields.cuts);
|
|
326
328
|
const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TakeLayers } from './takelayers.ts';
|
|
1
2
|
import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
|
|
2
3
|
import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
|
|
3
4
|
import type { DepthConfig } from './depthsheets.ts';
|
|
@@ -299,6 +300,8 @@ export interface AlternateSetConfig {
|
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
export interface Assemble {
|
|
303
|
+
/** Verified named replacements from another See-through run (#866). */
|
|
304
|
+
take?: TakeLayers[];
|
|
302
305
|
/** Rig pixels per source pixel. */
|
|
303
306
|
rig_scale: number;
|
|
304
307
|
plan: PlanEntry[];
|
|
@@ -1619,7 +1622,7 @@ export interface EarlyConfig {
|
|
|
1619
1622
|
depth?: DepthConfig;
|
|
1620
1623
|
key: string;
|
|
1621
1624
|
seethrough: SeeThrough;
|
|
1622
|
-
assemble: { rig_scale: number; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
1625
|
+
assemble: { rig_scale: number; take?: TakeLayers[]; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
1623
1626
|
}
|
|
1624
1627
|
|
|
1625
1628
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1688,7 +1691,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1688
1691
|
if (door === 'layers') {
|
|
1689
1692
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1690
1693
|
if ('assemble' in t) {
|
|
1691
|
-
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting']);
|
|
1694
|
+
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take']);
|
|
1692
1695
|
if (a !== null) {
|
|
1693
1696
|
if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1694
1697
|
if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
|
|
@@ -1696,6 +1699,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1696
1699
|
}
|
|
1697
1700
|
checkKeepUnderOccluders(c, a, null);
|
|
1698
1701
|
if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
|
|
1702
|
+
if ('take' in a) checkTakeLayers(c, a.take);
|
|
1699
1703
|
if ('yield_refused' in a && typeof a.yield_refused !== 'boolean') {
|
|
1700
1704
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.yield_refused', `is ${show(a.yield_refused)}; true or false is required`);
|
|
1701
1705
|
}
|
|
@@ -2433,9 +2437,29 @@ function checkKeepUnderOccluders(c: Check, a: Record<string, Json>, plan: readon
|
|
|
2433
2437
|
});
|
|
2434
2438
|
}
|
|
2435
2439
|
|
|
2440
|
+
function checkTakeLayers(c: Check, value: Json): void {
|
|
2441
|
+
const at = 'config.assemble.take';
|
|
2442
|
+
if (!c.array(at, value, false)) return;
|
|
2443
|
+
const seen = new Set<string>();
|
|
2444
|
+
(value as Json[]).forEach((v, i) => {
|
|
2445
|
+
const p = `${at}[${i}]`, o = c.object(p, v, ['run', 'run_dir', 'st_input', 'tags'], []);
|
|
2446
|
+
if (o === null) return;
|
|
2447
|
+
if (o.run !== 'full' && o.run !== 'head') c.fail('CONFIG_FIELD_TYPE', p + '.run', '"full" or "head" is required');
|
|
2448
|
+
for (const k of ['run_dir', 'st_input']) c.string(`${p}.${k}`, o[k]);
|
|
2449
|
+
if (!c.array(p + '.tags', o.tags, true)) return;
|
|
2450
|
+
(o.tags as Json[]).forEach((tag, j) => {
|
|
2451
|
+
const reading = typeof tag === 'string' ? readLayerTag(tag) : null;
|
|
2452
|
+
if (reading === null || reading.group === 'author') c.fail('CONFIG_TAG_KNOWN', `${p}.tags[${j}]`, 'a See-through tag is required; author layers do not carry a See-through role');
|
|
2453
|
+
const key = `${String(o.run)}:${String(tag)}`;
|
|
2454
|
+
if (seen.has(key)) c.fail('CONFIG_TAKE_UNIQUE', `${p}.tags[${j}]`, `names ${key} again; one donor per target tag is required`);
|
|
2455
|
+
seen.add(key);
|
|
2456
|
+
});
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2436
2460
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
2437
2461
|
const required = sectionRequired(door, 'assemble');
|
|
2438
|
-
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting']);
|
|
2462
|
+
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take']);
|
|
2439
2463
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
2440
2464
|
if (required.includes('plan') && !('plan' in a)) {
|
|
2441
2465
|
c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
|
|
@@ -2471,6 +2495,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
|
|
|
2471
2495
|
}
|
|
2472
2496
|
checkKeepUnderOccluders(c, a, parts);
|
|
2473
2497
|
if ('extend_painting' in a) checkPaintingExtensions(c, a.extend_painting);
|
|
2498
|
+
if ('take' in a) checkTakeLayers(c, a.take);
|
|
2474
2499
|
if ('extend_below_crop' in a && c.array(`${p}.extend_below_crop`, a.extend_below_crop, false)) {
|
|
2475
2500
|
(a.extend_below_crop as Json[]).forEach((entry, i) => {
|
|
2476
2501
|
const at = `${p}.extend_below_crop[${i}]`;
|
package/src/layers.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TakenLayerSource } from './takesource.ts';
|
|
1
2
|
/**
|
|
2
3
|
* The input contract: one See-through decomposition as a `LayerSet`.
|
|
3
4
|
*
|
|
@@ -53,6 +54,8 @@ export const CORE_ALPHA = 250;
|
|
|
53
54
|
export const NEAR_WHITE_MIN = 235;
|
|
54
55
|
|
|
55
56
|
export interface Layer {
|
|
57
|
+
/** Verified donor identities retained through generated splits. */
|
|
58
|
+
takenLayers?: TakenLayerSource[];
|
|
56
59
|
/** As the input names it: a v3 tag, possibly with a `-r`/`-l` side, or an author-made layer's `author:<label>`. */
|
|
57
60
|
name: string;
|
|
58
61
|
tag: LayerTagReading;
|
package/src/parts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { takenSourceProblems, type TakenLayerSource } from './takesource.ts';
|
|
1
2
|
import { AUTOMATIC_WRITERS, provenanceAddsUp, type PixelProvenance, type CutOwnership } from './provenance.ts';
|
|
2
3
|
import { baseAnchorProblems, orderedBaseAnchor, type BaseAnchor } from './baseanchor.ts';
|
|
3
4
|
import { partDepthProblems, type PartDepthReference } from './depthsheets.ts';
|
|
@@ -97,6 +98,8 @@ import { edgeLength } from './length.ts';
|
|
|
97
98
|
import { recordedFocusProblems } from './setfocus.ts';
|
|
98
99
|
|
|
99
100
|
export interface PartRecord {
|
|
101
|
+
/** Verified donor sources, including sources of cuts, limbs and extensions. */
|
|
102
|
+
taken_layers?: TakenLayerSource[];
|
|
100
103
|
/** Report-only final last-writer census (#817); absent on older records. */
|
|
101
104
|
provenance?: PixelProvenance;
|
|
102
105
|
/** Painting ownership gained by #848, independent of final raw-value equality. */
|
|
@@ -426,6 +429,7 @@ export const PART_KEYS = [
|
|
|
426
429
|
'name',
|
|
427
430
|
'from',
|
|
428
431
|
'cut_from',
|
|
432
|
+
'taken_layers',
|
|
429
433
|
'x',
|
|
430
434
|
'y',
|
|
431
435
|
'w',
|
|
@@ -451,10 +455,10 @@ const FRINGE_KEY = 'fringe_pushed_back';
|
|
|
451
455
|
const CUT_FROM_KEY = 'cut_from';
|
|
452
456
|
/** The one part field that is a record of its own: a limb segment's provenance (issue #349), written only on one. */
|
|
453
457
|
const LIMB_KEY = 'limb';
|
|
454
|
-
const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance') as ReadonlyArray<(typeof PART_KEYS)[number]>;
|
|
458
|
+
const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance' && k !== 'taken_layers') as ReadonlyArray<(typeof PART_KEYS)[number]>;
|
|
455
459
|
/** The port's own counts: all three or none (none = a record the reference wrote). */
|
|
456
460
|
export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
|
|
457
|
-
const REQUIRED_KEYS = PART_KEYS.filter((k) => k !== 'painting_extension_px' && !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance');
|
|
461
|
+
const REQUIRED_KEYS = PART_KEYS.filter((k) => k !== 'painting_extension_px' && !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY && k !== 'depth' && k !== 'provenance' && k !== 'taken_layers');
|
|
458
462
|
|
|
459
463
|
/** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
|
|
460
464
|
export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
|
|
@@ -504,7 +508,7 @@ function orderedProvenance(p: PixelProvenance): PixelProvenance {
|
|
|
504
508
|
|
|
505
509
|
function serializePartList(parts: readonly PartRecord[]): Array<Record<string, unknown>> {
|
|
506
510
|
return parts.map((p) =>
|
|
507
|
-
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 === 'provenance' ? orderedProvenance(p.provenance as PixelProvenance) : p[k]])),
|
|
511
|
+
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]])),
|
|
508
512
|
);
|
|
509
513
|
}
|
|
510
514
|
|
|
@@ -649,6 +653,7 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
649
653
|
return;
|
|
650
654
|
}
|
|
651
655
|
const r = p as Record<string, unknown>;
|
|
656
|
+
if ('taken_layers' in r) problems.push(...takenSourceProblems(r.taken_layers, `${at}.taken_layers`));
|
|
652
657
|
if ('depth' in r) problems.push(...partDepthProblems(r.depth, `${path} part depth`));
|
|
653
658
|
const label = typeof r.name === 'string' ? `part "${r.name}"` : at;
|
|
654
659
|
for (const k of Object.keys(r)) if (!(PART_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${label} key "${k}"`, `is not a part field; known: ${PART_KEYS.join(', ')}`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Verified replacement in an existing full/head run; never writes or forges a run manifest. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import type { EarlyConfig, PlanEntry, Run } from './config.ts';
|
|
5
|
+
import { CORE_KERNEL } from './assemble.ts';
|
|
6
|
+
import { makeInputs } from './inputs.ts';
|
|
7
|
+
import { OPAQUE_ALPHA_ABOVE, readLayers, type Layer, type LayerSet } from './layers.ts';
|
|
8
|
+
import { PartsError, refuseIfAny, type Problem } from './errors.ts';
|
|
9
|
+
import { dilate } from './raster/morph.ts';
|
|
10
|
+
import { newMask, type Raster } from './raster/types.ts';
|
|
11
|
+
import { readPng } from './raster/png.ts';
|
|
12
|
+
import type { TakenLayerSource } from './takesource.ts';
|
|
13
|
+
|
|
14
|
+
export interface TakeLayers {
|
|
15
|
+
run: Run;
|
|
16
|
+
run_dir: string;
|
|
17
|
+
/** The exact PNG supplied to See-through, resolved from the config directory. */
|
|
18
|
+
st_input: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
}
|
|
21
|
+
function refuse(code: string, object: string, detail: string): never { throw new PartsError([{ code, object, detail }]); }
|
|
22
|
+
function metadata(path: string): { prompt_id: string; seed: number } {
|
|
23
|
+
let value: unknown;
|
|
24
|
+
try { value = JSON.parse(readFileSync(path, 'utf8')); } catch { return refuse('ASSEMBLE_TAKE_META', path, 'a readable meta.json with prompt_id and seed from the donor run is required'); }
|
|
25
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return refuse('ASSEMBLE_TAKE_META', path, 'a metadata object with prompt_id and seed is required');
|
|
26
|
+
const o = value as Record<string, unknown>;
|
|
27
|
+
if (typeof o.prompt_id !== 'string' || !o.prompt_id.trim() || !Number.isSafeInteger(o.seed) || (o.seed as number) < 0) return refuse('ASSEMBLE_TAKE_META', path, 'a nonempty prompt_id and nonnegative safe integer seed are required; neither is invented');
|
|
28
|
+
return { prompt_id: o.prompt_id, seed: o.seed as number };
|
|
29
|
+
}
|
|
30
|
+
function identity(layer: Layer, canvas: LayerSet['canvas'], donor: Raster, target: Raster, object: string): { support: number; checked: Uint8Array } {
|
|
31
|
+
const mask = newMask(canvas.w, canvas.h); let support = 0;
|
|
32
|
+
for (let y = 0; y < layer.pixels.height; y++) for (let x = 0; x < layer.pixels.width; x++) if (layer.pixels.data[(y * layer.pixels.width + x) * 4 + 3] > OPAQUE_ALPHA_ABOVE) {
|
|
33
|
+
mask.data[(layer.top + y) * canvas.w + layer.left + x] = 1; support++;
|
|
34
|
+
}
|
|
35
|
+
if (support === 0) return refuse('ASSEMBLE_TAKE_SUPPORT', object, `has 0 pixels of alpha above ${OPAQUE_ALPHA_ABOVE}; a nonempty taken layer is required`);
|
|
36
|
+
const grown = dilate(mask, CORE_KERNEL, CORE_KERNEL);
|
|
37
|
+
const checked = new Uint8Array(target.width * target.height);
|
|
38
|
+
// A run pixel is a cell on st_input, not a sample that can be rounded to a
|
|
39
|
+
// neighbouring centre. Compare EVERY intersecting input cell before any resize.
|
|
40
|
+
for (let y = 0; y < canvas.h; y++) for (let x = 0; x < canvas.w; x++) if (grown.data[y * canvas.w + x] === 1) {
|
|
41
|
+
for (let iy = Math.floor(y * target.height / canvas.h); iy < Math.ceil((y + 1) * target.height / canvas.h); iy++)
|
|
42
|
+
for (let ix = Math.floor(x * target.width / canvas.w); ix < Math.ceil((x + 1) * target.width / canvas.w); ix++) checked[iy * target.width + ix] = 1;
|
|
43
|
+
}
|
|
44
|
+
let differing = 0, x0 = target.width, y0 = target.height, x1 = 0, y1 = 0;
|
|
45
|
+
for (let p = 0; p < checked.length; p++) if (checked[p] === 1 && [0, 1, 2, 3].some(c => donor.data[p * 4 + c] !== target.data[p * 4 + c])) {
|
|
46
|
+
differing++; const x = p % target.width, y = Math.floor(p / target.width); x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x + 1); y1 = Math.max(y1, y + 1);
|
|
47
|
+
}
|
|
48
|
+
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`);
|
|
49
|
+
return { support, checked };
|
|
50
|
+
}
|
|
51
|
+
export function takeLayerNote(source: TakenLayerSource): string {
|
|
52
|
+
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`;
|
|
53
|
+
}
|
|
54
|
+
/** Apply every declaration together, so replacing all tied layers from one run can retain its known order. */
|
|
55
|
+
export function applyTakenLayers(runs: { full: LayerSet; head: LayerSet; single: boolean }, painting: Raster, cfg: Pick<EarlyConfig, 'seethrough' | 'depth'> & { assemble: { take?: TakeLayers[]; plan?: PlanEntry[] } }, configPath: string): { full: LayerSet; head: LayerSet; single: boolean; notes: string[] } {
|
|
56
|
+
const declarations = cfg.assemble.take ?? [];
|
|
57
|
+
if (declarations.length === 0) return { ...runs, notes: [] };
|
|
58
|
+
if (cfg.depth !== undefined || [...runs.full.layers, ...runs.head.layers].some(l => l.depthSheet !== undefined)) return refuse('ASSEMBLE_TAKE_DEPTH_SHEET', 'config.assemble.take', 'active scalar depth sheets have no mixed-run binding; disable the depth opt-in or assemble a separately bound run before taking layers (numeric depth_median ordering is retained)');
|
|
59
|
+
const inputs = makeInputs(painting, cfg, 'take target painting');
|
|
60
|
+
const base = dirname(resolve(configPath));
|
|
61
|
+
const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
|
|
62
|
+
const origins = new Map<Layer, string>();
|
|
63
|
+
const notes: string[] = [], seen = new Set<string>();
|
|
64
|
+
for (const d of declarations) {
|
|
65
|
+
const at = `config.assemble.take run "${d.run_dir}" (${d.run})`;
|
|
66
|
+
if (d.run === 'head' && runs.single) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, 'there is no --head run; choose full or supply a head run');
|
|
67
|
+
const target = d.run === 'head' ? inputs.head : runs.single && (runs.full.canvas.w !== runs.full.canvas.h) ? painting : inputs.full;
|
|
68
|
+
const set = runs[d.run];
|
|
69
|
+
const donor = readLayers(resolve(base, d.run_dir));
|
|
70
|
+
const meta = metadata(resolve(base, d.run_dir, 'meta.json'));
|
|
71
|
+
let image: Raster;
|
|
72
|
+
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`); }
|
|
73
|
+
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)`);
|
|
74
|
+
const union = new Uint8Array(image.width * image.height);
|
|
75
|
+
for (const tag of d.tags) {
|
|
76
|
+
const key = `${d.run}:${tag}`;
|
|
77
|
+
if (seen.has(key)) return refuse('ASSEMBLE_TAKE_UNIQUE', at, `tag "${tag}" replaces ${key} twice; declare one donor per target tag`);
|
|
78
|
+
seen.add(key);
|
|
79
|
+
const layer = donor.layers.find(l => l.name === tag);
|
|
80
|
+
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`);
|
|
81
|
+
const checked = identity(layer, donor.canvas, image, target, `${at} tag "${tag}"`);
|
|
82
|
+
for (let p = 0; p < union.length; p++) union[p] |= checked.checked[p];
|
|
83
|
+
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 };
|
|
84
|
+
const replacement: Layer = { ...layer, takenLayers: [source] };
|
|
85
|
+
lists[d.run] = [...lists[d.run].filter(l => l.name !== tag), replacement];
|
|
86
|
+
origins.set(replacement, resolve(base, d.run_dir));
|
|
87
|
+
notes.push(takeLayerNote(source));
|
|
88
|
+
}
|
|
89
|
+
notes.push(`take union ${d.run} from ${d.run_dir}: checked ${union.reduce((a, b) => a + b, 0)} input px; differing 0`);
|
|
90
|
+
}
|
|
91
|
+
for (const run of ['full', 'head'] as const) {
|
|
92
|
+
const list = lists[run], targetOrigin = resolve(dirname(runs[run].source));
|
|
93
|
+
const origin = (l: Layer): string => origins.get(l) ?? targetOrigin;
|
|
94
|
+
const problems: Problem[] = [];
|
|
95
|
+
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) if (origin(list[i]) !== origin(list[j]) && (list[i].depth === null || list[j].depth === null || list[i].depth === list[j].depth)) problems.push({ code: 'ASSEMBLE_TAKE_DEPTH_AMBIGUOUS', object: `${run}: "${list[i].name}" (${origin(list[i])}) / "${list[j].name}" (${origin(list[j])})`, detail: `depths ${list[i].depth} / ${list[j].depth}; distinct numeric depths across runs are required; manifest order resolves ties only inside the same run` });
|
|
96
|
+
refuseIfAny(problems);
|
|
97
|
+
list.sort((a, b) => a.depth === null || b.depth === null || a.depth === b.depth ? a.drawOrder - b.drawOrder : b.depth - a.depth);
|
|
98
|
+
lists[run] = list.map((l, drawOrder) => ({ ...l, drawOrder }));
|
|
99
|
+
const selected = (cfg.assemble.plan ?? []).filter(p => p[1] === run).map(p => ({ part: p[0], layer: list.find(l => l.name === p[2]) }));
|
|
100
|
+
for (let i = 0; i < selected.length; i++) for (let j = i + 1; j < selected.length; j++) {
|
|
101
|
+
const a = selected[i], b = selected[j];
|
|
102
|
+
if (a.layer !== undefined && b.layer !== undefined && origin(a.layer) !== origin(b.layer) && list.indexOf(a.layer) > list.indexOf(b.layer)) problems.push({ code: 'ASSEMBLE_TAKE_PLAN_ORDER', object: `${run} plan parts "${a.part}" / "${b.part}"`, detail: `plan order reverses mixed-run depths ${a.layer.depth} / ${b.layer.depth}; put the larger depth behind, as in assemble --propose-plan` });
|
|
103
|
+
}
|
|
104
|
+
refuseIfAny(problems);
|
|
105
|
+
const replaced = new Set(declarations.filter(d => d.run === run).flatMap(d => d.tags));
|
|
106
|
+
if (runs[run].hidden.some(l => replaced.has(l.of)) || declarations.some(d => d.run === run && readLayers(resolve(base, d.run_dir)).hidden.some(l => d.tags.includes(l.of)))) return refuse('ASSEMBLE_TAKE_HIDDEN', run, 'a replaced layer has hidden companion art; separate hidden provenance is not declared, so the combination is refused');
|
|
107
|
+
}
|
|
108
|
+
const full = { ...runs.full, layers: lists.full }, head = runs.single ? full : { ...runs.head, layers: lists.head };
|
|
109
|
+
return { full, head, single: runs.single, notes };
|
|
110
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Layer identity receipts, retained independently of part names and derived geometry. */
|
|
2
|
+
import type { Problem } from './errors.ts';
|
|
3
|
+
import { readLayerTag } from './tags.ts';
|
|
4
|
+
export interface TakenLayerSource {
|
|
5
|
+
run: 'full' | 'head';
|
|
6
|
+
tag: string;
|
|
7
|
+
run_dir: string;
|
|
8
|
+
st_input: string;
|
|
9
|
+
prompt_id: string;
|
|
10
|
+
seed: number;
|
|
11
|
+
support_px: number;
|
|
12
|
+
checked_px: number;
|
|
13
|
+
differing_px: 0;
|
|
14
|
+
}
|
|
15
|
+
export function unionTakenSources(lists: readonly (readonly TakenLayerSource[] | undefined)[]): TakenLayerSource[] {
|
|
16
|
+
const out: TakenLayerSource[] = [];
|
|
17
|
+
for (const list of lists) for (const item of list ?? []) if (!out.some(p => p.run === item.run && p.tag === item.tag && p.run_dir === item.run_dir)) out.push(item);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** Strict parts reader: unknown fields and partial or invented identity receipts are refused. */
|
|
21
|
+
export function takenSourceProblems(value: unknown, at: string): Problem[] {
|
|
22
|
+
const problems: Problem[] = [];
|
|
23
|
+
const fail = (detail: string): void => { problems.push({ code: 'PARTS_TAKEN_LAYERS', object: at, detail }); };
|
|
24
|
+
const keys = ['run', 'tag', 'run_dir', 'st_input', 'prompt_id', 'seed', 'support_px', 'checked_px', 'differing_px'];
|
|
25
|
+
if (!Array.isArray(value) || value.length === 0) { fail('a non-empty list of verified layer sources is required'); return problems; }
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
for (const item of value) {
|
|
28
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) { fail('each source must be a complete identity receipt'); continue; }
|
|
29
|
+
const o = item as Record<string, unknown>;
|
|
30
|
+
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)) ||
|
|
32
|
+
(o.run !== 'full' && o.run !== 'head') || tag === null || tag.group === 'author' ||
|
|
33
|
+
['run_dir', 'st_input', 'prompt_id'].some(k => typeof o[k] !== 'string' || !(o[k] as string).trim()) ||
|
|
34
|
+
!Number.isSafeInteger(o.seed) || (o.seed as number) < 0 ||
|
|
35
|
+
['support_px', 'checked_px'].some(k => !Number.isSafeInteger(o[k]) || (o[k] as number) <= 0) || o.differing_px !== 0) {
|
|
36
|
+
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
|
+
}
|
|
38
|
+
const key = JSON.stringify([o.run, o.tag, o.run_dir]);
|
|
39
|
+
if (seen.has(key)) fail(`duplicate source ${key}; each source must appear once`);
|
|
40
|
+
seen.add(key);
|
|
41
|
+
}
|
|
42
|
+
return problems;
|
|
43
|
+
}
|