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 +57 -25
- package/cli.ts +150 -59
- package/docs/AUTHORING.md +392 -36
- package/package.json +1 -1
- package/skills/spine-parts/SKILL.md +34 -8
- package/src/apng.ts +160 -7
- package/src/assemble.ts +545 -31
- package/src/build.ts +97 -27
- package/src/check.ts +412 -25
- package/src/comfy/painting.ts +3 -3
- package/src/config.ts +289 -48
- package/src/gif.ts +15 -127
- package/src/instruments.ts +507 -0
- package/src/layers.ts +169 -0
- package/src/motion.ts +4 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +215 -9
- package/src/propose.ts +485 -48
- package/src/rig.ts +80 -3
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.
|
|
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
|
|
46
|
-
12 fps, 1,
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
212
|
-
| `loop --frames <dir> --out <file.gif \| file.png
|
|
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
|
-
|
|
234
|
-
|
|
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`
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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")
|
|
99
|
-
|
|
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.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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), .
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
136
|
-
|
|
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]
|
|
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
|
|
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.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 === '.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
);
|