rig-parts 1.82.2 → 1.83.1
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 +106 -0
- package/docs/STABILITY.md +2 -1
- package/package.json +1 -1
- package/src/assemble.ts +37 -5
- 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 +127 -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,99 @@ 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. Plan-order checks compare only
|
|
672
|
+
different original runs' layers whose original supports intersect (both alpha
|
|
673
|
+
> 8, in the target-run canvas, without dilation). Disjoint supports have no
|
|
674
|
+
visible order; intersecting bounding boxes alone do not establish overlap.
|
|
675
|
+
Within one role band, larger depth must draw behind smaller depth. In a two-run
|
|
676
|
+
plan, full-run rows follow the proposer's head-fallback roles before scalar
|
|
677
|
+
depth: back hair, body, headwear/earwear, face, head details, front hair. Thus
|
|
678
|
+
fallback headwear is after the body and before the face, and fallback front
|
|
679
|
+
hair stays over the face even when its donor depth is larger. Roles come from
|
|
680
|
+
source tags, never part names. Head-run rows and single-run plans keep scalar
|
|
681
|
+
depth order. A supplied plan reversing an overlapping mixed-run pair under
|
|
682
|
+
these rules is refused; `--propose-plan` drafts the same order. Null/tied depth
|
|
683
|
+
ambiguity remains a refusal even for disjoint supports.
|
|
684
|
+
Replacing a layer with hidden companion art is refused because independent
|
|
685
|
+
hidden-art identity has not been declared. Active scalar depth sheets (`depth`
|
|
686
|
+
opt-in or already bound sheets) are also refused: selecting donor art does not
|
|
687
|
+
rebind a target atlas or invent donor scalar units. Inactive `depth_filename`
|
|
688
|
+
hints remain inactive; numeric `depth_median` ordering is preserved.
|
|
689
|
+
|
|
690
|
+
The proposal's `notes` and assemble/build log name every taken layer's run
|
|
691
|
+
directory, prompt ID, seed and input path, plus support/checked/differing counts.
|
|
692
|
+
`parts.json` adds `taken_layers` only to affected parts: each entry records
|
|
693
|
+
`run`, `tag`, `run_dir`, `st_input`, `prompt_id`, `seed`, `support_px` (run cells),
|
|
694
|
+
`checked_px` (original input cells), and `differing_px` (zero). Cuts, generated
|
|
695
|
+
arm layers, limb segments and copied extension sources retain these receipts.
|
|
696
|
+
The strict parts reader refuses partial, unknown, duplicate or nonzero-difference
|
|
697
|
+
receipts. These source identities are separate from pixel-writer counts.
|
|
698
|
+
|
|
699
|
+
Motivation, from the recorded 2026-10-30 decomposition measurements: a side-view
|
|
700
|
+
control held 62,627 front-hair, 36,643 headwear and 595 mouth pixels at
|
|
701
|
+
**alpha >= 250**. Three body repaints reported zero pixels at that threshold
|
|
702
|
+
for front hair and mouth at seed 42; their alpha > 8 counts were nonzero.
|
|
703
|
+
Six further runs at seeds 43, 101 and 2027 reported no run retaining both front
|
|
704
|
+
hair and headwear. These are recorded measurements, not a new pixel audit:
|
|
705
|
+
the retained run receipts contain no original layer PNGs. The records report
|
|
706
|
+
identical head input pixels and body-dependent limb results; this feature makes
|
|
707
|
+
identity an executable check on supplied original inputs instead of trusting
|
|
708
|
+
that claim or hand-editing `layers.json`. Seed and painting selection remain
|
|
709
|
+
the author's decisions.
|
|
710
|
+
|
|
618
711
|
### Alternate part layers (`assemble.alternates[i].layers`, issue #666)
|
|
619
712
|
|
|
620
713
|
A gesture run may hold a raised arm only in a layer the author added, while
|
|
@@ -4365,6 +4458,19 @@ those settings. Changing a config seed alone cannot change a no-config run.
|
|
|
4365
4458
|
|
|
4366
4459
|
| rule | means | change |
|
|
4367
4460
|
| --- | --- | --- |
|
|
4461
|
+
| <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 |
|
|
4462
|
+
| <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 |
|
|
4463
|
+
| <a id="ASSEMBLE_TAKE_SUPPORT"></a>`ASSEMBLE_TAKE_SUPPORT` | selected layer has zero alpha > 8 pixels | choose a nonempty donor layer |
|
|
4464
|
+
| <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 |
|
|
4465
|
+
| <a id="ASSEMBLE_TAKE_PLAN_ORDER"></a>`ASSEMBLE_TAKE_PLAN_ORDER` | supplied plan reverses an overlapping mixed-run pair: two-run full-layer head roles first, scalar depth within a role band (head-run and single-run rows use scalar depth); names the parts and support intersection | use the order drafted by `assemble --propose-plan`; fallback headwear follows the body and precedes the face, front hair follows the face; disjoint supports impose no plan-order constraint |
|
|
4466
|
+
| <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 |
|
|
4467
|
+
| <a id="ASSEMBLE_TAKE_META"></a>`ASSEMBLE_TAKE_META` | donor `meta.json` lacks a readable prompt ID or seed | retain the original run metadata |
|
|
4468
|
+
| <a id="ASSEMBLE_TAKE_INPUT"></a>`ASSEMBLE_TAKE_INPUT` | declared donor input PNG cannot be read | name its original PNG relative to the config |
|
|
4469
|
+
| <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 |
|
|
4470
|
+
| <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 |
|
|
4471
|
+
| <a id="ASSEMBLE_TAKE_UNIQUE"></a>`ASSEMBLE_TAKE_UNIQUE` | repeated donor for one target tag at runtime | declare each target run/tag once |
|
|
4472
|
+
| <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 |
|
|
4473
|
+
| <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
4474
|
| <a id="ASSEMBLE_FIELD_PRESENT"></a>`ASSEMBLE_FIELD_PRESENT` | `seethrough.head_box` is missing | `propose --head-box` proposes it (§3) |
|
|
4369
4475
|
| <a id="ASSEMBLE_RIG_SIZE"></a>`ASSEMBLE_RIG_SIZE` | `rig_scale` makes an empty rig | `assemble.rig_scale` |
|
|
4370
4476
|
| <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.1",
|
|
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 }) });
|
|
@@ -6028,6 +6048,18 @@ export const PROPOSE_HEAD_TAGS: readonly string[] = [
|
|
|
6028
6048
|
'eyebrow-l',
|
|
6029
6049
|
];
|
|
6030
6050
|
|
|
6051
|
+
/** #869: two-run full rows are body rows or head fallbacks, regardless of part spelling.
|
|
6052
|
+
* Within one band the run's depth order survives; the bands encode the proposer's roles.
|
|
6053
|
+
*/
|
|
6054
|
+
export function fullPlanBand(tag: string): number {
|
|
6055
|
+
if (tag === 'back hair') return 0;
|
|
6056
|
+
if (!PROPOSE_HEAD_TAGS.includes(tag)) return 1;
|
|
6057
|
+
if (tag === 'headwear' || tag === 'earwear') return 2;
|
|
6058
|
+
if (tag === 'face') return 3;
|
|
6059
|
+
if (tag === 'front hair') return 5;
|
|
6060
|
+
return 4;
|
|
6061
|
+
}
|
|
6062
|
+
|
|
6031
6063
|
/** #863: prefer suitable head layers; only unusable head candidates may fall back for these tags. */
|
|
6032
6064
|
const HEAD_FALLBACK_TAGS: readonly string[] = ['back hair', 'face', 'front hair'];
|
|
6033
6065
|
|
|
@@ -6086,7 +6118,7 @@ function fmt0(v: number): string {
|
|
|
6086
6118
|
* A suitable head candidate is retained. Every fallback names the two rig
|
|
6087
6119
|
* areas and the reason. A `headwear`/`earwear` the head run drops or
|
|
6088
6120
|
* shrinks (its rig-pixel area below 0.8 of the full run's) is taken from the
|
|
6089
|
-
* full run instead, drawn
|
|
6121
|
+
* full run instead, drawn after the body and before the face — noted. `front hair` and
|
|
6090
6122
|
* `back hair` taken from the head run are extended below the crop from the
|
|
6091
6123
|
* full run when the full run's layer reaches more than 8 rig pixels below the
|
|
6092
6124
|
* crop line. A lone `handwear` layer is named `sleeves`.
|
|
@@ -6212,7 +6244,7 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
|
|
|
6212
6244
|
return tag !== 'nose' && !(run === 'head' && tag === 'face' && headFaceRefusal !== null) &&
|
|
6213
6245
|
l !== undefined && l.opaqueRunPx >= floor && !implausible(run, tag, 'not proposed');
|
|
6214
6246
|
};
|
|
6215
|
-
const body = runs.full.filter((l) =>
|
|
6247
|
+
const body = runs.full.filter((l) => fullPlanBand(l.tag) === 1 && ok('full', l.tag)).map((l) => l.tag);
|
|
6216
6248
|
// Issue #256: an author-made layer is proposed from the run that holds it, at its place in that run's draw order,
|
|
6217
6249
|
// like any layer; one both runs hold is proposed once, from the full run when the full run's is proposed, and the note says so.
|
|
6218
6250
|
const authorBoth = runs.head.filter((l) => readAuthorTag(l.tag) !== null && body.includes(l.tag)).map((l) => l.tag);
|
|
@@ -6259,15 +6291,15 @@ export function proposePlan(full0: LayerSet, head0: LayerSet, g: Geometry, minPx
|
|
|
6259
6291
|
const draw = t === 'back hair' ? 'behind the body and face' : t === 'face' ? 'before head details and front hair' : 'in front of the face';
|
|
6260
6292
|
notes.push(`${t}: head run ${fmt0(hp)} rig px (${reason}); full run ${fmt0(fp)} rig px -> full-run layer, drawn ${draw}`);
|
|
6261
6293
|
}
|
|
6262
|
-
|
|
6294
|
+
// Keep the full run's order inside the accessory band, including mixed donors.
|
|
6295
|
+
for (const { tag: t } of runs.full.filter(l => fullPlanBand(l.tag) === 2)) {
|
|
6263
6296
|
const hl = find('head', t);
|
|
6264
6297
|
const fl = find('full', t);
|
|
6265
6298
|
const hp = hl !== undefined ? hl.opaqueRunPx * headArea : 0;
|
|
6266
6299
|
const fp = fl !== undefined ? fl.opaqueRunPx * fullArea : 0;
|
|
6267
6300
|
if (ok('full', t) && hp < 0.8 * fp) {
|
|
6268
6301
|
drop(t);
|
|
6269
|
-
|
|
6270
|
-
plan.splice(at, 0, [PROPOSE_ALIAS[t], 'full', t]);
|
|
6302
|
+
plan.push([PROPOSE_ALIAS[t], 'full', t]);
|
|
6271
6303
|
notes.push(`${t}: head run ${fmt0(hp)} rig px < 0.8 x full run ${fmt0(fp)} -> full-run layer, drawn behind the face`);
|
|
6272
6304
|
}
|
|
6273
6305
|
}
|
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,127 @@
|
|
|
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, fullPlanBand } 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
|
+
/** Intersection of original alpha supports in the common target-run canvas; no bbox or dilation proxy. */
|
|
55
|
+
function supportOverlap(a: Layer, b: Layer): number {
|
|
56
|
+
let px = 0;
|
|
57
|
+
for (let y = Math.max(a.top, b.top); y < Math.min(a.bottom, b.bottom); y++)
|
|
58
|
+
for (let x = Math.max(a.left, b.left); x < Math.min(a.right, b.right); x++) {
|
|
59
|
+
if (a.pixels.data[((y - a.top) * a.pixels.width + x - a.left) * 4 + 3] > OPAQUE_ALPHA_ABOVE &&
|
|
60
|
+
b.pixels.data[((y - b.top) * b.pixels.width + x - b.left) * 4 + 3] > OPAQUE_ALPHA_ABOVE) px++;
|
|
61
|
+
}
|
|
62
|
+
return px;
|
|
63
|
+
}
|
|
64
|
+
/** Apply every declaration together, so replacing all tied layers from one run can retain its known order. */
|
|
65
|
+
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[] } {
|
|
66
|
+
const declarations = cfg.assemble.take ?? [];
|
|
67
|
+
if (declarations.length === 0) return { ...runs, notes: [] };
|
|
68
|
+
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)');
|
|
69
|
+
const inputs = makeInputs(painting, cfg, 'take target painting');
|
|
70
|
+
const base = dirname(resolve(configPath));
|
|
71
|
+
const lists = { full: [...runs.full.layers], head: [...runs.head.layers] };
|
|
72
|
+
const origins = new Map<Layer, string>();
|
|
73
|
+
const notes: string[] = [], seen = new Set<string>();
|
|
74
|
+
for (const d of declarations) {
|
|
75
|
+
const at = `config.assemble.take run "${d.run_dir}" (${d.run})`;
|
|
76
|
+
if (d.run === 'head' && runs.single) return refuse('ASSEMBLE_TAKE_GEOMETRY', at, 'there is no --head run; choose full or supply a head run');
|
|
77
|
+
const target = d.run === 'head' ? inputs.head : runs.single && (runs.full.canvas.w !== runs.full.canvas.h) ? painting : inputs.full;
|
|
78
|
+
const set = runs[d.run];
|
|
79
|
+
const donor = readLayers(resolve(base, d.run_dir));
|
|
80
|
+
const meta = metadata(resolve(base, d.run_dir, 'meta.json'));
|
|
81
|
+
let image: Raster;
|
|
82
|
+
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`); }
|
|
83
|
+
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)`);
|
|
84
|
+
const union = new Uint8Array(image.width * image.height);
|
|
85
|
+
for (const tag of d.tags) {
|
|
86
|
+
const key = `${d.run}:${tag}`;
|
|
87
|
+
if (seen.has(key)) return refuse('ASSEMBLE_TAKE_UNIQUE', at, `tag "${tag}" replaces ${key} twice; declare one donor per target tag`);
|
|
88
|
+
seen.add(key);
|
|
89
|
+
const layer = donor.layers.find(l => l.name === tag);
|
|
90
|
+
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`);
|
|
91
|
+
const checked = identity(layer, donor.canvas, image, target, `${at} tag "${tag}"`);
|
|
92
|
+
for (let p = 0; p < union.length; p++) union[p] |= checked.checked[p];
|
|
93
|
+
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 };
|
|
94
|
+
const replacement: Layer = { ...layer, takenLayers: [source] };
|
|
95
|
+
lists[d.run] = [...lists[d.run].filter(l => l.name !== tag), replacement];
|
|
96
|
+
origins.set(replacement, resolve(base, d.run_dir));
|
|
97
|
+
notes.push(takeLayerNote(source));
|
|
98
|
+
}
|
|
99
|
+
notes.push(`take union ${d.run} from ${d.run_dir}: checked ${union.reduce((a, b) => a + b, 0)} input px; differing 0`);
|
|
100
|
+
}
|
|
101
|
+
for (const run of ['full', 'head'] as const) {
|
|
102
|
+
const list = lists[run], targetOrigin = resolve(dirname(runs[run].source));
|
|
103
|
+
const origin = (l: Layer): string => origins.get(l) ?? targetOrigin;
|
|
104
|
+
const problems: Problem[] = [];
|
|
105
|
+
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` });
|
|
106
|
+
refuseIfAny(problems);
|
|
107
|
+
list.sort((a, b) => a.depth === null || b.depth === null || a.depth === b.depth ? a.drawOrder - b.drawOrder : b.depth - a.depth);
|
|
108
|
+
lists[run] = list.map((l, drawOrder) => ({ ...l, drawOrder }));
|
|
109
|
+
const selected = (cfg.assemble.plan ?? []).filter(p => p[1] === run).map(p => ({ part: p[0], layer: list.find(l => l.name === p[2]) }));
|
|
110
|
+
for (let i = 0; i < selected.length; i++) for (let j = i + 1; j < selected.length; j++) {
|
|
111
|
+
const a = selected[i], b = selected[j];
|
|
112
|
+
if (a.layer === undefined || b.layer === undefined || origin(a.layer) === origin(b.layer)) continue;
|
|
113
|
+
const overlap = supportOverlap(a.layer, b.layer);
|
|
114
|
+
if (overlap === 0) continue;
|
|
115
|
+
// Only full rows in a two-run plan have the proposer's fallback roles.
|
|
116
|
+
// Head rows and one-run plans retain the scalar order, as do peers in a band.
|
|
117
|
+
const band = !runs.single && run === 'full' ? fullPlanBand(a.layer.name) - fullPlanBand(b.layer.name) : 0;
|
|
118
|
+
const reversed = band === 0 ? list.indexOf(a.layer) > list.indexOf(b.layer) : band > 0;
|
|
119
|
+
if (reversed) problems.push({ code: 'ASSEMBLE_TAKE_PLAN_ORDER', object: `${run} plan parts "${a.part}" / "${b.part}"`, detail: `plan order reverses ${band === 0 ? `mixed-run depths ${a.layer.depth} / ${b.layer.depth}; put the larger depth behind` : `two-run full-layer roles "${a.layer.name}" / "${b.layer.name}"; order back hair, body, headwear/earwear, face, head details, front hair`}; supports overlap at ${overlap} run px (alpha > ${OPAQUE_ALPHA_ABOVE}); use assemble --propose-plan` });
|
|
120
|
+
}
|
|
121
|
+
refuseIfAny(problems);
|
|
122
|
+
const replaced = new Set(declarations.filter(d => d.run === run).flatMap(d => d.tags));
|
|
123
|
+
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');
|
|
124
|
+
}
|
|
125
|
+
const full = { ...runs.full, layers: lists.full }, head = runs.single ? full : { ...runs.head, layers: lists.head };
|
|
126
|
+
return { full, head, single: runs.single, notes };
|
|
127
|
+
}
|
|
@@ -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
|
+
}
|