spine-parts 0.1.0 → 0.3.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/README.md CHANGED
@@ -15,7 +15,7 @@ input is silent.
15
15
 
16
16
  <p align="center">
17
17
  <img src="https://raw.githubusercontent.com/firejune/spine-parts/main/assets/demo-source.png" alt="The demo painting: a generated full-body character in a white and pink frilled dress with long pink twin tails, standing with her hands clasped" height="420" />
18
- <img src="https://raw.githubusercontent.com/firejune/spine-parts/main/assets/demo-idle.gif" alt="The same character as a Spine rig, breathing, blinking once and swaying her hair, sleeves and skirt in a four-second loop" height="420" />
18
+ <img src="https://raw.githubusercontent.com/firejune/spine-parts/main/assets/demo-idle.png" alt="The same character as a Spine rig, breathing, blinking once and swaying her hair, sleeves and skirt in a four-second loop" height="420" />
19
19
  </p>
20
20
 
21
21
  <p align="center">
@@ -42,8 +42,10 @@ is taken from the full run, because the twin tails leave the head crop sideways
42
42
  <code>--seam silhouette</code> repaired the navy blobs the default rule leaves on the white
43
43
  blouse (recomposite error pixels 11,050 → 9,540); the default stays
44
44
  <code>near-white</code>, the reference implementation's rule, so the examples stay
45
- comparable with it. The loop is <code>spine-parts loop</code>'s GIF, 48 frames at
46
- 12 fps, 1,812,041 bytes; the painting is shown at half size, resampled and written
45
+ comparable with it. The loop is <code>spine-parts loop --palette</code>'s indexed APNG, 48 frames at
46
+ 12 fps, 1,706,468 bytes, one 256-entry palette at a measured error of max 57, mean 1.601
47
+ per channel over every frame (the lossless APNG beside it, the exactness record, is
48
+ 13,645,611 bytes; the GIF, at the same error, 1,812,041); the painting is shown at half size, resampled and written
47
49
  by this package's PNG codec (nothing here encodes JPEG). <code>spine-parts sheet</code>
48
50
  made the contact sheet.
49
51
  </em></p>
@@ -83,10 +85,10 @@ page** (issue #2):
83
85
  | path under `--out` | what |
84
86
  | --- | --- |
85
87
  | `check/build/skeleton.json`, `skeleton.atlas`, `skeleton.png` | **the artifact** — Spine 4.3 skeleton data and one packed page, written by `rigc build --pack` and gated under both profiles |
86
- | `parts/*.png`, `parts.json`, `recomposite_rig.png` | the loose parts, each cropped to its alpha box; the record of where every part came from and how many of its pixels were re-taken from the painting; the flat stack of parts |
88
+ | `parts/*.png`, `parts.json`, `recomposite_rig.png`, `recomposite_error_rig.png` | the loose parts, each cropped to its alpha box; the record of where every part came from, how many of its pixels were re-taken from the painting, and the recomposite's uncovered holes with their boxes; the flat stack of parts; its error map — red where no part covers a pixel the painting has, blue where a part covers it in the wrong colour |
87
89
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
88
90
  | `check/` | both gate files verbatim, the idle's frames, `contact.png`, `motion_heat.png`, `check.json` |
89
- | `idle.gif`, `idle.png` | with `--loop`: the idle as a GIF and a lossless APNG |
91
+ | `idle.png`, `idle-indexed.png`, `idle.gif` | with `--loop`: the idle as a lossless APNG (the exactness record), an indexed APNG with one shared palette (the small one) and a GIF; the last two print their palette error |
90
92
 
91
93
  The last lines of a green build are the pack line, printed beside the Spine example
92
94
  export's `spineboy.png` as a yardstick (a reference, not a bar), and the three
@@ -166,9 +168,9 @@ than queueing behind someone else's job.
166
168
  ## The loop, for an agent
167
169
 
168
170
  ```sh
169
- spine-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png; the config needs only key, seethrough, assemble.rig_scale
171
+ spine-parts inputs --source painting.png --config config.json --out inputs # st_input_full.png
170
172
  # See-through on st_input_full.png (external, or `spine-parts comfy seethrough`) -> layers/full
171
- spine-parts layers layers/full # every layer: box, opaque px, depth
173
+ spine-parts layers layers/full # every layer: box, opaque px, depth, plausibility figures; WARN lines
172
174
  spine-parts propose --head-box --full layers/full --canvas 1664x2432
173
175
  # -> seethrough.head_box into config.json
174
176
  spine-parts inputs --source painting.png --config config.json --out inputs # now st_input_head.png too
@@ -177,6 +179,10 @@ spine-parts sheet --source painting.png --layers layers/full --layers layers/hea
177
179
  spine-parts assemble --propose-plan --source painting.png --full layers/full --head layers/head --config config.json
178
180
  # -> assemble.plan and extend_below_crop
179
181
  spine-parts assemble --source painting.png --full layers/full --head layers/head --config config.json --out work
182
+ # -> work/rig: parts.json and parts/; the config holds no bones, meshes, regions or motion yet
183
+ # -> read the `uncovered hole N:` lines and look at work/render/recomposite_error_rig.png:
184
+ # red is painting that no part holds, and no later gate can see it
185
+ # -> a large red hole neither run holds? add an assemble.patches entry (cut from the painting) and assemble again
180
186
  spine-parts propose --parts work/rig --source painting.png --out work
181
187
  # -> proposal.json and render/landmarks.png; correct it, copy bones/meshes/regions/motion into config.json
182
188
  spine-parts propose --parts work/rig --source painting.png --out work --from-config config.json
@@ -185,6 +191,11 @@ spine-parts build --config config.json --source painting.png --full layers/full
185
191
  # -> read out/check/check.json; every FAIL line names what has to change
186
192
  ```
187
193
 
194
+ At each step the config holds only what that step reads; the one table of what
195
+ that is, step by step, is [docs/AUTHORING.md §4](docs/AUTHORING.md#4-the-command-order).
196
+ The selftest runs this block in order, command by command, on each fetched example,
197
+ from a config holding only what the first step reads (`RL01`).
198
+
188
199
  `propose` is deliberately not a step of `build`: the proposal is a draft to correct
189
200
  against its overlay, and a config with bones is `build`'s input. `rig`, `check` and
190
201
  `loop` are the same stages one at a time. [docs/AUTHORING.md](docs/AUTHORING.md) is
@@ -200,17 +211,17 @@ agent skill.
200
211
  | `inputs --source <png> --config <json> --out <dir>` | the two images See-through is fed: the painting on a white square, and the head box's crop once the config has one |
201
212
  | `comfy paint --config --out [--host]` | optional: generate the painting on a ComfyUI box from the config's `generation` block |
202
213
  | `comfy seethrough --image --out [--host]` | optional: run the ComfyUI See-through wrapper on one image and write the form `layers` reads |
203
- | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth |
214
+ | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth, and its translucent, background and area figures; a `WARN` line for a layer `--propose-plan` will leave out |
204
215
  | `sheet --source <png> --layers <path>… --out <png>` | a labelled contact sheet of the painting and every layer or part |
205
- | `assemble --propose-plan …` | propose `assemble.plan` and `extend_below_crop` from the two runs |
206
- | `assemble --source --full --head --config --out [--seam]` | merge the two runs into rig-space parts, `parts.json` and the recomposite |
216
+ | `assemble --propose-plan …` | propose `assemble.plan` and `extend_below_crop` from the two runs, leaving out an implausible layer with a note naming the rule |
217
+ | `assemble --source --full --head --config --out [--seam] [--project]` | merge the two runs into rig-space parts, `parts.json`, the recomposite and its error map, and list the uncovered holes |
207
218
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
208
219
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
209
220
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
210
221
  | `rig --config --parts --out` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green |
211
- | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam and loop |
212
- | `loop --frames <dir> --out <file.gif \| file.png>` | encode a rendered idle as a looping GIF or APNG |
213
- | `build --config --source --full --head --out [--seam] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
222
+ | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the five judgement lines, and report the recomposite's holes from `parts.json` |
223
+ | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
224
+ | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
214
225
 
215
226
  `spine-parts --help` has every flag. Exit codes: 0 done, 1 input refused (every
216
227
  reason is a FAIL line), 2 a usage error or a command this version does not implement.
@@ -229,24 +240,45 @@ These are limits of the approach, stated so nobody reads more into a green run:
229
240
  that is, is below.
230
241
  - **No success rate is claimed.** Ten characters have been measured stage by stage
231
242
  against the reference implementation this package ports — the two public examples
232
- here and eight private ones, one See-through seed each — and all ten check green.
233
- That is an existence proof, not a rate.
234
- - **`loop` writes GIF and APNG, not WebP**: an animated WebP needs a VP8/VP8L
243
+ here and eight private ones, one See-through seed each — and all ten check green
244
+ on the gates, the seam and the loop. The judgement lines `check` added since
245
+ (issue #11) have been measured on the two public examples only. That is an
246
+ existence proof, not a rate.
247
+ - **`loop` writes GIF and APNG (lossless and indexed), not WebP**: an animated WebP needs a VP8/VP8L
235
248
  encoder, which this package does not carry.
236
249
  - **The proposer reads tags, not pictures.** A swinging element painted inside another
237
250
  layer (a sash tail in the skirt), hair that is none of the shapes it knows, and
238
- whether an accessory swings are the corrector's to add.
251
+ whether an accessory swings are the corrector's to add. The one accessory shape it
252
+ measures is a hanging strand on a headwear or earwear layer: each gets a pendulum
253
+ chain, and a strand it cannot chain is named in a note rather than left stiff.
239
254
 
240
255
  ### How much of a rig the model painted
241
256
 
242
- `parts.json` records, per part, how many opaque pixels were re-taken from the painting
243
- (`source_px_taken`) against the part's opaque pixels (`opaque_px`); the rest is
244
- See-through's synthesis. Across the ten reference rigs, **41.8 %** of all rig pixels
245
- are not source pixels (issue #9); on the two public examples it is 38.6 % (`sample`)
246
- and 36.4 % (`demo`), from their `expected/parts.json`. Most of it is art that really
247
- is hidden — a back-hair layer, a neck — but the figure **over-counts**: a thin part
248
- that is fully visible (a brow, a lash, an iris) has no eroded opaque core, so none of
249
- its pixels qualify for projection and they count as synthesized (issue #9).
257
+ `parts.json` splits each part's opaque pixels (`opaque_px`) into `visible_px` — no
258
+ later layer of its See-through run is opaque in front of them — and `occluded_px`,
259
+ and counts the visible ones whose colour was not taken from the painting
260
+ (`visible_not_projected_px`). On the two public examples, from the totals of their
261
+ `expected/parts.json` (the default `--project core`):
262
+
263
+ | | opaque px | occluded | visible, not projected | taken from the painting |
264
+ | --- | --- | --- | --- | --- |
265
+ | `sample` | 298,632 | 79,143 (26.5 %) | 36,227 (12.1 %) | 183,262 (61.4 %) |
266
+ | `demo` | 767,102 | 202,346 (26.4 %) | 76,801 (10.0 %) | 487,955 (63.6 %) |
267
+
268
+ The occluded share is art the painting does not show — a back-hair layer, a neck, the
269
+ parts of an ear under the hair — and is See-through's synthesis by necessity; it is
270
+ what the decomposition is for. The visible-but-not-projected share is synthesis where
271
+ the painting was there to be taken: a part too thin for the reference rule's 5x5
272
+ eroded core, an anti-aliased fringe below alpha 250, a rim beside a layer in front, a
273
+ pixel refused for drift. `--project visible` keeps the erosion only along a rim with a
274
+ layer in front, which takes that share to 22,476 (7.5 %) on `sample` and 49,672 (6.5 %)
275
+ on `demo` (from the `parts.json` of a `--project visible` build). On the eleven thin
276
+ head parts — brows, lashes, irises, eye whites, ears, mouth — it takes 296 of
277
+ `sample`'s 1,289 visible pixels where `core` takes 110, and 824 of `demo`'s 2,162
278
+ where `core` takes 343. What neither rule takes is the fringe: a per-pixel
279
+ classification of those parts under `visible`, recorded with the change that added
280
+ the flag, puts 813 of `sample`'s 993 still-unprojected pixels and 879 of `demo`'s
281
+ 1,338 below alpha 250.
250
282
 
251
283
  ## Requirements
252
284
 
package/cli.ts CHANGED
@@ -16,16 +16,16 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri
16
16
  import { tmpdir } from 'node:os';
17
17
  import { basename, dirname, extname, join } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { DEFAULT_SEAM_RULE, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
- import { assembleStage, build, checkStage, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
21
- import { findRigc, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK } from './src/check.ts';
19
+ import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
+ import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
21
+ import { findRigc, PARTS_HOME_SENTENCE, type RigcRunner, SEAM_MEAN_BAR, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK } from './src/check.ts';
22
22
  import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
23
23
  import { type CharacterConfig, loadConfig, loadEarlyConfig } from './src/config.ts';
24
24
  import { PartsError, problemLine } from './src/errors.ts';
25
25
  import { proposeHeadBox } from './src/headbox.ts';
26
26
  import { makeInputs } from './src/inputs.ts';
27
- import { type LayerSet, readLayers } from './src/layers.ts';
28
- import { checkProposal, compare, compareLines, drawLandmarks, lint, lintLine, type PartSet, propose, readPartSet, serializeProposal } from './src/propose.ts';
27
+ import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
28
+ import { checkProposal, compare, compareLines, drawLandmarks, HIP_MIN_FRACTION, lint, lintLine, type PartSet, propose, readPartSet, serializeProposal } from './src/propose.ts';
29
29
  import { readPng, writePng } from './src/raster/png.ts';
30
30
  import { buildSheet, defaultCaption, type Tile, tilesFrom } from './src/sheet.ts';
31
31
 
@@ -52,9 +52,14 @@ usage:
52
52
  spine-parts layers <dir | layers.json | file.psd>
53
53
  Read a See-through decomposition — the ComfyUI wrapper form (a directory
54
54
  holding layers.json and one PNG per layer) or an upstream .psd — and print
55
- every layer: draw order, name, tag group, box, size, opaque pixels, depth.
56
- Refuses, by name, a missing file, an unknown tag, a PNG whose size is not
57
- its box, and anything else outside the input contract.
55
+ every layer: draw order, name, tag group, box, size, opaque pixels, depth,
56
+ and three plausibility figures over its opaque pixels — translucent share
57
+ (alpha below 128), background share (min channel above 235) and area as a
58
+ multiple of the rest of the figure (the other layers' union). A layer that
59
+ crosses a plausibility rule gets a WARN line naming the rule and the bar;
60
+ --propose-plan leaves it out. Refuses, by name, a missing file, an
61
+ unknown tag, a PNG whose size is not its box, and anything else outside
62
+ the input contract — never a WARN.
58
63
 
59
64
  spine-parts sheet --source <painting.png> --layers <path> [--layers <path> ...]
60
65
  --out <sheet.png> [--cell <px>] [--cols <n>]
@@ -68,9 +73,15 @@ usage:
68
73
  Propose bones, meshes, regions and an idle from the assembled parts (<dir>
69
74
  holds parts.json and parts/). Roles come from each part's See-through tag,
70
75
  never its name. Writes <out>/proposal.json (config-shaped: bones, meshes,
71
- regions, motion with its blink, and notes) and the overlay to correct
72
- against, <out>/render/landmarks.png and landmarks_head.png. Prints every
73
- note and a LINT line for each chain link that lies off its mesh's art.
76
+ regions, motion with its blink — and a blink.still cut for a lash that
77
+ reaches far above its eyewhite, when a clear row allows one — and notes)
78
+ and the overlay to correct against, <out>/render/landmarks.png and
79
+ landmarks_head.png. Prints every
80
+ note and a LINT line for each chain link that lies off its mesh's art, for a
81
+ hip that is not below the chest, and for a hip above ${HIP_MIN_FRACTION} of the figure's
82
+ height (the shoulders); a headwear/earwear layer with hanging strands
83
+ gets one pendulum chain per strand and a note with each strand's x, rows
84
+ and width ("-- no chain proposed" is the one to act on; AUTHORING §3).
74
85
  --compare prints each shared bone's distance, proposal to config, in px.
75
86
 
76
87
  spine-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
@@ -86,7 +97,9 @@ usage:
86
97
  Author the rig: unrotated bones at the config's landmarks (a chain makes
87
98
  <chain>0..n), a square lattice mesh over every part in config.meshes
88
99
  weighted by distance to its candidate bone segments, a region for every
89
- part in config.regions, and one idle of sines and a blink. --parts is the
100
+ part in config.regions (a motion.blink.still part as two: the rows above
101
+ its row on a second slot <part>_still, which the blink does not move),
102
+ and one idle of sines and a blink. --parts is the
90
103
  directory holding parts.json and parts/<name>.png. The result is built
91
104
  through spine-rigc (profile spine-html, packed, then validated under
92
105
  profile spine) in a scratch directory first, and --out receives
@@ -95,45 +108,86 @@ usage:
95
108
  spine-parts check --rig <dir> --out <dir> [--parts <dir>]
96
109
  Build, gate, render and measure a rig through spine-rigc's CLI (the rigc at
97
110
  node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json
98
- (with an "idle"); --parts holds parts.json and parts/ and defaults to
99
- --rig. Both are only read. Into --out:
111
+ (with an "idle").
112
+ ${PARTS_HOME_SENTENCE}.
113
+ Both are only read. Into --out:
100
114
  build/ (rigc build --profile spine-html --pack: the packed atlas is the
101
115
  artifact), gate_spine-html.txt and gate_spine.txt (the gate lines
102
116
  verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640),
103
117
  contact.png, motion_heat.png and check.json. PASS needs both gates
104
118
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
105
119
  |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} px over ${SEAM_PX_LEVEL}, and the loop (idle frame 0 vs the
106
- frame at t = duration) at max |d| 0. Prints the pack line beside the
107
- spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not a bar.
108
- Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value and the
109
- value required.
110
-
111
- spine-parts loop --frames <dir> --out <file.png | file.gif>
120
+ frame at t = duration) at max |d| 0. Then five judgement lines, each in
121
+ check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
122
+ bars (AUTHORING §7): BREATH_VISIBLE (the topwear moves, the footwear does
123
+ not, each rendered alone), BLINK_NO_HOLE (the setup pose with the blink
124
+ held shut shows no background inside the eyewhite box), CHAIN_LAG (every
125
+ rotate track lags its keyed ancestor and amplitude grows down each
126
+ chain, read off motion.json), TIP_OVER_ROOT (each handwear/bottomwear
127
+ part's lower half travels further than its upper half) and
128
+ STILL_REGIONS_DARK (the heat map over the face outline and the feet).
129
+ Regions come from parts.json's See-through tags; a line with nothing to
130
+ read says SKIP and why — neither a pass nor a failure — and PASS needs
131
+ every line that measured to be PASS. Then RECOMPOSITE_HOLES: REPORTED,
132
+ read from parts.json's recomposite block (uncovered error px, hole count,
133
+ the largest hole's box and the parts bordering it) — a line with no bar,
134
+ never a FAIL, because a pixel no part holds is missing from both sides of
135
+ the seam; SKIP when parts.json has no such block. Prints the pack line
136
+ beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
137
+ a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
138
+ and the value required.
139
+
140
+ spine-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
112
141
  Encode a frame set rigc render wrote (its --out directory, or the set
113
142
  directory inside it) as a looping animation: .png writes an APNG
114
- (acTL/fcTL/fdAT, lossless), .gif a GIF89a (one 255-colour median-cut
115
- palette, no dithering, LZW, delays rounded so the loop's length is exact).
116
- The fps is read from frames.json. When the last frame equals frame 0 it is
117
- dropped, and the output says so: the loop wraps onto frame 0 itself.
118
- Animated WebP is not written — it needs a VP8/VP8L encoder, out of scope.
143
+ (acTL/fcTL/fdAT, lossless — the exactness record), .png with --palette an
144
+ indexed APNG (colour type 3, one palette for every frame: 255 median-cut
145
+ colours plus one transparent entry, alpha graded per entry, no dithering,
146
+ filter None — the README-sized file), .gif a GIF89a (the same 255-colour
147
+ median cut, no dithering, LZW, delays rounded so the loop's length is
148
+ exact). The indexed APNG and the GIF print their palette error, per
149
+ channel over every frame. The fps is read from frames.json. When the last
150
+ frame equals frame 0 it is dropped, and the output says so: the loop wraps
151
+ onto frame 0 itself. Animated WebP is not written — it needs a VP8/VP8L
152
+ encoder, out of scope.
119
153
 
120
154
  spine-parts assemble --source <painting.png> --full <dir|psd> --head <dir|psd>
121
155
  --config <config.json> --out <dir> [--seam near-white|silhouette]
156
+ [--project core|visible]
122
157
  Merge the full-body and head-crop See-through runs into rig-space parts:
123
- <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json
124
- and <out>/render/recomposite_rig.png. Reads config.seethrough.head_box and
125
- .resolution and config.assemble.rig_scale, .plan and .extend_below_crop.
126
- Prints one line per part, the seam override counts, and
127
- \`recomposite vs source\` (mean |d| and % within 8 over the mean channel;
128
- error px: max channel > 40; uncovered: of those, where no part has alpha
129
- above 128). Writes nothing unless every check passed. --seam defaults to
130
- ${DEFAULT_SEAM_RULE}.
158
+ <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json,
159
+ <out>/render/recomposite_rig.png and <out>/render/${ERROR_MAP_FILE} (the error
160
+ map: uncovered error px red, covered error px blue, the rest the painting in
161
+ light grey). Reads config.seethrough.head_box and
162
+ .resolution and config.assemble.rig_scale, .plan, .extend_below_crop and
163
+ .patches — extra parts cut from the painting itself over a rig-pixel box
164
+ (alpha "silhouette": the painting's figure inside it; "box": all of it),
165
+ drawn "back", "front" or {"before": <plan part>}, recorded in parts.json
166
+ as from "painting:<name>" and counted 100 % source — and no rig section:
167
+ bones, meshes, regions and motion need not exist yet, because propose
168
+ drafts them from these parts (AUTHORING §4, the table).
169
+ Prints one line per part, the seam override counts, the \`pixels:\` totals
170
+ (opaque = visible + occluded; taken from the painting; visible but not
171
+ projected), and \`recomposite vs source\` (mean |d| and % within 8 over the
172
+ mean channel; error px: max channel > 40; uncovered: of those, where no
173
+ part has alpha above 128), then the uncovered holes (8-connected) and the
174
+ largest ${HOLES_LISTED}, each \`uncovered hole N: <px> px at x,y wxh (between
175
+ "<part>" <px> px, …)\` — the list parts.json holds under recomposite.
176
+ Writes nothing unless every check passed.
177
+ --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
178
+ the painting's pixel: core (the reference's) erodes every layer's top-most
179
+ opaque area by 5x5 first, so a part a few pixels wide takes none; visible
180
+ erodes only along a rim where a later layer is in front. It defaults to
181
+ ${DEFAULT_PROJECT_RULE}.
131
182
 
132
183
  spine-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
133
184
  --head <dir|psd> --config <config.json>
134
185
  Print {plan, extend_below_crop, notes} for config.assemble, from the two
135
- runs. Reads only config.seethrough.head_box, config.seethrough.resolution
136
- and config.assemble.rig_scale — the rest of the config need not exist yet.
186
+ runs. A layer \`layers\` WARNs about (PLAN_LAYER_TRANSLUCENT,
187
+ PLAN_LAYER_BACKGROUND, PLAN_LAYER_OVERSIZED) is not proposed, and a note
188
+ names it, its figures and the rule. Reads only config.seethrough.head_box,
189
+ config.seethrough.resolution and config.assemble.rig_scale — the rest of
190
+ the config need not exist yet.
137
191
 
138
192
  spine-parts inputs --source <painting.png> --config <config.json> --out <dir>
139
193
  Cut the two images See-through is fed: <out>/st_input_full.png (the
@@ -162,21 +216,27 @@ usage:
162
216
  verbatim, checkpoint, LoRAs, sampler, control, elapsed) for --seeds
163
217
  seeds from --seed0, and control_<skeleton>.png when generation.control
164
218
  is set. The pose words are generation.pose, or the control skeleton's own.
219
+ The config needs only key and generation here; the rest comes later.
165
220
 
166
221
  spine-parts build --config <config.json> --source <painting.png> --full <dir|psd>
167
- --head <dir|psd> --out <dir> [--seam near-white|silhouette] [--loop]
222
+ --head <dir|psd> --out <dir> [--seam near-white|silhouette]
223
+ [--project core|visible] [--loop]
168
224
  assemble, then rig, then check, in one process, each stage's own lines
169
225
  printed under [assemble], [rig] and [check]; the first stage that refuses
170
226
  stops the build with its own FAIL lines. The config must already carry
171
227
  bones, meshes, regions and motion — propose is not a step of build: run it,
172
228
  correct the proposal against its overlay, and write the result into the
173
- config. Into --out: parts/ + parts.json + recomposite_rig.png (assemble),
229
+ config. Into --out: parts/ + parts.json + recomposite_rig.png +
230
+ ${ERROR_MAP_FILE} (assemble),
174
231
  rig/ (rig), check/ (check, with the packed build in check/build/), and with
175
- --loop idle.gif and idle.png (APNG) from check/idle_frames/. The paths it
176
- writes are cleared first. A green build ends with the pack line beside the
177
- spineboy yardstick and the three artifact paths — skeleton .json, .atlas
178
- and the packed page: the packed atlas is the result, the loose parts are
179
- the intermediate it was made from. --seam defaults to ${DEFAULT_SEAM_RULE}.
232
+ --loop idle.png (lossless APNG), idle-indexed.png (indexed APNG) and
233
+ idle.gif from check/idle_frames/, then one loop: line with the three sizes
234
+ and the two palette errors. The paths it writes are cleared first. A
235
+ green build ends with the pack line beside the spineboy yardstick and the
236
+ three artifact paths — skeleton .json, .atlas and the packed page: the
237
+ packed atlas is the result, the loose parts are the intermediate it was
238
+ made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
239
+ ${DEFAULT_PROJECT_RULE} (both as for assemble).
180
240
 
181
241
  spine-parts --version
182
242
  spine-parts --help
@@ -206,7 +266,8 @@ function fixed(v: number | null): string {
206
266
  function printLayerTable(set: LayerSet): void {
207
267
  console.log(`spine-parts layers: ${set.form === 'wrapper' ? 'ComfyUI wrapper form' : 'PSD'}, ${set.source}`);
208
268
  console.log(` canvas ${set.canvas.w}x${set.canvas.h}, ${set.layers.length} layer(s), back to front`);
209
- const rows = set.layers.map((l) => [
269
+ const figures = layerFigures(set);
270
+ const rows = set.layers.map((l, i) => [
210
271
  String(l.drawOrder),
211
272
  l.name,
212
273
  l.tag.group,
@@ -215,14 +276,26 @@ function printLayerTable(set: LayerSet): void {
215
276
  `${l.pixels.width}x${l.pixels.height}`,
216
277
  String(l.opaquePx),
217
278
  fixed(l.depth),
279
+ pct(figures[i].translucent),
280
+ pct(figures[i].background),
281
+ times(figures[i].areaRatio),
218
282
  ]);
219
- const head = ['order', 'name', 'group', 'left,top', 'right,bottom', 'size', 'opaque_px', 'depth'];
283
+ const head = ['order', 'name', 'group', 'left,top', 'right,bottom', 'size', 'opaque_px', 'depth', 'translucent', 'background', 'area'];
220
284
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
221
285
  const line = (cells: string[]): string => ` ${cells.map((c, i) => c.padEnd(widths[i])).join(' ')}`.trimEnd();
222
286
  console.log(line(head));
223
287
  for (const r of rows) console.log(line(r));
224
288
  const painted = set.layers.filter((l) => l.opaquePx > 0).length;
225
289
  console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
290
+ // A reader refuses nothing on plausibility: it says what --propose-plan will leave out, and why.
291
+ let warned = 0;
292
+ for (const f of figures) {
293
+ for (const rule of implausibleRules(f)) {
294
+ console.log(` WARN ${rule}: layer "${f.name}" — ${figuresPhrase(f)}; ${ruleSummary(rule)} is required, so --propose-plan leaves it out`);
295
+ warned++;
296
+ }
297
+ }
298
+ console.log(` ${warned} WARN line(s)`);
226
299
  }
227
300
 
228
301
  function cmdLayers(args: string[]): number {
@@ -271,6 +344,9 @@ function cmdSheet(args: string[]): number {
271
344
  const tiles: Tile[] = [{ name: 'source', image: src, caption: basename(source) }];
272
345
  for (const path of layers) tiles.push(...tilesFrom(path));
273
346
  const sheet = buildSheet(tiles, cols, cell);
347
+ // The README's loop writes sheets/layers.png into a folder nothing made yet;
348
+ // every other command creates its --out, so this one does too.
349
+ mkdirSync(dirname(out), { recursive: true });
274
350
  writePng(out, sheet);
275
351
  console.log(`spine-parts sheet: ${out}`);
276
352
  console.log(` ${tiles.length} tile(s) in ${cols} column(s) of ${cell} px, sheet ${sheet.width}x${sheet.height}`);
@@ -369,7 +445,8 @@ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes:
369
445
  const res = lint(P, spec);
370
446
  for (const f of res.findings) console.log(lintLine(f));
371
447
  for (const m of res.unknownMeshes) console.log(`note: mesh ${JSON.stringify(m)} names no part in parts.json, so it was not linted`);
372
- console.log(`${res.findings.length} LINT line(s) over ${Object.keys(spec.meshes).length - res.unknownMeshes.length} mesh(es)`);
448
+ for (const b of res.missingTorsoBones) console.log(`note: no single bone named ${JSON.stringify(b)}, so the hip was not linted against the chest${b === 'hip' ? ' or the figure height' : ''}`);
449
+ console.log(`${res.findings.length} LINT line(s) over ${Object.keys(spec.meshes).length - res.unknownMeshes.length} mesh(es) and the hip`);
373
450
  return res.findings.length;
374
451
  }
375
452
 
@@ -456,14 +533,21 @@ function cmdPropose(args: string[]): number {
456
533
  }
457
534
 
458
535
  function cmdLoop(args: string[]): number {
459
- const f = flags(args, ['--frames', '--out'], 'loop');
536
+ const palettes = args.filter((a) => a === '--palette').length;
537
+ if (palettes > 1) return usage('--palette is given twice');
538
+ const f = flags(
539
+ args.filter((a) => a !== '--palette'),
540
+ ['--frames', '--out'],
541
+ 'loop',
542
+ );
460
543
  if (typeof f === 'string') return usage(f);
461
544
  const out = f.get('--out') as string;
462
545
  const ext = extname(out).toLowerCase();
463
546
  if (ext === '.webp') return usage(`--out ${out}: animated WebP needs a VP8/VP8L encoder, which is out of scope; write .png (APNG) or .gif`);
464
547
  if (ext !== '.png' && ext !== '.gif') return usage(`--out ${out} is neither .png (APNG) nor .gif`);
548
+ if (palettes === 1 && ext !== '.png') return usage(`--palette selects the indexed APNG, so --out must be a .png; ${out} is a GIF, which is always one palette`);
465
549
  try {
466
- loopStage(f.get('--frames') as string, out, ext === '.png' ? 'apng' : 'gif', console.log);
550
+ loopStage(f.get('--frames') as string, out, ext === '.gif' ? 'gif' : palettes === 1 ? 'indexed' : 'apng', console.log);
467
551
  return EXIT_OK;
468
552
  } catch (err) {
469
553
  return printRefusal(err);
@@ -473,7 +557,7 @@ function cmdLoop(args: string[]): number {
473
557
  function cmdAssemble(args: string[]): number {
474
558
  const flags = new Map<string, string>();
475
559
  let propose = false;
476
- const valued = ['--source', '--full', '--head', '--config', '--out', '--seam'];
560
+ const valued = ['--source', '--full', '--head', '--config', '--out', '--seam', '--project'];
477
561
  for (let i = 0; i < args.length; i++) {
478
562
  const flag = args[i];
479
563
  if (flag === '--propose-plan') {
@@ -488,24 +572,26 @@ function cmdAssemble(args: string[]): number {
488
572
  i++;
489
573
  }
490
574
  for (const f of ['--source', '--full', '--head', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
491
- if (propose && (flags.has('--out') || flags.has('--seam'))) return usage('--propose-plan prints to the console; it takes neither --out nor --seam');
575
+ if (propose && (flags.has('--out') || flags.has('--seam') || flags.has('--project'))) return usage('--propose-plan prints to the console; it takes none of --out, --seam, --project');
492
576
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
493
577
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
494
578
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
579
+ const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
580
+ if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
495
581
  const [source, full, head, config] = ['--source', '--full', '--head', '--config'].map((f) => flags.get(f) as string);
496
582
  try {
497
583
  if (propose) {
498
584
  const src = readSource(source);
499
585
  const runs = readRuns(full, head);
500
- const g = proposeFields(loadEarlyConfig(config));
586
+ const g = proposeFields(loadEarlyConfig(config, 'layers'));
501
587
  const proposal = proposePlan(runs.full, runs.head, { sourceW: src.width, sourceH: src.height, ...g });
502
588
  console.log(JSON.stringify(proposal, null, 2));
503
589
  return EXIT_OK;
504
590
  }
505
591
  const out = flags.get('--out') as string;
506
592
  assembleStage(
507
- { source, full, head, config, seam: seam as SeamRule },
508
- { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png') },
593
+ { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule },
594
+ { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png'), errorMap: join(out, 'render', ERROR_MAP_FILE) },
509
595
  console.log,
510
596
  );
511
597
  return EXIT_OK;
@@ -567,7 +653,12 @@ async function cmdComfy(args: string[]): Promise<number> {
567
653
  for (const x of [wait, timeout, poll]) if (typeof x === 'string') return usage(x);
568
654
  const out = v.get('--out');
569
655
  if (out === undefined) return usage(`comfy ${sub} needs --out <dir>`);
656
+ const configPath = v.get('--config');
657
+ if (sub === 'paint' && configPath === undefined) return usage('comfy paint needs --config <config.json>');
570
658
  try {
659
+ // The config is a local file, so it is answered before any host is: a
660
+ // config comfy paint cannot paint from is refused with no box named.
661
+ const cfg = sub === 'paint' ? loadEarlyConfig(configPath as string, 'paint') : null;
571
662
  const host = resolveHost(v.get('--host'), process.env.COMFY_HOST);
572
663
  const client = new ComfyClient(host, { poll: poll as number, request: 30 });
573
664
  if (sub === 'seethrough') {
@@ -599,12 +690,10 @@ async function cmdComfy(args: string[]): Promise<number> {
599
690
  console.log(` wrote ${join(out, 'layers.json')}, meta.json, parts/ (${r.layers.length} PNG) and previews/ (${r.previews.length} PNG); GPU job ended`);
600
691
  return EXIT_OK;
601
692
  }
602
- const configPath = v.get('--config');
603
- if (configPath === undefined) return usage('comfy paint needs --config <config.json>');
604
- const cfg = loadConfig(configPath);
693
+ if (cfg === null) return usage('comfy paint needs --config <config.json>');
605
694
  const seeds = intFlag(v, '--seeds', 1, 1);
606
695
  if (typeof seeds === 'string') return usage(seeds);
607
- const seed0 = intFlag(v, '--seed0', cfg.generation?.seed ?? 0, 0);
696
+ const seed0 = intFlag(v, '--seed0', cfg.generation.seed, 0);
608
697
  if (typeof seed0 === 'string') return usage(seed0);
609
698
  console.log(`spine-parts comfy paint: ${cfg.key} -> ${out}`);
610
699
  console.log(` ${seeds} seed(s) from ${seed0}${v.has('--seed0') ? '' : ' (generation.seed)'}; wait <= ${wait} s per seed, timeout ${timeout} s`);
@@ -623,7 +712,7 @@ function cmdInputs(args: string[]): number {
623
712
  const out = f.get('--out') as string;
624
713
  try {
625
714
  if (!existsSync(source)) throw new PartsError([{ code: 'INPUTS_SOURCE_PRESENT', object: source, detail: 'no such file; the painting is required' }]);
626
- const cfg = loadEarlyConfig(f.get('--config') as string);
715
+ const cfg = loadEarlyConfig(f.get('--config') as string, 'layers');
627
716
  const painting = readPng(source);
628
717
  const r = makeInputs(painting, cfg, source);
629
718
  mkdirSync(out, { recursive: true });
@@ -645,7 +734,7 @@ function cmdInputs(args: string[]): number {
645
734
  function cmdBuild(args: string[]): number {
646
735
  const flags = new Map<string, string>();
647
736
  let loop = false;
648
- const valued = ['--config', '--source', '--full', '--head', '--out', '--seam'];
737
+ const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project'];
649
738
  for (let i = 0; i < args.length; i++) {
650
739
  const flag = args[i];
651
740
  if (flag === '--loop') {
@@ -663,6 +752,8 @@ function cmdBuild(args: string[]): number {
663
752
  for (const f of ['--config', '--source', '--full', '--head', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
664
753
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
665
754
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
755
+ const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
756
+ if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
666
757
  const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
667
758
  let bin: string;
668
759
  try {
@@ -673,7 +764,7 @@ function cmdBuild(args: string[]): number {
673
764
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-build-'));
674
765
  try {
675
766
  const r = build(
676
- { config, source, full, head, out, seam: seam as SeamRule, loop },
767
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop },
677
768
  { rig: rigGateRunner(), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
678
769
  console.log,
679
770
  );