@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
package/cli.ts ADDED
@@ -0,0 +1,1240 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * rig-parts — one painting and its See-through layers in, Spine-ready parts
4
+ * and rig specs out, verified through rig-c.
5
+ *
6
+ * Every command prints named, numeric findings and nothing else an agent has
7
+ * to interpret: a refusal is a `FAIL` line naming the rule, the object, the
8
+ * value found and the value required. Exit codes are part of that interface:
9
+ *
10
+ * 0 the command did what it says
11
+ * 1 it refused its input — every refusal is printed as a FAIL line
12
+ * 2 a usage error, or a command this version does not implement
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { basename, dirname, extname, join } from 'node:path';
18
+ import { DEFAULT_PROJECT_RULE, DEFAULT_SEAM_RULE, HOLES_LISTED, PROJECT_RULES, type ProjectRule, proposeFields, proposePlan, SEAM_RULES, type SeamRule } from './src/assemble.ts';
19
+ import { assembleStage, build, checkStage, ERROR_MAP_FILE, loopStage, readRuns, readSource, rigStage } from './src/build.ts';
20
+ import { ACCEPTABLE_BARS, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, findRigc, PACK_SHAPES, type PackShape, PAGE_EDGES, type PageEdges, PARTS_HOME_SENTENCE, RIGC_GEOMETRY_VERSION, type RigcRunner, NO_PARTS_SENTENCE, SEAM_MEAN_BAR, SOURCE_SENTENCE, SEAM_PX_BAR, SEAM_PX_LEVEL, SPINEBOY_YARDSTICK, TEXTURE_STRETCH_CEILING } from './src/check.ts';
21
+ import { ComfyClient, resolveHost, runPainting, runSeeThrough } from './src/comfy/index.ts';
22
+ import { type CharacterConfig, loadConfig, loadEarlyConfig, loadPaintConfig } from './src/config.ts';
23
+ import { PartsError, problemLine } from './src/errors.ts';
24
+ import { proposeHeadBox } from './src/headbox.ts';
25
+ import { makeInputs } from './src/inputs.ts';
26
+ import { idleKeysOf, isIdleKeys } from './src/rig.ts';
27
+ import { figuresPhrase, implausibleRules, layerFigures, type LayerSet, pct, readLayers, ruleSummary, times } from './src/layers.ts';
28
+ import { checkImageSize, choosePerson, KEYPOINTS_SPEC, loadKeypoints, type RigJoints, toRigJoints } from './src/keypoints.ts';
29
+ import { basisLines, checkProposal, compare, compareLines, drawLandmarks, HIP_MIN_FRACTION, lint, lintLine, type PartSet, proposeWithBasis, readPartSet, serializeProposal, WRIST_ON_PART_PX } from './src/propose.ts';
30
+ import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
31
+ import { readPng, writePng } from './src/raster/png.ts';
32
+ import { REQUIREMENTS_SENTENCE } from './src/requirements.ts';
33
+ import { composeStage, IMAGE_SEP, PLATE, PROVENANCES, SCENE_REPORT_FILE, SCENE_REPORT_SPEC, SCENE_SPEC } from './src/scene.ts';
34
+ import { buildSheet, defaultCaption, type Tile, tilesFrom } from './src/sheet.ts';
35
+ import { loadComparison, requiredProblems, structureLines } from './src/structure.ts';
36
+
37
+ const EXIT_OK = 0;
38
+ const EXIT_REFUSED = 1;
39
+ const EXIT_USAGE = 2;
40
+
41
+ function version(): string {
42
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, 'package.json'), 'utf8')) as { version?: string };
43
+ return pkg.version ?? '(no version field)';
44
+ }
45
+
46
+ /**
47
+ * The commands a later version will carry, registered now so the surface and
48
+ * the help are honest about what exists. Each one exits 2 with the same
49
+ * sentence, and the selftest holds every name here to that. Empty in this
50
+ * version — every command is real — and kept so the next one has a place.
51
+ */
52
+ const LATER: ReadonlyArray<[string, string]> = [];
53
+
54
+ const HELP = `rig-parts ${version()} — Spine-ready parts from one painting and its See-through layers
55
+
56
+ usage:
57
+ rig-parts layers <dir | layers.json | file.psd>
58
+ Read a See-through decomposition — the ComfyUI wrapper form (a directory
59
+ holding layers.json and one PNG per layer) or an upstream .psd — and print
60
+ every layer: draw order, name, tag group, box, size, opaque pixels, depth,
61
+ and three plausibility figures over its opaque pixels — translucent share
62
+ (alpha below 128), background share (min channel above 235) and area as a
63
+ multiple of the rest of the figure (the other layers' union). A layer that
64
+ crosses a plausibility rule gets a WARN line naming the rule and the bar;
65
+ --propose-plan leaves it out. Refuses, by name, a missing file, an
66
+ unknown tag, a PNG whose size is not its box, and anything else outside
67
+ the input contract — never a WARN.
68
+
69
+ rig-parts sheet --source <painting.png> --layers <path> [--layers <path> ...]
70
+ --out <sheet.png> [--cell <px>] [--cols <n>]
71
+ A contact sheet: the painting, then every layer or part, each on a
72
+ checkerboard and labelled with its name, size and opaque pixel count.
73
+ --layers takes anything \`layers\` reads, or a parts.json (its PNGs are read
74
+ from parts/ beside it). --cell defaults to 220, --cols to 8; both are
75
+ printed. The same information is printed as text.
76
+
77
+ rig-parts propose --parts <dir> --source <painting.png> --out <dir> [--compare <config.json>]
78
+ Propose bones, meshes, regions and an idle from the assembled parts (<dir>
79
+ holds parts.json and parts/). Roles come from each part's See-through tag,
80
+ never its name. Writes <out>/proposal.json (config-shaped: bones, meshes,
81
+ regions, motion with its blink — and a blink.still cut for a lash that
82
+ reaches far above its eyewhite, when a clear row allows one; no blink
83
+ when no part is an eyewhite, and no brows in it when no part is an
84
+ eyebrow, each said in a note; an iris or lash on a side with no
85
+ eyewhite rides head, said in a note — and notes)
86
+ and the overlay to correct against, <out>/render/landmarks.png and
87
+ landmarks_head.png. A figure with no face part gets a face box derived
88
+ from the head run's hair and neck, said in the first note; with no
89
+ head-run hair or neck it is refused (PROPOSE_FACE_PRESENT). Prints every
90
+ note and a LINT line for each chain link that lies off its mesh's art, for a
91
+ hip that is not below the chest, and for a hip above ${HIP_MIN_FRACTION} of the figure's
92
+ height (the shoulders); a headwear/earwear layer with hanging strands
93
+ gets one pendulum chain per strand and a note with each strand's x, rows
94
+ and width ("-- no chain proposed" is the one to act on; AUTHORING §3).
95
+ --compare prints each shared bone's distance, proposal to config, in px.
96
+ It reads bone origins only — not parents, tips, lengths, directions or
97
+ names, so a changed parent or tip scores 0; rig-parts compare reads those.
98
+ After the LINT lines, one "coverage" line per bone says which check read
99
+ it ("checked, clean" or "checked, LINT") or that none did and why ("not
100
+ checked": its role — a control binds nothing and may sit off the art —
101
+ a single bone or an explicit [bone, from, to] segment, which the off-art
102
+ check does not read, a region, a mesh naming no part), and a summary
103
+ counts the three apart with the roles. Writes <out>/${BASIS_FILE} beside
104
+ the proposal (spec "${BASIS_SPEC}"): per proposed bone, what its origin
105
+ and its stated tip rest on — a joint (with its state and score as the
106
+ file declared them), a measurement off a part, a ratio with its
107
+ constants, or other bones — and under --compare whether the config's
108
+ bone differs (origin, tip, parent; how far, in px). No confidence is
109
+ computed. It prints "wrote ${BASIS_FILE} (…)" after the coverage lines.
110
+
111
+ rig-parts propose … [--keypoints <keypoints.json> [--person <id>] [--wrist-snap <px>]]
112
+ Read the figure's pose from one explicit file (spec "${KEYPOINTS_SPEC}"):
113
+ its space (painting-px, origin top-left, y down — another is refused),
114
+ the image's width and height (held to --source's, KEYPOINTS_IMAGE_SIZE),
115
+ its source, and people, each an id and body-18 joints (r and l are the
116
+ subject's sides), each observed with a position, occluded with an
117
+ optional position (the producer's estimate) or missing; an unlisted
118
+ joint is missing. More than one person needs --person (nothing picks
119
+ one). Painting px reach rig px by the overlay's map, x * W/width,
120
+ y * H/height. A joint with a position places its bone as given: neck,
121
+ hip (the midpoint of r_hip and l_hip), the chest half way along neck ->
122
+ hip, and each sleeve chain shoulder -> elbow with its tip at the wrist
123
+ when every handwear part holds a wrist; a missing joint leaves its bone to
124
+ the rule. One note per joint says which. LINT then reads the torso along
125
+ the neck -> hips line and each sleeve chain along shoulder -> wrist
126
+ instead of by screen y, and prints the rule set it used first. Without
127
+ --keypoints nothing changes. A wrist holds a part when it lies within
128
+ ${WRIST_ON_PART_PX} px of its art (the larger of the x and y gaps);
129
+ --wrist-snap <px>, an integer above ${WRIST_ON_PART_PX}, binds a wrist on no
130
+ part to the one nearest part within <px>, said in a "wrist snap:" note
131
+ (with --keypoints, not with --from-config). Without it nothing changes.
132
+
133
+ rig-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
134
+ Draw the config's CURRENT bones instead (<out>/render/landmarks_config.png
135
+ and _head) and LINT them (by the joints too, given --keypoints), with the
136
+ coverage lines after them; no ${BASIS_FILE}, as nothing is proposed.
137
+ Exits 1 when any LINT line is printed.
138
+
139
+ rig-parts propose --head-box --full <dir | layers.json | file.psd> --canvas <W>x<H>
140
+ Propose seethrough.head_box (source px, square) from the full run's
141
+ layers for a painting of WxH px (portrait or landscape: the full run is
142
+ mapped back through the pad inputs added), held inside the painting; a
143
+ shift is printed when one was needed.
144
+
145
+ rig-parts compare --left <file> --right <file> [--map <bonemap.json>]
146
+ Compare two skeletons bone by bone. Each file is a config, a proposal.json
147
+ or a rig.json (rigc-rig/1), told apart by what it states: "spec" (read by
148
+ rig-c's own reader), "key" (the config loader) or "bones". No parts and
149
+ no painting are read. Both sides are brought to one form — rig px, y down;
150
+ a rig.json through its stage (skeleton x, y, width, height), the y flip
151
+ through rig-c's — and each pair prints its origin distance, its
152
+ parent (the same through the pairing; the right's or the left's ancestor
153
+ at depth k, with the bones between that are in no pair; DIFFERENT, naming
154
+ both; or NOT MAPPED), its tip distance, both lengths and their
155
+ difference, and the turn of its direction in degrees (clockwise as
156
+ drawn). A bone of length 0 has no tip: those three say SKIP with the
157
+ reason, never 0. A rig with no stage, or two configs at different
158
+ rig_scale, are not in one frame: origin, tip, the length difference and
159
+ direction say SKIP "frames not related", and parents, required bones and
160
+ roles still run. Then the unmapped bones of each side by name, the
161
+ required bones, and each side's roles read off the spec — target (an ik
162
+ target, a transform source), deforms (a mesh weight or segment, a region),
163
+ control (binds nothing; keyed, or parents others), unclassified (said why)
164
+ — with the control and target counts, reported and never failed.
165
+ With no --map a bone pairs only with the bone of the same name; nothing
166
+ pairs by resemblance. --map is {"spec": "spine-parts-bonemap/1"} with
167
+ optional "pairs" ([[left, right], ...]: the whole pairing), "required"
168
+ (left names that must be present on both sides) and "frame" ({"scale",
169
+ "offset": [x, y]}: right = scale * left + offset, numbers the author
170
+ wrote — nothing is fitted). Exit 1 names each required bone missing
171
+ (STRUCTURE_REQUIRED_PRESENT) or each refusal of a file; every other row is
172
+ a figure and exits 0.
173
+
174
+ rig-parts rig --config <config.json> --parts <dir> --out <dir> [--idle-keys ctl|direct]
175
+ [--page-edges pot|free] [--pack-shape rect|polygon]
176
+ Author the rig: bones at the config's landmarks (a chain makes
177
+ <chain>0..n, each link turned along its chain with its length, so a
178
+ physics constraint added later has a lever; every offset under a turned
179
+ link is in its frame, so nothing moves; a translate key under a turned
180
+ link or a scale/shear key on one is refused, RIG_KEY_FRAME_UNTURNED),
181
+ a square lattice mesh over every part in config.meshes (a contour
182
+ mesh where the entry says contour, with its regions' bones weighted
183
+ by their declared falloff, AUTHORING §3)
184
+ weighted by distance to its candidate bone segments, a region for every
185
+ part in config.regions (a motion.blink.still part as two: the rows above
186
+ its row on a second slot <part>_still, which the blink does not move),
187
+ and one idle of sines and a blink whose closed hold is at least one
188
+ 12 fps frame, so the idle frames and the loop show the eyes shut
189
+ (RIG_BLINK_HOLD_SPANS_A_FRAME otherwise); no blink when the config
190
+ states no motion.blink, and a blink group that names no bone, or one
191
+ bone twice, is refused by the loader, CONFIG_BLINK_GROUP_MEMBERS or
192
+ CONFIG_BLINK_GROUP_UNIQUE, before rigc starts, as are two tracks on one
193
+ bone property, CONFIG_BONE_PROPERTY_KEYED_ONCE. Beside the idle,
194
+ every animation of the file config.motion.animations_from names is
195
+ written into motion.json as read, for rigc's gate to judge (AUTHORING
196
+ §3). --parts is the
197
+ directory holding parts.json and parts/<name>.png. The result is built
198
+ through rig-c (profile spine-html, packed with --page-edges and
199
+ --pack-shape as for check; rigc's build gates the compile and the packed pages on disk) in a
200
+ scratch directory first, and --out receives images/*.png, rig.json,
201
+ motion.json and mesh_report.json only when it is green. Prints one line
202
+ per mesh and the rigc gate lines.
203
+ --idle-keys says where the idle's keys on a bone a mesh is weighted to go:
204
+ ctl (the default) keys a same-origin <bone>_ctl parent instead, which
205
+ passes A15_IDLE_NO_MESH_BONE_KEYS on any rig-c; direct keys the bone
206
+ itself and declares invariants.idleDrivesMeshes in rig.json, which needs
207
+ rig-c 1.3.0 or later and makes A15 a SKIP that prints its cost (the
208
+ stage prints that SKIP line). The pose is the same to one level of float
209
+ rounding, and so is the per-frame mesh work (AUTHORING §5).
210
+ rig-parts check --rig <dir> --out <dir> [--parts <dir>] [--source <painting.png>]
211
+ [--page-edges pot|free] [--pack-shape rect|polygon]
212
+ [--requirements <file.json>] [--seam-pairs] [--keep-stills]
213
+ Build, gate, render and measure a rig through rig-c's CLI (the rigc at
214
+ node_modules/.bin/rigc, or on PATH). --rig holds rig.json and motion.json:
215
+ a rig spec, which rigc build compiles, not a compiled skeleton.json.
216
+ motion.json is required (rigc build takes --motion; "animations": {}
217
+ builds); an "idle" in it is not.
218
+ ${PARTS_HOME_SENTENCE}; ${NO_PARTS_SENTENCE}.
219
+ Both are only read. Into --out:
220
+ build/ (rigc build --profile spine-html --pack --page-edges <value>
221
+ --pack-shape <value>: the packed atlas is the artifact), gate_spine-html.txt (the gate lines
222
+ verbatim), idle_frames/ (rigc render --animation idle --fps 12 --max 640
223
+ --geometry: the frames and geometry.json, the skinned vertices and
224
+ every bone's world transform per frame),
225
+ contact.png, motion_heat.png and check.json (with rigc_entry, the rigc
226
+ entry that gated the build, and pack_mode, the two pack flags). PASS needs every gate summary
227
+ "0 failed", the seam (setup pose vs the flat composite of parts/) at mean
228
+ |d| <= ${SEAM_MEAN_BAR.toFixed(1)} with <= ${SEAM_PX_BAR} rig px over ${SEAM_PX_LEVEL} (the frame pixels over ${SEAM_PX_LEVEL}
229
+ divided by s², s the frame scale the seam: line prints and check.json's
230
+ seam_frame_scale holds, so the bar means the same at every scale), and
231
+ the loop (idle frame 0 vs the frame at t = duration) at max |d| 0. A
232
+ failing seam, or any seam under --seam-pairs, is split by the pair of
233
+ parts each counted pixel lies between: one "seam pair" line each, worst
234
+ first, with its count in frame px, frame and rig boxes and a picture
235
+ under seam/, and check.json's seam_pairs (AUTHORING §7). A seam picture
236
+ is the setup-pose frame with that pair's counted pixels painted magenta
237
+ (255, 0, 255) — magenta = a counted pixel — and a cyan (0, 255, 255)
238
+ box one pixel outside them. --keep-stills leaves the setup-pose still the seam reads,
239
+ rigc's render directory with its frames.json (viewport and background),
240
+ under setup_still/. Then six judgement lines, each in
241
+ check.json and on the console as NAME: PASS|FAIL|SKIP with its figures and
242
+ bars (AUTHORING §7): BREATH_VISIBLE (the topwear moves, the footwear does
243
+ not, each rendered alone), BLINK_NO_HOLE (the setup pose with the blink
244
+ held shut shows no background inside the eyewhite box), CHAIN_LAG (every
245
+ rotate track lags its keyed ancestor and amplitude grows down each
246
+ chain, read off motion.json), TIP_OVER_ROOT (each handwear/bottomwear
247
+ part's lower half travels further than its upper half, measured from
248
+ the idle's posed geometry, so the render size does not move it),
249
+ STILL_REGIONS_DARK (the face and the parts over it the head bone alone
250
+ carries keep their place relative to that bone, and the feet theirs on
251
+ the screen, from the idle's posed geometry up to the arithmetic's own
252
+ rounding; how deep any other slot's art swings into the face is
253
+ reported, with no bar) and
254
+ TEXTURE_STRETCH (every mesh triangle's edges over the idle against their
255
+ rest length, max(ratio, 1/ratio) <= ${TEXTURE_STRETCH_CEILING}; the worst
256
+ triangle is named by slot, triangle, vertices, edge and frame).
257
+ Needs rig-c ${RIGC_GEOMETRY_VERSION} or later, whose render writes
258
+ geometry.json; an older rigc is refused, CHECK_RIGC_VERSION, before
259
+ anything is built.
260
+ Regions come from parts.json's See-through tags; a line with nothing to
261
+ read says SKIP and why — neither a pass nor a failure — and PASS needs
262
+ every line that measured to be PASS. Then RECOMPOSITE_HOLES: REPORTED,
263
+ read from parts.json's recomposite block (uncovered error px, hole count,
264
+ the largest hole's box and the parts bordering it) — a line with no bar,
265
+ never a FAIL, because a pixel no part holds is missing from both sides of
266
+ the seam; SKIP when parts.json has no such block. What a bar cannot
267
+ read it says SKIP for, by name, with the reason (AUTHORING §7, Measuring a
268
+ rig rig-parts did not assemble): without parts.json the seam,
269
+ BREATH_VISIBLE, BLINK_NO_HOLE, TIP_OVER_ROOT, STILL_REGIONS_DARK and
270
+ RECOMPOSITE_HOLES; without an idle the loop and every line that reads idle
271
+ frames, and no idle is rendered. The gate always runs. The last line says
272
+ how many of the nine bars measured and names the skipped ones.
273
+ --source adds SETUP_POSE_VS_SOURCE: REPORTED, never a FAIL — the setup
274
+ pose against the painting by assemble's own recomposite figures (mean |d|,
275
+ % within 8, error px over 40, uncovered error px where the pose has alpha
276
+ 128 or less, holes). ${SOURCE_SENTENCE}; a painting that is not this
277
+ rig's is refused, CHECK_SOURCE_SIZE, before anything is built. Prints the pack line
278
+ beside the spineboy yardstick (${SPINEBOY_YARDSTICK}), a reference and not
279
+ a bar. Exit 0 on PASS, 1 on FAIL — every FAIL line names the bar, the value
280
+ and the value required.
281
+ --page-edges is handed to rigc verbatim and defaults to ${DEFAULT_PAGE_EDGES}: the
282
+ least-area page the parts need (on the two public examples, rect, 967x1338
283
+ and 479x1166, where pot writes 1024x2048 and 512x2048), at the cost rigc
284
+ states — region attachments sample within 1 LSB of the loose build
285
+ rather than exactly (measured on both examples: every idle frame within
286
+ 1 level of the pot build's; AUTHORING §5, Page edges). pot is a power of two on both edges, for a consumer
287
+ that mipmaps or repeats the page (the atlas declares filter Linear,
288
+ Linear and no repeat). rigc's pack line carries ", page edges free" under
289
+ free; a line that disagrees with the value passed, or a pot page that is
290
+ not a power of two, is refused, CHECK_PACK_PAGE_EDGES.
291
+ --pack-shape is handed to rigc verbatim and defaults to ${DEFAULT_PACK_SHAPE}:
292
+ a region only meshes draw is packed by its emitted hull, so a neighbour
293
+ may sit inside its rectangle where the hull is not (on the two public
294
+ examples, under free, 922x1348 and 477x1151 where rect writes 967x1338
295
+ and 479x1166), at the cost of the same 1-level class as free (measured
296
+ on both examples: every idle frame within 1 level of the rect build's;
297
+ AUTHORING §5, Pack shape). rect keeps every region's cell apart and
298
+ writes the page earlier releases wrote. rigc's pack line ends
299
+ ", shape rect" or ", shape polygon"; a line that disagrees with the
300
+ value passed is refused, CHECK_PACK_SHAPE.
301
+ ${REQUIREMENTS_SENTENCE} (AUTHORING §7, Declared requirements). It is
302
+ read and every name in it resolved against rig.json and motion.json
303
+ before anything is built: a bone, constraint, slot, mesh attachment or
304
+ animation that does not resolve, a missing field or bar, or a follow
305
+ naming a property its constraint does not drive is refused by name
306
+ (REQUIREMENTS_*). Each named animation is rendered once with --geometry
307
+ into requirements/as-declared/<animation>/; scene targets (a bone whose
308
+ parent is the root, placed at a stage point or at stage points at stated
309
+ times) are written onto a throwaway copy, never the rig. Each follow is
310
+ measured from three poses of the same frames — as declared, released
311
+ (that mix forced to 0, its keys removed) and full (forced to 1) — into
312
+ requirements/released/<name>/ and requirements/full/<name>/. One line per
313
+ requirement, NAME: PASS|FAIL|NOT MEASURABLE with its figures, the bar as
314
+ declared and the worst frame, a requirements block in check.json, and a
315
+ summary line naming the kinds not declared. A FAIL is
316
+ CHECK_REQUIREMENT_MET; a NOT MEASURABLE (a tip or an axis on a bone of
317
+ length 0, an aim whose target sits on the origin on every frame, a
318
+ follow no frame of which reaches its least drive) is
319
+ CHECK_REQUIREMENT_MEASURABLE, and the run is not PASS. Without the flag
320
+ nothing is read, written or printed for it.
321
+
322
+ rig-parts loop --frames <dir> --out <file.png | file.gif> [--palette]
323
+ Encode a frame set rigc render wrote (its --out directory, or the set
324
+ directory inside it) as a looping animation: .png writes an APNG
325
+ (acTL/fcTL/fdAT, lossless — the exactness record), .png with --palette an
326
+ indexed APNG (colour type 3, one palette for every frame: 255 median-cut
327
+ colours plus one transparent entry, alpha graded per entry, no dithering,
328
+ filter None — the README-sized file), .gif a GIF89a (the same 255-colour
329
+ median cut, no dithering, LZW, delays rounded so the loop's length is
330
+ exact). The indexed APNG and the GIF print their palette error, per
331
+ channel over every frame. The fps is read from frames.json. When the last
332
+ frame equals frame 0 it is dropped, and the output says so: the loop wraps
333
+ onto frame 0 itself. Animated WebP is not written — it needs a VP8/VP8L
334
+ encoder, out of scope.
335
+
336
+ rig-parts assemble --source <painting.png> --full <dir|psd> --head <dir|psd>
337
+ --config <config.json> --out <dir> [--seam near-white|silhouette]
338
+ [--project core|visible]
339
+ Merge the full-body and head-crop See-through runs into rig-space parts:
340
+ <out>/rig/parts/<name>.png (each cropped to its alpha box), <out>/rig/parts.json,
341
+ <out>/render/recomposite_rig.png and <out>/render/${ERROR_MAP_FILE} (the error
342
+ map: uncovered error px red, covered error px blue, the rest the painting in
343
+ light grey). Reads config.seethrough.head_box and
344
+ .resolution and config.assemble.rig_scale, .plan, .extend_below_crop,
345
+ .patches — extra parts cut from the painting itself over a rig-pixel box
346
+ (alpha "silhouette": the painting's figure inside it; "box": all of it),
347
+ drawn "back", "front" or {"before": <plan part>}, recorded in parts.json
348
+ as from "painting:<name>" and counted 100 % source — and .cuts — a plan
349
+ part's pixels inside a rig-pixel polygon taken into a new part, drawn the
350
+ same way, recorded with the plan part's from — and no rig section:
351
+ bones, meshes, regions and motion need not exist yet, because propose
352
+ drafts them from these parts (AUTHORING §4, the table).
353
+ Prints one line per part, one \`cut:\` line per part a cut split (its
354
+ opaque pixels = what it kept + what each cut took), the seam override
355
+ counts, the \`pixels:\` totals
356
+ (opaque = visible + occluded; taken from the painting; visible but not
357
+ projected), and \`recomposite vs source\` (mean |d| and % within 8 over the
358
+ mean channel; error px: max channel > 40; uncovered: of those, where no
359
+ part has alpha above 128), then the uncovered holes (8-connected) and the
360
+ largest ${HOLES_LISTED}, each \`uncovered hole N: <px> px at x,y wxh (between
361
+ "<part>" <px> px, …)\` — the list parts.json holds under recomposite.
362
+ Writes nothing unless every check passed.
363
+ --seam defaults to ${DEFAULT_SEAM_RULE}. --project says where a layer takes
364
+ the painting's pixel: core (the reference's) erodes every layer's top-most
365
+ opaque area by 5x5 first, so a part a few pixels wide takes none; visible
366
+ erodes only along a rim where a later layer is in front. It defaults to
367
+ ${DEFAULT_PROJECT_RULE}.
368
+
369
+ rig-parts assemble --propose-plan --source <painting.png> --full <dir|psd>
370
+ --head <dir|psd> --config <config.json>
371
+ Print {plan, extend_below_crop, notes} for config.assemble, from the two
372
+ runs. A layer \`layers\` WARNs about (PLAN_LAYER_TRANSLUCENT,
373
+ PLAN_LAYER_BACKGROUND, PLAN_LAYER_OVERSIZED) is not proposed, and a note
374
+ names it, its figures and the rule. Reads only config.seethrough.head_box,
375
+ config.seethrough.resolution and config.assemble.rig_scale — the rest of
376
+ the config need not exist yet.
377
+
378
+ rig-parts inputs --source <painting.png> --config <config.json> --out <dir>
379
+ Cut the two images See-through is fed: <out>/st_input_full.png (the
380
+ painting centred on a white square of its longer side: white left and
381
+ right of a portrait painting, above and below a landscape one) and, when
382
+ the config sets seethrough.head_box, <out>/st_input_head.png (that box,
383
+ cropped at its exact size). Refuses a translucent painting and a head box
384
+ outside the painting, by name.
385
+
386
+ rig-parts comfy seethrough --image <png> --out <dir> [--host <url>]
387
+ [--resolution 1024] [--steps 30] [--seed 42] [--offload] [--lama] [--nf4]
388
+ [--prefix spine_parts] [--wait 1800] [--timeout 3600] [--poll 3]
389
+ Optional. Run See-through on a ComfyUI box with the jtydhr88/ComfyUI-See-through
390
+ wrapper installed, and write the wrapper form \`layers\` reads into <out>
391
+ (absent or empty): layers.json, parts/<tag>.png, meta.json, previews/.
392
+ The host is --host or COMFY_HOST and has no default. Checks every node and
393
+ input against the box's /object_info before uploading, waits for an empty
394
+ queue (at most --wait s), polls /history (at most --timeout s), and
395
+ writes <out> only after the layer reader accepts what came back.
396
+ --lama turns the wrapper's LaMa inpainting on and --nf4 its nf4
397
+ quantisation; both are off unless given, as --offload is.
398
+
399
+ rig-parts comfy paint --config <config.json> --out <dir> [--host <url>]
400
+ [--seeds 1] [--seed0 <generation.seed>] [--wait 1800] [--timeout 3600] [--poll 3]
401
+ Optional. Generate the painting from the config's inline generation block:
402
+ <out>/painting_<seed>.png and painting_<seed>_meta.json (the prompts
403
+ verbatim, checkpoint, LoRAs, sampler, control, elapsed) for --seeds
404
+ seeds from --seed0, and control_<skeleton>.png when generation.control
405
+ is set. The pose words are generation.pose, or the control skeleton's own.
406
+ The config needs only key and generation here; the rest comes later.
407
+
408
+ rig-parts build --config <config.json> --source <painting.png> --full <dir|psd>
409
+ --head <dir|psd> --out <dir> [--seam near-white|silhouette]
410
+ [--project core|visible] [--page-edges pot|free]
411
+ [--pack-shape rect|polygon] [--loop] [--requirements <file.json>]
412
+ [--idle-keys ctl|direct]
413
+ assemble, then rig, then check, in one process, each stage's own lines
414
+ printed under [assemble], [rig] and [check]; the first stage that refuses
415
+ stops the build with its own FAIL lines. The config must already carry
416
+ bones, meshes, regions and motion — propose is not a step of build: run it,
417
+ correct the proposal against its overlay, and write the result into the
418
+ config. Into --out: parts/ + parts.json + recomposite_rig.png +
419
+ ${ERROR_MAP_FILE} (assemble),
420
+ rig/ (rig), check/ (check, with the packed build in check/build/), and with
421
+ --loop idle.png (lossless APNG), idle-indexed.png (indexed APNG) and
422
+ idle.gif from check/idle_frames/, then one loop: line with the three sizes
423
+ and the two palette errors. The paths it writes are cleared first. A
424
+ green build ends with the pack line beside the spineboy yardstick and the
425
+ three artifact paths — skeleton .json, .atlas and the packed page: the
426
+ packed atlas is the result, the loose parts are the intermediate it was
427
+ made from. --seam defaults to ${DEFAULT_SEAM_RULE}, --project to
428
+ ${DEFAULT_PROJECT_RULE} (both as for assemble), --page-edges to ${DEFAULT_PAGE_EDGES} and
429
+ --pack-shape to ${DEFAULT_PACK_SHAPE} (as for check; both packed builds, rig's gate
430
+ and check's artifact, take them). --requirements is forwarded to check
431
+ (as for check); the file's own shape is read before assemble runs, and
432
+ its names resolve against the rig in the check, before it builds.
433
+ --idle-keys is forwarded to the rig stage (as for rig) and defaults to
434
+ ctl; the first line names it when it is direct. A config with a
435
+ two-bone ik over chain links the idle keys needs --idle-keys direct:
436
+ rigc refuses the pair a control splits, and the RIG_RIGC_GREEN line
437
+ names the flag on the command that ran.
438
+
439
+ rig-parts compose --scene <scene.json> --out <dir> [--requirements <file.json>] [--accept <bar>[,<bar>...]]
440
+ Bind several finished characters into one rig. The scene file (spec
441
+ "${SCENE_SPEC}") states the canvas {width, height}, an optional plate
442
+ {image, provenance: ${PROVENANCES.join(' | ')}, note?}, the characters
443
+ [{id, build, offset: [x, y], scale?}] — each build the --out of a green
444
+ \`rig-parts build\` (its check/check.json PASS), each offset a
445
+ translation from its rig px to canvas px, each scale (a positive
446
+ number, 1 when absent) the character's size about its stage's top-left
447
+ corner: every length it carries is scaled as Spine's loader scales a
448
+ skeleton (bones, regions, weight binds, constraint lengths, translate
449
+ keys), and a physics wind or gravity, which no per-character value
450
+ can scale, is refused (SCENE_SCALE_EXACT) — and order: the draw order,
451
+ back to front, of character ids (that character's remaining slots, in
452
+ its own order) and "<id>:<slot>" entries, every slot exactly once.
453
+ Paths are relative to the scene file. Every bone, slot, attachment,
454
+ constraint, track, group and easing of a character is renamed
455
+ "<id>:<name>" under one shared root; its images "<id>${IMAGE_SEP}<file>" (an
456
+ atlas region cannot hold ":"). The offset is added once, to what the
457
+ character places in root's own frame: its first-level bones, and a
458
+ region on a slot root carries or a weight bound to root. The root is
459
+ shared, so a character whose idle keys it, or whose constraint names it,
460
+ is refused. The plate is a region on bone "${PLATE}" under
461
+ root, drawn first, its image at canvas (0, 0). One idle holds every
462
+ character's tracks; an animation beside a character's idle (its
463
+ config's motion.animations_from) rides along as "<id>:<name>",
464
+ prefixed as the idle is and scheduled by nothing. Refused by name, every problem at once: an id
465
+ repeated, empty or holding ":" (or "/", "\\", a leading "."); a build
466
+ missing a file or whose own check is not green; characters built at
467
+ different rig_scale; a character whose parts' boxes, placed, leave the
468
+ canvas; an order entry naming no character or slot; a slot named twice
469
+ or never; a constraint, or a --requirements name, that names a bone of
470
+ another character without its prefix (SCENE_NAME_PREFIXED); a plate not
471
+ of the canvas size; idles of different durations; a key in a build's
472
+ rig or motion compose does not know. The result is gated through
473
+ rig-c as rig gates its own (build --profile spine-html --pack, the
474
+ compile and the packed pages on disk); --out receives rig/ (rig.json,
475
+ motion.json, images/) and ${SCENE_REPORT_FILE} (spec "${SCENE_REPORT_SPEC}":
476
+ every setting, each character's offset, scale, bounds and shift (and what it
477
+ moved),
478
+ the plate's provenance, the declared and the composed order) only when
479
+ it is green; then check runs over rig/ into check/, as on a rig it did
480
+ not build (no parts.json: the seam, BREATH_VISIBLE, BLINK_NO_HOLE,
481
+ TIP_OVER_ROOT, STILL_REGIONS_DARK and RECOMPOSITE_HOLES say SKIP and
482
+ why), --requirements forwarded. Nothing is inferred: not the order, not
483
+ an overlap, not a scale, not a person; the plate's provenance and the
484
+ order are the author's and judged by nothing. --accept (or a
485
+ character's "accept": [...] in the scene file; the two lists are
486
+ joined, the command line's applying to every character) takes a build
487
+ whose own check failed only the bars named — ${ACCEPTABLE_BARS.join(', ')};
488
+ the gate, a declared requirement and an unknown name are refused
489
+ (SCENE_ACCEPT_KNOWN). It is for looking, not shipping: the [compose]
490
+ line and ${SCENE_REPORT_FILE} name what was accepted, and the run exits 1.
491
+ Exit 0 when the check is PASS and nothing was accepted, 1 otherwise.
492
+
493
+ rig-parts --version
494
+ rig-parts --help
495
+
496
+ ${LATER.length === 0 ? '' : `not implemented in this version (each exits 2):\n${LATER.map(([name, what]) => ` ${name.padEnd(9)} ${what}`).join('\n')}\n\n`}exit codes: 0 done, 1 input refused (FAIL lines name every reason), 2 usage or not implemented
497
+ `;
498
+
499
+ function printRefusal(err: unknown): number {
500
+ if (err instanceof PartsError) {
501
+ for (const p of err.problems) console.log(` FAIL ${problemLine(p)}`);
502
+ console.log(`refused: ${err.problems.length} problem(s)`);
503
+ return EXIT_REFUSED;
504
+ }
505
+ throw err;
506
+ }
507
+
508
+ function usage(message: string): number {
509
+ console.log(` FAIL USAGE: ${message}`);
510
+ console.log('rig-parts --help lists the commands');
511
+ return EXIT_USAGE;
512
+ }
513
+
514
+ function fixed(v: number | null): string {
515
+ return v === null ? '-' : v.toFixed(4);
516
+ }
517
+
518
+ function printLayerTable(set: LayerSet): void {
519
+ console.log(`rig-parts layers: ${set.form === 'wrapper' ? 'ComfyUI wrapper form' : 'PSD'}, ${set.source}`);
520
+ console.log(` canvas ${set.canvas.w}x${set.canvas.h}, ${set.layers.length} layer(s), back to front`);
521
+ const figures = layerFigures(set);
522
+ const rows = set.layers.map((l, i) => [
523
+ String(l.drawOrder),
524
+ l.name,
525
+ l.tag.group,
526
+ `${l.left},${l.top}`,
527
+ `${l.right},${l.bottom}`,
528
+ `${l.pixels.width}x${l.pixels.height}`,
529
+ String(l.opaquePx),
530
+ fixed(l.depth),
531
+ pct(figures[i].translucent),
532
+ pct(figures[i].background),
533
+ times(figures[i].areaRatio),
534
+ ]);
535
+ const head = ['order', 'name', 'group', 'left,top', 'right,bottom', 'size', 'opaque_px', 'depth', 'translucent', 'background', 'area'];
536
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
537
+ const line = (cells: string[]): string => ` ${cells.map((c, i) => c.padEnd(widths[i])).join(' ')}`.trimEnd();
538
+ console.log(line(head));
539
+ for (const r of rows) console.log(line(r));
540
+ const painted = set.layers.filter((l) => l.opaquePx > 0).length;
541
+ console.log(` ${set.layers.length} layer(s): ${painted} with opaque pixels, ${set.layers.length - painted} with none`);
542
+ // A reader refuses nothing on plausibility: it says what --propose-plan will leave out, and why.
543
+ let warned = 0;
544
+ for (const f of figures) {
545
+ for (const rule of implausibleRules(f)) {
546
+ console.log(` WARN ${rule}: layer "${f.name}" — ${figuresPhrase(f)}; ${ruleSummary(rule)} is required, so --propose-plan leaves it out`);
547
+ warned++;
548
+ }
549
+ }
550
+ console.log(` ${warned} WARN line(s)`);
551
+ }
552
+
553
+ /**
554
+ * Every flag each command takes: `valued` flags carry a value, `switches` are bare. Each parser reads its row to
555
+ * decide what it accepts, and {@link notTaken} prints the same row in the unknown-flag refusal, so the refusal
556
+ * lists exactly the flags the parser accepts (CL26). `layers` takes one positional path and no flag, so it has no row.
557
+ */
558
+ const TAKES = {
559
+ sheet: { valued: ['--source', '--layers', '--out', '--cell', '--cols'], switches: [] },
560
+ rig: { valued: ['--config', '--parts', '--out', '--idle-keys', '--page-edges', '--pack-shape'], switches: [] },
561
+ propose: { valued: ['--parts', '--source', '--out', '--from-config', '--compare', '--full', '--canvas', '--keypoints', '--person', '--wrist-snap'], switches: ['--head-box'] },
562
+ compare: { valued: ['--left', '--right', '--map'], switches: [] },
563
+ check: { valued: ['--rig', '--out', '--parts', '--source', '--page-edges', '--pack-shape', '--requirements'], switches: ['--seam-pairs', '--keep-stills'] },
564
+ loop: { valued: ['--frames', '--out'], switches: ['--palette'] },
565
+ assemble: { valued: ['--source', '--full', '--head', '--config', '--out', '--seam', '--project'], switches: ['--propose-plan'] },
566
+ 'comfy seethrough': { valued: ['--image', '--out', '--resolution', '--steps', '--seed', '--prefix', '--host', '--wait', '--timeout', '--poll'], switches: ['--offload', '--lama', '--nf4'] },
567
+ 'comfy paint': { valued: ['--config', '--out', '--seeds', '--seed0', '--host', '--wait', '--timeout', '--poll'], switches: [] },
568
+ inputs: { valued: ['--source', '--config', '--out'], switches: [] },
569
+ build: { valued: ['--config', '--source', '--full', '--head', '--out', '--seam', '--project', '--page-edges', '--pack-shape', '--requirements', '--idle-keys'], switches: ['--loop'] },
570
+ compose: { valued: ['--scene', '--out', '--requirements', '--accept'], switches: [] },
571
+ } as const satisfies Record<string, { valued: readonly string[]; switches: readonly string[] }>;
572
+
573
+ type Command = keyof typeof TAKES;
574
+
575
+ /** Whether `command` takes `flag` as a valued flag, read off its {@link TAKES} row. */
576
+ function takesValued(command: Command, flag: string): boolean {
577
+ return (TAKES[command].valued as readonly string[]).includes(flag);
578
+ }
579
+
580
+ /** The unknown-flag refusal: the flag, and every flag the command's {@link TAKES} row holds, valued then bare. */
581
+ function notTaken(command: Command, flag: string): string {
582
+ const row = TAKES[command];
583
+ return `${command} does not take "${flag}"; it takes ${[...row.valued, ...row.switches].join(', ')}`;
584
+ }
585
+
586
+ function cmdLayers(args: string[]): number {
587
+ if (args.length !== 1) return usage(`layers takes one path; got ${args.length}`);
588
+ try {
589
+ printLayerTable(readLayers(args[0]));
590
+ return EXIT_OK;
591
+ } catch (err) {
592
+ return printRefusal(err);
593
+ }
594
+ }
595
+
596
+ function cmdSheet(args: string[]): number {
597
+ const layers: string[] = [];
598
+ let source: string | null = null;
599
+ let out: string | null = null;
600
+ let cell = 220;
601
+ let cols = 8;
602
+ for (let i = 0; i < args.length; i++) {
603
+ const flag = args[i];
604
+ const value = args[i + 1];
605
+ if (!takesValued('sheet', flag)) return usage(notTaken('sheet', flag));
606
+ if (value === undefined) return usage(`${flag} needs a value`);
607
+ i++;
608
+ if (flag === '--source') source = value;
609
+ else if (flag === '--layers') layers.push(value);
610
+ else if (flag === '--out') out = value;
611
+ else {
612
+ const n = Number(value);
613
+ if (!Number.isInteger(n) || n < (flag === '--cell' ? 40 : 1)) {
614
+ return usage(`${flag} ${value} is not an integer of at least ${flag === '--cell' ? 40 : 1}`);
615
+ }
616
+ if (flag === '--cell') cell = n;
617
+ else cols = n;
618
+ }
619
+ }
620
+ if (source === null) return usage('sheet needs --source <painting.png>');
621
+ if (layers.length === 0) return usage('sheet needs at least one --layers <path>');
622
+ if (out === null) return usage('sheet needs --out <sheet.png>');
623
+ if (extname(out).toLowerCase() !== '.png') return usage(`--out ${out} is not a .png; the sheet is written as PNG only`);
624
+ try {
625
+ if (!existsSync(source)) {
626
+ throw new PartsError([{ code: 'SHEET_SOURCE_PRESENT', object: source, detail: 'no such file; the painting is the first tile' }]);
627
+ }
628
+ const src = readPng(source);
629
+ const tiles: Tile[] = [{ name: 'source', image: src, caption: basename(source) }];
630
+ for (const path of layers) tiles.push(...tilesFrom(path));
631
+ const sheet = buildSheet(tiles, cols, cell);
632
+ // The README's loop writes sheets/layers.png into a folder nothing made yet;
633
+ // every other command creates its --out, so this one does too.
634
+ mkdirSync(dirname(out), { recursive: true });
635
+ writePng(out, sheet);
636
+ console.log(`rig-parts sheet: ${out}`);
637
+ console.log(` ${tiles.length} tile(s) in ${cols} column(s) of ${cell} px, sheet ${sheet.width}x${sheet.height}`);
638
+ tiles.forEach((t, i) => console.log(` tile ${String(i).padStart(3)} ${t.name} ${t.caption ?? defaultCaption(t.image)}`));
639
+ return EXIT_OK;
640
+ } catch (err) {
641
+ return printRefusal(err);
642
+ }
643
+ }
644
+
645
+ /** `--page-edges`, or its default; a value rig-c does not take is a usage error naming the two it does. */
646
+ function pageEdgesOf(value: string | undefined): PageEdges | string {
647
+ const edges = value ?? DEFAULT_PAGE_EDGES;
648
+ return (PAGE_EDGES as readonly string[]).includes(edges) ? (edges as PageEdges) : `--page-edges ${edges}; one of ${PAGE_EDGES.join(', ')} is required`;
649
+ }
650
+
651
+ function isPageEdges(v: PageEdges | string): v is PageEdges {
652
+ return (PAGE_EDGES as readonly string[]).includes(v);
653
+ }
654
+
655
+ /** `--pack-shape`, or its default; a value rig-c does not take is a usage error naming the two it does. */
656
+ function packShapeOf(value: string | undefined): PackShape | string {
657
+ const shape = value ?? DEFAULT_PACK_SHAPE;
658
+ return (PACK_SHAPES as readonly string[]).includes(shape) ? (shape as PackShape) : `--pack-shape ${shape}; one of ${PACK_SHAPES.join(', ')} is required`;
659
+ }
660
+
661
+ function isPackShape(v: PackShape | string): v is PackShape {
662
+ return (PACK_SHAPES as readonly string[]).includes(v);
663
+ }
664
+
665
+ function cmdRig(args: string[]): number {
666
+ let config: string | null = null;
667
+ let partsDir: string | null = null;
668
+ let out: string | null = null;
669
+ let idleKeys: string | null = null;
670
+ let pageEdges: string | null = null;
671
+ let packShape: string | null = null;
672
+ for (let i = 0; i < args.length; i++) {
673
+ const flag = args[i];
674
+ const value = args[i + 1];
675
+ if (!takesValued('rig', flag)) return usage(notTaken('rig', flag));
676
+ if (value === undefined) return usage(`${flag} needs a value`);
677
+ i++;
678
+ if (flag === '--config') config = value;
679
+ else if (flag === '--parts') partsDir = value;
680
+ else if (flag === '--idle-keys') {
681
+ if (idleKeys !== null) return usage('--idle-keys is given twice');
682
+ idleKeys = value;
683
+ } else if (flag === '--page-edges') {
684
+ if (pageEdges !== null) return usage('--page-edges is given twice');
685
+ pageEdges = value;
686
+ } else if (flag === '--pack-shape') {
687
+ if (packShape !== null) return usage('--pack-shape is given twice');
688
+ packShape = value;
689
+ } else out = value;
690
+ }
691
+ if (config === null) return usage('rig needs --config <config.json>');
692
+ if (partsDir === null) return usage('rig needs --parts <dir> (the directory holding parts.json and parts/)');
693
+ if (out === null) return usage('rig needs --out <dir>');
694
+ const keys = idleKeysOf(idleKeys ?? undefined);
695
+ if (!isIdleKeys(keys)) return usage(keys);
696
+ const edges = pageEdgesOf(pageEdges ?? undefined);
697
+ if (!isPageEdges(edges)) return usage(edges);
698
+ const shape = packShapeOf(packShape ?? undefined);
699
+ if (!isPackShape(shape)) return usage(shape);
700
+ let bin: string;
701
+ try {
702
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
703
+ } catch (err) {
704
+ return printRefusal(err);
705
+ }
706
+ const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-rig-'));
707
+ try {
708
+ rigStage({ config, parts: partsDir, out, idleKeys: keys, pageEdges: edges, packShape: shape }, rigcRunner(bin), scratch, console.log);
709
+ return EXIT_OK;
710
+ } catch (err) {
711
+ return printRefusal(err);
712
+ } finally {
713
+ rmSync(scratch, { recursive: true, force: true });
714
+ }
715
+ }
716
+
717
+ /**
718
+ * The rigc process, for every stage that runs rigc — `rig`'s gate, `check`, and
719
+ * both inside `build`: the `rigc` binary {@link findRigc} located, spawned as
720
+ * it is, so rig-c's own launcher (`bin/rigc.cjs`) chooses the entry —
721
+ * `cli.ts` and the spine-core round trip where the runtime resolves beside it,
722
+ * `cli_core.ts` and rigc's own validator where it does not. No stage runs a
723
+ * rigc source file by path: under rig-c 2.0, `cli.ts` by path is the
724
+ * spine-core entry whether or not spine-core is there. `src/` is pure and takes
725
+ * the spawn from here.
726
+ */
727
+ function rigcRunner(bin: string): RigcRunner {
728
+ return (args) => {
729
+ const r = spawnSync(bin, [...args], { encoding: 'utf8', maxBuffer: 1 << 28 });
730
+ if (r.error !== undefined) return { status: 127, out: `could not start ${bin}: ${r.error.message}` };
731
+ return { status: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
732
+ };
733
+ }
734
+
735
+ /** The command's valued flags as `--flag value` pairs, each one its {@link TAKES} row holds; `required` must all be given. Its bare switches are taken out by the caller before this reads the rest. A string is a usage error. */
736
+ function flags(args: string[], command: Command, required: readonly string[]): Map<string, string> | string {
737
+ const got = new Map<string, string>();
738
+ for (let i = 0; i < args.length; i += 2) {
739
+ const flag = args[i];
740
+ if (!takesValued(command, flag)) return notTaken(command, flag);
741
+ if (args[i + 1] === undefined) return `${flag} needs a value`;
742
+ if (got.has(flag)) return `${flag} is given twice`;
743
+ got.set(flag, args[i + 1]);
744
+ }
745
+ for (const k of required) if (!got.has(k)) return `${command} needs ${k} <value>`;
746
+ return got;
747
+ }
748
+
749
+ function cmdCheck(args: string[]): number {
750
+ // The two switches check takes (issues #202, #209): they carry no value, so they are taken out before the valued flags are read.
751
+ const seamPairs = args.filter((a) => a === '--seam-pairs').length;
752
+ if (seamPairs > 1) return usage('--seam-pairs is given twice');
753
+ const keepStills = args.filter((a) => a === '--keep-stills').length;
754
+ if (keepStills > 1) return usage('--keep-stills is given twice');
755
+ const f = flags(
756
+ args.filter((a) => a !== '--seam-pairs' && a !== '--keep-stills'),
757
+ 'check',
758
+ ['--rig', '--out'],
759
+ );
760
+ if (typeof f === 'string') return usage(f);
761
+ const rig = f.get('--rig') as string;
762
+ const out = f.get('--out') as string;
763
+ const edges = pageEdgesOf(f.get('--page-edges'));
764
+ if (!isPageEdges(edges)) return usage(edges);
765
+ const shape = packShapeOf(f.get('--pack-shape'));
766
+ if (!isPackShape(shape)) return usage(shape);
767
+ try {
768
+ const bin = findRigc(import.meta.dir, process.env.PATH ?? '');
769
+ const r = checkStage(
770
+ { rig, parts: f.get('--parts'), source: f.get('--source'), out, pageEdges: edges, packShape: shape, requirements: f.get('--requirements'), seamPairs: seamPairs === 1, keepStills: keepStills === 1 },
771
+ rigcRunner(bin),
772
+ bin,
773
+ console.log,
774
+ );
775
+ return r.figures.PASS ? EXIT_OK : EXIT_REFUSED;
776
+ } catch (err) {
777
+ return printRefusal(err);
778
+ }
779
+ }
780
+
781
+ function parseCanvas(v: string): { w: number; h: number } | null {
782
+ const m = /^(\d+)x(\d+)$/.exec(v);
783
+ return m === null ? null : { w: Number(m[1]), h: Number(m[2]) };
784
+ }
785
+
786
+ function printLint(P: PartSet, spec: { bones: CharacterConfig['bones']; meshes: CharacterConfig['meshes']; regions: CharacterConfig['regions']; motion: CharacterConfig['motion']; constraints?: CharacterConfig['constraints'] }, joints?: RigJoints): number {
787
+ const res = lint(P, spec, joints);
788
+ // Under --keypoints only: which rule set read the torso and the chains, and on what basis.
789
+ if (res.basis !== undefined) for (const l of basisLines(res.basis)) console.log(l);
790
+ for (const f of res.findings) console.log(lintLine(f));
791
+ for (const m of res.unknownMeshes) console.log(`note: mesh ${JSON.stringify(m)} names no part in parts.json, so it was not linted`);
792
+ for (const b of res.missingTorsoBones) {
793
+ console.log(
794
+ res.basis?.torso.read === true
795
+ ? `note: no single bone named ${JSON.stringify(b)}, so it was not linted against the torso joints`
796
+ : `note: no single bone named ${JSON.stringify(b)}, so the hip was not linted against the chest${b === 'hip' ? ' or the figure height' : ''}`,
797
+ );
798
+ }
799
+ console.log(`${res.findings.length} LINT line(s) over ${Object.keys(spec.meshes).length - res.unknownMeshes.length} mesh(es) and the hip`);
800
+ // Issue #86: after every line above, which check read each bone, or why none did.
801
+ for (const l of coverageLines(res.coverage)) console.log(l);
802
+ return res.findings.length;
803
+ }
804
+
805
+ function cmdPropose(args: string[]): number {
806
+ const flags = new Map<string, string>();
807
+ let headBox = false;
808
+ for (let i = 0; i < args.length; i++) {
809
+ const flag = args[i];
810
+ if (flag === '--head-box') {
811
+ headBox = true;
812
+ continue;
813
+ }
814
+ if (!takesValued('propose', flag)) return usage(notTaken('propose', flag));
815
+ const value = args[i + 1];
816
+ if (value === undefined) return usage(`${flag} needs a value`);
817
+ if (flags.has(flag)) return usage(`${flag} is given twice`);
818
+ flags.set(flag, value);
819
+ i++;
820
+ }
821
+ try {
822
+ if (headBox) {
823
+ const stray = [...flags.keys()].filter((f) => f !== '--full' && f !== '--canvas');
824
+ if (stray.length > 0) return usage(`--head-box takes --full and --canvas only; got ${stray.join(', ')}`);
825
+ const full = flags.get('--full');
826
+ const canvasArg = flags.get('--canvas');
827
+ if (full === undefined) return usage('--head-box needs --full <dir | layers.json | file.psd>, the full run\'s layers');
828
+ if (canvasArg === undefined) return usage('--head-box needs --canvas <W>x<H>, the painting\'s size in px');
829
+ const canvas = parseCanvas(canvasArg);
830
+ if (canvas === null) return usage(`--canvas ${canvasArg} is not <W>x<H> in integer px`);
831
+ const r = proposeHeadBox(readLayers(full), canvas);
832
+ console.log(`rig-parts propose --head-box: ${full}`);
833
+ console.log(` painting ${canvas.w}x${canvas.h}; head layers ${r.layers.map(([n, c]) => `${n} ${c} px`).join(', ')}`);
834
+ if (r.shift[0] !== 0 || r.shift[1] !== 0) {
835
+ console.log(` clamped: the proposed box [${r.unclamped.join(', ')}] leaves the ${canvas.w}x${canvas.h} painting; shifted by ${r.shift[0]},${r.shift[1]} px at the same size`);
836
+ } else console.log(' inside the painting, no shift');
837
+ console.log(` head_box [${r.head_box.join(', ')}], ${r.head_box[2] - r.head_box[0]}x${r.head_box[3] - r.head_box[1]}`);
838
+ console.log(JSON.stringify({ head_box: r.head_box }));
839
+ return EXIT_OK;
840
+ }
841
+ for (const f of ['--full', '--canvas']) if (flags.has(f)) return usage(`${f} belongs to --head-box`);
842
+ const partsArg = flags.get('--parts');
843
+ const source = flags.get('--source');
844
+ const out = flags.get('--out');
845
+ if (partsArg === undefined) return usage('propose needs --parts <dir>, the directory holding parts.json and parts/');
846
+ if (source === undefined) return usage('propose needs --source <painting.png>, drawn under the overlay');
847
+ if (out === undefined) return usage('propose needs --out <dir>');
848
+ if (flags.has('--from-config') && flags.has('--compare')) return usage('--from-config and --compare are two modes; give one');
849
+ if (flags.has('--person') && !flags.has('--keypoints')) return usage('--person names a person in the --keypoints file; give --keypoints <file>');
850
+ // Issue #199: a declared tolerance for a wrist on no handwear part. Whole px above the built-in one: the distance is
851
+ // counted in whole px, so a fraction would bind what its floor binds, and a value at or under the built-in binds nothing.
852
+ const snapArg = flags.get('--wrist-snap');
853
+ let wristSnap: number | null = null;
854
+ if (snapArg !== undefined) {
855
+ if (!flags.has('--keypoints')) return usage('--wrist-snap binds a wrist joint to a handwear part; give --keypoints <file>');
856
+ if (flags.has('--from-config')) return usage('--wrist-snap binds a wrist while proposing; --from-config proposes nothing');
857
+ if (!/^[0-9]+$/.test(snapArg) || Number(snapArg) <= WRIST_ON_PART_PX) {
858
+ return usage(`--wrist-snap ${snapArg} is not an integer above ${WRIST_ON_PART_PX}, the px within which a wrist already holds a part`);
859
+ }
860
+ wristSnap = Number(snapArg);
861
+ }
862
+ const partsDir = existsSync(partsArg) && statSync(partsArg).isFile() ? dirname(partsArg) : partsArg;
863
+ if (!existsSync(source)) {
864
+ throw new PartsError([{ code: 'PROPOSE_SOURCE_PRESENT', object: source, detail: 'no such file; the painting is drawn under the overlay' }]);
865
+ }
866
+ const P = readPartSet(partsDir);
867
+ const painting = readPng(source);
868
+ // Issue #75: the keypoint file, held to this painting's size and taken to rig px by the overlay's map.
869
+ const kpPath = flags.get('--keypoints');
870
+ let joints: RigJoints | undefined;
871
+ if (kpPath !== undefined) {
872
+ const kp = loadKeypoints(kpPath);
873
+ checkImageSize(kp, painting.width, painting.height, kpPath, source);
874
+ joints = toRigJoints(kp, choosePerson(kp, flags.get('--person'), kpPath), P.W, P.H);
875
+ }
876
+ const render = join(out, 'render');
877
+ const fromConfig = flags.get('--from-config');
878
+ if (fromConfig !== undefined) {
879
+ const cfg = loadConfig(fromConfig);
880
+ const img = drawLandmarks(P, painting, cfg.bones, 'config bones');
881
+ mkdirSync(render, { recursive: true });
882
+ writePng(join(render, 'landmarks_config.png'), img.full);
883
+ if (img.head !== null) writePng(join(render, 'landmarks_config_head.png'), img.head);
884
+ console.log(`wrote ${join(render, 'landmarks_config.png')}${img.head !== null ? ' (+_head)' : ' (no bone named "head", so no head crop)'}`);
885
+ return printLint(P, cfg, joints) > 0 ? EXIT_REFUSED : EXIT_OK;
886
+ }
887
+ const cmpPath = flags.get('--compare');
888
+ const cfg = cmpPath === undefined ? null : loadConfig(cmpPath);
889
+ const { proposal: prop, basis } = proposeWithBasis(P, joints, wristSnap);
890
+ // Emit only after green: a proposal the config loader would refuse is not written, nor a basis record that does not hold to it.
891
+ checkProposal(P, prop);
892
+ const bfile = basisFile(P, prop, basis, cfg === null || cmpPath === undefined ? null : { where: cmpPath, config: cfg });
893
+ const img = drawLandmarks(P, painting, prop.bones, 'PROPOSAL - CORRECT ME');
894
+ mkdirSync(render, { recursive: true });
895
+ writeFileSync(join(out, 'proposal.json'), serializeProposal(prop));
896
+ writeFileSync(join(out, BASIS_FILE), serializeBasis(bfile));
897
+ writePng(join(render, 'landmarks.png'), img.full);
898
+ if (img.head !== null) writePng(join(render, 'landmarks_head.png'), img.head);
899
+ console.log(`wrote proposal.json (${prop.bones.length} bone entries, ${Object.keys(prop.meshes).length} meshes) and render/landmarks.png (+_head) under ${out}`);
900
+ for (const n of prop.notes) console.log(`note: ${n}`);
901
+ printLint(P, prop, joints);
902
+ console.log(basisLine(bfile, out));
903
+ if (cfg !== null) for (const l of compareLines(compare(prop.bones, cfg.bones))) console.log(l);
904
+ return EXIT_OK;
905
+ } catch (err) {
906
+ return printRefusal(err);
907
+ }
908
+ }
909
+
910
+ function cmdCompare(args: string[]): number {
911
+ const flags = new Map<string, string>();
912
+ for (let i = 0; i < args.length; i++) {
913
+ const flag = args[i];
914
+ if (!takesValued('compare', flag)) return usage(notTaken('compare', flag));
915
+ const value = args[i + 1];
916
+ if (value === undefined) return usage(`${flag} needs a value`);
917
+ if (flags.has(flag)) return usage(`${flag} is given twice`);
918
+ flags.set(flag, value);
919
+ i++;
920
+ }
921
+ const left = flags.get('--left');
922
+ const right = flags.get('--right');
923
+ if (left === undefined) return usage('compare needs --left <config.json | proposal.json | rig.json>');
924
+ if (right === undefined) return usage('compare needs --right <config.json | proposal.json | rig.json>');
925
+ const map = flags.get('--map') ?? null;
926
+ try {
927
+ const c = loadComparison(left, right, map);
928
+ console.log(`rig-parts compare: left ${left}, right ${right}${map === null ? ', no map' : `, map ${map}`}`);
929
+ for (const l of structureLines(c)) console.log(l);
930
+ const missing = requiredProblems(c, map ?? 'the map');
931
+ for (const p of missing) console.log(` FAIL ${problemLine(p)}`);
932
+ return missing.length > 0 ? EXIT_REFUSED : EXIT_OK;
933
+ } catch (err) {
934
+ return printRefusal(err);
935
+ }
936
+ }
937
+
938
+ function cmdLoop(args: string[]): number {
939
+ const palettes = args.filter((a) => a === '--palette').length;
940
+ if (palettes > 1) return usage('--palette is given twice');
941
+ const f = flags(
942
+ args.filter((a) => a !== '--palette'),
943
+ 'loop',
944
+ ['--frames', '--out'],
945
+ );
946
+ if (typeof f === 'string') return usage(f);
947
+ const out = f.get('--out') as string;
948
+ const ext = extname(out).toLowerCase();
949
+ if (ext === '.webp') return usage(`--out ${out}: animated WebP needs a VP8/VP8L encoder, which is out of scope; write .png (APNG) or .gif`);
950
+ if (ext !== '.png' && ext !== '.gif') return usage(`--out ${out} is neither .png (APNG) nor .gif`);
951
+ 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`);
952
+ try {
953
+ loopStage(f.get('--frames') as string, out, ext === '.gif' ? 'gif' : palettes === 1 ? 'indexed' : 'apng', console.log);
954
+ return EXIT_OK;
955
+ } catch (err) {
956
+ return printRefusal(err);
957
+ }
958
+ }
959
+
960
+ function cmdAssemble(args: string[]): number {
961
+ const flags = new Map<string, string>();
962
+ let propose = false;
963
+ for (let i = 0; i < args.length; i++) {
964
+ const flag = args[i];
965
+ if (flag === '--propose-plan') {
966
+ propose = true;
967
+ continue;
968
+ }
969
+ if (!takesValued('assemble', flag)) return usage(notTaken('assemble', flag));
970
+ const value = args[i + 1];
971
+ if (value === undefined) return usage(`${flag} needs a value`);
972
+ if (flags.has(flag)) return usage(`${flag} is given twice`);
973
+ flags.set(flag, value);
974
+ i++;
975
+ }
976
+ for (const f of ['--source', '--full', '--head', '--config']) if (!flags.has(f)) return usage(`assemble needs ${f}`);
977
+ 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');
978
+ if (!propose && !flags.has('--out')) return usage('assemble needs --out <dir>');
979
+ const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
980
+ if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
981
+ const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
982
+ if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
983
+ const [source, full, head, config] = ['--source', '--full', '--head', '--config'].map((f) => flags.get(f) as string);
984
+ try {
985
+ if (propose) {
986
+ const src = readSource(source);
987
+ const runs = readRuns(full, head);
988
+ const g = proposeFields(loadEarlyConfig(config, 'layers'));
989
+ const proposal = proposePlan(runs.full, runs.head, { sourceW: src.width, sourceH: src.height, ...g });
990
+ console.log(JSON.stringify(proposal, null, 2));
991
+ return EXIT_OK;
992
+ }
993
+ const out = flags.get('--out') as string;
994
+ assembleStage(
995
+ { source, full, head, config, seam: seam as SeamRule, project: project as ProjectRule },
996
+ { 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) },
997
+ console.log,
998
+ );
999
+ return EXIT_OK;
1000
+ } catch (err) {
1001
+ return printRefusal(err);
1002
+ }
1003
+ }
1004
+
1005
+ /** Parse `--flag value` pairs and bare `--switch`es, as the command's {@link TAKES} row names them; a string is a usage error. */
1006
+ function comfyFlags(args: string[], command: 'comfy seethrough' | 'comfy paint'): { v: Map<string, string>; on: Set<string> } | string {
1007
+ const switches: readonly string[] = TAKES[command].switches;
1008
+ const v = new Map<string, string>();
1009
+ const on = new Set<string>();
1010
+ for (let i = 0; i < args.length; i++) {
1011
+ const flag = args[i];
1012
+ if (switches.includes(flag)) {
1013
+ if (on.has(flag)) return `${flag} is given twice`;
1014
+ on.add(flag);
1015
+ continue;
1016
+ }
1017
+ if (!takesValued(command, flag)) return notTaken(command, flag);
1018
+ const value = args[i + 1];
1019
+ if (value === undefined) return `${flag} needs a value`;
1020
+ if (v.has(flag)) return `${flag} is given twice`;
1021
+ v.set(flag, value);
1022
+ i++;
1023
+ }
1024
+ return { v, on };
1025
+ }
1026
+
1027
+ /** An integer flag at or above `min`, or its stated default; a string is a usage error. */
1028
+ function intFlag(v: Map<string, string>, flag: string, fallback: number, min: number): number | string {
1029
+ const raw = v.get(flag);
1030
+ if (raw === undefined) return fallback;
1031
+ const n = Number(raw);
1032
+ return Number.isInteger(n) && n >= min ? n : `${flag} ${raw} is not an integer of at least ${min}`;
1033
+ }
1034
+
1035
+ /** A seconds flag above 0, or its stated default. */
1036
+ function secondsFlag(v: Map<string, string>, flag: string, fallback: number): number | string {
1037
+ const raw = v.get(flag);
1038
+ if (raw === undefined) return fallback;
1039
+ const n = Number(raw);
1040
+ return Number.isFinite(n) && n > 0 ? n : `${flag} ${raw} is not a number of seconds above 0`;
1041
+ }
1042
+
1043
+ async function cmdComfy(args: string[]): Promise<number> {
1044
+ const [sub, ...rest] = args;
1045
+ if (sub !== 'seethrough' && sub !== 'paint') return usage(`comfy takes seethrough or paint; got ${sub === undefined ? 'nothing' : `"${sub}"`}`);
1046
+ const f = comfyFlags(rest, `comfy ${sub}`);
1047
+ if (typeof f === 'string') return usage(f);
1048
+ const { v, on } = f;
1049
+ const wait = secondsFlag(v, '--wait', 1800);
1050
+ const timeout = secondsFlag(v, '--timeout', 3600);
1051
+ const poll = secondsFlag(v, '--poll', 3);
1052
+ for (const x of [wait, timeout, poll]) if (typeof x === 'string') return usage(x);
1053
+ const out = v.get('--out');
1054
+ if (out === undefined) return usage(`comfy ${sub} needs --out <dir>`);
1055
+ const configPath = v.get('--config');
1056
+ if (sub === 'paint' && configPath === undefined) return usage('comfy paint needs --config <config.json>');
1057
+ try {
1058
+ // The config is a local file, so it is answered before any host is: a
1059
+ // config comfy paint cannot paint from is refused with no box named.
1060
+ const paint = sub === 'paint' ? loadPaintConfig(configPath as string) : null;
1061
+ const cfg = paint === null ? null : paint.config;
1062
+ const host = resolveHost(v.get('--host'), process.env.COMFY_HOST);
1063
+ const client = new ComfyClient(host, { poll: poll as number, request: 30 });
1064
+ if (sub === 'seethrough') {
1065
+ const image = v.get('--image');
1066
+ if (image === undefined) return usage('comfy seethrough needs --image <png>');
1067
+ const resolution = intFlag(v, '--resolution', 1024, 64);
1068
+ const steps = intFlag(v, '--steps', 30, 1);
1069
+ const seed = intFlag(v, '--seed', 42, 0);
1070
+ for (const x of [resolution, steps, seed]) if (typeof x === 'string') return usage(x);
1071
+ const prefix = v.get('--prefix') ?? 'spine_parts';
1072
+ if (!/^[A-Za-z0-9_-]+$/.test(prefix)) return usage(`--prefix ${prefix} is not letters, digits, "_" and "-"`);
1073
+ const run = {
1074
+ image,
1075
+ out,
1076
+ prefix,
1077
+ resolution: resolution as number,
1078
+ steps: steps as number,
1079
+ seed: seed as number,
1080
+ offload: on.has('--offload'),
1081
+ lama: on.has('--lama'),
1082
+ quant: on.has('--nf4') ? ('nf4' as const) : ('none' as const),
1083
+ wait: wait as number,
1084
+ timeout: timeout as number,
1085
+ };
1086
+ console.log(`rig-parts comfy seethrough: ${image} -> ${out}`);
1087
+ console.log(` resolution ${run.resolution}, steps ${run.steps}, seed ${run.seed}, offload ${run.offload}, lama ${run.lama}, quant ${run.quant}; wait <= ${run.wait} s, timeout ${run.timeout} s`);
1088
+ const r = await runSeeThrough(client, run, (l) => console.log(l));
1089
+ console.log(` ${r.layers.length} layer(s) on a ${r.canvas[0]}x${r.canvas[1]} canvas, read back green by the layer reader: ${r.layers.join(', ')}`);
1090
+ console.log(` wrote ${join(out, 'layers.json')}, meta.json, parts/ (${r.layers.length} PNG) and previews/ (${r.previews.length} PNG); GPU job ended`);
1091
+ return EXIT_OK;
1092
+ }
1093
+ if (cfg === null) return usage('comfy paint needs --config <config.json>');
1094
+ const seeds = intFlag(v, '--seeds', 1, 1);
1095
+ if (typeof seeds === 'string') return usage(seeds);
1096
+ const seed0 = intFlag(v, '--seed0', cfg.generation.seed, 0);
1097
+ if (typeof seed0 === 'string') return usage(seed0);
1098
+ console.log(`rig-parts comfy paint: ${cfg.key} -> ${out}`);
1099
+ console.log(` ${seeds} seed(s) from ${seed0}${v.has('--seed0') ? '' : ' (generation.seed)'}; wait <= ${wait} s per seed, timeout ${timeout} s`);
1100
+ const done = await runPainting(client, { config: cfg, pose: paint === null ? null : paint.pose, out, seeds, seed0, wait: wait as number, timeout: timeout as number }, (l) => console.log(l));
1101
+ console.log(` wrote ${done.length} painting(s): ${done.map((d) => `${d.file} ${d.size[0]}x${d.size[1]} in ${d.elapsed.toFixed(1)} s`).join(', ')}; GPU job(s) ended`);
1102
+ return EXIT_OK;
1103
+ } catch (err) {
1104
+ return printRefusal(err);
1105
+ }
1106
+ }
1107
+
1108
+ function cmdInputs(args: string[]): number {
1109
+ const f = flags(args, 'inputs', ['--source', '--config', '--out']);
1110
+ if (typeof f === 'string') return usage(f);
1111
+ const source = f.get('--source') as string;
1112
+ const out = f.get('--out') as string;
1113
+ try {
1114
+ if (!existsSync(source)) throw new PartsError([{ code: 'INPUTS_SOURCE_PRESENT', object: source, detail: 'no such file; the painting is required' }]);
1115
+ const cfg = loadEarlyConfig(f.get('--config') as string, 'layers');
1116
+ const painting = readPng(source);
1117
+ const r = makeInputs(painting, cfg, source);
1118
+ mkdirSync(out, { recursive: true });
1119
+ writePng(join(out, 'st_input_full.png'), r.full);
1120
+ console.log(`rig-parts inputs: ${source} (${painting.width}x${painting.height}) -> ${out}`);
1121
+ console.log(
1122
+ r.padTop > 0
1123
+ ? ` st_input_full.png ${r.full.width}x${r.full.height}: the painting at y ${r.padTop}, white above and below (${r.padTop} + ${r.full.height - painting.height - r.padTop} px)`
1124
+ : ` st_input_full.png ${r.full.width}x${r.full.height}: the painting at x ${r.padLeft}, white either side (${r.padLeft} + ${r.full.width - painting.width - r.padLeft} px)`,
1125
+ );
1126
+ if (r.head !== null && r.headBox !== null) {
1127
+ writePng(join(out, 'st_input_head.png'), r.head);
1128
+ console.log(` st_input_head.png ${r.head.width}x${r.head.height}: seethrough.head_box [${r.headBox.join(', ')}]`);
1129
+ } else {
1130
+ console.log(' no seethrough.head_box in the config, so no st_input_head.png: run the full See-through pass, then `propose --head-box`, then this again');
1131
+ }
1132
+ return EXIT_OK;
1133
+ } catch (err) {
1134
+ return printRefusal(err);
1135
+ }
1136
+ }
1137
+
1138
+ function cmdBuild(args: string[]): number {
1139
+ const flags = new Map<string, string>();
1140
+ let loop = false;
1141
+ for (let i = 0; i < args.length; i++) {
1142
+ const flag = args[i];
1143
+ if (flag === '--loop') {
1144
+ if (loop) return usage('--loop is given twice');
1145
+ loop = true;
1146
+ continue;
1147
+ }
1148
+ if (!takesValued('build', flag)) return usage(notTaken('build', flag));
1149
+ const value = args[i + 1];
1150
+ if (value === undefined) return usage(`${flag} needs a value`);
1151
+ if (flags.has(flag)) return usage(`${flag} is given twice`);
1152
+ flags.set(flag, value);
1153
+ i++;
1154
+ }
1155
+ for (const f of ['--config', '--source', '--full', '--head', '--out']) if (!flags.has(f)) return usage(`build needs ${f}`);
1156
+ const seam = flags.get('--seam') ?? DEFAULT_SEAM_RULE;
1157
+ if (!(SEAM_RULES as readonly string[]).includes(seam)) return usage(`--seam ${seam}; one of ${SEAM_RULES.join(', ')} is required`);
1158
+ const project = flags.get('--project') ?? DEFAULT_PROJECT_RULE;
1159
+ if (!(PROJECT_RULES as readonly string[]).includes(project)) return usage(`--project ${project}; one of ${PROJECT_RULES.join(', ')} is required`);
1160
+ const edges = pageEdgesOf(flags.get('--page-edges'));
1161
+ if (!isPageEdges(edges)) return usage(edges);
1162
+ const shape = packShapeOf(flags.get('--pack-shape'));
1163
+ if (!isPackShape(shape)) return usage(shape);
1164
+ const keys = idleKeysOf(flags.get('--idle-keys'));
1165
+ if (!isIdleKeys(keys)) return usage(keys);
1166
+ const [config, source, full, head, out] = ['--config', '--source', '--full', '--head', '--out'].map((f) => flags.get(f) as string);
1167
+ let bin: string;
1168
+ try {
1169
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
1170
+ } catch (err) {
1171
+ return printRefusal(err);
1172
+ }
1173
+ const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-build-'));
1174
+ try {
1175
+ const r = build(
1176
+ { config, source, full, head, out, seam: seam as SeamRule, project: project as ProjectRule, loop, pageEdges: edges, packShape: shape, ...(flags.has('--requirements') ? { requirements: flags.get('--requirements') as string } : {}), idleKeys: keys },
1177
+ { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'rig-gate') },
1178
+ console.log,
1179
+ );
1180
+ return r.stoppedAt === null ? EXIT_OK : EXIT_REFUSED;
1181
+ } finally {
1182
+ rmSync(scratch, { recursive: true, force: true });
1183
+ }
1184
+ }
1185
+
1186
+ function cmdCompose(args: string[]): number {
1187
+ const f = flags(args, 'compose', ['--scene', '--out']);
1188
+ if (typeof f === 'string') return usage(f);
1189
+ let bin: string;
1190
+ try {
1191
+ bin = findRigc(import.meta.dir, process.env.PATH ?? '');
1192
+ } catch (err) {
1193
+ return printRefusal(err);
1194
+ }
1195
+ const scratch = mkdtempSync(join(tmpdir(), 'rig-parts-compose-'));
1196
+ try {
1197
+ const req = f.get('--requirements');
1198
+ const accept = f.get('--accept');
1199
+ const r = composeStage(
1200
+ { scene: f.get('--scene') as string, out: f.get('--out') as string, ...(req === undefined ? {} : { requirements: req }), ...(accept === undefined ? {} : { accept: accept.split(',') }) },
1201
+ { rig: rigcRunner(bin), check: rigcRunner(bin), checkBin: bin, scratch: join(scratch, 'gate') },
1202
+ console.log,
1203
+ );
1204
+ return r.stoppedAt === null ? EXIT_OK : EXIT_REFUSED;
1205
+ } finally {
1206
+ rmSync(scratch, { recursive: true, force: true });
1207
+ }
1208
+ }
1209
+
1210
+ function main(argv: string[]): number | Promise<number> {
1211
+ const [command, ...rest] = argv;
1212
+ if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
1213
+ console.log(HELP);
1214
+ return command === undefined ? EXIT_USAGE : EXIT_OK;
1215
+ }
1216
+ if (command === '--version' || command === '-v') {
1217
+ console.log(version());
1218
+ return EXIT_OK;
1219
+ }
1220
+ if (command === 'layers') return cmdLayers(rest);
1221
+ if (command === 'sheet') return cmdSheet(rest);
1222
+ if (command === 'rig') return cmdRig(rest);
1223
+ if (command === 'propose') return cmdPropose(rest);
1224
+ if (command === 'compare') return cmdCompare(rest);
1225
+ if (command === 'check') return cmdCheck(rest);
1226
+ if (command === 'loop') return cmdLoop(rest);
1227
+ if (command === 'assemble') return cmdAssemble(rest);
1228
+ if (command === 'inputs') return cmdInputs(rest);
1229
+ if (command === 'comfy') return cmdComfy(rest);
1230
+ if (command === 'build') return cmdBuild(rest);
1231
+ if (command === 'compose') return cmdCompose(rest);
1232
+ const later = LATER.find(([name]) => name === command);
1233
+ if (later !== undefined) {
1234
+ console.log(` FAIL NOT_IMPLEMENTED: \`rig-parts ${command}\` (${later[1]}) is not implemented in this version, ${version()}`);
1235
+ return EXIT_USAGE;
1236
+ }
1237
+ return usage(`unknown command "${command}"`);
1238
+ }
1239
+
1240
+ process.exit(await main(process.argv.slice(2)));