spine-parts 0.1.0 → 0.2.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>
@@ -86,7 +88,7 @@ page** (issue #2):
86
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 |
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
@@ -203,14 +205,14 @@ agent skill.
203
205
  | `layers <dir \| layers.json \| file.psd>` | print every layer of a decomposition: draw order, name, tag group, box, size, opaque pixels, depth |
204
206
  | `sheet --source <png> --layers <path>… --out <png>` | a labelled contact sheet of the painting and every layer or part |
205
207
  | `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 |
208
+ | `assemble --source --full --head --config --out [--seam] [--project]` | merge the two runs into rig-space parts, `parts.json` and the recomposite |
207
209
  | `propose --head-box --full <run> --canvas WxH` | propose `seethrough.head_box` from the full run, held inside the painting |
208
210
  | `propose --parts --source --out [--compare <config>]` | propose bones, meshes, regions and an idle; draw the overlay |
209
211
  | `propose … --from-config <config>` | draw and LINT the config's current bones |
210
212
  | `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 |
213
+ | `check --rig --out [--parts]` | build packed, gate under both profiles, render the idle, measure seam, loop and the five judgement lines |
214
+ | `loop --frames <dir> --out <file.gif \| file.png> [--palette]` | encode a rendered idle as a looping GIF, lossless APNG, or indexed APNG (`--palette`) |
215
+ | `build --config --source --full --head --out [--seam] [--project] [--loop]` | assemble, rig and check in one process, stopping at the first refusal |
214
216
 
215
217
  `spine-parts --help` has every flag. Exit codes: 0 done, 1 input refused (every
216
218
  reason is a FAIL line), 2 a usage error or a command this version does not implement.
@@ -229,9 +231,11 @@ These are limits of the approach, stated so nobody reads more into a green run:
229
231
  that is, is below.
230
232
  - **No success rate is claimed.** Ten characters have been measured stage by stage
231
233
  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
234
+ here and eight private ones, one See-through seed each — and all ten check green
235
+ on the gates, the seam and the loop. The judgement lines `check` added since
236
+ (issue #11) have been measured on the two public examples only. That is an
237
+ existence proof, not a rate.
238
+ - **`loop` writes GIF and APNG (lossless and indexed), not WebP**: an animated WebP needs a VP8/VP8L
235
239
  encoder, which this package does not carry.
236
240
  - **The proposer reads tags, not pictures.** A swinging element painted inside another
237
241
  layer (a sash tail in the skirt), hair that is none of the shapes it knows, and
@@ -239,14 +243,31 @@ These are limits of the approach, stated so nobody reads more into a green run:
239
243
 
240
244
  ### How much of a rig the model painted
241
245
 
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).
246
+ `parts.json` splits each part's opaque pixels (`opaque_px`) into `visible_px` — no
247
+ later layer of its See-through run is opaque in front of them — and `occluded_px`,
248
+ and counts the visible ones whose colour was not taken from the painting
249
+ (`visible_not_projected_px`). On the two public examples, from the totals of their
250
+ `expected/parts.json` (the default `--project core`):
251
+
252
+ | | opaque px | occluded | visible, not projected | taken from the painting |
253
+ | --- | --- | --- | --- | --- |
254
+ | `sample` | 298,632 | 79,143 (26.5 %) | 36,227 (12.1 %) | 183,262 (61.4 %) |
255
+ | `demo` | 767,102 | 202,346 (26.4 %) | 76,801 (10.0 %) | 487,955 (63.6 %) |
256
+
257
+ The occluded share is art the painting does not show — a back-hair layer, a neck, the
258
+ parts of an ear under the hair — and is See-through's synthesis by necessity; it is
259
+ what the decomposition is for. The visible-but-not-projected share is synthesis where
260
+ the painting was there to be taken: a part too thin for the reference rule's 5x5
261
+ eroded core, an anti-aliased fringe below alpha 250, a rim beside a layer in front, a
262
+ pixel refused for drift. `--project visible` keeps the erosion only along a rim with a
263
+ layer in front, which takes that share to 22,476 (7.5 %) on `sample` and 49,672 (6.5 %)
264
+ on `demo` (from the `parts.json` of a `--project visible` build). On the eleven thin
265
+ head parts — brows, lashes, irises, eye whites, ears, mouth — it takes 296 of
266
+ `sample`'s 1,289 visible pixels where `core` takes 110, and 824 of `demo`'s 2,162
267
+ where `core` takes 343. What neither rule takes is the fringe: a per-pixel
268
+ classification of those parts under `visible`, recorded with the change that added
269
+ the flag, puts 813 of `sample`'s 993 still-unprojected pixels and 879 of `demo`'s
270
+ 1,338 below alpha 250.
250
271
 
251
272
  ## Requirements
252
273
 
package/cli.ts CHANGED
@@ -16,7 +16,7 @@ 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';
19
+ import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
20
20
  import { assembleStage, build, checkStage, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
21
21
  import { findRigc, 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';
@@ -103,31 +103,53 @@ usage:
103
103
  contact.png, motion_heat.png and check.json. PASS needs both gates
104
104
  "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
105
105
  |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>
106
+ frame at t = duration) at max |d| 0. Then five judgement lines, each in
107
+ check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
108
+ bars (AUTHORING §7): BREATH_VISIBLE (the topwear moves, the footwear does
109
+ not, each rendered alone), BLINK_NO_HOLE (the setup pose with the blink
110
+ held shut shows no background inside the eyewhite box), CHAIN_LAG (every
111
+ rotate track lags its keyed ancestor and amplitude grows down each
112
+ chain, read off motion.json), TIP_OVER_ROOT (each handwear/bottomwear
113
+ part's lower half travels further than its upper half) and
114
+ STILL_REGIONS_DARK (the heat map over the face outline and the feet).
115
+ Regions come from parts.json's See-through tags; a line with nothing to
116
+ 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
118
+ beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
119
+ a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
120
+ and the value required.
121
+
122
+ spine-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
112
123
  Encode a frame set rigc render wrote (its --out directory, or the set
113
124
  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.
125
+ (acTL/fcTL/fdAT, lossless — the exactness record), .png with --palette an
126
+ indexed APNG (colour type 3, one palette for every frame: 255 median-cut
127
+ colours plus one transparent entry, alpha graded per entry, no dithering,
128
+ filter None — the README-sized file), .gif a GIF89a (the same 255-colour
129
+ median cut, no dithering, LZW, delays rounded so the loop's length is
130
+ exact). The indexed APNG and the GIF print their palette error, per
131
+ channel over every frame. The fps is read from frames.json. When the last
132
+ frame equals frame 0 it is dropped, and the output says so: the loop wraps
133
+ onto frame 0 itself. Animated WebP is not written — it needs a VP8/VP8L
134
+ encoder, out of scope.
119
135
 
120
136
  spine-parts assemble --source <painting.png> --full <dir|psd> --head <dir|psd>
121
137
  --config <config.json> --out <dir> [--seam near-white|silhouette]
138
+ [--project core|visible]
122
139
  Merge the full-body and head-crop See-through runs into rig-space parts:
123
140
  <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json
124
141
  and <out>/render/recomposite_rig.png. Reads config.seethrough.head_box and
125
142
  .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}.
143
+ Prints one line per part, the seam override counts, the \`pixels:\` totals
144
+ (opaque = visible + occluded; taken from the painting; visible but not
145
+ projected), and \`recomposite vs source\` (mean |d| and % within 8 over the
146
+ mean channel; error px: max channel > 40; uncovered: of those, where no
147
+ part has alpha above 128). Writes nothing unless every check passed.
148
+ --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
149
+ the painting's pixel: core (the reference's) erodes every layer's top-most
150
+ opaque area by 5x5 first, so a part a few pixels wide takes none; visible
151
+ erodes only along a rim where a later layer is in front. It defaults to
152
+ ${DEFAULT_PROJECT_RULE}.
131
153
 
132
154
  spine-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
133
155
  --head <dir|psd> --config <config.json>
@@ -162,9 +184,11 @@ usage:
162
184
  verbatim, checkpoint, LoRAs, sampler, control, elapsed) for --seeds
163
185
  seeds from --seed0, and control_<skeleton>.png when generation.control
164
186
  is set. The pose words are generation.pose, or the control skeleton's own.
187
+ The config needs only key and generation here; the rest comes later.
165
188
 
166
189
  spine-parts build --config <config.json> --source <painting.png> --full <dir|psd>
167
- --head <dir|psd> --out <dir> [--seam near-white|silhouette] [--loop]
190
+ --head <dir|psd> --out <dir> [--seam near-white|silhouette]
191
+ [--project core|visible] [--loop]
168
192
  assemble, then rig, then check, in one process, each stage's own lines
169
193
  printed under [assemble], [rig] and [check]; the first stage that refuses
170
194
  stops the build with its own FAIL lines. The config must already carry
@@ -172,11 +196,14 @@ usage:
172
196
  correct the proposal against its overlay, and write the result into the
173
197
  config. Into --out: parts/ + parts.json + recomposite_rig.png (assemble),
174
198
  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}.
199
+ --loop idle.png (lossless APNG), idle-indexed.png (indexed APNG) and
200
+ idle.gif from check/idle_frames/, then one loop: line with the three sizes
201
+ and the two palette errors. The paths it writes are cleared first. A
202
+ green build ends with the pack line beside the spineboy yardstick and the
203
+ three artifact paths — skeleton .json, .atlas and the packed page: the
204
+ packed atlas is the result, the loose parts are the intermediate it was
205
+ made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
206
+ ${DEFAULT_PROJECT_RULE} (both as for assemble).
180
207
 
181
208
  spine-parts --version
182
209
  spine-parts --help
@@ -456,14 +483,21 @@ function cmdPropose(args: string[]): number {
456
483
  }
457
484
 
458
485
  function cmdLoop(args: string[]): number {
459
- const f = flags(args, ['--frames', '--out'], 'loop');
486
+ const palettes = args.filter((a) => a === '--palette').length;
487
+ if (palettes > 1) return usage('--palette is given twice');
488
+ const f = flags(
489
+ args.filter((a) => a !== '--palette'),
490
+ ['--frames', '--out'],
491
+ 'loop',
492
+ );
460
493
  if (typeof f === 'string') return usage(f);
461
494
  const out = f.get('--out') as string;
462
495
  const ext = extname(out).toLowerCase();
463
496
  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
497
  if (ext !== '.png' && ext !== '.gif') return usage(`--out ${out} is neither .png (APNG) nor .gif`);
498
+ 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
499
  try {
466
- loopStage(f.get('--frames') as string, out, ext === '.png' ? 'apng' : 'gif', console.log);
500
+ loopStage(f.get('--frames') as string, out, ext === '.gif' ? 'gif' : palettes === 1 ? 'indexed' : 'apng', console.log);
467
501
  return EXIT_OK;
468
502
  } catch (err) {
469
503
  return printRefusal(err);
@@ -473,7 +507,7 @@ function cmdLoop(args: string[]): number {
473
507
  function cmdAssemble(args: string[]): number {
474
508
  const flags = new Map<string, string>();
475
509
  let propose = false;
476
- const valued = ['--source', '--full', '--head', '--config', '--out', '--seam'];
510
+ const valued = ['--source', '--full', '--head', '--config', '--out', '--seam', '--project'];
477
511
  for (let i = 0; i < args.length; i++) {
478
512
  const flag = args[i];
479
513
  if (flag === '--propose-plan') {
@@ -488,23 +522,25 @@ function cmdAssemble(args: string[]): number {
488
522
  i++;
489
523
  }
490
524
  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');
525
+ 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
526
  if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
493
527
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
494
528
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
529
+ const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
530
+ if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
495
531
  const [source, full, head, config] = ['--source', '--full', '--head', '--config'].map((f) => flags.get(f) as string);
496
532
  try {
497
533
  if (propose) {
498
534
  const src = readSource(source);
499
535
  const runs = readRuns(full, head);
500
- const g = proposeFields(loadEarlyConfig(config));
536
+ const g = proposeFields(loadEarlyConfig(config, 'layers'));
501
537
  const proposal = proposePlan(runs.full, runs.head, { sourceW: src.width, sourceH: src.height, ...g });
502
538
  console.log(JSON.stringify(proposal, null, 2));
503
539
  return EXIT_OK;
504
540
  }
505
541
  const out = flags.get('--out') as string;
506
542
  assembleStage(
507
- { source, full, head, config, seam: seam as SeamRule },
543
+ { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule },
508
544
  { partsJson: join(out, 'rig', 'parts.json'), partsDir: join(out, 'rig', 'parts'), recomposite: join(out, 'render', 'recomposite_rig.png') },
509
545
  console.log,
510
546
  );
@@ -567,7 +603,12 @@ async function cmdComfy(args: string[]): Promise<number> {
567
603
  for (const x of [wait, timeout, poll]) if (typeof x === 'string') return usage(x);
568
604
  const out = v.get('--out');
569
605
  if (out === undefined) return usage(`comfy ${sub} needs --out <dir>`);
606
+ const configPath = v.get('--config');
607
+ if (sub === 'paint' && configPath === undefined) return usage('comfy paint needs --config <config.json>');
570
608
  try {
609
+ // The config is a local file, so it is answered before any host is: a
610
+ // config comfy paint cannot paint from is refused with no box named.
611
+ const cfg = sub === 'paint' ? loadEarlyConfig(configPath as string, 'paint') : null;
571
612
  const host = resolveHost(v.get('--host'), process.env.COMFY_HOST);
572
613
  const client = new ComfyClient(host, { poll: poll as number, request: 30 });
573
614
  if (sub === 'seethrough') {
@@ -599,12 +640,10 @@ async function cmdComfy(args: string[]): Promise<number> {
599
640
  console.log(` wrote ${join(out, 'layers.json')}, meta.json, parts/ (${r.layers.length} PNG) and previews/ (${r.previews.length} PNG); GPU job ended`);
600
641
  return EXIT_OK;
601
642
  }
602
- const configPath = v.get('--config');
603
- if (configPath === undefined) return usage('comfy paint needs --config <config.json>');
604
- const cfg = loadConfig(configPath);
643
+ if (cfg === null) return usage('comfy paint needs --config <config.json>');
605
644
  const seeds = intFlag(v, '--seeds', 1, 1);
606
645
  if (typeof seeds === 'string') return usage(seeds);
607
- const seed0 = intFlag(v, '--seed0', cfg.generation?.seed ?? 0, 0);
646
+ const seed0 = intFlag(v, '--seed0', cfg.generation.seed, 0);
608
647
  if (typeof seed0 === 'string') return usage(seed0);
609
648
  console.log(`spine-parts comfy paint: ${cfg.key} -> ${out}`);
610
649
  console.log(` ${seeds} seed(s) from ${seed0}${v.has('--seed0') ? '' : ' (generation.seed)'}; wait <= ${wait} s per seed, timeout ${timeout} s`);
@@ -623,7 +662,7 @@ function cmdInputs(args: string[]): number {
623
662
  const out = f.get('--out') as string;
624
663
  try {
625
664
  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);
665
+ const cfg = loadEarlyConfig(f.get('--config') as string, 'layers');
627
666
  const painting = readPng(source);
628
667
  const r = makeInputs(painting, cfg, source);
629
668
  mkdirSync(out, { recursive: true });
@@ -645,7 +684,7 @@ function cmdInputs(args: string[]): number {
645
684
  function cmdBuild(args: string[]): number {
646
685
  const flags = new Map<string, string>();
647
686
  let loop = false;
648
- const valued = ['--config', '--source', '--full', '--head', '--out', '--seam'];
687
+ const valued = ['--config', '--source', '--full', '--head', '--out', '--seam', '--project'];
649
688
  for (let i = 0; i < args.length; i++) {
650
689
  const flag = args[i];
651
690
  if (flag === '--loop') {
@@ -663,6 +702,8 @@ function cmdBuild(args: string[]): number {
663
702
  for (const f of ['--config', '--source', '--full', '--head', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
664
703
  const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
665
704
  if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
705
+ const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
706
+ if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
666
707
  const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
667
708
  let bin: string;
668
709
  try {
@@ -673,7 +714,7 @@ function cmdBuild(args: string[]): number {
673
714
  const scratch = mkdtempSync(join(tmpdir(), 'spine-parts-build-'));
674
715
  try {
675
716
  const r = build(
676
- { config, source, full, head, out, seam: seam as SeamRule, loop },
717
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop },
677
718
  { rig: rigGateRunner(), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
678
719
  console.log,
679
720
  );
package/docs/AUTHORING.md CHANGED
@@ -9,7 +9,8 @@ has to change.
9
9
  The worked example throughout is [`examples/sample`](../examples/sample): its
10
10
  `config.json` is a complete, loading config, `proposal.json` is what the proposer
11
11
  wrote for it, and `expected/` is what the reference implementation produced from
12
- the same inputs. `bun run fetch-examples` puts its painting and See-through layers
12
+ the same inputs — except `check.json`, which this port's `build` regenerates since
13
+ the reference has no judgement lines (§7). `bun run fetch-examples` puts its painting and See-through layers
13
14
  into `examples/sample/inputs/`.
14
15
 
15
16
  ## 1. Prerequisites
@@ -90,11 +91,22 @@ swing, hair of another shape, whether an accessory swings — is yours to add, a
90
91
 
91
92
  Two steps are external: the See-through runs (by any route; the optional `comfy seethrough` adapter is only a client for a ComfyUI box). Everything else is this tool.
92
93
 
94
+ 0. **The painting (optional).** `spine-parts comfy paint --config config.json --out inputs --host <url>`
95
+ generates `painting_<seed>.png` on a ComfyUI box; any other route to a painting
96
+ skips this step and leaves `generation` out. At this point the config needs only
97
+ `key` and `generation`: `comfy paint` reads it through the partial loader's `paint`
98
+ door (`parseEarlyConfig(raw, 'paint')`), which requires those two, reads nothing
99
+ else, and refuses unknown and retired keys like the full loader. A config without
100
+ `generation` is refused as `CONFIG_FIELD_PRESENT: config.generation`, naming the
101
+ fields the block holds. Everything else arrives later: `seethrough` and
102
+ `assemble.rig_scale` before step 1, `head_box` from step 4, the plan from step 6,
103
+ and `bones`, `meshes`, `regions` and `motion` from `propose` in step 7.
93
104
  1. **The full image.** `spine-parts inputs --source inputs/painting.png --config config.json --out inputs`
94
105
  writes `st_input_full.png`, the painting centred on a white square as tall as it is.
95
106
  At this point the config needs only `key`, `seethrough` (without `head_box`) and
96
107
  `assemble.rig_scale`: `inputs` and `assemble --propose-plan` read it through the
97
- partial loader (`parseEarlyConfig`), which refuses unknown and retired keys like the
108
+ partial loader's `layers` door (`parseEarlyConfig(raw, 'layers')`), which checks
109
+ `generation` too when it is present, and which refuses unknown and retired keys like the
98
110
  full one and leaves the later sections for later. A landscape or translucent
99
111
  painting is refused (`INPUTS_PAINTING_PORTRAIT`, `INPUTS_PAINTING_OPAQUE`).
100
112
  2. **See-through, full run** (external).
@@ -123,10 +135,24 @@ Two steps are external: the See-through runs (by any route; the optional `comfy
123
135
  | --- | --- | --- | --- |
124
136
  | `layers` | the table | every tag the plan will need has opaque pixels; one `face` in the head run | a run whose layer PNG is not its box's size (`LAYERS_PNG_MATCHES_BBOX`) |
125
137
  | `sheet` of both runs | the tile list (and the sheet, if you can see) | eyes, irises, lashes and brows as left/right pairs in the head run | a head box that cut off an ornament: move `head_box`, re-run the head crop |
126
- | `assemble` | one line per part, then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px` | on the examples: `sample` 0.84 / 98.0 % / 4,512 / 1,185; `demo` 2.39 / 95.8 % / 11,050 / 1,564 (default rule) | `uncovered error px` high: part of the figure is in no layer — a plan entry is missing, or hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason) |
138
+ | `assemble` | one line per part, the `pixels:` totals (opaque = visible + occluded; taken; visible but not projected), then `recomposite vs source: mean \|d\|, within 8, error px > 40, uncovered error px` | on the examples: `sample` 0.84 / 98.0 % / 4,512 / 1,185; `demo` 2.39 / 95.8 % / 11,050 / 1,564 (default rule) | `uncovered error px` high: part of the figure is in no layer — a plan entry is missing, or hair left the head crop sideways (the demo's `hair_back` is taken from the full run for that reason) |
127
139
  | `propose` | `note:` lines, `LINT` lines, `landmarks.png` | no LINT line: every chain link lies on its mesh's art | a link off the art (a bone on the background) — move it onto the layer |
128
140
  | `rig` (inside `build`) | one line per mesh: vertices, triangles, bones, influences, `cover`; then rigc's gate lines | `cover 1.00000` on every mesh, both gates `0 failed` | `RIG_LATTICE_ONE_LOOP`: change that mesh's `grid` |
129
- | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, `check.json` | `check: PASS` | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
141
+ | `check` (inside `build`) | the gate lines verbatim, the pack line, `loop:`, `seam:`, the five judgement lines (§7), `check.json` | `check: PASS`, and a judgement line SKIP only where the character lacks what it reads | `CHECK_SEAM_WITHIN_BAR` or `CHECK_LOOP_CLOSES` (§6) |
142
+ | `loop` (inside `build --loop`, or `loop --frames … --out …`) | the dropped-duplicate line, each file's line, then `loop: idle.png N B (lossless); idle-indexed.png N B (max …, mean …); idle.gif N B (max …, mean …)` | `f0048.png equals f0000.png byte for byte, so it is dropped` | `LOOP_ENCODE` (§6) |
143
+
144
+ `loop` writes three files from one frame set, and they are not interchangeable.
145
+ `idle.png` (`loop --out x.png`) is the lossless APNG: every frame decodes to the
146
+ rendered frame byte for byte, so it is the exactness record. `idle-indexed.png`
147
+ (`loop --out x.png --palette`) is an indexed APNG — colour type 3, one palette for
148
+ every frame (255 median-cut colours and one transparent entry, alpha graded per
149
+ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
150
+ (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
151
+ print their palette error, per channel over R, G and B of every frame (and alpha's
152
+ max for the APNG); a figure is a measurement of the file, not a bar. On the demo:
153
+ 13,645,611 B lossless, 1,706,468 B indexed and 1,812,041 B GIF, both palette files at
154
+ max 57, mean 1.601 — the two share one quantiser, so their error is the same by
155
+ construction and the size is the difference.
130
156
 
131
157
  `--seam near-white` (the default) is the reference implementation's rule: where the
132
158
  flat stack of parts differs from the painting by more than 60, the top part takes the
@@ -136,6 +162,38 @@ outside the figure. On the demo it lowered recomposite error pixels from 11,050
136
162
  9,540 and changed no check bar; on the sample it changed nothing. The default stays
137
163
  the reference's so the examples stay comparable with it.
138
164
 
165
+ `--project core` (the default) is the reference's projection rule: a layer takes the
166
+ painting's pixel only inside its top-most `alpha >= 250` area eroded by a 5x5 square,
167
+ so a part a few pixels wide (a lash, a brow, an iris) takes none. `--project visible`
168
+ keeps the erosion only along a rim where a later layer of the run is in front, and
169
+ takes every other top-most `alpha >= 250` pixel; neither rule takes a fringe pixel
170
+ (alpha below 250). On the examples it lowered the pixels that are visible but not
171
+ projected from 36,227 to 22,476 (`sample`) and 76,801 to 49,672 (`demo`), recomposite
172
+ error pixels from 4,512 to 4,116 and 11,050 to 9,820, and the check seam from 0.207 to
173
+ 0.206 and 0.326 to 0.325, with no other check figure changed. The default stays the
174
+ reference's so the examples stay comparable with it. `build` takes both flags.
175
+
176
+ `parts.json` holds one record per part, in plan order. Its counts:
177
+
178
+ | field | counts |
179
+ | --- | --- |
180
+ | `opaque_px` | the part PNG's pixels with alpha above 8 |
181
+ | `visible_px` | of those, the ones no later layer of their See-through run is opaque (alpha >= 250) in front of — a pixel copied in below the head crop is judged in its extend layer's run |
182
+ | `occluded_px` | the rest of `opaque_px`: art the painting does not show, See-through's synthesis by necessity |
183
+ | `projected_core_px` | the projection rule's candidates: top-most `alpha >= 250`, eroded (`core`) or kept off a front rim (`visible`) |
184
+ | `source_px_taken` | of those, the ones that took the painting's pixel (the reference's count, before any merge) |
185
+ | `visible_not_projected_px` | visible pixels whose colour did not come from projection — too thin for the core, a fringe, a rim, refused for drift, or a merge ring |
186
+ | `refused_drift_px` | candidates refused because See-through's pixel and the painting's differ by more than 90 |
187
+ | `merged_px` | pixels brought in below the head crop, and the ring that closes their seam |
188
+ | `seam_override_px` | pixels the seam pass recoloured to the painting |
189
+
190
+ `visible_px + occluded_px = opaque_px`, and every projected pixel is visible; the stage
191
+ refuses (`ASSEMBLE_COUNTS_ADD_UP`) rather than write counts that break either, and the
192
+ reader refuses a record that breaks them (`PARTS_COUNTS_ADD_UP`). The three visibility
193
+ counts are this port's: a `parts.json` the reference wrote has none of them and still
194
+ reads, but a record with only some of them is refused. The `pixels:` line after the
195
+ per-part lines prints their totals.
196
+
139
197
  ## 6. Refusals: the rule, and what has to change
140
198
 
141
199
  Every refusal is `FAIL RULE: object — detail`. Inside `build` it is printed under the
@@ -184,7 +242,7 @@ stage's prefix (`[assemble] FAIL …`), and the build stops there.
184
242
  | `COMFY_QUEUE_EMPTY`, `COMFY_PROMPT_ACCEPTED`, `COMFY_HISTORY_WITHIN`, `COMFY_RUN_OK` | someone else's job kept the queue busy past `--wait`, the box rejected the graph, the job did not finish within `--timeout`, or it ended in error (quoted) | `--wait`/`--timeout`, or what the quoted error names |
185
243
  | `COMFY_HISTORY_OUTPUTS`, `COMFY_VIEW_PRESENT`, `COMFY_MANIFEST_NAMED`, `COMFY_MANIFEST_IS_THIS_RUN`, `COMFY_LAYER_NAME` | the job's outputs are missing, unreadable, belong to another run, or name a layer that is not a plain file name | the wrapper's version on the box; report it |
186
244
  | `COMFY_IMAGE_PRESENT`, `COMFY_OUT_EMPTY`, `COMFY_OUT_FREE` | no `--image`, an `--out` that already holds files, or a seed whose painting is already on disk | `--image`, `--out`, `--seed0` |
187
- | `COMFY_CONFIG_GENERATION`, `COMFY_PAINTING_SIZE` | the config has no `generation` block, or the painting that came back is not twice the latent | `config.generation` |
245
+ | `CONFIG_FIELD_PRESENT` on `config.generation`, `COMFY_PAINTING_SIZE` | `comfy paint` was given a config with no `generation` block (the line names the fields it holds), or the painting that came back is not twice the latent | `config.generation` |
188
246
 
189
247
  ### assemble
190
248
 
@@ -197,6 +255,7 @@ stage's prefix (`[assemble] FAIL …`), and the build stops there.
197
255
  | `ASSEMBLE_RUN_CANVAS` | a run's canvas is not `resolution` square | `seethrough.resolution`, or the run |
198
256
  | `ASSEMBLE_PLAN_TAG_IN_RUN`, `ASSEMBLE_EXTEND_TAG_IN_RUN` | an entry takes a tag its run does not hold (the detail lists what it does hold) | that entry's run or tag |
199
257
  | `ASSEMBLE_PART_OPAQUE` | a part ended with no opaque pixel | drop the entry, or take the tag from the other run |
258
+ | `ASSEMBLE_COUNTS_ADD_UP` | a part's visible and occluded counts do not add up to its opaque pixels, or a projected pixel is not visible — an assembler bug, not an input problem | report it with the part named; nothing was written |
200
259
 
201
260
  ### propose
202
261
 
@@ -206,7 +265,7 @@ stage's prefix (`[assemble] FAIL …`), and the build stops there.
206
265
  | `PROPOSE_SOURCE_PRESENT`, `PROPOSE_PNG_PRESENT`, `PROPOSE_PNG_MATCHES_BOX` | the painting or a part PNG is missing, or a PNG is not its box | `--source`, `--parts` (re-run assemble) |
207
266
  | `PROPOSE_FACE_PRESENT` | no part comes from a `face` layer; every other rule scales by it | `assemble.plan` |
208
267
  | `PROPOSE_ACCESSORY_BODY` | an accessory has nothing above its pendant rows to hang its bone on | that part's plan entry, or author its bones by hand |
209
- | `PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`) | the `parts.json` read is not assemble's contract | re-run assemble; do not edit `parts.json` |
268
+ | `PARTS_*` (`PARTS_FILE_PRESENT`, `PARTS_IS_JSON`, `PARTS_KEY_KNOWN`, `PARTS_FIELD_PRESENT`, `PARTS_FIELD_TYPE`, `PARTS_NAME_UNIQUE`, `PARTS_FROM_KNOWN`, `PARTS_BOX_INSIDE_RIG`, `PARTS_COUNTS_ADD_UP`) | the `parts.json` read is not assemble's contract (`PARTS_COUNTS_ADD_UP`: `visible_px + occluded_px` is not `opaque_px`, or `visible_not_projected_px` is above `visible_px`) | re-run assemble; do not edit `parts.json` |
210
269
 
211
270
  ### rig
212
271
 
@@ -225,13 +284,18 @@ stage's prefix (`[assemble] FAIL …`), and the build stops there.
225
284
  | rule | means | change |
226
285
  | --- | --- | --- |
227
286
  | `CHECK_RIGC_PRESENT` | no `rigc` binary found (every place looked is listed) | `bun install` |
228
- | `CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` | re-run rig (`build` does both) |
287
+ | `CHECK_INPUT_PRESENT`, `CHECK_INPUT_IS_JSON`, `CHECK_PART_PNG_PRESENT`, `CHECK_PART_PNG_MATCHES_BOX`, `CHECK_PART_SLOT_PRESENT`, `CHECK_RIG_STAGE_PRESENT`, `CHECK_RIG_STAGE_IS_THE_CANVAS`, `CHECK_RIG_ROOT_BONE`, `CHECK_IDLE_PRESENT` | the rig directory is incomplete or disagrees with `parts.json` (`CHECK_PART_SLOT_PRESENT`: a part with no slot of its own name, which the judgement lines render it by) | re-run rig (`build` does both) |
229
288
  | `CHECK_RIGC_GREEN` | a rigc step failed; its line is quoted | as `RIG_RIGC_GREEN` |
230
289
  | `CHECK_LOOP_LAST_FRAME_AT_DURATION` | the idle's last frame does not sit at `duration` | `motion.duration` — a whole number of 1/12 s |
231
290
  | `CHECK_LOOP_CLOSES` | frame 0 and the frame at `duration` differ (max and first pixel quoted) | a track whose last key is not its first |
232
291
  | `CHECK_SEAM_WITHIN_BAR` | the setup pose does not reproduce the flat stack of parts | usually a region or mesh placed off its part; compare with `recomposite_rig.png` |
292
+ | `CHECK_BREATH_VISIBLE` | the torso (`topwear`), rendered alone, barely moves over the idle — or the feet (`footwear`), rendered alone, move at all | the chest's breath tracks (`motion.tracks` on `chest`), or the torso mesh's `segments`; for the feet, the bone their region rides (`regions.<part>`, `root` in both examples) |
293
+ | `CHECK_BLINK_NO_HOLE` | with the blink held shut, the eyewhite box shows the page where the open eye had art | the layer under the eye: the `face` part has no art there. Take the face from the other run, or add a part under the eye; `motion.blink.squash` only hides the hole less |
294
+ | `CHECK_CHAIN_LAG` | a rotate track leads (or does not lag) the keyed bone above it, or a chain link swings less than the link above | that chain track's `phase`/`lag` (a positive `lag`, a child `phase` above its parent's) or its `amps` (non-decreasing toward the tip) |
295
+ | `CHECK_TIP_OVER_ROOT` | a `handwear`/`bottomwear` part's lower half travels less than 1.4725 times as far as its upper half | the chain track's `amps` (grow toward the tip), or the mesh's `segments` (the chain must be among them) |
296
+ | `CHECK_STILL_REGIONS_DARK` | the heat map is brighter than the ceiling over the face outline or over the feet | the part that moves there: a mesh weighted to a swinging bone (`segments`), or a region on the wrong bone |
233
297
  | `CHECK_SEAM_FRAME_SIZE`, `FRAMES_SIDECAR` | rigc's render is not what its `frames.json` says | a rigc problem; report it |
234
- | `LOOP_ENCODE` | the loop encoder refused a frame (translucent pixel in a GIF, a size change) | the frames |
298
+ | `LOOP_ENCODE` | the loop encoder refused a frame (translucent pixel in a GIF, a size change) | the frames; for a translucent frame write the lossless or the indexed APNG, which keep alpha |
235
299
  | `BUILD_ARTIFACT_PRESENT` | the packed build lacks its `.json`, `.atlas` or page | a rigc problem; report it |
236
300
 
237
301
  ## 7. The bars `check` enforces, and what only an eye answers today
@@ -249,16 +313,76 @@ implementation's bars):
249
313
  On the examples: `sample` 23/23 and 14/14, seam 0.207 with 0 pixels over 40, loop 0;
250
314
  `demo` (default rule) 23/23 and 14/14, seam 0.326 with 2 pixels over 40, loop 0.
251
315
 
252
- A green check cannot see a wrong animation. Seven judgements still need an eye, and
253
- each is a missing instrument (issue #11), not a question to ask a person:
254
-
255
- - breathing is visible (chest to waist) while the feet stay put;
256
- - one blink, and no hole or colour patch behind the closed eye;
257
- - hair and accessories lag the head (phase delay down each chain);
258
- - sleeve ends, hems and skirt edges swing while their roots barely move;
259
- - at rest, no gap, white rim or doubled line between layers;
260
- - no visible texture stretch (it appears when amplitudes grow);
261
- - `motion_heat.png` is dark where nothing should move (face outline, shoes).
316
+ A green gate cannot see a wrong animation, so `check` also writes five **judgement
317
+ lines** (issue #11), each a key of `check.json` and a console line
318
+ `NAME: PASS|FAIL|SKIP — <figures and bars>`, read the same way as the lines above: a
319
+ FAIL makes `PASS` false and prints its own `FAIL CHECK_<NAME>` line (§6); a SKIP
320
+ says why the rig gave the line nothing to read, and is neither a pass nor a failure —
321
+ report it as not verified. Every region is chosen by the See-through tag in
322
+ `parts.json`'s `from`, never by a part's name. Heat is a pixel's largest per-channel
323
+ change from idle frame 0, in levels of 255, on the idle's 640-pixel grid.
324
+
325
+ Each bar follows one rule: a floor is half the weaker example's figure and a ceiling
326
+ twice the worse one's, so the weaker example clears it by a factor of two; a bar the
327
+ model itself fixes (a part on an unkeyed bone does not move, a lag is above 0, a hole
328
+ is 0 pixels) is that value, not a margin. The figures are this port's, measured on
329
+ the two examples [observed]:
330
+
331
+ | line | measures | bar | `demo` | `sample` | SKIP when |
332
+ | --- | --- | --- | --- | --- | --- |
333
+ | `BREATH_VISIBLE` | `topwear` parts rendered alone (`rigc render --slot`): heat mean over their box; `footwear` parts alone: heat max over theirs | torso mean ≥ 3.809; feet max ≤ 0 | 15.252; 0 | 7.618; 0 | no `topwear` or no `footwear` part |
334
+ | `BLINK_NO_HOLE` | the setup pose with every `scaley` track on the eyewhite slots' bones held at its closed value, against the setup pose, at full size: pixels in the eyewhite box that show the page where the open eye had art | 0 px | 0 | 0 | no `eyewhite` part, or no `scaley` track on its bones goes below its first key |
335
+ | ″ (reported) | the same box: each closed-eye pixel's max-channel distance to the nearest colour the open eye's box holds — max, and pixels over 40 | none | 15; 0 | 11; 0 | as above |
336
+ | `CHAIN_LAG` | `motion.json`'s rotate tracks read as sines (DFT of the keys: period, amplitude, phase) and arranged by the bone tree — a keyed bone's parent is its nearest keyed ancestor | every lag ≥ 0.001 cycle; amplitude non-decreasing down each unbranched chain | lags 0.040 (neck to head) to 0.120; 12 chains | lags 0.040 to 0.100; 9 chains | no rotate track under another of the same period |
337
+ | `TIP_OVER_ROOT` | each `handwear`/`bottomwear` part alone: how far the centroid of its art travels in the lower half of its box against the upper half | ratio ≥ 1.4725 | `bottomwear` 2.945, `sleeves` 3.716 | `bottomwear` 4.396, `sleeves` 12.475 | no such part |
338
+ | `STILL_REGIONS_DARK` | the idle's heat over the face outline (where `face` is the top part of the flat stack, less the boxes of `eyewhite`, `irides`, `eyelash`, `eyebrow` and `mouth`) and over the feet (where `footwear` is on top); max reported | face mean ≤ 33.976; feet mean ≤ 3.244 | 16.988; 1.622 | 11.475; 0 | no `face` and no `footwear` part (one of the two absent leaves that half unmeasured) |
339
+
340
+ What each figure is, and is not:
341
+
342
+ - **The seam is the answer to "at rest, no gap, white rim or doubled line between
343
+ layers".** A gap shows the page, a rim a colour no part has there, a doubled line a
344
+ part drawn off its place; each changes the setup-pose render against the flat stack,
345
+ which is what the seam bar measures. No separate line is written for it.
346
+ - **The blink is measured at the setup pose, not in an idle frame.** On both examples
347
+ the eyes are fully shut from 2.37 s to 2.41 s, and no 12 fps idle frame falls inside
348
+ that window (`idle_frames_closed` is empty: frames 28 and 29 are 2.333 s and
349
+ 2.417 s), so the idle render and the loop encoded from it never show the closed eye.
350
+ rigc's `render` takes no time, so the closed pose is a throwaway animation holding
351
+ the blink tracks' closed value, built and rendered beside the seam's still on the
352
+ same grid — the comparison is then of what the blink alone changed.
353
+ - **The colour-patch figure is not reliable enough for a bar.** "Nearest colour in the
354
+ open eye's box" counts a legitimate colour the open eye never showed (skin under the
355
+ lid) as a patch, and a patch the open eye happened to contain as none. It is
356
+ reported, and 15 and 11 on the examples are what a clean lid looks like.
357
+ - **A lag is read modulo half a cycle.** A sine's sign is half a cycle of phase, and
358
+ the keys cannot tell `amp −0.6, phase 0.2` from `amp 0.6, phase 0.7` — the mirrored
359
+ chains of both examples are written the first way — so the reading folds a step into
360
+ (−¼, ¼] of a cycle and reports amplitudes unsigned. A lag of a quarter cycle or more
361
+ cannot be told from a lead. Tracks whose keys are not a sampled sine are listed as
362
+ `unread`, and a keyed bone under a keyed ancestor of another period (the demo's
363
+ tassel under the head) is listed in `other_period` and not compared.
364
+ - **Tip over root is a centroid, not a displacement.** Art entering or leaving a half
365
+ moves its centroid too. The halves' mean heat was the brief's first choice and was
366
+ rejected: heat is texture times motion, and on the demo's sleeves the lower half's
367
+ mean heat is 1.1 times the upper half's while its centroid travels 3.7 times as far.
368
+ The halves split the box across its rows, which assumes the part hangs — true of a
369
+ front-facing standing figure, the only input this tool takes.
370
+ - **The face ceiling is weak, and the reason is in the examples.** The head rolls, so
371
+ the face outline is lit in both (16.988 and 11.475); twice the worse is a ceiling a
372
+ smooth face sliding two rig pixels stays under (the selftest's fixture measures 24.8
373
+ at that slide and 85.5 at eight). A face-outline instrument that removes the head's
374
+ own motion needs the head bone's world transform per frame, which rigc's `render`
375
+ does not export. The feet half has the same shape on the demo: its long skirt swings
376
+ over the shoes, which lights 1.622.
377
+
378
+ Still only an eye answers, each a missing instrument rather than a question to ask:
379
+
380
+ - **no visible texture stretch** (it appears when amplitudes grow): the measure is a
381
+ mesh triangle's deformed edge length over its rest length, frame by frame, and
382
+ nothing this package runs gives deformed vertices — spine-parts does not link
383
+ `spine-core`, and rigc's `render` exports pixels, not vertices;
384
+ - **the face outline held still in the head's own frame** (above): the heat map
385
+ without the head's roll.
262
386
 
263
387
  ## 8. What one character costs
264
388
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-parts",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "AI-authored Spine 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through spine-rigc before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {