@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,633 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * generate-identity.js — Drive ComfyUI to produce named-subject identity images.
5
+ *
6
+ * Reads identity packets from inputs/identity-packets/ (NOT the archetype
7
+ * prompt packs in inputs/prompts/). Emits records with extended `identity`
8
+ * and `lineage` blocks per canon/identity-gates.md.
9
+ *
10
+ * Usage:
11
+ * node scripts/generate-identity.js inputs/identity-packets/wave27a-identity-spine.json
12
+ * node scripts/generate-identity.js inputs/identity-packets/wave27a-identity-spine.json --dry-run
13
+ * node scripts/generate-identity.js inputs/identity-packets/wave27a-identity-spine.json --subject kael_maren
14
+ * node scripts/generate-identity.js inputs/identity-packets/wave27a-identity-spine.json --seeds 3
15
+ *
16
+ * Options:
17
+ * --dry-run Print what would be generated without touching ComfyUI
18
+ * --subject NAME Only generate shots for one subject (by subject_name)
19
+ * --seeds N Number of discovery seeds per shot (default: 3)
20
+ * --phase MODE Generation phase: discovery | follow_on (default: discovery)
21
+ * --anchor FILE Anchor source image path (required for --phase follow_on)
22
+ * --denoise N Denoise strength for follow_on phase (default: 0.38)
23
+ *
24
+ * Phases:
25
+ * discovery — txt2img from prompt, 3 seeds per shot. No prior image input.
26
+ * follow_on — img2img from anchor image. Requires --anchor and --denoise.
27
+ * Anchor curation happens between phases (manual step).
28
+ */
29
+
30
+ import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
31
+ import { join } from "node:path";
32
+
33
+ const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
34
+ const REPO_ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1");
35
+ const GAME = process.argv.find((a, i) => process.argv[i - 1] === '--game') || 'star-freight';
36
+ const GAME_ROOT = join(REPO_ROOT, 'games', GAME);
37
+
38
+ // ── Defaults (match existing lab pipeline) ──
39
+
40
+ const DEFAULTS = {
41
+ checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
42
+ loras: [{ name: "classipeintxl_v21.safetensors", weight: 1.0 }],
43
+ steps: 8,
44
+ cfg: 2.0,
45
+ sampler: "dpmpp_sde",
46
+ scheduler: "karras",
47
+ width: 1024,
48
+ height: 1024,
49
+ base_seed: 27000,
50
+ };
51
+
52
+ // ── Validation ──
53
+
54
+ const REQUIRED_IDENTITY_FIELDS = [
55
+ "subject_name",
56
+ "subject_type",
57
+ "faction",
58
+ "role",
59
+ ];
60
+
61
+ const REQUIRED_SHOT_FIELDS = [
62
+ "id",
63
+ "view_type",
64
+ "shot_type",
65
+ "identity_anchor",
66
+ "scene_function",
67
+ "prompt",
68
+ ];
69
+
70
+ const VALID_VIEW_TYPES = [
71
+ "anchor_portrait",
72
+ "full_body",
73
+ "context",
74
+ "expression_variant",
75
+ "establishing",
76
+ "signature_interior",
77
+ "functional_detail",
78
+ "lived_in_proof",
79
+ ];
80
+
81
+ const VALID_VARIANT_INTENTS = [
82
+ "persistence_under_shift",
83
+ "role_read_shift",
84
+ ];
85
+
86
+ function validatePacket(pack) {
87
+ const errors = [];
88
+
89
+ if (!pack.lane) errors.push("Missing 'lane'");
90
+ if (!pack.style_prefix) errors.push("Missing 'style_prefix'");
91
+ if (!pack.negative_base) errors.push("Missing 'negative_base'");
92
+ if (!pack.subjects?.length) errors.push("No subjects defined");
93
+
94
+ for (const subject of pack.subjects || []) {
95
+ const sn = subject.subject_name || "(unnamed)";
96
+
97
+ for (const field of REQUIRED_IDENTITY_FIELDS) {
98
+ if (!subject[field]) {
99
+ errors.push(`Subject ${sn}: missing required field '${field}'`);
100
+ }
101
+ }
102
+
103
+ if (!subject.identity_lock) {
104
+ errors.push(`Subject ${sn}: missing identity_lock`);
105
+ } else if (subject.subject_type === "named_character") {
106
+ if (!subject.identity_lock.non_negotiable_details?.length) {
107
+ errors.push(`Subject ${sn}: identity_lock missing non_negotiable_details`);
108
+ }
109
+ if (!subject.identity_lock.forbidden_drift_cues?.length) {
110
+ errors.push(`Subject ${sn}: identity_lock missing forbidden_drift_cues`);
111
+ }
112
+ } else if (subject.subject_type === "named_ship") {
113
+ if (!subject.identity_lock.non_negotiable_details?.length) {
114
+ errors.push(`Subject ${sn}: ship identity_lock missing non_negotiable_details`);
115
+ }
116
+ if (!subject.identity_lock.recurring_identity_cues?.length) {
117
+ errors.push(`Subject ${sn}: ship identity_lock missing recurring_identity_cues`);
118
+ }
119
+ }
120
+
121
+ for (const shot of subject.shots || []) {
122
+ for (const field of REQUIRED_SHOT_FIELDS) {
123
+ if (shot[field] === undefined || shot[field] === null) {
124
+ errors.push(`Subject ${sn}, shot ${shot.id || "(unnamed)"}: missing '${field}'`);
125
+ }
126
+ }
127
+
128
+ if (!VALID_VIEW_TYPES.includes(shot.view_type)) {
129
+ errors.push(`Subject ${sn}, shot ${shot.id}: invalid view_type '${shot.view_type}'`);
130
+ }
131
+
132
+ // Variant intent enforcement
133
+ if (shot.view_type === "expression_variant") {
134
+ if (!shot.variant_intent) {
135
+ errors.push(`Subject ${sn}, shot ${shot.id}: expression_variant missing variant_intent`);
136
+ } else if (!VALID_VARIANT_INTENTS.includes(shot.variant_intent)) {
137
+ errors.push(
138
+ `Subject ${sn}, shot ${shot.id}: invalid variant_intent '${shot.variant_intent}'. ` +
139
+ `Must be one of: ${VALID_VARIANT_INTENTS.join(", ")}`
140
+ );
141
+ }
142
+ if (!shot.variant_constraint) {
143
+ errors.push(`Subject ${sn}, shot ${shot.id}: expression_variant missing variant_constraint`);
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ return errors;
150
+ }
151
+
152
+ // ── ComfyUI API ──
153
+
154
+ async function comfyHealth() {
155
+ try {
156
+ const res = await fetch(`${COMFY_URL}/system_stats`);
157
+ return res.ok;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ function buildTxt2ImgWorkflow(prompt, negativePrompt, seed) {
164
+ const nodes = {};
165
+ let nextId = 1;
166
+
167
+ const ckptId = String(nextId++);
168
+ nodes[ckptId] = {
169
+ class_type: "CheckpointLoaderSimple",
170
+ inputs: { ckpt_name: DEFAULTS.checkpoint },
171
+ };
172
+
173
+ let modelOut = [ckptId, 0];
174
+ let clipOut = [ckptId, 1];
175
+
176
+ for (const lora of DEFAULTS.loras) {
177
+ const loraId = String(nextId++);
178
+ nodes[loraId] = {
179
+ class_type: "LoraLoader",
180
+ inputs: {
181
+ model: modelOut,
182
+ clip: clipOut,
183
+ lora_name: lora.name,
184
+ strength_model: lora.weight,
185
+ strength_clip: lora.weight,
186
+ },
187
+ };
188
+ modelOut = [loraId, 0];
189
+ clipOut = [loraId, 1];
190
+ }
191
+
192
+ const posId = String(nextId++);
193
+ nodes[posId] = {
194
+ class_type: "CLIPTextEncode",
195
+ inputs: { text: prompt, clip: clipOut },
196
+ };
197
+
198
+ const negId = String(nextId++);
199
+ nodes[negId] = {
200
+ class_type: "CLIPTextEncode",
201
+ inputs: { text: negativePrompt, clip: clipOut },
202
+ };
203
+
204
+ const latentId = String(nextId++);
205
+ nodes[latentId] = {
206
+ class_type: "EmptyLatentImage",
207
+ inputs: { width: DEFAULTS.width, height: DEFAULTS.height, batch_size: 1 },
208
+ };
209
+
210
+ const samplerId = String(nextId++);
211
+ nodes[samplerId] = {
212
+ class_type: "KSampler",
213
+ inputs: {
214
+ model: modelOut,
215
+ positive: [posId, 0],
216
+ negative: [negId, 0],
217
+ latent_image: [latentId, 0],
218
+ seed,
219
+ steps: DEFAULTS.steps,
220
+ cfg: DEFAULTS.cfg,
221
+ sampler_name: DEFAULTS.sampler,
222
+ scheduler: DEFAULTS.scheduler,
223
+ denoise: 1.0,
224
+ },
225
+ };
226
+
227
+ const vaeId = String(nextId++);
228
+ nodes[vaeId] = {
229
+ class_type: "VAEDecode",
230
+ inputs: { samples: [samplerId, 0], vae: [ckptId, 2] },
231
+ };
232
+
233
+ const saveId = String(nextId++);
234
+ nodes[saveId] = {
235
+ class_type: "SaveImage",
236
+ inputs: { images: [vaeId, 0], filename_prefix: "idp" },
237
+ };
238
+
239
+ return nodes;
240
+ }
241
+
242
+ function buildImg2ImgWorkflow(prompt, negativePrompt, seed, imagePath, denoise) {
243
+ const nodes = {};
244
+ let nextId = 1;
245
+
246
+ const ckptId = String(nextId++);
247
+ nodes[ckptId] = {
248
+ class_type: "CheckpointLoaderSimple",
249
+ inputs: { ckpt_name: DEFAULTS.checkpoint },
250
+ };
251
+
252
+ let modelOut = [ckptId, 0];
253
+ let clipOut = [ckptId, 1];
254
+
255
+ for (const lora of DEFAULTS.loras) {
256
+ const loraId = String(nextId++);
257
+ nodes[loraId] = {
258
+ class_type: "LoraLoader",
259
+ inputs: {
260
+ model: modelOut,
261
+ clip: clipOut,
262
+ lora_name: lora.name,
263
+ strength_model: lora.weight,
264
+ strength_clip: lora.weight,
265
+ },
266
+ };
267
+ modelOut = [loraId, 0];
268
+ clipOut = [loraId, 1];
269
+ }
270
+
271
+ // Load source image
272
+ const loadImgId = String(nextId++);
273
+ nodes[loadImgId] = {
274
+ class_type: "LoadImage",
275
+ inputs: { image: imagePath },
276
+ };
277
+
278
+ // Encode image to latent via VAE
279
+ const encodeId = String(nextId++);
280
+ nodes[encodeId] = {
281
+ class_type: "VAEEncode",
282
+ inputs: { pixels: [loadImgId, 0], vae: [ckptId, 2] },
283
+ };
284
+
285
+ const posId = String(nextId++);
286
+ nodes[posId] = {
287
+ class_type: "CLIPTextEncode",
288
+ inputs: { text: prompt, clip: clipOut },
289
+ };
290
+
291
+ const negId = String(nextId++);
292
+ nodes[negId] = {
293
+ class_type: "CLIPTextEncode",
294
+ inputs: { text: negativePrompt, clip: clipOut },
295
+ };
296
+
297
+ const samplerId = String(nextId++);
298
+ nodes[samplerId] = {
299
+ class_type: "KSampler",
300
+ inputs: {
301
+ model: modelOut,
302
+ positive: [posId, 0],
303
+ negative: [negId, 0],
304
+ latent_image: [encodeId, 0],
305
+ seed,
306
+ steps: 10,
307
+ cfg: 2.5,
308
+ sampler_name: DEFAULTS.sampler,
309
+ scheduler: DEFAULTS.scheduler,
310
+ denoise,
311
+ },
312
+ };
313
+
314
+ const vaeId = String(nextId++);
315
+ nodes[vaeId] = {
316
+ class_type: "VAEDecode",
317
+ inputs: { samples: [samplerId, 0], vae: [ckptId, 2] },
318
+ };
319
+
320
+ const saveId = String(nextId++);
321
+ nodes[saveId] = {
322
+ class_type: "SaveImage",
323
+ inputs: { images: [vaeId, 0], filename_prefix: "idp" },
324
+ };
325
+
326
+ return nodes;
327
+ }
328
+
329
+ const MAX_POLL_MS = 600_000; // 10 minutes
330
+
331
+ async function submitAndWait(workflow) {
332
+ const clientId = `idp-${Date.now()}`;
333
+ const res = await fetch(`${COMFY_URL}/prompt`, {
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ prompt: workflow, client_id: clientId }),
337
+ });
338
+
339
+ if (!res.ok) {
340
+ const text = await res.text();
341
+ throw new Error(`ComfyUI submit failed: ${res.status} ${text}`);
342
+ }
343
+
344
+ const { prompt_id: promptId } = await res.json();
345
+
346
+ const start = Date.now();
347
+ while (true) {
348
+ if (Date.now() - start > MAX_POLL_MS) throw new Error(`ComfyUI poll timeout after ${MAX_POLL_MS/1000}s for prompt ${promptId}`);
349
+ await new Promise((r) => setTimeout(r, 1000));
350
+ const histRes = await fetch(`${COMFY_URL}/history/${promptId}`);
351
+ if (!histRes.ok) continue;
352
+ const history = await histRes.json();
353
+ const entry = history[promptId];
354
+ if (!entry) continue;
355
+ if (entry.status?.completed) return entry;
356
+ if (entry.status?.status_str === "error") {
357
+ throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status)}`);
358
+ }
359
+ }
360
+ }
361
+
362
+ async function downloadImage(filename, subfolder, type = "output") {
363
+ const url = `${COMFY_URL}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || "")}&type=${type}`;
364
+ const res = await fetch(url);
365
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
366
+ return Buffer.from(await res.arrayBuffer());
367
+ }
368
+
369
+ // ── Record Builder ──
370
+
371
+ function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, elapsedMs, phase, anchorInfo) {
372
+ const assetId = `${shot.id}_s${seedIndex}`;
373
+ const fullPrompt = `${pack.style_prefix}, ${shot.prompt}`;
374
+ const negativePrompt = [
375
+ pack.negative_base,
376
+ shot.negative_additions || "",
377
+ ].filter(Boolean).join(", ");
378
+
379
+ return {
380
+ id: assetId,
381
+ schema_version: "2.0.0",
382
+ created_at: new Date().toISOString(),
383
+ asset_path: destPath,
384
+ image: {
385
+ format: "png",
386
+ width: DEFAULTS.width,
387
+ height: DEFAULTS.height,
388
+ bytes: imgData.length,
389
+ },
390
+ provenance: {
391
+ workflow_id: pack.lane,
392
+ workflow_version: "1.0.0",
393
+ checkpoint: DEFAULTS.checkpoint,
394
+ loras: DEFAULTS.loras,
395
+ prompt: fullPrompt,
396
+ negative_prompt: negativePrompt,
397
+ seed,
398
+ steps: phase === "follow_on" ? 10 : DEFAULTS.steps,
399
+ cfg: phase === "follow_on" ? 2.5 : DEFAULTS.cfg,
400
+ sampler: DEFAULTS.sampler,
401
+ scheduler: DEFAULTS.scheduler,
402
+ width: DEFAULTS.width,
403
+ height: DEFAULTS.height,
404
+ generation_time_ms: elapsedMs,
405
+ gpu_model: "RTX 5080",
406
+ denoise: phase === "follow_on" ? (anchorInfo?.denoise || 0.38) : 1.0,
407
+ },
408
+ identity: {
409
+ subject_name: subject.subject_name,
410
+ subject_type: subject.subject_type,
411
+ faction: subject.faction,
412
+ role: subject.role,
413
+ view_type: shot.view_type,
414
+ shot_type: shot.shot_type,
415
+ identity_anchor: shot.identity_anchor,
416
+ location_name: shot.location_name || null,
417
+ ship_name: subject.subject_type === "named_ship" ? subject.subject_name : null,
418
+ scene_function: shot.scene_function,
419
+ variant_intent: shot.variant_intent || null,
420
+ variant_constraint: shot.variant_constraint || null,
421
+ },
422
+ lineage: {
423
+ generation_phase: phase === "follow_on" ? "follow_on" : "discovery",
424
+ anchor_source_image: anchorInfo?.source_image || null,
425
+ anchor_subject_version: anchorInfo?.subject_version || null,
426
+ identity_persistence_score: null,
427
+ derived_from_record_id: anchorInfo?.record_id || null,
428
+ },
429
+ judgment: null,
430
+ canon: null,
431
+ iteration: null,
432
+ };
433
+ }
434
+
435
+ // ── Main ──
436
+
437
+ async function main() {
438
+ const args = process.argv.slice(2);
439
+ const packPath = args.find((a) => !a.startsWith("--"));
440
+ const dryRun = args.includes("--dry-run");
441
+ const subjectFilter = args.includes("--subject")
442
+ ? args[args.indexOf("--subject") + 1]
443
+ : null;
444
+ const seedCount = args.includes("--seeds")
445
+ ? parseInt(args[args.indexOf("--seeds") + 1], 10)
446
+ : 3;
447
+ const phase = args.includes("--phase")
448
+ ? args[args.indexOf("--phase") + 1]
449
+ : "discovery";
450
+ const anchorPath = args.includes("--anchor")
451
+ ? args[args.indexOf("--anchor") + 1]
452
+ : null;
453
+ const denoise = args.includes("--denoise")
454
+ ? parseFloat(args[args.indexOf("--denoise") + 1])
455
+ : 0.38;
456
+
457
+ if (!packPath) {
458
+ console.error("Usage: node scripts/generate-identity.js <packet-file> [options]");
459
+ process.exit(1);
460
+ }
461
+
462
+ if (phase === "follow_on" && !anchorPath) {
463
+ console.error("Error: --phase follow_on requires --anchor <image-path>");
464
+ process.exit(1);
465
+ }
466
+
467
+ const fullPackPath = join(GAME_ROOT, packPath);
468
+ const pack = JSON.parse(await readFile(fullPackPath, "utf-8"));
469
+
470
+ // Validate
471
+ const errors = validatePacket(pack);
472
+ if (errors.length > 0) {
473
+ console.error("\x1b[31mPacket validation failed:\x1b[0m");
474
+ for (const e of errors) console.error(` - ${e}`);
475
+ process.exit(1);
476
+ }
477
+
478
+ // Filter subjects if requested
479
+ let subjects = pack.subjects;
480
+ if (subjectFilter) {
481
+ subjects = subjects.filter((s) => s.subject_name === subjectFilter);
482
+ if (subjects.length === 0) {
483
+ console.error(`No subject found with name '${subjectFilter}'`);
484
+ process.exit(1);
485
+ }
486
+ }
487
+
488
+ // Count shots
489
+ const totalShots = subjects.reduce((sum, s) => sum + s.shots.length, 0);
490
+ const totalImages = totalShots * seedCount;
491
+
492
+ console.log("\x1b[1mstyle-dataset-lab\x1b[0m identity-packet generator");
493
+ console.log(` Lane: ${pack.lane}`);
494
+ console.log(` Phase: ${phase}`);
495
+ console.log(` Subjects: ${subjects.length}`);
496
+ console.log(` Shots: ${totalShots}`);
497
+ console.log(` Seeds per shot: ${seedCount}`);
498
+ console.log(` Total images: ${totalImages}`);
499
+ if (phase === "follow_on") {
500
+ console.log(` Anchor: ${anchorPath}`);
501
+ console.log(` Denoise: ${denoise}`);
502
+ }
503
+ console.log("");
504
+
505
+ if (!dryRun) {
506
+ const online = await comfyHealth();
507
+ if (!online) {
508
+ console.error("\x1b[31mError:\x1b[0m ComfyUI not reachable at " + COMFY_URL);
509
+ console.error(
510
+ "Start ComfyUI: cd F:/AI-Models/ComfyUI-runtime && python main.py --listen 127.0.0.1 --port 8188"
511
+ );
512
+ process.exit(1);
513
+ }
514
+ console.log("\x1b[32m+\x1b[0m ComfyUI online");
515
+ }
516
+
517
+ await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
518
+ await mkdir(join(GAME_ROOT, "records"), { recursive: true });
519
+
520
+ let generated = 0;
521
+ let imageIndex = 0;
522
+
523
+ for (const subject of subjects) {
524
+ console.log(`\n\x1b[1m${subject.subject_name}\x1b[0m (${subject.subject_type}, ${subject.faction})`);
525
+
526
+ for (const shot of subject.shots) {
527
+ console.log(` ${shot.id} [${shot.view_type}]`);
528
+
529
+ const fullPrompt = `${pack.style_prefix}, ${shot.prompt}`;
530
+ const negativePrompt = [
531
+ pack.negative_base,
532
+ shot.negative_additions || "",
533
+ ].filter(Boolean).join(", ");
534
+
535
+ for (let si = 0; si < seedCount; si++) {
536
+ const seed = DEFAULTS.base_seed + imageIndex;
537
+ const assetId = `${shot.id}_s${si}`;
538
+ const destPath = `outputs/candidates/${assetId}.png`;
539
+
540
+ console.log(` [${generated + 1}/${totalImages}] ${assetId} (seed: ${seed})`);
541
+
542
+ if (dryRun) {
543
+ generated++;
544
+ imageIndex++;
545
+ continue;
546
+ }
547
+
548
+ const startMs = Date.now();
549
+ let workflow;
550
+
551
+ if (phase === "follow_on") {
552
+ workflow = buildImg2ImgWorkflow(fullPrompt, negativePrompt, seed, anchorPath, denoise);
553
+ } else {
554
+ workflow = buildTxt2ImgWorkflow(fullPrompt, negativePrompt, seed);
555
+ }
556
+
557
+ try {
558
+ const result = await submitAndWait(workflow);
559
+ const elapsed = Date.now() - startMs;
560
+
561
+ const outputs = result.outputs || {};
562
+ let imageFile = null;
563
+ let imageSubfolder = "";
564
+ for (const nodeOut of Object.values(outputs)) {
565
+ if (nodeOut.images?.length > 0) {
566
+ imageFile = nodeOut.images[0].filename;
567
+ imageSubfolder = nodeOut.images[0].subfolder || "";
568
+ break;
569
+ }
570
+ }
571
+
572
+ if (!imageFile) {
573
+ console.log(" \x1b[33m!\x1b[0m No output image");
574
+ generated++;
575
+ imageIndex++;
576
+ continue;
577
+ }
578
+
579
+ const imgData = await downloadImage(imageFile, imageSubfolder);
580
+ await writeFile(join(GAME_ROOT, destPath), imgData);
581
+
582
+ const anchorInfo =
583
+ phase === "follow_on"
584
+ ? {
585
+ source_image: anchorPath,
586
+ subject_version: `${subject.subject_name}_v1`,
587
+ denoise,
588
+ record_id: null,
589
+ }
590
+ : null;
591
+
592
+ const record = buildRecord(
593
+ pack, subject, shot, seed, si,
594
+ imgData, destPath, elapsed, phase, anchorInfo
595
+ );
596
+
597
+ await writeFile(
598
+ join(GAME_ROOT, `records/${assetId}.json`),
599
+ JSON.stringify(record, null, 2)
600
+ );
601
+
602
+ console.log(` \x1b[32m+\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
603
+ } catch (err) {
604
+ console.log(` \x1b[31mx\x1b[0m ${err.message}`);
605
+ }
606
+
607
+ generated++;
608
+ imageIndex++;
609
+ }
610
+ }
611
+ }
612
+
613
+ console.log(`\n\x1b[32m+\x1b[0m Generated ${generated} images`);
614
+ if (dryRun) console.log(" (dry run — no images produced)");
615
+
616
+ // Summary
617
+ console.log("\nNext steps:");
618
+ if (phase === "discovery") {
619
+ console.log(" 1. Review candidates in outputs/candidates/");
620
+ console.log(" 2. Select anchors and move to outputs/approved/");
621
+ console.log(" 3. Update anchor records: generation_phase -> 'anchor', identity_anchor -> true");
622
+ console.log(" 4. Run follow_on phase: --phase follow_on --anchor <anchor-image>");
623
+ } else {
624
+ console.log(" 1. Review follow-on images against their anchor");
625
+ console.log(" 2. Score identity_persistence_score in the lineage block");
626
+ console.log(" 3. Apply identity gates (IC-1..6 or IL-1..5) for acceptance");
627
+ }
628
+ }
629
+
630
+ main().catch((err) => {
631
+ console.error(err);
632
+ process.exit(1);
633
+ });