spine-parts 0.2.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
@@ -85,7 +85,7 @@ page** (issue #2):
85
85
  | path under `--out` | what |
86
86
  | --- | --- |
87
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 |
88
- | `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 |
89
89
  | `rig/` | `rig.json` and `motion.json` in spine-rigc's spec, `mesh_report.json`, the padded `images/` |
90
90
  | `check/` | both gate files verbatim, the idle's frames, `contact.png`, `motion_heat.png`, `check.json` |
91
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 |
@@ -168,9 +168,9 @@ than queueing behind someone else's job.
168
168
  ## The loop, for an agent
169
169
 
170
170
  ```sh
171
- 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
172
172
  # See-through on st_input_full.png (external, or `spine-parts comfy seethrough`) -> layers/full
173
- 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
174
174
  spine-parts propose --head-box --full layers/full --canvas 1664x2432
175
175
  # -> seethrough.head_box into config.json
176
176
  spine-parts inputs --source painting.png --config config.json --out inputs # now st_input_head.png too
@@ -179,6 +179,10 @@ spine-parts sheet --source painting.png --layers layers/full --layers layers/hea
179
179
  spine-parts assemble --propose-plan --source painting.png --full layers/full --head layers/head --config config.json
180
180
  # -> assemble.plan and extend_below_crop
181
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
182
186
  spine-parts propose --parts work/rig --source painting.png --out work
183
187
  # -> proposal.json and render/landmarks.png; correct it, copy bones/meshes/regions/motion into config.json
184
188
  spine-parts propose --parts work/rig --source painting.png --out work --from-config config.json
@@ -187,6 +191,11 @@ spine-parts build --config config.json --source painting.png --full layers/full
187
191
  # -> read out/check/check.json; every FAIL line names what has to change
188
192
  ```
189
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
+
190
199
  `propose` is deliberately not a step of `build`: the proposal is a draft to correct
191
200
  against its overlay, and a config with bones is `build`'s input. `rig`, `check` and
192
201
  `loop` are the same stages one at a time. [docs/AUTHORING.md](docs/AUTHORING.md) is
@@ -202,15 +211,15 @@ agent skill.
202
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 |
203
212
  | `comfy paint --config --out [--host]` | optional: generate the painting on a ComfyUI box from the config's `generation` block |
204
213
  | `comfy seethrough --image --out [--host]` | optional: run the ComfyUI See-through wrapper on one image and write the form `layers` reads |
205
- | `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 |
206
215
  | `sheet --source <png> --layers <path>… --out <png>` | a labelled contact sheet of the painting and every layer or part |
207
- | `assemble --propose-plan …` | propose `assemble.plan` and `extend_below_crop` from the two runs |
208
- | `assemble --source --full --head --config --out [--seam] [--project]` | 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 |
209
218
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
210
219
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
211
220
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
212
221
  | `rig --config --parts --out` | author `rig.json` + `motion.json`, written only after spine-rigc's round trip is green |
213
- | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the five judgement lines |
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` |
214
223
  | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
215
224
  | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
216
225
 
@@ -239,7 +248,9 @@ These are limits of the approach, stated so nobody reads more into a green run:
239
248
  encoder, which this package does not carry.
240
249
  - **The proposer reads tags, not pictures.** A swinging element painted inside another
241
250
  layer (a sash tail in the skirt), hair that is none of the shapes it knows, and
242
- 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.
243
254
 
244
255
  ### How much of a rig the model painted
245
256
 
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_PROJECT_RULE, DEFAULT_SEAM_RULE, PROJECT_RULES, type ProjectRule, 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,8 +108,9 @@ 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),
@@ -114,7 +128,11 @@ usage:
114
128
  STILL_REGIONS_DARK (the heat map over the face outline and the feet).
115
129
  Regions come from parts.json's See-through tags; a line with nothing to
116
130
  read says SKIP and why — neither a pass nor a failure — and PASS needs
117
- every line that measured to be PASS. Prints the pack line
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
118
136
  beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
119
137
  a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
120
138
  and the value required.
@@ -137,14 +155,25 @@ usage:
137
155
  --config <config.json> --out <dir> [--seam near-white|silhouette]
138
156
  [--project core|visible]
139
157
  Merge the full-body and head-crop See-through runs into rig-space parts:
140
- <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json
141
- and <out>/render/recomposite_rig.png. Reads config.seethrough.head_box and
142
- .resolution and config.assemble.rig_scale, .plan and .extend_below_crop.
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).
143
169
  Prints one line per part, the seam override counts, the \`pixels:\` totals
144
170
  (opaque = visible + occluded; taken from the painting; visible but not
145
171
  projected), and \`recomposite vs source\` (mean |d| and % within 8 over the
146
172
  mean channel; error px: max channel > 40; uncovered: of those, where no
147
- part has alpha above 128). Writes nothing unless every check passed.
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.
148
177
  --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
149
178
  the painting's pixel: core (the reference's) erodes every layer's top-most
150
179
  opaque area by 5x5 first, so a part a few pixels wide takes none; visible
@@ -154,8 +183,11 @@ usage:
154
183
  spine-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
155
184
  --head <dir|psd> --config <config.json>
156
185
  Print {plan, extend_below_crop, notes} for config.assemble, from the two
157
- runs. Reads only config.seethrough.head_box, config.seethrough.resolution
158
- 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.
159
191
 
160
192
  spine-parts inputs --source <painting.png> --config <config.json> --out <dir>
161
193
  Cut the two images See-through is fed: <out>/st_input_full.png (the
@@ -194,7 +226,8 @@ usage:
194
226
  stops the build with its own FAIL lines. The config must already carry
195
227
  bones, meshes, regions and motion — propose is not a step of build: run it,
196
228
  correct the proposal against its overlay, and write the result into the
197
- 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),
198
231
  rig/ (rig), check/ (check, with the packed build in check/build/), and with
199
232
  --loop idle.png (lossless APNG), idle-indexed.png (indexed APNG) and
200
233
  idle.gif from check/idle_frames/, then one loop: line with the three sizes
@@ -233,7 +266,8 @@ function fixed(v: number | null): string {
233
266
  function printLayerTable(set: LayerSet): void {
234
267
  console.log(`spine-parts layers: ${set.form === 'wrapper' ? 'ComfyUI wrapper form' : 'PSD'}, ${set.source}`);
235
268
  console.log(` canvas ${set.canvas.w}x${set.canvas.h}, ${set.layers.length} layer(s), back to front`);
236
- const rows = set.layers.map((l) => [
269
+ const figures = layerFigures(set);
270
+ const rows = set.layers.map((l, i) => [
237
271
  String(l.drawOrder),
238
272
  l.name,
239
273
  l.tag.group,
@@ -242,14 +276,26 @@ function printLayerTable(set: LayerSet): void {
242
276
  `${l.pixels.width}x${l.pixels.height}`,
243
277
  String(l.opaquePx),
244
278
  fixed(l.depth),
279
+ pct(figures[i].translucent),
280
+ pct(figures[i].background),
281
+ times(figures[i].areaRatio),
245
282
  ]);
246
- 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'];
247
284
  const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
248
285
  const line = (cells: string[]): string => ` ${cells.map((c, i) => c.padEnd(widths[i])).join(' ')}`.trimEnd();
249
286
  console.log(line(head));
250
287
  for (const r of rows) console.log(line(r));
251
288
  const painted = set.layers.filter((l) => l.opaquePx > 0).length;
252
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)`);
253
299
  }
254
300
 
255
301
  function cmdLayers(args: string[]): number {
@@ -298,6 +344,9 @@ function cmdSheet(args: string[]): number {
298
344
  const tiles: Tile[] = [{ name: 'source', image: src, caption: basename(source) }];
299
345
  for (const path of layers) tiles.push(...tilesFrom(path));
300
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 });
301
350
  writePng(out, sheet);
302
351
  console.log(`spine-parts sheet: ${out}`);
303
352
  console.log(` ${tiles.length} tile(s) in ${cols} column(s) of ${cell} px, sheet ${sheet.width}x${sheet.height}`);
@@ -396,7 +445,8 @@ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes:
396
445
  const res = lint(P, spec);
397
446
  for (const f of res.findings) console.log(lintLine(f));
398
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`);
399
- 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`);
400
450
  return res.findings.length;
401
451
  }
402
452
 
@@ -541,7 +591,7 @@ function cmdAssemble(args: string[]): number {
541
591
  const out = flags.get('--out') as string;
542
592
  assembleStage(
543
593
  { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule },
544
- { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png') },
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) },
545
595
  console.log,
546
596
  );
547
597
  return EXIT_OK;