@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
@@ -14,12 +14,13 @@
14
14
 
15
15
  import { writeFile, mkdir } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
- import { getProjectName, parseNumberFlag } from "../lib/args.js";
18
- import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
17
+ import { getProjectName, parseNumberFlag, takeFlagValue } from "../lib/args.js";
18
+ import { getProjectRoot, resolveSafeProjectPath } from "../lib/paths.js";
19
19
  import { readJsonFile } from "../lib/config.js";
20
20
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
21
21
  import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
22
22
  import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
23
+ import { isResumable, logResumedSkip } from "./_resume.js";
23
24
 
24
25
  const DEFAULTS = {
25
26
  checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
@@ -33,27 +34,32 @@ const DEFAULTS = {
33
34
  base_seed: 27100,
34
35
  };
35
36
 
37
+ // H4: takeFlagValue() (lib/args.js) recognizes BOTH `--flag value` and
38
+ // `--flag=value`. The prior `argv.indexOf(flag)`-based get() only matched a
39
+ // token EXACTLY equal to e.g. "--weight" — false for the single token
40
+ // "--weight=1.1" — so the equals form silently fell back to the default
41
+ // with no error and no warning.
36
42
  function parseLocalArgs(argv) {
37
- const get = (flag, def) => {
38
- const i = argv.indexOf(flag);
39
- if (i < 0 || i + 1 >= argv.length) return def;
40
- const v = argv[i + 1];
41
- if (typeof v === 'string' && v.startsWith('--')) {
42
- throw inputError('INPUT_MISSING_VALUE', `Flag ${flag} is missing its value (got another flag: ${v}).`);
43
- }
44
- return v;
43
+ const get = (flagName, def) => {
44
+ const v = takeFlagValue(argv, flagName);
45
+ return v === undefined ? def : v;
45
46
  };
46
47
  return {
47
- subject: get("--subject", "unknown"),
48
- guidePath: get("--guide", null),
49
- prompt: get("--prompt", null),
50
- promptFile: get("--prompt-file", null),
51
- negative: get("--negative", null),
52
- seeds: parseNumberFlag('seeds', get("--seeds", "4"), { int: true, min: 1 }),
53
- weight: parseNumberFlag('weight', get("--weight", "0.70"), { min: 0, max: 2 }),
54
- guidanceEnd: parseNumberFlag('guidance-end', get("--guidance-end", "0.65"), { min: 0, max: 1 }),
55
- controlModel: get("--model", "controlnet-canny-sdxl-1.0.safetensors"),
48
+ subject: get("subject", "unknown"),
49
+ guidePath: get("guide", null),
50
+ prompt: get("prompt", null),
51
+ promptFile: get("prompt-file", null),
52
+ negative: get("negative", null),
53
+ seeds: parseNumberFlag('seeds', get("seeds", "4"), { int: true, min: 1 }),
54
+ weight: parseNumberFlag('weight', get("weight", "0.70"), { min: 0, max: 2 }),
55
+ guidanceEnd: parseNumberFlag('guidance-end', get("guidance-end", "0.65"), { min: 0, max: 1 }),
56
+ controlModel: get("model", "controlnet-canny-sdxl-1.0.safetensors"),
56
57
  dryRun: argv.includes("--dry-run"),
58
+ // H5: skip a seed slot whose output image already exists. This script
59
+ // (unlike generate.js / generate-identity.js) never writes a
60
+ // records/<id>.json — only the output PNG — so resumability here is
61
+ // image-existence only; see scripts/_resume.js's requireRecord doc.
62
+ resume: argv.includes("--resume"),
57
63
  };
58
64
  }
59
65
 
@@ -169,7 +175,7 @@ function buildControlNetWorkflow(prompt, negativePrompt, seed, guideImagePath, c
169
175
 
170
176
  export async function run(argv = process.argv.slice(2)) {
171
177
  const projectName = getProjectName(argv);
172
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
178
+ const GAME_ROOT = getProjectRoot(projectName);
173
179
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
174
180
 
175
181
  const opts = parseLocalArgs(argv);
@@ -180,8 +186,7 @@ export async function run(argv = process.argv.slice(2)) {
180
186
 
181
187
  // Freeze gate (advisory): refuse if the subject's canon entry is frozen.
182
188
  // Supports --i-know + --reason bypass for soft-advisory entries.
183
- const reasonFlagIdx = argv.indexOf('--reason');
184
- const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
189
+ const bypassReason = takeFlagValue(argv, 'reason') ?? null;
185
190
  await assertNotFrozenBySubject(GAME_ROOT, opts.subject, {
186
191
  action: 'generate:controlnet',
187
192
  allowSoftAdvisoryBypass: argv.includes('--i-know'),
@@ -236,14 +241,28 @@ export async function run(argv = process.argv.slice(2)) {
236
241
  console.log(`\x1b[32m+\x1b[0m Guide uploaded: ${guideFilename}`);
237
242
  }
238
243
 
239
- await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
244
+ // SDL-M11: only create the output dir on a real run — a --dry-run preview
245
+ // must be side-effect-free (README.md promises "preview the effect first").
246
+ if (!opts.dryRun) {
247
+ await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
248
+ }
240
249
 
241
- let successes = 0, failures = 0;
250
+ let successes = 0, failures = 0, skipped = 0;
242
251
  for (let si = 0; si < opts.seeds; si++) {
243
252
  const seed = DEFAULTS.base_seed + si;
244
253
  const assetId = `${opts.subject}_cn_s${si}`;
245
254
  const destPath = `outputs/candidates/${assetId}.png`;
246
255
 
256
+ // H5: --resume skips a seed slot whose output image already exists.
257
+ // Seed here is derived from the loop index (si), not an externally
258
+ // mutated running counter, so skipping never desyncs a later seed — the
259
+ // for-loop's own si++ always fires regardless of this continue.
260
+ if (opts.resume && !opts.dryRun && isResumable(GAME_ROOT, assetId, { requireRecord: false })) {
261
+ logResumedSkip(si + 1, opts.seeds, assetId);
262
+ skipped++;
263
+ continue;
264
+ }
265
+
247
266
  console.log(` [${si + 1}/${opts.seeds}] ${assetId} (seed: ${seed})`);
248
267
 
249
268
  if (opts.dryRun) continue;
@@ -285,7 +304,8 @@ export async function run(argv = process.argv.slice(2)) {
285
304
  }
286
305
  }
287
306
 
288
- console.log(`\n\x1b[32m+\x1b[0m ControlNet discovery complete (${successes} success, ${failures} failed)`);
307
+ const skippedSuffix = skipped > 0 ? `, ${skipped} resumed` : '';
308
+ console.log(`\n\x1b[32m+\x1b[0m ControlNet discovery complete (${successes} success, ${failures} failed${skippedSuffix})`);
289
309
  if (opts.dryRun) console.log(" (dry run)");
290
310
  if (!opts.dryRun && failures > 0 && successes === 0) {
291
311
  throw runtimeError('RUNTIME_ALL_FAILED', `All ${opts.seeds} ControlNet generation attempts failed.`);
@@ -18,16 +18,20 @@
18
18
  * --phase MODE Generation phase: discovery | follow_on (default: discovery)
19
19
  * --anchor FILE Anchor source image path (required for --phase follow_on)
20
20
  * --denoise N Denoise strength for follow_on phase (default: 0.38)
21
+ * --resume Skip shots whose record + output image already exist
22
+ * (seed slots still advance, so a resumed run stays
23
+ * bit-identical to an uninterrupted one)
21
24
  */
22
25
 
23
26
  import { writeFile, mkdir } from "node:fs/promises";
24
27
  import { join } from "node:path";
25
- import { getProjectName, parseNumberFlag } from "../lib/args.js";
26
- import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
28
+ import { getProjectName, parseNumberFlag, takeFlagValue } from "../lib/args.js";
29
+ import { getProjectRoot, resolveSafeProjectPath } from "../lib/paths.js";
27
30
  import { readJsonFile, loadProjectMeta, resolveGpuModel } from "../lib/config.js";
28
31
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
29
32
  import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
30
33
  import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
34
+ import { isResumable, logResumedSkip } from "./_resume.js";
31
35
 
32
36
  // ── Defaults (match existing lab pipeline) ──
33
37
 
@@ -378,12 +382,12 @@ function buildRecord(pack, subject, shot, seed, seedIndex, imgData, destPath, el
378
382
 
379
383
  export async function run(argv = process.argv.slice(2)) {
380
384
  const projectName = getProjectName(argv);
381
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
385
+ const GAME_ROOT = getProjectRoot(projectName);
382
386
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
383
387
  const gpuModel = resolveGpuModel(loadProjectMeta(GAME_ROOT)?.defaults);
384
388
 
385
389
  // Collect positionals, skipping values that belong to known flags
386
- const knownFlagsWithValue = new Set(['--subject', '--seeds', '--phase', '--anchor', '--denoise', '--project', '--game']);
390
+ const knownFlagsWithValue = new Set(['--subject', '--seeds', '--phase', '--anchor', '--denoise', '--project', '--game', '--reason']);
387
391
  const positionals = [];
388
392
  for (let k = 0; k < argv.length; k++) {
389
393
  const a = argv[k];
@@ -395,16 +399,23 @@ export async function run(argv = process.argv.slice(2)) {
395
399
  }
396
400
  const packPath = positionals[0];
397
401
  const dryRun = argv.includes("--dry-run");
398
- const subjectFilter = argv.includes("--subject")
399
- ? argv[argv.indexOf("--subject") + 1]
400
- : null;
402
+ const resume = argv.includes("--resume");
403
+
404
+ // H4: takeFlagValue() (lib/args.js) recognizes BOTH `--flag value` and
405
+ // `--flag=value`. The previous `argv.includes('--x') ?
406
+ // argv[argv.indexOf('--x')+1] : default` pattern checked argv for a token
407
+ // EXACTLY EQUAL to "--x" — false for the single token "--phase=follow_on",
408
+ // so the equals form silently fell back to the default. For --phase that
409
+ // meant `--phase=follow_on` ran the discovery (txt2img) workflow instead
410
+ // of follow_on (img2img) — silently, on an hours-long GPU job. Same gap
411
+ // closed here for --seeds / --anchor / --denoise / --subject / --reason.
412
+ const subjectFilter = takeFlagValue(argv, 'subject') ?? null;
401
413
 
402
414
  // Freeze gate (advisory): when --subject is given, refuse if that subject's
403
415
  // canon entry is frozen. No-op when --subject is absent (pack-driven) or
404
416
  // when the project has no canon-build config.
405
417
  if (subjectFilter) {
406
- const reasonFlagIdx = argv.indexOf('--reason');
407
- const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
418
+ const bypassReason = takeFlagValue(argv, 'reason') ?? null;
408
419
  await assertNotFrozenBySubject(GAME_ROOT, subjectFilter, {
409
420
  action: 'generate:identity',
410
421
  allowSoftAdvisoryBypass: argv.includes('--i-know'),
@@ -412,17 +423,15 @@ export async function run(argv = process.argv.slice(2)) {
412
423
  by: 'mike',
413
424
  });
414
425
  }
415
- const seedCount = argv.includes("--seeds")
416
- ? parseNumberFlag('seeds', argv[argv.indexOf("--seeds") + 1], { int: true, min: 1 })
426
+ const seedsRaw = takeFlagValue(argv, 'seeds');
427
+ const seedCount = seedsRaw !== undefined
428
+ ? parseNumberFlag('seeds', seedsRaw, { int: true, min: 1 })
417
429
  : 3;
418
- const phase = argv.includes("--phase")
419
- ? argv[argv.indexOf("--phase") + 1]
420
- : "discovery";
421
- const anchorPath = argv.includes("--anchor")
422
- ? argv[argv.indexOf("--anchor") + 1]
423
- : null;
424
- const denoise = argv.includes("--denoise")
425
- ? parseNumberFlag('denoise', argv[argv.indexOf("--denoise") + 1], { min: 0, max: 1 })
430
+ const phase = takeFlagValue(argv, 'phase') ?? "discovery";
431
+ const anchorPath = takeFlagValue(argv, 'anchor') ?? null;
432
+ const denoiseRaw = takeFlagValue(argv, 'denoise');
433
+ const denoise = denoiseRaw !== undefined
434
+ ? parseNumberFlag('denoise', denoiseRaw, { min: 0, max: 1 })
426
435
  : 0.38;
427
436
 
428
437
  if (!packPath) {
@@ -474,11 +483,16 @@ export async function run(argv = process.argv.slice(2)) {
474
483
  console.log("\x1b[32m+\x1b[0m ComfyUI online");
475
484
  }
476
485
 
477
- await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
478
- await mkdir(join(GAME_ROOT, "records"), { recursive: true });
486
+ // SDL-M11: only create output dirs on a real run — a --dry-run preview must
487
+ // be side-effect-free (README.md promises "preview the effect first").
488
+ if (!dryRun) {
489
+ await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
490
+ await mkdir(join(GAME_ROOT, "records"), { recursive: true });
491
+ }
479
492
 
480
493
  let generated = 0;
481
494
  let errors = 0;
495
+ let skipped = 0;
482
496
  let imageIndex = 0;
483
497
 
484
498
  for (const subject of subjects) {
@@ -498,6 +512,21 @@ export async function run(argv = process.argv.slice(2)) {
498
512
  const assetId = `${shot.id}_s${si}`;
499
513
  const destPath = `outputs/candidates/${assetId}.png`;
500
514
 
515
+ // H5: --resume skips a shot whose record AND output image already
516
+ // exist, but imageIndex (and therefore seed) still advances exactly
517
+ // as it would have if the shot had been processed — a resumed run
518
+ // reproduces the identical seed sequence an uninterrupted run would
519
+ // have produced. Ported from generate.js, the one generator that had
520
+ // this; generate-identity is the most expensive per-image generator
521
+ // in the pipeline, so it is the one that most needed it.
522
+ if (resume && !dryRun && isResumable(GAME_ROOT, assetId)) {
523
+ logResumedSkip(generated + 1, totalImages, assetId);
524
+ generated++;
525
+ skipped++;
526
+ imageIndex++;
527
+ continue;
528
+ }
529
+
501
530
  console.log(` [${generated + 1}/${totalImages}] ${assetId} (seed: ${seed})`);
502
531
 
503
532
  if (dryRun) {
@@ -572,8 +601,9 @@ export async function run(argv = process.argv.slice(2)) {
572
601
  }
573
602
  }
574
603
 
575
- const succeeded = generated - errors;
576
- console.log(`\n\x1b[32m+\x1b[0m Generated ${succeeded} images (${errors} errors)`);
604
+ const succeeded = generated - errors - skipped;
605
+ const skippedSuffix = skipped > 0 ? `, ${skipped} resumed` : '';
606
+ console.log(`\n\x1b[32m+\x1b[0m Generated ${succeeded} images (${errors} errors${skippedSuffix})`);
577
607
  if (dryRun) console.log(" (dry run — no images produced)");
578
608
  if (!dryRun && errors > 0 && succeeded === 0) {
579
609
  throw runtimeError('RUNTIME_ALL_FAILED', `All ${generated} generation attempts failed.`);
@@ -13,11 +13,12 @@
13
13
 
14
14
  import { writeFile, mkdir } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
- import { getProjectName, parseNumberFlag } from "../lib/args.js";
17
- import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
16
+ import { getProjectName, parseNumberFlag, takeFlagValue } from "../lib/args.js";
17
+ import { getProjectRoot, resolveSafeProjectPath } from "../lib/paths.js";
18
18
  import { inputError, runtimeError, handleCliError } from "../lib/errors.js";
19
19
  import { comfyHealth, submitAndWait, downloadImage, uploadImage } from "../lib/comfyui.js";
20
20
  import { assertNotFrozenBySubject } from "../lib/freeze-gate.js";
21
+ import { isResumable, logResumedSkip } from "./_resume.js";
21
22
 
22
23
  const DEFAULTS = {
23
24
  checkpoint: "dreamshaperXL_v21TurboDPMSDE.safetensors",
@@ -33,26 +34,31 @@ const DEFAULTS = {
33
34
  base_seed: 27200,
34
35
  };
35
36
 
37
+ // H4: takeFlagValue() (lib/args.js) recognizes BOTH `--flag value` and
38
+ // `--flag=value`. The prior `argv.indexOf(flag)`-based get() only matched a
39
+ // token EXACTLY equal to e.g. "--weight" — false for the single token
40
+ // "--weight=1.1" — so the equals form silently fell back to the default
41
+ // with no error and no warning.
36
42
  function parseLocalArgs(argv) {
37
- const get = (flag, def) => {
38
- const i = argv.indexOf(flag);
39
- if (i < 0 || i + 1 >= argv.length) return def;
40
- const v = argv[i + 1];
41
- if (typeof v === 'string' && v.startsWith('--')) {
42
- throw inputError('INPUT_MISSING_VALUE', `Flag ${flag} is missing its value (got another flag: ${v}).`);
43
- }
44
- return v;
43
+ const get = (flagName, def) => {
44
+ const v = takeFlagValue(argv, flagName);
45
+ return v === undefined ? def : v;
45
46
  };
46
47
  return {
47
- subject: get("--subject", "unknown"),
48
- refPath: get("--ref", null),
49
- prompt: get("--prompt", null),
50
- negative: get("--negative", null),
51
- seeds: parseNumberFlag('seeds', get("--seeds", "4"), { int: true, min: 1 }),
52
- weight: parseNumberFlag('weight', get("--weight", "0.55"), { min: 0, max: 2 }),
53
- start: parseNumberFlag('start', get("--start", "0.0"), { min: 0, max: 1 }),
54
- end: parseNumberFlag('end', get("--end", "0.8"), { min: 0, max: 1 }),
48
+ subject: get("subject", "unknown"),
49
+ refPath: get("ref", null),
50
+ prompt: get("prompt", null),
51
+ negative: get("negative", null),
52
+ seeds: parseNumberFlag('seeds', get("seeds", "4"), { int: true, min: 1 }),
53
+ weight: parseNumberFlag('weight', get("weight", "0.55"), { min: 0, max: 2 }),
54
+ start: parseNumberFlag('start', get("start", "0.0"), { min: 0, max: 1 }),
55
+ end: parseNumberFlag('end', get("end", "0.8"), { min: 0, max: 1 }),
55
56
  dryRun: argv.includes("--dry-run"),
57
+ // H5: skip a seed slot whose output image already exists. This script
58
+ // (unlike generate.js / generate-identity.js) never writes a
59
+ // records/<id>.json — only the output PNG — so resumability here is
60
+ // image-existence only; see scripts/_resume.js's requireRecord doc.
61
+ resume: argv.includes("--resume"),
56
62
  };
57
63
  }
58
64
 
@@ -175,7 +181,7 @@ function buildIPAdapterWorkflow(prompt, negativePrompt, seed, refImageName, weig
175
181
 
176
182
  export async function run(argv = process.argv.slice(2)) {
177
183
  const projectName = getProjectName(argv);
178
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
184
+ const GAME_ROOT = getProjectRoot(projectName);
179
185
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
180
186
 
181
187
  const opts = parseLocalArgs(argv);
@@ -188,8 +194,7 @@ export async function run(argv = process.argv.slice(2)) {
188
194
  }
189
195
 
190
196
  // Freeze gate (advisory): refuse if the subject's canon entry is frozen.
191
- const reasonFlagIdx = argv.indexOf('--reason');
192
- const bypassReason = reasonFlagIdx >= 0 && argv[reasonFlagIdx + 1] ? argv[reasonFlagIdx + 1] : null;
197
+ const bypassReason = takeFlagValue(argv, 'reason') ?? null;
193
198
  await assertNotFrozenBySubject(GAME_ROOT, opts.subject, {
194
199
  action: 'generate:ipadapter',
195
200
  allowSoftAdvisoryBypass: argv.includes('--i-know'),
@@ -224,13 +229,28 @@ export async function run(argv = process.argv.slice(2)) {
224
229
  console.log(`\x1b[32m+\x1b[0m Reference uploaded: ${refFilename}`);
225
230
  }
226
231
 
227
- await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
232
+ // SDL-M11: only create the output dir on a real run — a --dry-run preview
233
+ // must be side-effect-free (README.md promises "preview the effect first").
234
+ if (!opts.dryRun) {
235
+ await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
236
+ }
228
237
 
238
+ let skipped = 0;
229
239
  for (let si = 0; si < opts.seeds; si++) {
230
240
  const seed = DEFAULTS.base_seed + si;
231
241
  const assetId = `${opts.subject}_ipa_s${si}`;
232
242
  const destPath = `outputs/candidates/${assetId}.png`;
233
243
 
244
+ // H5: --resume skips a seed slot whose output image already exists.
245
+ // Seed here is derived from the loop index (si), not an externally
246
+ // mutated running counter, so skipping never desyncs a later seed — the
247
+ // for-loop's own si++ always fires regardless of this continue.
248
+ if (opts.resume && !opts.dryRun && isResumable(GAME_ROOT, assetId, { requireRecord: false })) {
249
+ logResumedSkip(si + 1, opts.seeds, assetId);
250
+ skipped++;
251
+ continue;
252
+ }
253
+
234
254
  console.log(` [${si + 1}/${opts.seeds}] ${assetId} (seed: ${seed})`);
235
255
 
236
256
  if (opts.dryRun) continue;
@@ -269,7 +289,8 @@ export async function run(argv = process.argv.slice(2)) {
269
289
  }
270
290
  }
271
291
 
272
- console.log("\n\x1b[32m+\x1b[0m IP-Adapter discovery complete");
292
+ const skippedSuffix = skipped > 0 ? ` (${skipped} resumed)` : '';
293
+ console.log(`\n\x1b[32m+\x1b[0m IP-Adapter discovery complete${skippedSuffix}`);
273
294
  if (opts.dryRun) console.log(" (dry run)");
274
295
  }
275
296
 
@@ -14,13 +14,14 @@ import { writeFile, mkdir } from "node:fs/promises";
14
14
  import { existsSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { parseArgs, getProjectName } from "../lib/args.js";
17
- import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
17
+ import { getProjectRoot, resolveSafeProjectPath } from "../lib/paths.js";
18
18
  import { readJsonFile, loadProjectMeta, resolveGpuModel } from "../lib/config.js";
19
19
  import { runtimeError, inputError, handleCliError } from "../lib/errors.js";
20
20
  import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
21
21
  import { pickOutputImage } from "../lib/comfyui-output.js";
22
22
  import { buildQwenGraph, QWEN_DEFAULTS } from "../lib/adapters/comfyui-workflows.js";
23
- import { info, result } from "../lib/log.js";
23
+ import { buildPinning, SEED_POLICIES, createHashCache, flushHashCache } from "../lib/run-manifest.js";
24
+ import { info, result, warn } from "../lib/log.js";
24
25
 
25
26
  /**
26
27
  * Format milliseconds as a human ETA string like "6m 12s" or "42s".
@@ -178,6 +179,34 @@ function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, c
178
179
  return { nodes, saveNodeId: saveId };
179
180
  }
180
181
 
182
+ /**
183
+ * Coordinator addendum (same session as H4/H5): every domain template's
184
+ * example-wave.json ships subject prompts with a literal `[DESCRIBE ...]`
185
+ * placeholder (e.g. "[DESCRIBE YOUR HERO — head and shoulders, ...]") that
186
+ * the user is meant to replace before generating. `sdlab init`'s own
187
+ * printed next-steps run generate before "edit the pack" is ever a
188
+ * mandatory step, so following them literally on an unedited pack burns a
189
+ * GPU wave on template text with no feedback until the images come back
190
+ * looking like a literal instruction string.
191
+ *
192
+ * All 5 domain templates use this exact "[DESCRIBE " shape (verified across
193
+ * templates/domains/*\/inputs/prompts/example-wave.json), so the check is
194
+ * narrow and specific to it — it does NOT flag ordinary SD prompt-editing
195
+ * bracket syntax such as "[word]" or "[from:to:0.5]", neither of which
196
+ * starts with the literal word DESCRIBE.
197
+ */
198
+ const PLACEHOLDER_PROMPT_RE = /\[DESCRIBE\b/;
199
+
200
+ function findPlaceholderPrompts(jobs) {
201
+ const hits = [];
202
+ for (const job of jobs) {
203
+ if (typeof job.prompt === 'string' && PLACEHOLDER_PROMPT_RE.test(job.prompt)) {
204
+ hits.push(job.assetId);
205
+ }
206
+ }
207
+ return hits;
208
+ }
209
+
181
210
  /**
182
211
  * Detect the input schema and normalize to a unified shape:
183
212
  * { lane, style_prefix, negative, defaults, base, jobs:[{assetId, subjectId, variationId, prompt, seed, loras}] }
@@ -263,7 +292,7 @@ function normalizeGenerationInput(raw, projectMeta) {
263
292
 
264
293
  export async function run(argv = process.argv.slice(2)) {
265
294
  const projectName = getProjectName(argv);
266
- const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
295
+ const GAME_ROOT = getProjectRoot(projectName);
267
296
  const COMFY_URL = process.env.COMFY_URL || "http://127.0.0.1:8188";
268
297
 
269
298
  const parsed = parseArgs(argv, {
@@ -272,12 +301,18 @@ export async function run(argv = process.argv.slice(2)) {
272
301
  game: { type: 'string' },
273
302
  'dry-run': { type: 'boolean' },
274
303
  resume: { type: 'boolean' },
304
+ 'hash-models': { type: 'boolean' },
275
305
  },
276
306
  deprecated: { game: 'project' },
277
307
  });
278
308
  const packPath = parsed.positionals[0] || "inputs/prompts/rpg-icons-lane1.json";
279
309
  const dryRun = parsed.flags['dry-run'] === true;
280
310
  const resume = parsed.flags['resume'] === true;
311
+ // PIN_PER_STEP: --hash-models opts into content hashing of the checkpoint/
312
+ // unet/clip/vae/LoRA files (best-effort, cached). Without it the pinning block
313
+ // still records comfy_workflow_sha + model name/size + seed_policy.
314
+ const hashModels = parsed.flags['hash-models'] === true;
315
+ const hashCache = hashModels ? createHashCache() : undefined;
281
316
 
282
317
  const fullPackPath = resolveSafeProjectPath(GAME_ROOT, packPath, { flagName: 'pack-path' });
283
318
  const raw = await readJsonFile(fullPackPath);
@@ -316,6 +351,17 @@ export async function run(argv = process.argv.slice(2)) {
316
351
  console.log(` Total candidates: ${pack.jobs.length}`);
317
352
  console.log("");
318
353
 
354
+ // Coordinator addendum: catch unedited template text before it burns GPU
355
+ // time (or, under --dry-run, before the user thinks the pack is ready).
356
+ const placeholderHits = findPlaceholderPrompts(pack.jobs);
357
+ if (placeholderHits.length > 0) {
358
+ warn(
359
+ `${placeholderHits.length} of ${pack.jobs.length} prompt(s) still contain unedited "[DESCRIBE ...]" template ` +
360
+ `text (e.g. "${placeholderHits[0]}"). Edit the prompt pack before generating for real — otherwise you'll spend ` +
361
+ `GPU time rendering placeholder instructions instead of your subject.`
362
+ );
363
+ }
364
+
319
365
  if (!dryRun) {
320
366
  const online = await comfyHealth(COMFY_URL);
321
367
  if (!online) {
@@ -324,8 +370,12 @@ export async function run(argv = process.argv.slice(2)) {
324
370
  console.log("\x1b[32m✓\x1b[0m ComfyUI online");
325
371
  }
326
372
 
327
- await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
328
- await mkdir(join(GAME_ROOT, "records"), { recursive: true });
373
+ // SDL-M11: only create output dirs on a real run — a --dry-run preview must
374
+ // be side-effect-free (README.md promises "preview the effect first").
375
+ if (!dryRun) {
376
+ await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
377
+ await mkdir(join(GAME_ROOT, "records"), { recursive: true });
378
+ }
329
379
 
330
380
  let generated = 0;
331
381
  let errors = 0;
@@ -432,6 +482,20 @@ export async function run(argv = process.argv.slice(2)) {
432
482
  provenance.loras = loras;
433
483
  }
434
484
 
485
+ // PIN_PER_STEP: pin the exact graph + model/LoRA content identity + seed
486
+ // policy via the shared contract (lib/run-manifest.js). Model hashing is
487
+ // wave-constant; the shared cache means it runs at most once per wave.
488
+ provenance.pinning = buildPinning({
489
+ graph: nodes,
490
+ models: isQwen
491
+ ? { unet: provenance.unet, clip: provenance.clip, vae: provenance.vae }
492
+ : { checkpoint: d.checkpoint },
493
+ loras: isQwen ? (d.loras || []) : loras,
494
+ seedPolicy: SEED_POLICIES.BASE_INCREMENT,
495
+ hashModels,
496
+ cache: hashCache,
497
+ });
498
+
435
499
  const record = {
436
500
  id: assetId,
437
501
  schema_version: "1.0.0",
@@ -467,6 +531,9 @@ export async function run(argv = process.argv.slice(2)) {
467
531
  }
468
532
  }
469
533
 
534
+ // Persist any newly computed model hashes so the next wave reuses them.
535
+ flushHashCache(hashCache);
536
+
470
537
  console.log("");
471
538
  const succeeded = generated - errors - skipped;
472
539
  const totalElapsedMs = Date.now() - runStartMs;
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { join } from 'node:path';
13
13
  import { parseArgs, getProjectName } from '../lib/args.js';
14
- import { REPO_ROOT } from '../lib/paths.js';
14
+ import { getProjectRoot } from '../lib/paths.js';
15
15
  import { inputError, handleCliError } from '../lib/errors.js';
16
16
  import { listTrainingManifests } from '../lib/training-manifests.js';
17
17
  import { buildImplementationPack, listImplementationPacks, loadImplementationPack } from '../lib/implementation-packs.js';
@@ -26,7 +26,7 @@ export async function run(argv = process.argv.slice(2)) {
26
26
  });
27
27
 
28
28
  const projectName = flags.project || getProjectName(argv);
29
- const projectRoot = join(REPO_ROOT, 'projects', projectName);
29
+ const projectRoot = getProjectRoot(projectName);
30
30
  const subcommand = positionals.find(a => !a.startsWith('impl-') && !a.startsWith('tm-')) || 'list';
31
31
 
32
32
  if (subcommand === 'build') {