@mcptoolshop/style-dataset-lab 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.es.md +142 -0
- package/README.fr.md +142 -0
- package/README.hi.md +142 -0
- package/README.it.md +142 -0
- package/README.ja.md +142 -0
- package/README.md +178 -0
- package/README.pt-BR.md +142 -0
- package/README.zh.md +142 -0
- package/SECURITY.md +28 -0
- package/package.json +45 -0
- package/scripts/bulk-curate-wave2-5.js +138 -0
- package/scripts/bulk-curate-waves11-18.js +413 -0
- package/scripts/canon-bind.js +437 -0
- package/scripts/compare.js +116 -0
- package/scripts/curate-wave25.js +209 -0
- package/scripts/curate.js +136 -0
- package/scripts/generate-controlnet.js +357 -0
- package/scripts/generate-identity.js +633 -0
- package/scripts/generate-ipadapter.js +339 -0
- package/scripts/generate.js +351 -0
- package/scripts/migrate-records.js +147 -0
- package/scripts/painterly-test.js +149 -0
- package/scripts/painterly.js +349 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* generate-ipadapter.js — IP-Adapter guided generation for structural subjects.
|
|
5
|
+
*
|
|
6
|
+
* Uses a reference image to guide the shape/structure of the output
|
|
7
|
+
* while the text prompt adds identity-specific details (wear, patching, name).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scripts/generate-ipadapter.js --subject corrigan --ref <image> --seeds 4
|
|
11
|
+
* node scripts/generate-ipadapter.js --subject corrigan --ref <image> --seeds 4 --dry-run
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --subject NAME Subject name for output file naming
|
|
15
|
+
* --ref PATH Reference image path (the structural truth source)
|
|
16
|
+
* --prompt TEXT Generation prompt
|
|
17
|
+
* --negative TEXT Negative prompt
|
|
18
|
+
* --seeds N Number of seeds (default: 4)
|
|
19
|
+
* --weight W IP-Adapter weight (default: 0.55)
|
|
20
|
+
* --start S IP-Adapter start (default: 0.0)
|
|
21
|
+
* --end E IP-Adapter end (default: 0.8)
|
|
22
|
+
* --dry-run Print workflow without running
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
|
|
29
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
30
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
31
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
32
|
+
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
|
|
35
|
+
loras: [{ name: "classipeintxl_v21.safetensors", weight: 1.0 }],
|
|
36
|
+
ipadapter_model: "ip-adapter-plus_sdxl_vit-h.safetensors",
|
|
37
|
+
clip_vision_model: "CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors",
|
|
38
|
+
steps: 10,
|
|
39
|
+
cfg: 2.5,
|
|
40
|
+
sampler: "dpmpp_sde",
|
|
41
|
+
scheduler: "karras",
|
|
42
|
+
width: 1024,
|
|
43
|
+
height: 1024,
|
|
44
|
+
base_seed: 27200,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function parseArgs() {
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
const get = (flag, def) => {
|
|
50
|
+
const i = args.indexOf(flag);
|
|
51
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : def;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
subject: get("--subject", "unknown"),
|
|
55
|
+
refPath: get("--ref", null),
|
|
56
|
+
prompt: get("--prompt", null),
|
|
57
|
+
negative: get("--negative", null),
|
|
58
|
+
seeds: parseInt(get("--seeds", "4"), 10),
|
|
59
|
+
weight: parseFloat(get("--weight", "0.55")),
|
|
60
|
+
start: parseFloat(get("--start", "0.0")),
|
|
61
|
+
end: parseFloat(get("--end", "0.8")),
|
|
62
|
+
dryRun: args.includes("--dry-run"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildIPAdapterWorkflow(prompt, negativePrompt, seed, refImageName, weight, start, end) {
|
|
67
|
+
const nodes = {};
|
|
68
|
+
let nextId = 1;
|
|
69
|
+
|
|
70
|
+
// Checkpoint
|
|
71
|
+
const ckptId = String(nextId++);
|
|
72
|
+
nodes[ckptId] = {
|
|
73
|
+
class_type: "CheckpointLoaderSimple",
|
|
74
|
+
inputs: { ckpt_name: DEFAULTS.checkpoint },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let modelOut = [ckptId, 0];
|
|
78
|
+
let clipOut = [ckptId, 1];
|
|
79
|
+
|
|
80
|
+
// LoRA
|
|
81
|
+
for (const lora of DEFAULTS.loras) {
|
|
82
|
+
const loraId = String(nextId++);
|
|
83
|
+
nodes[loraId] = {
|
|
84
|
+
class_type: "LoraLoader",
|
|
85
|
+
inputs: {
|
|
86
|
+
model: modelOut,
|
|
87
|
+
clip: clipOut,
|
|
88
|
+
lora_name: lora.name,
|
|
89
|
+
strength_model: lora.weight,
|
|
90
|
+
strength_clip: lora.weight,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
modelOut = [loraId, 0];
|
|
94
|
+
clipOut = [loraId, 1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Load CLIP Vision
|
|
98
|
+
const clipVisionId = String(nextId++);
|
|
99
|
+
nodes[clipVisionId] = {
|
|
100
|
+
class_type: "CLIPVisionLoader",
|
|
101
|
+
inputs: { clip_name: DEFAULTS.clip_vision_model },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Load IP-Adapter model
|
|
105
|
+
const ipaLoadId = String(nextId++);
|
|
106
|
+
nodes[ipaLoadId] = {
|
|
107
|
+
class_type: "IPAdapterModelLoader",
|
|
108
|
+
inputs: { ipadapter_file: DEFAULTS.ipadapter_model },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Load reference image
|
|
112
|
+
const refImgId = String(nextId++);
|
|
113
|
+
nodes[refImgId] = {
|
|
114
|
+
class_type: "LoadImage",
|
|
115
|
+
inputs: { image: refImageName },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Apply IP-Adapter (using IPAdapterAdvanced from ComfyUI_IPAdapter_plus)
|
|
119
|
+
const ipaApplyId = String(nextId++);
|
|
120
|
+
nodes[ipaApplyId] = {
|
|
121
|
+
class_type: "IPAdapterAdvanced",
|
|
122
|
+
inputs: {
|
|
123
|
+
model: modelOut,
|
|
124
|
+
ipadapter: [ipaLoadId, 0],
|
|
125
|
+
image: [refImgId, 0],
|
|
126
|
+
clip_vision: [clipVisionId, 0],
|
|
127
|
+
weight: weight,
|
|
128
|
+
weight_type: "linear",
|
|
129
|
+
combine_embeds: "concat",
|
|
130
|
+
start_at: start,
|
|
131
|
+
end_at: end,
|
|
132
|
+
embeds_scaling: "V only",
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
modelOut = [ipaApplyId, 0];
|
|
136
|
+
|
|
137
|
+
// Positive prompt
|
|
138
|
+
const posId = String(nextId++);
|
|
139
|
+
nodes[posId] = {
|
|
140
|
+
class_type: "CLIPTextEncode",
|
|
141
|
+
inputs: { text: prompt, clip: clipOut },
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Negative prompt
|
|
145
|
+
const negId = String(nextId++);
|
|
146
|
+
nodes[negId] = {
|
|
147
|
+
class_type: "CLIPTextEncode",
|
|
148
|
+
inputs: { text: negativePrompt, clip: clipOut },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Empty latent
|
|
152
|
+
const latentId = String(nextId++);
|
|
153
|
+
nodes[latentId] = {
|
|
154
|
+
class_type: "EmptyLatentImage",
|
|
155
|
+
inputs: { width: DEFAULTS.width, height: DEFAULTS.height, batch_size: 1 },
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// KSampler
|
|
159
|
+
const samplerId = String(nextId++);
|
|
160
|
+
nodes[samplerId] = {
|
|
161
|
+
class_type: "KSampler",
|
|
162
|
+
inputs: {
|
|
163
|
+
model: modelOut,
|
|
164
|
+
positive: [posId, 0],
|
|
165
|
+
negative: [negId, 0],
|
|
166
|
+
latent_image: [latentId, 0],
|
|
167
|
+
seed,
|
|
168
|
+
steps: DEFAULTS.steps,
|
|
169
|
+
cfg: DEFAULTS.cfg,
|
|
170
|
+
sampler_name: DEFAULTS.sampler,
|
|
171
|
+
scheduler: DEFAULTS.scheduler,
|
|
172
|
+
denoise: 1.0,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// VAE Decode
|
|
177
|
+
const vaeId = String(nextId++);
|
|
178
|
+
nodes[vaeId] = {
|
|
179
|
+
class_type: "VAEDecode",
|
|
180
|
+
inputs: { samples: [samplerId, 0], vae: [ckptId, 2] },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Save
|
|
184
|
+
const saveId = String(nextId++);
|
|
185
|
+
nodes[saveId] = {
|
|
186
|
+
class_type: "SaveImage",
|
|
187
|
+
inputs: { images: [vaeId, 0], filename_prefix: "ipa" },
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return nodes;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function comfyHealth() {
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(`${COMFY_URL}/system_stats`);
|
|
196
|
+
return res.ok;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const MAX_POLL_MS = 600_000; // 10 minutes
|
|
203
|
+
|
|
204
|
+
async function submitAndWait(workflow) {
|
|
205
|
+
const clientId = `ipa-${Date.now()}`;
|
|
206
|
+
const res = await fetch(`${COMFY_URL}/prompt`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
|
210
|
+
});
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
const text = await res.text();
|
|
213
|
+
throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
|
|
214
|
+
}
|
|
215
|
+
const { prompt_id: promptId } = await res.json();
|
|
216
|
+
const start = Date.now();
|
|
217
|
+
while (true) {
|
|
218
|
+
if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
|
|
219
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
220
|
+
const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
|
|
221
|
+
if (!histRes.ok) continue;
|
|
222
|
+
const history = await histRes.json();
|
|
223
|
+
const entry = history[promptId];
|
|
224
|
+
if (!entry) continue;
|
|
225
|
+
if (entry.status?.completed) return entry;
|
|
226
|
+
if (entry.status?.status_str === "error") {
|
|
227
|
+
throw new Error(`ComfyUI error: ${JSON.stringify(entry.status)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function downloadImage(filename, subfolder, type = "output") {
|
|
233
|
+
const url = `${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=${type}`;
|
|
234
|
+
const res = await fetch(url);
|
|
235
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
236
|
+
return Buffer.from(await res.arrayBuffer());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function main() {
|
|
240
|
+
const opts = parseArgs();
|
|
241
|
+
|
|
242
|
+
if (!opts.refPath) {
|
|
243
|
+
console.error("Error: --ref <path> is required");
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
if (!opts.prompt) {
|
|
247
|
+
console.error("Error: --prompt is required");
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const negative = opts.negative ||
|
|
252
|
+
"photorealistic, photograph, 3d render, anime, cartoon, text, watermark, blurry, low quality";
|
|
253
|
+
|
|
254
|
+
console.log("\x1b[1mstyle-dataset-lab\x1b[0m IP-Adapter structural discovery");
|
|
255
|
+
console.log(` Subject: ${opts.subject}`);
|
|
256
|
+
console.log(` Reference: ${opts.refPath}`);
|
|
257
|
+
console.log(` IP-Adapter: ${DEFAULTS.ipadapter_model}`);
|
|
258
|
+
console.log(` Weight: ${opts.weight}, Range: ${opts.start}-${opts.end}`);
|
|
259
|
+
console.log(` Seeds: ${opts.seeds}`);
|
|
260
|
+
console.log("");
|
|
261
|
+
|
|
262
|
+
if (!opts.dryRun) {
|
|
263
|
+
const online = await comfyHealth();
|
|
264
|
+
if (!online) {
|
|
265
|
+
console.error("\x1b[31mError:\x1b[0m ComfyUI not reachable");
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
console.log("\x1b[32m+\x1b[0m ComfyUI online");
|
|
269
|
+
|
|
270
|
+
// Upload reference image
|
|
271
|
+
const refData = await readFile(join(GAME_ROOT, opts.refPath));
|
|
272
|
+
const refFilename = `ipa_ref_${opts.subject}.png`;
|
|
273
|
+
const formData = new FormData();
|
|
274
|
+
formData.append("image", new Blob([refData], { type: "image/png" }), refFilename);
|
|
275
|
+
formData.append("overwrite", "true");
|
|
276
|
+
const uploadRes = await fetch(`${COMFY_URL}/upload/image`, { method: "POST", body: formData });
|
|
277
|
+
if (!uploadRes.ok) {
|
|
278
|
+
console.error(`Reference upload failed: ${uploadRes.status}`);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
const uploadResult = await uploadRes.json();
|
|
282
|
+
console.log(`\x1b[32m+\x1b[0m Reference uploaded: ${uploadResult.name}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
|
|
286
|
+
|
|
287
|
+
const refFilename = `ipa_ref_${opts.subject}.png`;
|
|
288
|
+
|
|
289
|
+
for (let si = 0; si < opts.seeds; si++) {
|
|
290
|
+
const seed = DEFAULTS.base_seed + si;
|
|
291
|
+
const assetId = `${opts.subject}_ipa_s${si}`;
|
|
292
|
+
const destPath = `outputs/candidates/${assetId}.png`;
|
|
293
|
+
|
|
294
|
+
console.log(` [${si + 1}/${opts.seeds}] ${assetId} (seed: ${seed})`);
|
|
295
|
+
|
|
296
|
+
if (opts.dryRun) continue;
|
|
297
|
+
|
|
298
|
+
const startMs = Date.now();
|
|
299
|
+
const workflow = buildIPAdapterWorkflow(
|
|
300
|
+
opts.prompt, negative, seed,
|
|
301
|
+
refFilename, opts.weight, opts.start, opts.end
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const result = await submitAndWait(workflow);
|
|
306
|
+
const elapsed = Date.now() - startMs;
|
|
307
|
+
|
|
308
|
+
const outputs = result.outputs || {};
|
|
309
|
+
let imageFile = null;
|
|
310
|
+
let imageSubfolder = "";
|
|
311
|
+
for (const nodeOut of Object.values(outputs)) {
|
|
312
|
+
if (nodeOut.images?.length > 0) {
|
|
313
|
+
imageFile = nodeOut.images[0].filename;
|
|
314
|
+
imageSubfolder = nodeOut.images[0].subfolder || "";
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!imageFile) {
|
|
320
|
+
console.log(" \x1b[33m!\x1b[0m No output image");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const imgData = await downloadImage(imageFile, imageSubfolder);
|
|
325
|
+
await writeFile(join(GAME_ROOT, destPath), imgData);
|
|
326
|
+
console.log(` \x1b[32m+\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.log(` \x1b[31mx\x1b[0m ${err.message}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log("\n\x1b[32m+\x1b[0m IP-Adapter discovery complete");
|
|
333
|
+
if (opts.dryRun) console.log(" (dry run)");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
main().catch((err) => {
|
|
337
|
+
console.error(err);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* generate.js — Drive ComfyUI to produce candidate assets from a prompt pack.
|
|
5
|
+
*
|
|
6
|
+
* Usage: node scripts/generate.js [--prompt-pack inputs/prompts/rpg-icons-lane1.json]
|
|
7
|
+
*
|
|
8
|
+
* For each subject × variation, submits a workflow to ComfyUI HTTP API,
|
|
9
|
+
* downloads the output PNG, and writes a provenance record to records/.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
13
|
+
import { join, basename } from "node:path";
|
|
14
|
+
|
|
15
|
+
const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
|
|
16
|
+
const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
|
|
17
|
+
const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
|
|
18
|
+
const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
|
|
19
|
+
|
|
20
|
+
// ── ComfyUI API helpers ──
|
|
21
|
+
|
|
22
|
+
async function comfyHealth() {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`${COMFY_URL}/system_stats`);
|
|
25
|
+
return res.ok;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, cfg, sampler, scheduler, width, height, ipAdapterConfig) {
|
|
32
|
+
const nodes = {};
|
|
33
|
+
let nextId = 1;
|
|
34
|
+
|
|
35
|
+
// Checkpoint loader
|
|
36
|
+
const ckptId = String(nextId++);
|
|
37
|
+
nodes[ckptId] = {
|
|
38
|
+
class_type: "CheckpointLoaderSimple",
|
|
39
|
+
inputs: { ckpt_name: checkpoint },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let modelOut = [ckptId, 0];
|
|
43
|
+
let clipOut = [ckptId, 1];
|
|
44
|
+
|
|
45
|
+
// LoRA loaders (chained)
|
|
46
|
+
for (const lora of loras) {
|
|
47
|
+
const loraId = String(nextId++);
|
|
48
|
+
nodes[loraId] = {
|
|
49
|
+
class_type: "LoraLoader",
|
|
50
|
+
inputs: {
|
|
51
|
+
model: modelOut,
|
|
52
|
+
clip: clipOut,
|
|
53
|
+
lora_name: lora.name,
|
|
54
|
+
strength_model: lora.weight,
|
|
55
|
+
strength_clip: lora.clip_weight ?? lora.weight,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
modelOut = [loraId, 0];
|
|
59
|
+
clipOut = [loraId, 1];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// IP-Adapter for style reference (if configured)
|
|
63
|
+
if (ipAdapterConfig) {
|
|
64
|
+
// Load CLIP Vision model
|
|
65
|
+
const clipVisionId = String(nextId++);
|
|
66
|
+
nodes[clipVisionId] = {
|
|
67
|
+
class_type: "CLIPVisionLoader",
|
|
68
|
+
inputs: { clip_name: ipAdapterConfig.clip_vision_model },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Load IP-Adapter model
|
|
72
|
+
const ipaLoaderId = String(nextId++);
|
|
73
|
+
nodes[ipaLoaderId] = {
|
|
74
|
+
class_type: "IPAdapterModelLoader",
|
|
75
|
+
inputs: { ipadapter_file: ipAdapterConfig.model },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Load reference image
|
|
79
|
+
const refImageId = String(nextId++);
|
|
80
|
+
nodes[refImageId] = {
|
|
81
|
+
class_type: "LoadImage",
|
|
82
|
+
inputs: { image: ipAdapterConfig.reference_image },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Apply IP-Adapter
|
|
86
|
+
const ipaApplyId = String(nextId++);
|
|
87
|
+
nodes[ipaApplyId] = {
|
|
88
|
+
class_type: "IPAdapterApply",
|
|
89
|
+
inputs: {
|
|
90
|
+
ipadapter: [ipaLoaderId, 0],
|
|
91
|
+
clip_vision: [clipVisionId, 0],
|
|
92
|
+
image: [refImageId, 0],
|
|
93
|
+
model: modelOut,
|
|
94
|
+
weight: ipAdapterConfig.weight || 0.6,
|
|
95
|
+
noise: ipAdapterConfig.noise || 0.0,
|
|
96
|
+
weight_type: ipAdapterConfig.weight_type || "linear",
|
|
97
|
+
start_at: ipAdapterConfig.start_at || 0.0,
|
|
98
|
+
end_at: ipAdapterConfig.end_at || 1.0,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
modelOut = [ipaApplyId, 0];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// CLIP Text Encode — positive
|
|
106
|
+
const posId = String(nextId++);
|
|
107
|
+
nodes[posId] = {
|
|
108
|
+
class_type: "CLIPTextEncode",
|
|
109
|
+
inputs: { text: prompt, clip: clipOut },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// CLIP Text Encode — negative
|
|
113
|
+
const negId = String(nextId++);
|
|
114
|
+
nodes[negId] = {
|
|
115
|
+
class_type: "CLIPTextEncode",
|
|
116
|
+
inputs: { text: negativePrompt, clip: clipOut },
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Empty latent image
|
|
120
|
+
const latentId = String(nextId++);
|
|
121
|
+
nodes[latentId] = {
|
|
122
|
+
class_type: "EmptyLatentImage",
|
|
123
|
+
inputs: { width, height, batch_size: 1 },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// KSampler
|
|
127
|
+
const samplerId = String(nextId++);
|
|
128
|
+
nodes[samplerId] = {
|
|
129
|
+
class_type: "KSampler",
|
|
130
|
+
inputs: {
|
|
131
|
+
model: modelOut,
|
|
132
|
+
positive: [posId, 0],
|
|
133
|
+
negative: [negId, 0],
|
|
134
|
+
latent_image: [latentId, 0],
|
|
135
|
+
seed,
|
|
136
|
+
steps,
|
|
137
|
+
cfg,
|
|
138
|
+
sampler_name: sampler,
|
|
139
|
+
scheduler,
|
|
140
|
+
denoise: 1.0,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// VAE Decode
|
|
145
|
+
const vaeId = String(nextId++);
|
|
146
|
+
nodes[vaeId] = {
|
|
147
|
+
class_type: "VAEDecode",
|
|
148
|
+
inputs: {
|
|
149
|
+
samples: [samplerId, 0],
|
|
150
|
+
vae: [ckptId, 2],
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Save Image
|
|
155
|
+
const saveId = String(nextId++);
|
|
156
|
+
nodes[saveId] = {
|
|
157
|
+
class_type: "SaveImage",
|
|
158
|
+
inputs: {
|
|
159
|
+
images: [vaeId, 0],
|
|
160
|
+
filename_prefix: "sdl",
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return { nodes, saveNodeId: saveId };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const MAX_POLL_MS = 600_000; // 10 minutes
|
|
168
|
+
|
|
169
|
+
async function submitAndWait(workflow) {
|
|
170
|
+
const clientId = `sdl-${Date.now()}`;
|
|
171
|
+
|
|
172
|
+
// Submit
|
|
173
|
+
const res = await fetch(`${COMFY_URL}/prompt`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "Content-Type": "application/json" },
|
|
176
|
+
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const text = await res.text();
|
|
181
|
+
throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { prompt_id: promptId } = await res.json();
|
|
185
|
+
|
|
186
|
+
// Poll history until complete
|
|
187
|
+
const start = Date.now();
|
|
188
|
+
while (true) {
|
|
189
|
+
if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
|
|
190
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
191
|
+
const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
|
|
192
|
+
if (!histRes.ok) continue;
|
|
193
|
+
const history = await histRes.json();
|
|
194
|
+
const entry = history[promptId];
|
|
195
|
+
if (!entry) continue;
|
|
196
|
+
if (entry.status?.completed) {
|
|
197
|
+
return entry;
|
|
198
|
+
}
|
|
199
|
+
if (entry.status?.status_str === "error") {
|
|
200
|
+
throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function downloadImage(filename, subfolder, type = "output") {
|
|
206
|
+
const url = `${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=${type}`;
|
|
207
|
+
const res = await fetch(url);
|
|
208
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
209
|
+
return Buffer.from(await res.arrayBuffer());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Main ──
|
|
213
|
+
|
|
214
|
+
async function main() {
|
|
215
|
+
const args = process.argv.slice(2);
|
|
216
|
+
const packPath = args.find((a) => !a.startsWith("--")) || "inputs/prompts/rpg-icons-lane1.json";
|
|
217
|
+
const dryRun = args.includes("--dry-run");
|
|
218
|
+
|
|
219
|
+
const fullPackPath = join(GAME_ROOT, packPath);
|
|
220
|
+
const pack = JSON.parse(await readFile(fullPackPath, "utf-8"));
|
|
221
|
+
|
|
222
|
+
console.log(`\x1b[1mstyle-dataset-lab\x1b[0m generate`);
|
|
223
|
+
console.log(` Lane: ${pack.lane}`);
|
|
224
|
+
console.log(` Subjects: ${pack.subjects.length}`);
|
|
225
|
+
console.log(` Variations: ${pack.variations.length}`);
|
|
226
|
+
console.log(` Total candidates: ${pack.subjects.length * pack.variations.length}`);
|
|
227
|
+
console.log("");
|
|
228
|
+
|
|
229
|
+
if (!dryRun) {
|
|
230
|
+
const online = await comfyHealth();
|
|
231
|
+
if (!online) {
|
|
232
|
+
console.error("\x1b[31mError:\x1b[0m ComfyUI not reachable at " + COMFY_URL);
|
|
233
|
+
console.error("Start ComfyUI: cd F:/AI-Models/ComfyUI-runtime && python main.py --listen 127.0.0.1 --port 8188");
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
console.log("\x1b[32m✓\x1b[0m ComfyUI online");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
|
|
240
|
+
await mkdir(join(GAME_ROOT, "records"), { recursive: true });
|
|
241
|
+
|
|
242
|
+
const d = pack.defaults;
|
|
243
|
+
let generated = 0;
|
|
244
|
+
const totalExpected = pack.subjects.length * pack.variations.length;
|
|
245
|
+
|
|
246
|
+
for (const subject of pack.subjects) {
|
|
247
|
+
for (const variation of variation_list(pack.variations)) {
|
|
248
|
+
const assetId = `${subject.id}_${variation.id}`;
|
|
249
|
+
const seed = (d.base_seed + generated) + (variation.seed_offset || 0);
|
|
250
|
+
const fullPrompt = `${pack.style_prefix}, ${subject.prompt}`;
|
|
251
|
+
const loras = variation.loras || [];
|
|
252
|
+
|
|
253
|
+
console.log(` [${generated + 1}/${totalExpected}] ${assetId} (seed: ${seed}, loras: ${loras.length})`);
|
|
254
|
+
|
|
255
|
+
if (dryRun) {
|
|
256
|
+
generated++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const startMs = Date.now();
|
|
261
|
+
const { nodes } = buildWorkflow(
|
|
262
|
+
fullPrompt, pack.negative,
|
|
263
|
+
d.checkpoint, loras, seed,
|
|
264
|
+
d.steps, d.cfg, d.sampler, d.scheduler,
|
|
265
|
+
d.width, d.height,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const result = await submitAndWait(nodes);
|
|
270
|
+
const elapsed = Date.now() - startMs;
|
|
271
|
+
|
|
272
|
+
// Find output image
|
|
273
|
+
const outputs = result.outputs || {};
|
|
274
|
+
let imageFile = null;
|
|
275
|
+
let imageSubfolder = "";
|
|
276
|
+
for (const nodeOut of Object.values(outputs)) {
|
|
277
|
+
if (nodeOut.images && nodeOut.images.length > 0) {
|
|
278
|
+
imageFile = nodeOut.images[0].filename;
|
|
279
|
+
imageSubfolder = nodeOut.images[0].subfolder || "";
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!imageFile) {
|
|
285
|
+
console.log(` \x1b[33m⚠\x1b[0m No output image found`);
|
|
286
|
+
generated++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Download and save
|
|
291
|
+
const imgData = await downloadImage(imageFile, imageSubfolder);
|
|
292
|
+
const destPath = `outputs/candidates/${assetId}.png`;
|
|
293
|
+
await writeFile(join(GAME_ROOT, destPath), imgData);
|
|
294
|
+
|
|
295
|
+
// Write provenance record
|
|
296
|
+
const record = {
|
|
297
|
+
id: assetId,
|
|
298
|
+
schema_version: "1.0.0",
|
|
299
|
+
created_at: new Date().toISOString(),
|
|
300
|
+
asset_path: destPath,
|
|
301
|
+
image: { format: "png", width: d.width, height: d.height, bytes: imgData.length },
|
|
302
|
+
provenance: {
|
|
303
|
+
workflow_id: pack.lane,
|
|
304
|
+
workflow_version: "1.0.0",
|
|
305
|
+
checkpoint: d.checkpoint,
|
|
306
|
+
loras,
|
|
307
|
+
prompt: fullPrompt,
|
|
308
|
+
negative_prompt: pack.negative,
|
|
309
|
+
seed,
|
|
310
|
+
steps: d.steps,
|
|
311
|
+
cfg: d.cfg,
|
|
312
|
+
sampler: d.sampler,
|
|
313
|
+
scheduler: d.scheduler,
|
|
314
|
+
width: d.width,
|
|
315
|
+
height: d.height,
|
|
316
|
+
generation_time_ms: elapsed,
|
|
317
|
+
gpu_model: "RTX 5080",
|
|
318
|
+
batch_index: generated,
|
|
319
|
+
},
|
|
320
|
+
judgment: null,
|
|
321
|
+
canon: null,
|
|
322
|
+
iteration: null,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
await writeFile(
|
|
326
|
+
join(GAME_ROOT, `records/${assetId}.json`),
|
|
327
|
+
JSON.stringify(record, null, 2),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
console.log(` \x1b[32m✓\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.log(` \x1b[31m✗\x1b[0m ${err.message}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
generated++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
console.log("");
|
|
340
|
+
console.log(`\x1b[32m✓\x1b[0m Generated ${generated} candidates`);
|
|
341
|
+
if (dryRun) console.log(" (dry run — no images produced)");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function variation_list(variations) {
|
|
345
|
+
return variations;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
main().catch((err) => {
|
|
349
|
+
console.error(err);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|