@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.
- package/LICENSE +21 -0
- package/NOTICE.md +134 -0
- package/README.md +433 -2
- package/bin/rig-parts.cjs +43 -0
- package/cli.ts +1240 -0
- package/docs/AUTHORING.md +2387 -0
- package/docs/MIGRATION.md +150 -0
- package/docs/STABILITY.md +262 -0
- package/package.json +70 -4
- package/skills/rig-parts/SKILL.md +167 -0
- package/src/apng.ts +381 -0
- package/src/assemble.ts +2259 -0
- package/src/autoamplitude.ts +194 -0
- package/src/autoenvelope.ts +404 -0
- package/src/automesh.ts +585 -0
- package/src/automotion.ts +447 -0
- package/src/autoreplay.ts +516 -0
- package/src/build.ts +1240 -0
- package/src/check.ts +2942 -0
- package/src/comfy/client.ts +245 -0
- package/src/comfy/index.ts +19 -0
- package/src/comfy/painting.ts +136 -0
- package/src/comfy/seethrough.ts +171 -0
- package/src/config.ts +2283 -0
- package/src/contour.ts +1901 -0
- package/src/coords.ts +26 -0
- package/src/diagnostics.ts +198 -0
- package/src/errors.ts +39 -0
- package/src/gif.ts +213 -0
- package/src/graphs.ts +302 -0
- package/src/headbox.ts +173 -0
- package/src/heat.ts +313 -0
- package/src/inputs.ts +100 -0
- package/src/instruments.ts +1145 -0
- package/src/keypoints.ts +296 -0
- package/src/layers.ts +598 -0
- package/src/localweights.ts +217 -0
- package/src/mesh.ts +362 -0
- package/src/motion.ts +311 -0
- package/src/palette.ts +208 -0
- package/src/parts.ts +410 -0
- package/src/propose.ts +2540 -0
- package/src/pyfmt.ts +135 -0
- package/src/raster/blur.ts +80 -0
- package/src/raster/components.ts +165 -0
- package/src/raster/composite.ts +81 -0
- package/src/raster/index.ts +9 -0
- package/src/raster/morph.ts +116 -0
- package/src/raster/png.ts +82 -0
- package/src/raster/poly.ts +166 -0
- package/src/raster/resize.ts +301 -0
- package/src/raster/types.ts +89 -0
- package/src/raster/warp.ts +121 -0
- package/src/requirements.ts +1240 -0
- package/src/rig.ts +1440 -0
- package/src/round.ts +62 -0
- package/src/scene.ts +1414 -0
- package/src/sheet.ts +128 -0
- package/src/skeleton.ts +400 -0
- package/src/structure.ts +858 -0
- package/src/tags.ts +121 -0
- package/src/weights.ts +124 -0
- package/workflows/seethrough.json +68 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ComfyUI HTTP client: the six calls the adapter makes, each refusing by
|
|
3
|
+
* name.
|
|
4
|
+
*
|
|
5
|
+
* `src/comfy/` is the one directory under `src/` allowed a network and a clock
|
|
6
|
+
* (CLAUDE.md, *Conventions*): driving a GPU box is a network call with a
|
|
7
|
+
* wall-clock wait. What is decided — the words, the graph, the check of a
|
|
8
|
+
* graph against the box — is pure and lives in `src/graphs.ts`; this file only
|
|
9
|
+
* carries it.
|
|
10
|
+
*
|
|
11
|
+
* 🔒 **The host is the user's, given at run time, and nothing else.** It comes
|
|
12
|
+
* from `--host` or `COMFY_HOST`; with neither, `resolveHost` refuses. There is
|
|
13
|
+
* no default address anywhere in this package, and the host is never written
|
|
14
|
+
* into an output file.
|
|
15
|
+
*
|
|
16
|
+
* Every failure is a `PartsError` whose problem names the call, the value found
|
|
17
|
+
* and the value required: a box that does not answer, a queue that does not
|
|
18
|
+
* empty within the wait, a `/prompt` the box rejected (with its own
|
|
19
|
+
* `node_errors`), a job that does not reach `/history` within the timeout, a
|
|
20
|
+
* job that ended in error, a history entry with no outputs.
|
|
21
|
+
*/
|
|
22
|
+
import { PartsError } from '../errors.ts';
|
|
23
|
+
|
|
24
|
+
export function refuse(code: string, object: string, detail: string): never {
|
|
25
|
+
throw new PartsError([{ code, object, detail }]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The host from `--host`, else `COMFY_HOST`; refused when neither is set. A trailing slash is dropped. */
|
|
29
|
+
export function resolveHost(flag: string | undefined, env: string | undefined): string {
|
|
30
|
+
const host = flag ?? (env !== undefined && env !== '' ? env : undefined);
|
|
31
|
+
if (host === undefined) {
|
|
32
|
+
refuse('COMFY_HOST_GIVEN', 'the ComfyUI host', 'is absent: neither --host nor COMFY_HOST is set; a URL such as http://<address>:8188 is required, and there is no default');
|
|
33
|
+
}
|
|
34
|
+
if (!/^https?:\/\/[^/\s]+/.test(host)) {
|
|
35
|
+
refuse('COMFY_HOST_GIVEN', 'the ComfyUI host', `is ${JSON.stringify(host)}; an http:// or https:// URL is required`);
|
|
36
|
+
}
|
|
37
|
+
return host.replace(/\/+$/, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ClientTiming {
|
|
41
|
+
/** Seconds between two polls of /queue or /history. */
|
|
42
|
+
poll: number;
|
|
43
|
+
/** Seconds one plain request may take. */
|
|
44
|
+
request: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface QueueState {
|
|
48
|
+
running: number;
|
|
49
|
+
pending: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OutputImage {
|
|
53
|
+
filename: string;
|
|
54
|
+
subfolder: string;
|
|
55
|
+
type: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HistoryEntry {
|
|
59
|
+
outputs: Record<string, { images?: OutputImage[] }>;
|
|
60
|
+
status: { status_str?: string; completed?: boolean; messages?: unknown[] } | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sleep = (s: number): Promise<void> => new Promise((r) => setTimeout(r, s * 1000));
|
|
64
|
+
|
|
65
|
+
function asObject(v: unknown): Record<string, unknown> | null {
|
|
66
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function brief(text: string, n = 600): string {
|
|
70
|
+
const t = text.replace(/\s+/g, ' ').trim();
|
|
71
|
+
return t.length > n ? `${t.slice(0, n - 3)}...` : t;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ComfyClient {
|
|
75
|
+
constructor(
|
|
76
|
+
readonly host: string,
|
|
77
|
+
readonly timing: ClientTiming,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
private async call(method: 'GET' | 'POST', path: string, body?: string | FormData, headers?: Record<string, string>, seconds = this.timing.request): Promise<Response> {
|
|
81
|
+
try {
|
|
82
|
+
return await fetch(`${this.host}${path}`, { method, body, headers, signal: AbortSignal.timeout(seconds * 1000) });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const e = err as Error;
|
|
85
|
+
const what = e.name === 'TimeoutError' ? `did not answer within ${seconds} s` : `could not be reached (${e.message})`;
|
|
86
|
+
return refuse('COMFY_REACHABLE', `${method} ${this.host}${path}`, `${what}; a running ComfyUI at this host is required`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async json(method: 'GET' | 'POST', path: string, body?: string | FormData, headers?: Record<string, string>): Promise<unknown> {
|
|
91
|
+
const res = await this.call(method, path, body, headers);
|
|
92
|
+
const text = await res.text();
|
|
93
|
+
if (!res.ok) refuse('COMFY_REQUEST_OK', `${method} ${this.host}${path}`, `answered HTTP ${res.status}: ${brief(text)}; HTTP 200 is required`);
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(text);
|
|
96
|
+
} catch {
|
|
97
|
+
return refuse('COMFY_REQUEST_OK', `${method} ${this.host}${path}`, `answered HTTP ${res.status} with a body that is not JSON: ${brief(text, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `GET /system_stats`, or a refusal naming the host. */
|
|
102
|
+
async systemStats(): Promise<Record<string, unknown>> {
|
|
103
|
+
const s = asObject(await this.json('GET', '/system_stats'));
|
|
104
|
+
if (s === null) refuse('COMFY_REQUEST_OK', `GET ${this.host}/system_stats`, 'answered with something that is not an object');
|
|
105
|
+
return s;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Free VRAM per device in GB (two decimals), keyed by the device name's first word; empty when the box does not say. */
|
|
109
|
+
async vramFree(): Promise<Record<string, number>> {
|
|
110
|
+
const s = await this.systemStats();
|
|
111
|
+
const out: Record<string, number> = {};
|
|
112
|
+
const devices = Array.isArray(s.devices) ? s.devices : [];
|
|
113
|
+
for (const d of devices) {
|
|
114
|
+
const o = asObject(d);
|
|
115
|
+
if (o === null || typeof o.name !== 'string' || typeof o.vram_free !== 'number') continue;
|
|
116
|
+
out[o.name.split(' ')[0]] = Math.round((o.vram_free / 1024 ** 3) * 100) / 100;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async queue(): Promise<QueueState> {
|
|
122
|
+
const q = asObject(await this.json('GET', '/queue'));
|
|
123
|
+
if (q === null || !Array.isArray(q.queue_running) || !Array.isArray(q.queue_pending)) {
|
|
124
|
+
refuse('COMFY_REQUEST_OK', `GET ${this.host}/queue`, 'answered without queue_running and queue_pending arrays');
|
|
125
|
+
}
|
|
126
|
+
return { running: (q.queue_running as unknown[]).length, pending: (q.queue_pending as unknown[]).length };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Wait until nothing is running or pending, for at most `wait` seconds. Returns the seconds waited. */
|
|
130
|
+
async waitQueueEmpty(wait: number, say: (line: string) => void): Promise<number> {
|
|
131
|
+
const t0 = Date.now();
|
|
132
|
+
let last: QueueState = await this.queue();
|
|
133
|
+
let reported = false;
|
|
134
|
+
while (last.running > 0 || last.pending > 0) {
|
|
135
|
+
const waited = (Date.now() - t0) / 1000;
|
|
136
|
+
if (waited >= wait) {
|
|
137
|
+
refuse(
|
|
138
|
+
'COMFY_QUEUE_EMPTY',
|
|
139
|
+
`${this.host}/queue`,
|
|
140
|
+
`still held ${last.running} running and ${last.pending} pending after ${waited.toFixed(1)} s; an empty queue within --wait ${wait} s is required, and this adapter never queues behind someone else's job`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!reported) say(` queue busy (running ${last.running}, pending ${last.pending}) — waiting up to ${wait} s`);
|
|
144
|
+
reported = true;
|
|
145
|
+
await sleep(Math.min(this.timing.poll, Math.max(0, wait - waited)));
|
|
146
|
+
last = await this.queue();
|
|
147
|
+
}
|
|
148
|
+
return (Date.now() - t0) / 1000;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `GET /object_info`: every node class the box has, with its inputs. */
|
|
152
|
+
async objectInfo(): Promise<Record<string, unknown>> {
|
|
153
|
+
const o = asObject(await this.json('GET', '/object_info'));
|
|
154
|
+
if (o === null) refuse('COMFY_REQUEST_OK', `GET ${this.host}/object_info`, 'answered with something that is not an object of node classes');
|
|
155
|
+
return o;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `POST /upload/image` into the box's input directory, overwriting; returns the name LoadImage takes. */
|
|
159
|
+
async uploadImage(bytes: Uint8Array, name: string): Promise<string> {
|
|
160
|
+
const form = new FormData();
|
|
161
|
+
form.append('image', new Blob([bytes], { type: 'image/png' }), name);
|
|
162
|
+
form.append('overwrite', 'true');
|
|
163
|
+
form.append('type', 'input');
|
|
164
|
+
form.append('subfolder', '');
|
|
165
|
+
const res = asObject(await this.json('POST', '/upload/image', form));
|
|
166
|
+
if (res === null || typeof res.name !== 'string') refuse('COMFY_REQUEST_OK', `POST ${this.host}/upload/image`, 'answered without the uploaded file\'s name');
|
|
167
|
+
return typeof res.subfolder === 'string' && res.subfolder !== '' ? `${res.subfolder}/${res.name}` : res.name;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `POST /prompt`; returns the prompt id. A rejection is refused with the box's own error text. */
|
|
171
|
+
async submit(graph: unknown, clientId: string): Promise<string> {
|
|
172
|
+
const res = await this.call('POST', '/prompt', JSON.stringify({ prompt: graph, client_id: clientId }), { 'Content-Type': 'application/json' });
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
if (!res.ok) refuse('COMFY_PROMPT_ACCEPTED', `POST ${this.host}/prompt`, `rejected the graph with HTTP ${res.status}: ${brief(text, 1500)}`);
|
|
175
|
+
let id: unknown;
|
|
176
|
+
try {
|
|
177
|
+
id = asObject(JSON.parse(text))?.prompt_id;
|
|
178
|
+
} catch {
|
|
179
|
+
id = undefined;
|
|
180
|
+
}
|
|
181
|
+
if (typeof id !== 'string') refuse('COMFY_PROMPT_ACCEPTED', `POST ${this.host}/prompt`, `answered without a prompt_id: ${brief(text, 300)}`);
|
|
182
|
+
return id;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Poll `/history/<id>` until the job is there, for at most `timeout`
|
|
187
|
+
* seconds, and check the box is still answering between polls. Returns the
|
|
188
|
+
* entry and the lowest free VRAM seen per device. An entry whose status is
|
|
189
|
+
* an error is refused with the box's messages; so is an entry with no
|
|
190
|
+
* outputs at all, because a finished job that saved nothing is not a result.
|
|
191
|
+
*/
|
|
192
|
+
async waitHistory(id: string, timeout: number): Promise<{ entry: HistoryEntry; vramLow: Record<string, number>; elapsed: number }> {
|
|
193
|
+
const t0 = Date.now();
|
|
194
|
+
const vramLow: Record<string, number> = {};
|
|
195
|
+
for (;;) {
|
|
196
|
+
for (const [k, v] of Object.entries(await this.vramFree())) if (!(k in vramLow) || v < vramLow[k]) vramLow[k] = v;
|
|
197
|
+
const h = asObject(await this.json('GET', `/history/${encodeURIComponent(id)}`));
|
|
198
|
+
const raw = h === null ? null : asObject(h[id]);
|
|
199
|
+
if (raw !== null) {
|
|
200
|
+
const entry: HistoryEntry = {
|
|
201
|
+
outputs: (asObject(raw.outputs) ?? {}) as HistoryEntry['outputs'],
|
|
202
|
+
status: asObject(raw.status) as HistoryEntry['status'],
|
|
203
|
+
};
|
|
204
|
+
const elapsed = (Date.now() - t0) / 1000;
|
|
205
|
+
if (entry.status?.status_str === 'error') {
|
|
206
|
+
refuse('COMFY_RUN_OK', `prompt ${id}`, `ended with status "error": ${brief(JSON.stringify(entry.status.messages ?? []), 2000)}`);
|
|
207
|
+
}
|
|
208
|
+
if (Object.keys(entry.outputs).length === 0) {
|
|
209
|
+
refuse(
|
|
210
|
+
'COMFY_HISTORY_OUTPUTS',
|
|
211
|
+
`prompt ${id}`,
|
|
212
|
+
`reached /history with status ${JSON.stringify(entry.status?.status_str ?? null)} and no outputs; a finished job lists what its save nodes wrote, and one that lists nothing is not a result`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return { entry, vramLow, elapsed };
|
|
216
|
+
}
|
|
217
|
+
const waited = (Date.now() - t0) / 1000;
|
|
218
|
+
if (waited >= timeout) {
|
|
219
|
+
refuse('COMFY_HISTORY_WITHIN', `prompt ${id}`, `was not in /history after ${waited.toFixed(1)} s; it is required within --timeout ${timeout} s (the job was left on the box, not cancelled)`);
|
|
220
|
+
}
|
|
221
|
+
await sleep(Math.min(this.timing.poll, Math.max(0, timeout - waited)));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `GET /view` of one file in the box's output (or input) directory. */
|
|
226
|
+
async view(filename: string, subfolder = '', type = 'output'): Promise<Uint8Array> {
|
|
227
|
+
const q = new URLSearchParams({ filename, subfolder, type }).toString();
|
|
228
|
+
const res = await this.call('GET', `/view?${q}`, undefined, undefined, Math.max(this.timing.request, 180));
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
refuse('COMFY_VIEW_PRESENT', `${type}/${subfolder === '' ? '' : `${subfolder}/`}${filename}`, `answered HTTP ${res.status} on /view; the file the job reported is required`);
|
|
231
|
+
}
|
|
232
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The images a history entry lists, in node-id order then list order. */
|
|
237
|
+
export function historyImages(entry: HistoryEntry): OutputImage[] {
|
|
238
|
+
const out: OutputImage[] = [];
|
|
239
|
+
for (const id of Object.keys(entry.outputs).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b))) {
|
|
240
|
+
for (const im of entry.outputs[id]?.images ?? []) {
|
|
241
|
+
if (typeof im?.filename === 'string') out.push({ filename: im.filename, subfolder: typeof im.subfolder === 'string' ? im.subfolder : '', type: typeof im.type === 'string' ? im.type : 'output' });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional ComfyUI adapter: the painting and the See-through run, driven
|
|
3
|
+
* on a GPU box the user names at run time (`--host` or `COMFY_HOST`).
|
|
4
|
+
*
|
|
5
|
+
* `src/` is pure — no clock, no randomness, no network — and this directory is
|
|
6
|
+
* the one exception it has, because driving a GPU box IS a network call with a
|
|
7
|
+
* wall-clock wait. Keeping the exception to one named directory is what keeps
|
|
8
|
+
* the rule checkable: the selftest's purity control (`TY06`) scans every file
|
|
9
|
+
* in `src/` except this directory, and a network call that appears anywhere
|
|
10
|
+
* else is refused by name. What can be pure is kept out of here — the words
|
|
11
|
+
* and the graphs are `src/graphs.ts`, the control skeleton `src/skeleton.ts`,
|
|
12
|
+
* the two See-through inputs `src/inputs.ts` — so the purity scan covers them.
|
|
13
|
+
*
|
|
14
|
+
* ComfyUI is optional: See-through can be run by any route in the README, and
|
|
15
|
+
* everything after it reads files.
|
|
16
|
+
*/
|
|
17
|
+
export { ComfyClient, type ClientTiming, resolveHost } from './client.ts';
|
|
18
|
+
export { runPainting, type PaintRun, type Painted } from './painting.ts';
|
|
19
|
+
export { runSeeThrough, type SeeThroughResult, type SeeThroughRun } from './seethrough.ts';
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The painting, generated on a ComfyUI box from the config's inline
|
|
3
|
+
* `generation` block: `painting_<seed>.png` and `painting_<seed>_meta.json`
|
|
4
|
+
* per seed, and `control_<skeleton>.png` when the config asks for structural
|
|
5
|
+
* control — `<skeleton>` is the built-in's name, or for a pose file (issue
|
|
6
|
+
* #198) `pose-` and the first 12 hex digits of the image's sha256, so two pose
|
|
7
|
+
* files drawn at one size never share a name on the box.
|
|
8
|
+
*
|
|
9
|
+
* The words and the graph are `src/graphs.ts` (`buildPrompts`,
|
|
10
|
+
* `paintingGraph`) and the skeleton is `src/skeleton.ts`; this file carries
|
|
11
|
+
* them to the box and back. Before anything is uploaded the whole graph is
|
|
12
|
+
* checked against the box's `/object_info` — a checkpoint, LoRA, ControlNet
|
|
13
|
+
* or upscale model the box does not list is refused by name, not discovered
|
|
14
|
+
* as a failed job.
|
|
15
|
+
*
|
|
16
|
+
* Every seed waits for an empty queue, and each painting is written only after
|
|
17
|
+
* its job is in `/history`, not in error, and the image it saved decodes at
|
|
18
|
+
* twice the latent size (4x-AnimeSharp then 0.5). A seed whose file already
|
|
19
|
+
* exists in `--out` is refused before the first job, so no painting is
|
|
20
|
+
* overwritten.
|
|
21
|
+
*
|
|
22
|
+
* The meta file holds the positive and negative prompts verbatim, what was
|
|
23
|
+
* dropped as `DUP_WORDS`, where the pose words came from, the checkpoint, the
|
|
24
|
+
* LoRAs, control and sampler as the graph used them (defaults resolved, each
|
|
25
|
+
* by its named fields, so an annotation or an `x-` record its author wrote in
|
|
26
|
+
* the config never reaches the meta), the elapsed seconds and the prompt id —
|
|
27
|
+
* never the host.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import type { ControlPose, PaintConfig } from '../config.ts';
|
|
33
|
+
import { type Problem, refuseIfAny } from '../errors.ts';
|
|
34
|
+
import { buildPrompts, checkGraph, paintingGraph, resolvedControl, resolvedLoras, resolvedSampler } from '../graphs.ts';
|
|
35
|
+
import { decodePngBytes, encodePngBytes } from '../raster/png.ts';
|
|
36
|
+
import { isSkeletonName, renderSkeleton } from '../skeleton.ts';
|
|
37
|
+
import { ComfyClient, historyImages, refuse } from './client.ts';
|
|
38
|
+
|
|
39
|
+
export interface PaintRun {
|
|
40
|
+
/** Read through `loadPaintConfig(path)`, which has already required and checked `generation`. */
|
|
41
|
+
config: PaintConfig;
|
|
42
|
+
/** The pose file `generation.control.skeleton` names, as `loadPaintConfig` read it; null for a built-in or no control. */
|
|
43
|
+
pose: ControlPose | null;
|
|
44
|
+
out: string;
|
|
45
|
+
seeds: number;
|
|
46
|
+
seed0: number;
|
|
47
|
+
wait: number;
|
|
48
|
+
timeout: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Painted {
|
|
52
|
+
seed: number;
|
|
53
|
+
file: string;
|
|
54
|
+
elapsed: number;
|
|
55
|
+
size: [number, number];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function runPainting(client: ComfyClient, run: PaintRun, say: (line: string) => void): Promise<Painted[]> {
|
|
59
|
+
const g = run.config.generation;
|
|
60
|
+
const seeds = Array.from({ length: run.seeds }, (_, i) => run.seed0 + i);
|
|
61
|
+
const taken: Problem[] = seeds
|
|
62
|
+
.filter((s) => existsSync(join(run.out, `painting_${s}.png`)))
|
|
63
|
+
.map((s) => ({ code: 'COMFY_OUT_FREE', object: join(run.out, `painting_${s}.png`), detail: 'already exists; a seed whose painting is on disk is not generated again over it' }));
|
|
64
|
+
refuseIfAny(taken);
|
|
65
|
+
const prompts = buildPrompts(g);
|
|
66
|
+
const [w, h] = g.latent;
|
|
67
|
+
say(` positive (${prompts.positive.length} chars, pose words from ${prompts.poseFrom}${prompts.dropped.length > 0 ? `, dropped ${prompts.dropped.join(' | ')}` : ''}): ${prompts.positive}`);
|
|
68
|
+
say(` negative (${prompts.negative.length} chars): ${prompts.negative}`);
|
|
69
|
+
|
|
70
|
+
await client.systemStats();
|
|
71
|
+
const control = g.control === undefined ? null : resolvedControl(g.control);
|
|
72
|
+
const drawn = control === null ? null : controlImage(control.skeleton, run.pose, w, h);
|
|
73
|
+
const skeletonName = drawn === null ? null : `spine_parts_${drawn.label}_${w}x${h}.png`;
|
|
74
|
+
const probe = paintingGraph(g, seeds[0], prompts, `spine_parts_${run.config.key}_${seeds[0]}`, skeletonName);
|
|
75
|
+
refuseIfAny(checkGraph(probe, await client.objectInfo(), client.host, new Set(['30.image'])));
|
|
76
|
+
say(' box: every node, input and model of the painting graph is on its /object_info');
|
|
77
|
+
|
|
78
|
+
mkdirSync(run.out, { recursive: true });
|
|
79
|
+
let skeletonRef: string | null = null;
|
|
80
|
+
if (control !== null && drawn !== null && skeletonName !== null) {
|
|
81
|
+
const local = join(run.out, `control_${drawn.label}.png`);
|
|
82
|
+
writeFileSync(local, drawn.png);
|
|
83
|
+
skeletonRef = await client.uploadImage(drawn.png, skeletonName);
|
|
84
|
+
say(` control: ${drawn.from} at ${w}x${h} -> ${local}, uploaded as input/${skeletonRef}; ${control.model} strength ${control.strength} end ${control.end_percent}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const done: Painted[] = [];
|
|
88
|
+
for (const seed of seeds) {
|
|
89
|
+
const waited = await client.waitQueueEmpty(run.wait, say);
|
|
90
|
+
if (waited > 0.05) say(` queue empty after ${waited.toFixed(1)} s`);
|
|
91
|
+
const graph = paintingGraph(g, seed, prompts, `spine_parts_${run.config.key}_${seed}`, skeletonRef);
|
|
92
|
+
const id = await client.submit(graph, 'rig-parts');
|
|
93
|
+
say(` seed ${seed}: queued prompt ${id}; GPU job started`);
|
|
94
|
+
const { entry, elapsed } = await client.waitHistory(id, run.timeout);
|
|
95
|
+
const image = historyImages(entry).find((im) => im.type === 'output');
|
|
96
|
+
if (image === undefined) {
|
|
97
|
+
refuse('COMFY_HISTORY_OUTPUTS', `prompt ${id}`, `lists outputs from node(s) ${Object.keys(entry.outputs).join(', ')} and no image of type "output"; SaveImage (node 9) is required to have saved one`);
|
|
98
|
+
}
|
|
99
|
+
const bytes = await client.view(image.filename, image.subfolder, image.type);
|
|
100
|
+
const img = decodePngBytes(bytes, `output/${image.filename}`);
|
|
101
|
+
if (img.width !== 2 * w || img.height !== 2 * h) {
|
|
102
|
+
refuse('COMFY_PAINTING_SIZE', `output/${image.filename}`, `is ${img.width}x${img.height}; ${2 * w}x${2 * h} is required (latent ${w}x${h}, 4x-AnimeSharp then 0.5)`);
|
|
103
|
+
}
|
|
104
|
+
const file = join(run.out, `painting_${seed}.png`);
|
|
105
|
+
writeFileSync(file, bytes);
|
|
106
|
+
const meta = {
|
|
107
|
+
seed,
|
|
108
|
+
elapsed_s: Math.round(elapsed * 10) / 10,
|
|
109
|
+
latent: [w, h],
|
|
110
|
+
size: [img.width, img.height],
|
|
111
|
+
key: run.config.key,
|
|
112
|
+
checkpoint: g.checkpoint,
|
|
113
|
+
loras: resolvedLoras(g.loras),
|
|
114
|
+
sampler: resolvedSampler(g.sampler),
|
|
115
|
+
control,
|
|
116
|
+
pose_from: prompts.poseFrom,
|
|
117
|
+
dropped: prompts.dropped,
|
|
118
|
+
positive: prompts.positive,
|
|
119
|
+
negative: prompts.negative,
|
|
120
|
+
prompt_id: id,
|
|
121
|
+
};
|
|
122
|
+
writeFileSync(join(run.out, `painting_${seed}_meta.json`), `${JSON.stringify(meta, null, 1)}\n`);
|
|
123
|
+
say(` seed ${seed}: GPU job finished in ${elapsed.toFixed(1)} s -> ${file} (${img.width}x${img.height})`);
|
|
124
|
+
done.push({ seed, file, elapsed, size: [img.width, img.height] });
|
|
125
|
+
}
|
|
126
|
+
return done;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The control image for a built-in skeleton or the pose file read beside the config, with the name it is written and uploaded under. */
|
|
130
|
+
function controlImage(skeleton: string, pose: ControlPose | null, w: number, h: number): { png: Uint8Array; label: string; from: string } {
|
|
131
|
+
if (isSkeletonName(skeleton)) return { png: encodePngBytes(renderSkeleton(skeleton, w, h)), label: skeleton, from: `${skeleton} skeleton` };
|
|
132
|
+
// loadPaintConfig reads the file a non-built-in skeleton names, or refuses; a run without it skipped the loader.
|
|
133
|
+
if (pose === null) throw new Error(`runPainting: generation.control.skeleton names the pose file "${skeleton}" and no pose was read; load the config through loadPaintConfig`);
|
|
134
|
+
const png = encodePngBytes(renderSkeleton(pose.points, w, h));
|
|
135
|
+
return { png, label: `pose-${createHash('sha256').update(png).digest('hex').slice(0, 12)}`, from: `pose file ${pose.file}` };
|
|
136
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One See-through run on a ComfyUI box, into a directory in the wrapper form
|
|
3
|
+
* `src/layers.ts` reads: `layers.json` (the wrapper's manifest, the bytes the
|
|
4
|
+
* box wrote), `parts/<tag>.png` (one RGBA PNG per layer), `meta.json` (this
|
|
5
|
+
* run's record) and `previews/` (the two preview sheets the graph saves).
|
|
6
|
+
*
|
|
7
|
+
* The route, in order, each step refusing by name:
|
|
8
|
+
*
|
|
9
|
+
* 1. the box answers `/system_stats`;
|
|
10
|
+
* 2. every node and input of `workflows/seethrough.json` is on the box's
|
|
11
|
+
* `/object_info` (`checkGraph`) — the wrapper not installed is refused
|
|
12
|
+
* here, before anything is uploaded;
|
|
13
|
+
* 3. the queue is empty within `--wait` (this never queues behind someone
|
|
14
|
+
* else's job);
|
|
15
|
+
* 4. the image is uploaded to the box's input directory and the graph queued;
|
|
16
|
+
* 5. `/history/<id>` holds the job within `--timeout`, not in error, with
|
|
17
|
+
* outputs;
|
|
18
|
+
* 6. the manifest is found the way the wrapper leaves it: SavePSD writes the
|
|
19
|
+
* layer PNGs and the manifest into the output directory but reports only
|
|
20
|
+
* the previews to `/history`, and records the manifest's file name in
|
|
21
|
+
* `output/seethrough_psd_info.log`. That log is overwritten by every run, so
|
|
22
|
+
* the manifest it names must carry THIS run's prefix — a prefix with a
|
|
23
|
+
* random suffix per run — or it is refused as another run's;
|
|
24
|
+
* 7. everything is written into a staging directory beside `--out`, read back
|
|
25
|
+
* through `readWrapperLayers`, and only then moved to `--out`. Emit only
|
|
26
|
+
* after green: a directory the layer reader would refuse is never left at
|
|
27
|
+
* the path the next stage reads.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { basename, dirname, join } from 'node:path';
|
|
31
|
+
import { refuseIfAny } from '../errors.ts';
|
|
32
|
+
import { checkGraph, fillSeeThrough, type SeeThroughParams } from '../graphs.ts';
|
|
33
|
+
import { readWrapperLayers } from '../layers.ts';
|
|
34
|
+
import { decodePngBytes } from '../raster/png.ts';
|
|
35
|
+
import { ComfyClient, historyImages, refuse } from './client.ts';
|
|
36
|
+
|
|
37
|
+
export const SEETHROUGH_WORKFLOW = new URL('../../workflows/seethrough.json', import.meta.url);
|
|
38
|
+
export const PSD_INFO_LOG = 'seethrough_psd_info.log';
|
|
39
|
+
|
|
40
|
+
export interface SeeThroughRun {
|
|
41
|
+
image: string;
|
|
42
|
+
out: string;
|
|
43
|
+
/** The prefix before the per-run suffix. */
|
|
44
|
+
prefix: string;
|
|
45
|
+
resolution: number;
|
|
46
|
+
steps: number;
|
|
47
|
+
seed: number;
|
|
48
|
+
offload: boolean;
|
|
49
|
+
lama: boolean;
|
|
50
|
+
quant: 'none' | 'nf4';
|
|
51
|
+
wait: number;
|
|
52
|
+
timeout: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SeeThroughResult {
|
|
56
|
+
promptId: string;
|
|
57
|
+
prefix: string;
|
|
58
|
+
elapsed: number;
|
|
59
|
+
layers: string[];
|
|
60
|
+
canvas: [number, number];
|
|
61
|
+
previews: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An out directory that is absent or empty; anything else is refused, because mixing two runs' layers is a set nobody can read. */
|
|
65
|
+
export function assertOutFree(out: string): void {
|
|
66
|
+
if (existsSync(out) && readdirSync(out).length > 0) {
|
|
67
|
+
refuse('COMFY_OUT_EMPTY', out, `holds ${readdirSync(out).length} entr(ies); an absent or empty directory is required, so one run's layers are never mixed with another's`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeName(name: string): boolean {
|
|
72
|
+
return name !== '' && !name.startsWith('.') && !/[\\/]/.test(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runSeeThrough(client: ComfyClient, run: SeeThroughRun, say: (line: string) => void): Promise<SeeThroughResult> {
|
|
76
|
+
if (!existsSync(run.image)) refuse('COMFY_IMAGE_PRESENT', run.image, 'no such file; the PNG See-through is to decompose is required');
|
|
77
|
+
const bytes = new Uint8Array(readFileSync(run.image));
|
|
78
|
+
const img = decodePngBytes(bytes, run.image);
|
|
79
|
+
assertOutFree(run.out);
|
|
80
|
+
|
|
81
|
+
await client.systemStats();
|
|
82
|
+
const prefix = `${run.prefix}_${crypto.randomUUID().slice(0, 8)}`;
|
|
83
|
+
const uploadName = `${prefix}_input.png`;
|
|
84
|
+
const template: unknown = JSON.parse(readFileSync(SEETHROUGH_WORKFLOW, 'utf8'));
|
|
85
|
+
const params: SeeThroughParams = { image: uploadName, seed: run.seed, resolution: run.resolution, steps: run.steps, quant: run.quant, offload: run.offload, lama: run.lama, prefix };
|
|
86
|
+
const info = await client.objectInfo();
|
|
87
|
+
refuseIfAny(checkGraph(fillSeeThrough(template, params), info, client.host, new Set(['1.image'])));
|
|
88
|
+
say(` box: every node and input of workflows/seethrough.json is on its /object_info`);
|
|
89
|
+
|
|
90
|
+
const waited = await client.waitQueueEmpty(run.wait, say);
|
|
91
|
+
say(` queue empty${waited > 0.05 ? ` after ${waited.toFixed(1)} s` : ''}`);
|
|
92
|
+
const ref = await client.uploadImage(bytes, uploadName);
|
|
93
|
+
say(` uploaded ${run.image} (${img.width}x${img.height}) as input/${ref}`);
|
|
94
|
+
const graph = fillSeeThrough(template, { ...params, image: ref });
|
|
95
|
+
const vramBefore = await client.vramFree();
|
|
96
|
+
const id = await client.submit(graph, 'rig-parts');
|
|
97
|
+
say(` queued prompt ${id} (prefix ${prefix}); GPU job started`);
|
|
98
|
+
const { entry, vramLow, elapsed } = await client.waitHistory(id, run.timeout);
|
|
99
|
+
say(` GPU job finished in ${elapsed.toFixed(1)} s`);
|
|
100
|
+
const previews = historyImages(entry);
|
|
101
|
+
if (previews.length === 0) {
|
|
102
|
+
refuse('COMFY_HISTORY_OUTPUTS', `prompt ${id}`, `lists outputs from node(s) ${Object.keys(entry.outputs).join(', ')} and no image; the graph's two preview SaveImage nodes are required to have saved`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const infoLog = new TextDecoder().decode(await client.view(PSD_INFO_LOG)).trim();
|
|
106
|
+
if (infoLog === '' || /[\\/]/.test(infoLog)) {
|
|
107
|
+
refuse('COMFY_MANIFEST_NAMED', `output/${PSD_INFO_LOG}`, `holds ${JSON.stringify(infoLog.slice(0, 200))}; the file name of the manifest SavePSD wrote is required`);
|
|
108
|
+
}
|
|
109
|
+
const manifestBytes = await client.view(infoLog);
|
|
110
|
+
let manifest: Record<string, unknown>;
|
|
111
|
+
try {
|
|
112
|
+
manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as Record<string, unknown>;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return refuse('COMFY_MANIFEST_NAMED', `output/${infoLog}`, `does not parse as JSON (${(err as Error).message})`);
|
|
115
|
+
}
|
|
116
|
+
if (manifest.prefix !== prefix) {
|
|
117
|
+
refuse(
|
|
118
|
+
'COMFY_MANIFEST_IS_THIS_RUN',
|
|
119
|
+
`output/${infoLog}`,
|
|
120
|
+
`carries prefix ${JSON.stringify(manifest.prefix)}; this run's prefix ${JSON.stringify(prefix)} is required — ${PSD_INFO_LOG} names the last manifest any run wrote, and this one is not ours`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const layers = Array.isArray(manifest.layers) ? (manifest.layers as Array<Record<string, unknown>>) : [];
|
|
124
|
+
if (layers.length === 0) refuse('COMFY_MANIFEST_NAMED', `output/${infoLog}`, 'lists no layers; a See-through run writes one per tag');
|
|
125
|
+
const bad = layers.filter((l) => typeof l.name !== 'string' || !safeName(l.name) || typeof l.filename !== 'string' || !safeName(l.filename));
|
|
126
|
+
if (bad.length > 0) {
|
|
127
|
+
refuse('COMFY_LAYER_NAME', `output/${infoLog}`, `names ${bad.length} layer(s) whose name or filename is not a plain file name (first ${JSON.stringify(bad[0]).slice(0, 200)}); each becomes parts/<name>.png`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const parent = dirname(run.out);
|
|
131
|
+
mkdirSync(parent, { recursive: true });
|
|
132
|
+
const stage = join(parent, `.${basename(run.out)}.partial-${prefix}`);
|
|
133
|
+
mkdirSync(join(stage, 'parts'), { recursive: true });
|
|
134
|
+
mkdirSync(join(stage, 'previews'), { recursive: true });
|
|
135
|
+
try {
|
|
136
|
+
for (const p of previews) writeFileSync(join(stage, 'previews', basename(p.filename)), await client.view(p.filename, p.subfolder, p.type));
|
|
137
|
+
for (const l of layers) writeFileSync(join(stage, 'parts', `${l.name as string}.png`), await client.view(l.filename as string));
|
|
138
|
+
writeFileSync(join(stage, 'layers.json'), manifestBytes);
|
|
139
|
+
const set = readWrapperLayers(stage);
|
|
140
|
+
const meta = {
|
|
141
|
+
prompt_id: id,
|
|
142
|
+
image: run.image,
|
|
143
|
+
elapsed_s: Math.round(elapsed * 10) / 10,
|
|
144
|
+
vram_free_before_gb: vramBefore,
|
|
145
|
+
vram_free_low_gb: vramLow,
|
|
146
|
+
prefix,
|
|
147
|
+
seed: run.seed,
|
|
148
|
+
resolution: run.resolution,
|
|
149
|
+
steps: run.steps,
|
|
150
|
+
quant: run.quant,
|
|
151
|
+
offload: run.offload,
|
|
152
|
+
lama: run.lama,
|
|
153
|
+
layer_count: layers.length,
|
|
154
|
+
layer_names: layers.map((l) => l.name as string),
|
|
155
|
+
canvas: [set.canvas.w, set.canvas.h],
|
|
156
|
+
};
|
|
157
|
+
writeFileSync(join(stage, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
158
|
+
if (existsSync(run.out)) rmdirSync(run.out);
|
|
159
|
+
renameSync(stage, run.out);
|
|
160
|
+
return {
|
|
161
|
+
promptId: id,
|
|
162
|
+
prefix,
|
|
163
|
+
elapsed,
|
|
164
|
+
layers: layers.map((l) => l.name as string),
|
|
165
|
+
canvas: [set.canvas.w, set.canvas.h],
|
|
166
|
+
previews: previews.map((p) => basename(p.filename)),
|
|
167
|
+
};
|
|
168
|
+
} finally {
|
|
169
|
+
if (existsSync(stage)) rmSync(stage, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
}
|