@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.
@@ -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
+ });