@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/src/layers.ts ADDED
@@ -0,0 +1,598 @@
1
+ /**
2
+ * The input contract: one See-through decomposition as a `LayerSet`.
3
+ *
4
+ * Two readers, one shape. See-through reaches a user by more than one route
5
+ * (README, *See-through routes*), and two of them leave different files:
6
+ *
7
+ * - **the ComfyUI wrapper form** — a `layers.json` manifest beside one RGBA PNG
8
+ * per layer. The manifest is the wrapper's own: `width`/`height` of the
9
+ * canvas and, per layer, `name` (the tag), `filename`, the layer's box as
10
+ * `left`/`top`/`right`/`bottom` in canvas pixels (right and bottom
11
+ * exclusive: **[observed]** every PNG is exactly `(right-left)x(bottom-top)`),
12
+ * and `depth_median`, See-through's per-layer depth, where a LARGER value is
13
+ * further back. `readWrapperLayers` reads it.
14
+ * - **an upstream `.psd`** — one pixel layer per tag, the layer name being the
15
+ * tag and the PSD stacking order being the draw order. `readPsdLayers`
16
+ * reads it. ⚠️ No upstream PSD has been measured by this reader yet: it is
17
+ * written to that stated contract and tested on PSDs the selftest writes, so
18
+ * it refuses anything outside the contract (hidden layers, non-normal
19
+ * blending, opacity, clipping, masks, bit depths other than 8) by name rather
20
+ * than interpreting it.
21
+ *
22
+ * Both return layers in DRAW ORDER, back to front. For the wrapper that is
23
+ * descending `depth_median` with ties kept in manifest order — a stable sort,
24
+ * which is what the reference implementation's `sorted(..., key=-depth)` is —
25
+ * and for a PSD it is the stacking order itself, with `depth` null because a
26
+ * PSD carries none. Every refusal is collected and thrown once, so one run
27
+ * names every missing file.
28
+ */
29
+ import { existsSync, readFileSync, statSync } from 'node:fs';
30
+ import { basename, dirname, join } from 'node:path';
31
+ import { initializeCanvas, type Layer as PsdLayer, readPsd } from 'ag-psd';
32
+ import { type Problem, refuseIfAny } from './errors.ts';
33
+ import { decodePngBytes } from './raster/png.ts';
34
+ import type { Raster } from './raster/types.ts';
35
+ import { acceptedTagNames, readTag, type TagReading } from './tags.ts';
36
+
37
+ /** Alpha strictly above this counts as painted. The reference uses the same cut for its ghost clean-up and its sheets. */
38
+ export const OPAQUE_ALPHA_ABOVE = 8;
39
+ /**
40
+ * A pixel whose min channel is above this is "near-white". The assemble stage's
41
+ * rim and seam guard apply it to the painting (`src/assemble.ts` re-exports it
42
+ * from here); `layerFigures` applies the same test to a layer's own colour, so
43
+ * "background-coloured" means one thing in this repository.
44
+ */
45
+ export const NEAR_WHITE_MIN = 235;
46
+
47
+ export interface Layer {
48
+ /** As the input names it; always a v3 tag, possibly with a `-r`/`-l` side. */
49
+ name: string;
50
+ tag: TagReading;
51
+ /** The file the pixels came from, or null for a PSD layer. */
52
+ file: string | null;
53
+ /** The layer's own pixels, `(right-left)x(bottom-top)`, straight alpha. */
54
+ pixels: Raster;
55
+ left: number;
56
+ top: number;
57
+ right: number;
58
+ bottom: number;
59
+ /** See-through's `depth_median` (larger = further back), or null where the input carries none. */
60
+ depth: number | null;
61
+ /** 0 is drawn first (furthest back). */
62
+ drawOrder: number;
63
+ /** Pixels with alpha above `OPAQUE_ALPHA_ABOVE`. */
64
+ opaquePx: number;
65
+ }
66
+
67
+ export interface LayerSet {
68
+ form: 'wrapper' | 'psd';
69
+ /** The manifest or PSD that was read. */
70
+ source: string;
71
+ canvas: { w: number; h: number };
72
+ /** In draw order, back to front. */
73
+ layers: Layer[];
74
+ }
75
+
76
+ function opaque(r: Raster): number {
77
+ let n = 0;
78
+ for (let i = 3; i < r.data.length; i += 4) if (r.data[i] > OPAQUE_ALPHA_ABOVE) n++;
79
+ return n;
80
+ }
81
+
82
+ function isInt(v: unknown): v is number {
83
+ return typeof v === 'number' && Number.isInteger(v);
84
+ }
85
+
86
+ function describe(v: unknown): string {
87
+ return v === undefined ? 'absent' : JSON.stringify(v);
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // the wrapper form
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const MANIFEST_KEYS = ['prefix', 'timestamp', 'layers', 'width', 'height'] as const;
95
+ const MANIFEST_REQUIRED = ['layers', 'width', 'height'] as const;
96
+ const LAYER_KEYS = ['name', 'filename', 'left', 'top', 'right', 'bottom', 'depth_median', 'depth_filename'] as const;
97
+ const LAYER_REQUIRED = ['name', 'filename', 'left', 'top', 'right', 'bottom', 'depth_median'] as const;
98
+
99
+ /**
100
+ * Read the wrapper form from a directory holding `layers.json`, or from the
101
+ * manifest path itself.
102
+ *
103
+ * A layer's PNG is looked for in the two places the two known layouts put it,
104
+ * and the choice is never silent: `<dir>/<filename>` (the wrapper's own output
105
+ * directory, flat) and `<dir>/parts/<name>.png` (the layout a fetcher that
106
+ * renames by tag writes). One present is used; neither is a refusal naming
107
+ * both; both present with different bytes is a refusal, because picking one
108
+ * would be a guess.
109
+ *
110
+ * ⚠️ An unknown key is refused, in the manifest and in a layer. The wrapper is
111
+ * not this repository's, and a field it adds tomorrow could carry meaning (an
112
+ * order, a scale) that silently ignoring would get wrong — so the reader says
113
+ * which key it does not know and stops.
114
+ */
115
+ export function readWrapperLayers(path: string): LayerSet {
116
+ const manifestPath = existsSync(path) && statSync(path).isDirectory() ? join(path, 'layers.json') : path;
117
+ const dir = dirname(manifestPath);
118
+ const problems: Problem[] = [];
119
+ const refuse = (code: string, object: string, detail: string): void => {
120
+ problems.push({ code, object, detail });
121
+ };
122
+ if (!existsSync(manifestPath)) {
123
+ refuse('LAYERS_MANIFEST_PRESENT', manifestPath, 'no such file; a See-through wrapper output directory holds layers.json');
124
+ refuseIfAny(problems);
125
+ }
126
+ let manifest: unknown;
127
+ try {
128
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
129
+ } catch (err) {
130
+ refuse('LAYERS_MANIFEST_IS_JSON', manifestPath, `does not parse as JSON: ${(err as Error).message}`);
131
+ refuseIfAny(problems);
132
+ }
133
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
134
+ refuse('LAYERS_MANIFEST_IS_JSON', manifestPath, `holds ${describe(manifest)}; an object with width, height and layers is required`);
135
+ refuseIfAny(problems);
136
+ }
137
+ const m = manifest as Record<string, unknown>;
138
+ for (const key of Object.keys(m)) {
139
+ if (!(MANIFEST_KEYS as readonly string[]).includes(key)) {
140
+ refuse('LAYERS_KEY_KNOWN', `${manifestPath} key "${key}"`, `is not a wrapper manifest field; known: ${MANIFEST_KEYS.join(', ')}`);
141
+ }
142
+ }
143
+ for (const key of MANIFEST_REQUIRED) {
144
+ if (!(key in m)) refuse('LAYERS_FIELD_PRESENT', `${manifestPath} field "${key}"`, 'is absent and required');
145
+ }
146
+ const width = m.width;
147
+ const height = m.height;
148
+ if ('width' in m && (!isInt(width) || width < 1)) {
149
+ refuse('LAYERS_FIELD_PRESENT', `${manifestPath} field "width"`, `is ${describe(width)}; a positive integer is required`);
150
+ }
151
+ if ('height' in m && (!isInt(height) || height < 1)) {
152
+ refuse('LAYERS_FIELD_PRESENT', `${manifestPath} field "height"`, `is ${describe(height)}; a positive integer is required`);
153
+ }
154
+ if ('layers' in m && !Array.isArray(m.layers)) {
155
+ refuse('LAYERS_FIELD_PRESENT', `${manifestPath} field "layers"`, `is ${describe(m.layers)}; an array is required`);
156
+ }
157
+ refuseIfAny(problems);
158
+ const W = width as number;
159
+ const H = height as number;
160
+ const entries = m.layers as unknown[];
161
+ if (entries.length === 0) refuse('LAYERS_FIELD_PRESENT', `${manifestPath} field "layers"`, 'is empty; at least one layer is required');
162
+
163
+ const read: Array<Omit<Layer, 'drawOrder'> & { index: number }> = [];
164
+ const seen = new Map<string, number>();
165
+ entries.forEach((entry, index) => {
166
+ const at = `${basename(manifestPath)} layers[${index}]`;
167
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
168
+ refuse('LAYERS_FIELD_PRESENT', at, `is ${describe(entry)}; an object is required`);
169
+ return;
170
+ }
171
+ const e = entry as Record<string, unknown>;
172
+ const label = typeof e.name === 'string' ? `layer "${e.name}"` : at;
173
+ let ok = true;
174
+ // Uniqueness first, before any other problem can end this entry's checks:
175
+ // a duplicate beside an entry that is also malformed is still a duplicate.
176
+ if (typeof e.name === 'string') {
177
+ if (seen.has(e.name)) {
178
+ refuse('LAYERS_NAME_UNIQUE', label, `appears at layers[${seen.get(e.name)}] and layers[${index}]; a tag names one layer`);
179
+ ok = false;
180
+ } else seen.set(e.name, index);
181
+ }
182
+ for (const key of Object.keys(e)) {
183
+ if (!(LAYER_KEYS as readonly string[]).includes(key)) {
184
+ refuse('LAYERS_KEY_KNOWN', `${label} key "${key}"`, `is not a wrapper layer field; known: ${LAYER_KEYS.join(', ')}`);
185
+ ok = false;
186
+ }
187
+ }
188
+ for (const key of LAYER_REQUIRED) {
189
+ if (!(key in e)) {
190
+ refuse('LAYERS_FIELD_PRESENT', `${label} field "${key}"`, 'is absent and required');
191
+ ok = false;
192
+ }
193
+ }
194
+ if (!ok) return;
195
+ if (typeof e.name !== 'string') {
196
+ refuse('LAYERS_FIELD_PRESENT', `${at} field "name"`, `is ${describe(e.name)}; a tag string is required`);
197
+ return;
198
+ }
199
+ const name = e.name;
200
+ const tag = readTag(name);
201
+ if (tag === null) {
202
+ refuse('LAYERS_TAG_KNOWN', `layer "${name}"`, `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required`);
203
+ ok = false;
204
+ }
205
+ for (const key of ['left', 'top', 'right', 'bottom'] as const) {
206
+ if (!isInt(e[key])) {
207
+ refuse('LAYERS_FIELD_PRESENT', `layer "${name}" field "${key}"`, `is ${describe(e[key])}; an integer is required`);
208
+ ok = false;
209
+ }
210
+ }
211
+ if (typeof e.depth_median !== 'number' || !Number.isFinite(e.depth_median)) {
212
+ refuse('LAYERS_FIELD_PRESENT', `layer "${name}" field "depth_median"`, `is ${describe(e.depth_median)}; a finite number is required`);
213
+ ok = false;
214
+ }
215
+ if (typeof e.filename !== 'string' || e.filename === '') {
216
+ refuse('LAYERS_FIELD_PRESENT', `layer "${name}" field "filename"`, `is ${describe(e.filename)}; a file name is required`);
217
+ ok = false;
218
+ }
219
+ if (!ok || tag === null) return;
220
+ const left = e.left as number;
221
+ const top = e.top as number;
222
+ const right = e.right as number;
223
+ const bottom = e.bottom as number;
224
+ if (left < 0 || top < 0 || right > W || bottom > H || left >= right || top >= bottom) {
225
+ refuse(
226
+ 'LAYERS_BBOX_INSIDE_CANVAS',
227
+ `layer "${name}"`,
228
+ `box left ${left} top ${top} right ${right} bottom ${bottom}; a non-empty box inside the ${W}x${H} canvas is required`,
229
+ );
230
+ return;
231
+ }
232
+ const flat = join(dir, e.filename as string);
233
+ const byTag = join(dir, 'parts', `${name}.png`);
234
+ const present = [flat, byTag].filter((p) => existsSync(p));
235
+ if (present.length === 0) {
236
+ refuse('LAYERS_PNG_PRESENT', `layer "${name}"`, `neither ${flat} nor ${byTag} exists; one of them is required`);
237
+ return;
238
+ }
239
+ const bytes = present.map((p) => new Uint8Array(readFileSync(p)));
240
+ if (bytes.length === 2 && Buffer.compare(Buffer.from(bytes[0]), Buffer.from(bytes[1])) !== 0) {
241
+ refuse('LAYERS_PNG_UNAMBIGUOUS', `layer "${name}"`, `${flat} and ${byTag} both exist with different bytes; exactly one source is required`);
242
+ return;
243
+ }
244
+ let pixels: Raster;
245
+ try {
246
+ pixels = decodePngBytes(bytes[0], present[0]);
247
+ } catch (err) {
248
+ refuse('LAYERS_PNG_DECODES', `layer "${name}"`, (err as Error).message);
249
+ return;
250
+ }
251
+ if (pixels.width !== right - left || pixels.height !== bottom - top) {
252
+ refuse(
253
+ 'LAYERS_PNG_MATCHES_BBOX',
254
+ `layer "${name}"`,
255
+ `${present[0]} is ${pixels.width}x${pixels.height}; the manifest box is ${right - left}x${bottom - top}`,
256
+ );
257
+ return;
258
+ }
259
+ read.push({
260
+ name,
261
+ tag,
262
+ file: present[0],
263
+ pixels,
264
+ left,
265
+ top,
266
+ right,
267
+ bottom,
268
+ depth: e.depth_median as number,
269
+ opaquePx: opaque(pixels),
270
+ index,
271
+ });
272
+ });
273
+ refuseIfAny(problems);
274
+ // Stable: Array.prototype.sort is stable, so equal depths keep manifest order.
275
+ const ordered = [...read].sort((a, b) => (b.depth as number) - (a.depth as number));
276
+ return {
277
+ form: 'wrapper',
278
+ source: manifestPath,
279
+ canvas: { w: W, h: H },
280
+ layers: ordered.map(({ index: _index, ...rest }, drawOrder) => ({ ...rest, drawOrder })),
281
+ };
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // the PSD form
286
+ // ---------------------------------------------------------------------------
287
+
288
+ let canvasStubbed = false;
289
+
290
+ /** The image-data shape ag-psd asks its factory for, named off ag-psd's own signature rather than a DOM lib. */
291
+ type PsdImageData = ReturnType<NonNullable<Parameters<typeof initializeCanvas>[1]>>;
292
+
293
+ /**
294
+ * ag-psd decodes into `ImageData` objects and asks a canvas factory for them,
295
+ * even with `useImageData`. There is no canvas here and none is wanted, so the
296
+ * factory hands back a plain `{ width, height, data }` and the canvas
297
+ * constructor throws: a code path that needed a real canvas would be one that
298
+ * rasterises something (a text layer, a composite), which this reader refuses
299
+ * anyway.
300
+ */
301
+ function stubCanvas(): void {
302
+ if (canvasStubbed) return;
303
+ initializeCanvas(
304
+ () => {
305
+ throw new Error('rig-parts reads PSD pixel data only; this PSD asked for a canvas');
306
+ },
307
+ (width: number, height: number) =>
308
+ ({ width, height, data: new Uint8ClampedArray(width * height * 4), colorSpace: 'srgb' }) as PsdImageData,
309
+ );
310
+ canvasStubbed = true;
311
+ }
312
+
313
+ /** PSD colour mode 3 is RGB, the only one See-through writes and the only one read here. */
314
+ const PSD_RGB = 3;
315
+
316
+ export function readPsdLayers(path: string): LayerSet {
317
+ const problems: Problem[] = [];
318
+ const refuse = (code: string, object: string, detail: string): void => {
319
+ problems.push({ code, object, detail });
320
+ };
321
+ if (!existsSync(path)) {
322
+ refuse('PSD_FILE_PRESENT', path, 'no such file');
323
+ refuseIfAny(problems);
324
+ }
325
+ stubCanvas();
326
+ let psd;
327
+ try {
328
+ psd = readPsd(readFileSync(path), {
329
+ useImageData: true,
330
+ skipCompositeImageData: true,
331
+ skipThumbnail: true,
332
+ skipLinkedFilesData: true,
333
+ });
334
+ } catch (err) {
335
+ refuse('PSD_PARSES', path, `ag-psd could not read it: ${(err as Error).message}`);
336
+ refuseIfAny(problems);
337
+ throw err;
338
+ }
339
+ if (psd.colorMode !== PSD_RGB) refuse('PSD_IS_RGB8', path, `colour mode ${String(psd.colorMode)}; RGB (3) is required`);
340
+ if (psd.bitsPerChannel !== 8) refuse('PSD_IS_RGB8', path, `${String(psd.bitsPerChannel)} bits per channel; 8 are required`);
341
+ const W = psd.width;
342
+ const H = psd.height;
343
+ const leaves: PsdLayer[] = [];
344
+ const walk = (children: PsdLayer[] | undefined, trail: string): void => {
345
+ for (const layer of children ?? []) {
346
+ const where = `${trail}${layer.name ?? '(unnamed)'}`;
347
+ if (layer.hidden) refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, 'is hidden; every layer of a decomposition is drawn');
348
+ if (layer.opacity !== undefined && layer.opacity !== 1) {
349
+ refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, `has opacity ${layer.opacity}; 1 is required`);
350
+ }
351
+ if (layer.children !== undefined) {
352
+ if (layer.blendMode !== undefined && layer.blendMode !== 'pass through' && layer.blendMode !== 'normal') {
353
+ refuse('PSD_LAYER_PLAIN', `PSD group "${where}"`, `blends "${layer.blendMode}"; pass through or normal is required`);
354
+ }
355
+ walk(layer.children, `${where}/`);
356
+ continue;
357
+ }
358
+ if (layer.blendMode !== undefined && layer.blendMode !== 'normal') {
359
+ refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, `blends "${layer.blendMode}"; normal is required`);
360
+ }
361
+ if (layer.clipping) refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, 'is a clipping layer; none is required');
362
+ if (layer.mask !== undefined) refuse('PSD_LAYER_PLAIN', `PSD layer "${where}"`, 'carries a layer mask; none is required');
363
+ leaves.push(layer);
364
+ }
365
+ };
366
+ walk(psd.children, '');
367
+ if (leaves.length === 0) refuse('PSD_HAS_LAYERS', path, 'holds no pixel layer; one per See-through tag is required');
368
+ const seen = new Set<string>();
369
+ const layers: Layer[] = [];
370
+ leaves.forEach((layer) => {
371
+ const name = layer.name ?? '';
372
+ const tag = readTag(name);
373
+ if (tag === null) {
374
+ refuse('LAYERS_TAG_KNOWN', `PSD layer "${name}"`, `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required`);
375
+ return;
376
+ }
377
+ if (seen.has(name)) {
378
+ refuse('LAYERS_NAME_UNIQUE', `PSD layer "${name}"`, 'appears twice; a tag names one layer');
379
+ return;
380
+ }
381
+ seen.add(name);
382
+ const img = layer.imageData;
383
+ const left = layer.left ?? 0;
384
+ const top = layer.top ?? 0;
385
+ const right = layer.right ?? 0;
386
+ const bottom = layer.bottom ?? 0;
387
+ if (img === undefined || img.width === 0 || img.height === 0) {
388
+ refuse('PSD_LAYER_HAS_PIXELS', `PSD layer "${name}"`, 'carries no pixel data; a painted layer is required');
389
+ return;
390
+ }
391
+ if (left < 0 || top < 0 || right > W || bottom > H) {
392
+ refuse(
393
+ 'LAYERS_BBOX_INSIDE_CANVAS',
394
+ `PSD layer "${name}"`,
395
+ `box left ${left} top ${top} right ${right} bottom ${bottom}; a box inside the ${W}x${H} canvas is required`,
396
+ );
397
+ return;
398
+ }
399
+ if (img.width !== right - left || img.height !== bottom - top) {
400
+ refuse('LAYERS_PNG_MATCHES_BBOX', `PSD layer "${name}"`, `pixels are ${img.width}x${img.height}; the layer box is ${right - left}x${bottom - top}`);
401
+ return;
402
+ }
403
+ const pixels: Raster = { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) };
404
+ layers.push({
405
+ name,
406
+ tag,
407
+ file: null,
408
+ pixels,
409
+ left,
410
+ top,
411
+ right,
412
+ bottom,
413
+ depth: null,
414
+ drawOrder: layers.length,
415
+ opaquePx: opaque(pixels),
416
+ });
417
+ });
418
+ refuseIfAny(problems);
419
+ return { form: 'psd', source: path, canvas: { w: W, h: H }, layers };
420
+ }
421
+
422
+ /** Read whichever form `path` is: a `.psd` file, or a wrapper directory / `layers.json`. */
423
+ export function readLayers(path: string): LayerSet {
424
+ if (/\.psd$/i.test(path)) return readPsdLayers(path);
425
+ if (existsSync(path) && (statSync(path).isDirectory() || basename(path) === 'layers.json')) return readWrapperLayers(path);
426
+ refuseIfAny([
427
+ {
428
+ code: 'LAYERS_INPUT_KIND',
429
+ object: path,
430
+ detail: existsSync(path)
431
+ ? 'is neither a .psd file nor a directory holding layers.json nor a layers.json'
432
+ : 'does not exist; a .psd file or a See-through wrapper output directory is required',
433
+ },
434
+ ]);
435
+ throw new Error('unreachable');
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // plausibility: three figures per layer, and the rules `--propose-plan` drops by
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /**
443
+ * An opaque pixel (alpha above `OPAQUE_ALPHA_ABOVE`) whose alpha is below this
444
+ * is "translucent". 128 is the tree's half-covered cut (`FRONT_ALPHA`,
445
+ * `COVERED_ALPHA` in `src/assemble.ts`).
446
+ */
447
+ export const TRANSLUCENT_ALPHA_BELOW = 128;
448
+ /**
449
+ * `PLAN_LAYER_TRANSLUCENT` and `PLAN_LAYER_BACKGROUND` judge only a layer at
450
+ * least this large against the rest of its run's figure. [observed] Every
451
+ * example layer of 150 px or more that is above 25 % translucent is a small
452
+ * feature — a lash, a brow, a mouth, a nose, an ear, an iris, an eye white, an
453
+ * earring (27 of the 116 layers of the two examples' four runs) — and the
454
+ * largest of them is 0.011x the rest of its figure (a lash of the sample's head
455
+ * run). An antialiased stroke is mostly rim, so its translucency says nothing
456
+ * about plausibility; 23 of the 116 layers are judged.
457
+ */
458
+ export const PLAUSIBLE_JUDGED_AREA_RATIO = 0.05;
459
+ /** [observed] Above this translucent share a judged layer is dropped. The most translucent judged example layer is 17.8 % (the sample's full-run front hair). */
460
+ export const PLAUSIBLE_TRANSLUCENT_MAX = 0.5;
461
+ /**
462
+ * [observed] Above this background share, with a translucent share above
463
+ * `PLAUSIBLE_BACKGROUND_TRANSLUCENT_MIN`, a judged layer is dropped. The most
464
+ * background-coloured judged example layer is 21.9 % (the demo's head-run
465
+ * topwear, a white dress). The second condition is what keeps an opaque white
466
+ * garment: its colour is the page's, its alpha is not the haze's.
467
+ */
468
+ export const PLAUSIBLE_BACKGROUND_MAX = 0.5;
469
+ /** [observed] Of the judged example layers more than 10 % background-coloured, the most translucent is 5.6 % (the demo's full-run handwear-r). */
470
+ export const PLAUSIBLE_BACKGROUND_TRANSLUCENT_MIN = 0.25;
471
+ /** [observed] Above this area ratio any layer is dropped. The largest example layer is 1.681x the rest of its figure (the demo's head-run back hair, which fills the crop). */
472
+ export const PLAUSIBLE_AREA_RATIO_MAX = 4;
473
+
474
+ export type PlausibilityRule = 'PLAN_LAYER_TRANSLUCENT' | 'PLAN_LAYER_BACKGROUND' | 'PLAN_LAYER_OVERSIZED';
475
+
476
+ export interface LayerFigures {
477
+ name: string;
478
+ /** Pixels with alpha above `OPAQUE_ALPHA_ABOVE` — the table's `opaque_px`. */
479
+ opaquePx: number;
480
+ /** Opaque pixels with alpha below `TRANSLUCENT_ALPHA_BELOW`. */
481
+ translucentPx: number;
482
+ /** Opaque pixels whose own colour (straight alpha) has a min channel above `NEAR_WHITE_MIN`. */
483
+ backgroundPx: number;
484
+ /** Opaque pixels of the union of every OTHER layer of the set, on the canvas. */
485
+ restPx: number;
486
+ /** `translucentPx / opaquePx`, or null for a layer with no opaque pixel. */
487
+ translucent: number | null;
488
+ /** `backgroundPx / opaquePx`, or null for a layer with no opaque pixel. */
489
+ background: number | null;
490
+ /** `opaquePx / restPx`, or null when no other layer has an opaque pixel. */
491
+ areaRatio: number | null;
492
+ }
493
+
494
+ /**
495
+ * The three plausibility figures of every layer of a set, in the set's order.
496
+ *
497
+ * ⚖️ Invariant: every figure is a ratio of pixel counts over the layer as read
498
+ * (no ghost clean-up), over its opaque pixels (alpha above 8) — never over
499
+ * alpha above 0, because See-through leaves a haze of alpha 1..8 over whole
500
+ * canvases (the demo's full-run face: 99.1 % of its alpha-above-0 pixels are
501
+ * below 128, 4.2 % of its opaque ones). The area ratio is taken against the
502
+ * REST of the figure, the other layers' union: over the whole union a layer
503
+ * could never exceed 1, and "twice the figure" could not be said.
504
+ */
505
+ export function layerFigures(set: LayerSet): LayerFigures[] {
506
+ const W = set.canvas.w;
507
+ const H = set.canvas.h;
508
+ // Cover counts saturate at 2: "this layer only" versus "some other layer too" is all the rest needs.
509
+ const cover = new Uint8Array(W * H);
510
+ for (const l of set.layers) {
511
+ const p = l.pixels;
512
+ for (let y = 0; y < p.height; y++) {
513
+ for (let x = 0; x < p.width; x++) {
514
+ if (p.data[(y * p.width + x) * 4 + 3] <= OPAQUE_ALPHA_ABOVE) continue;
515
+ const c = (l.top + y) * W + l.left + x;
516
+ if (cover[c] < 2) cover[c]++;
517
+ }
518
+ }
519
+ }
520
+ let union = 0;
521
+ for (let i = 0; i < W * H; i++) if (cover[i] > 0) union++;
522
+ return set.layers.map((l) => {
523
+ const p = l.pixels;
524
+ let opaquePx = 0;
525
+ let translucentPx = 0;
526
+ let backgroundPx = 0;
527
+ let alone = 0;
528
+ for (let y = 0; y < p.height; y++) {
529
+ for (let x = 0; x < p.width; x++) {
530
+ const i = (y * p.width + x) * 4;
531
+ const a = p.data[i + 3];
532
+ if (a <= OPAQUE_ALPHA_ABOVE) continue;
533
+ opaquePx++;
534
+ if (a < TRANSLUCENT_ALPHA_BELOW) translucentPx++;
535
+ if (Math.min(p.data[i], p.data[i + 1], p.data[i + 2]) > NEAR_WHITE_MIN) backgroundPx++;
536
+ if (cover[(l.top + y) * W + l.left + x] === 1) alone++;
537
+ }
538
+ }
539
+ const restPx = union - alone;
540
+ return {
541
+ name: l.name,
542
+ opaquePx,
543
+ translucentPx,
544
+ backgroundPx,
545
+ restPx,
546
+ translucent: opaquePx > 0 ? translucentPx / opaquePx : null,
547
+ background: opaquePx > 0 ? backgroundPx / opaquePx : null,
548
+ areaRatio: restPx > 0 ? opaquePx / restPx : null,
549
+ };
550
+ });
551
+ }
552
+
553
+ /**
554
+ * The rules a layer crosses, in a fixed order. A layer with no opaque pixel
555
+ * crosses none (the plan's pixel floor already leaves it out).
556
+ *
557
+ * - `PLAN_LAYER_TRANSLUCENT`: judged, and translucent share above `PLAUSIBLE_TRANSLUCENT_MAX`.
558
+ * - `PLAN_LAYER_BACKGROUND`: judged, background share above `PLAUSIBLE_BACKGROUND_MAX`
559
+ * and translucent share above `PLAUSIBLE_BACKGROUND_TRANSLUCENT_MIN` — a pale haze.
560
+ * - `PLAN_LAYER_OVERSIZED`: area ratio above `PLAUSIBLE_AREA_RATIO_MAX`.
561
+ *
562
+ * "Judged" is an area ratio of at least `PLAUSIBLE_JUDGED_AREA_RATIO`, or no
563
+ * ratio at all (a layer alone in its set is the whole figure, not a stroke).
564
+ */
565
+ export function implausibleRules(f: LayerFigures): PlausibilityRule[] {
566
+ if (f.opaquePx === 0 || f.translucent === null || f.background === null) return [];
567
+ const judged = f.areaRatio === null || f.areaRatio >= PLAUSIBLE_JUDGED_AREA_RATIO;
568
+ const out: PlausibilityRule[] = [];
569
+ if (judged && f.translucent > PLAUSIBLE_TRANSLUCENT_MAX) out.push('PLAN_LAYER_TRANSLUCENT');
570
+ if (judged && f.background > PLAUSIBLE_BACKGROUND_MAX && f.translucent > PLAUSIBLE_BACKGROUND_TRANSLUCENT_MIN) out.push('PLAN_LAYER_BACKGROUND');
571
+ if (f.areaRatio !== null && f.areaRatio > PLAUSIBLE_AREA_RATIO_MAX) out.push('PLAN_LAYER_OVERSIZED');
572
+ return out;
573
+ }
574
+
575
+ /** A share as `12.3%`. */
576
+ export function pct(v: number | null): string {
577
+ return v === null ? '-' : `${(v * 100).toFixed(1)}%`;
578
+ }
579
+
580
+ /** An area ratio as `1.681x`. */
581
+ export function times(v: number | null): string {
582
+ return v === null ? '-' : `${v.toFixed(3)}x`;
583
+ }
584
+
585
+ /** The figures, as a plan note and a WARN line say them. */
586
+ export function figuresPhrase(f: LayerFigures): string {
587
+ return `${pct(f.translucent)} translucent, ${pct(f.background)} background-coloured, ${f.areaRatio === null ? 'no other layer to compare with' : `${times(f.areaRatio)} the rest of the figure`}`;
588
+ }
589
+
590
+ /** What a rule requires, in words, so the line that names it says the bar. */
591
+ export function ruleSummary(rule: PlausibilityRule): string {
592
+ const judged = `for a layer at least ${PLAUSIBLE_JUDGED_AREA_RATIO}x the rest of the figure`;
593
+ if (rule === 'PLAN_LAYER_TRANSLUCENT') return `at most ${PLAUSIBLE_TRANSLUCENT_MAX * 100}% translucent (alpha below ${TRANSLUCENT_ALPHA_BELOW}) ${judged}`;
594
+ if (rule === 'PLAN_LAYER_BACKGROUND') {
595
+ return `at most ${PLAUSIBLE_BACKGROUND_MAX * 100}% background-coloured (min channel above ${NEAR_WHITE_MIN}), or at most ${PLAUSIBLE_BACKGROUND_TRANSLUCENT_MIN * 100}% translucent, ${judged}`;
596
+ }
597
+ return `at most ${PLAUSIBLE_AREA_RATIO_MAX}x the rest of the figure`;
598
+ }