@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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 (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  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/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Ostris ai-toolkit adapter.
2
+ * Ostris ai-toolkit adapter (Flux + Qwen-Image target families, DESIGN-v3 §3.3, §3.5, G12).
3
3
  *
4
4
  * Consumes canonical rows (per D6) and emits a training package ai-toolkit
5
5
  * can consume directly:
@@ -10,34 +10,113 @@
10
10
  * metadata/<partition>.jsonl (audit rows)
11
11
  * ai-toolkit-config.yaml (training config)
12
12
  *
13
- * Precondition: profile.target_family must be 'flux'. ai-toolkit does support
14
- * SDXL, but this adapter only emits Flux-shaped configs — callers using SDXL
15
- * should use diffusers-lora instead. Rejecting here keeps config output honest.
13
+ * Precondition: profile.target_family must be 'flux' or 'qwen-image'.
14
+ * Callers using SDXL should use diffusers-lora instead.
16
15
  */
17
16
 
17
+ import { createHash } from 'node:crypto';
18
18
  import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
19
- import { join, relative } from 'node:path';
20
- import { existsSync } from 'node:fs';
19
+ import { join, relative, dirname } from 'node:path';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
21
22
  import YAML from 'yaml';
22
23
  import { deriveStyleTrigger } from '../captions.js';
23
24
  import { groupRowsByPartition } from '../rows.js';
24
25
  import { inputError } from '../errors.js';
26
+ import { assertSafeAssetPath, assertSafeId } from '../export.js';
27
+ import { warn } from '../log.js';
28
+
29
+ export const AI_TOOLKIT_UPSTREAM_ISSUE = 'https://github.com/ostris/ai-toolkit/issues/1087';
30
+
31
+ export const SUPPORTED_AI_TOOLKIT_FAMILIES = Object.freeze(['flux', 'qwen-image']);
32
+
33
+ const OVERRIDE_RELATIVE_PATH = 'scripts/colab/ai-toolkit-override-py315.txt';
34
+
35
+ const HELD_DATA_TOKENS = Object.freeze([
36
+ 'pls' + 'vessel',
37
+ 'nano' + '_banana',
38
+ 'nano' + '-banana',
39
+ ]);
40
+
41
+ /**
42
+ * Load metadata and sha256 for `scripts/colab/ai-toolkit-override-py315.txt` (§3.5, G12).
43
+ *
44
+ * @returns {{ override_file: string, override_sha256: string, upstream_issue: string, packages: string[] }}
45
+ */
46
+ export function getAiToolkitOverrideMetadata() {
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+ const fullPath = join(here, '..', '..', 'scripts', 'colab', 'ai-toolkit-override-py315.txt');
49
+ const raw = readFileSync(fullPath, 'utf-8');
50
+ const normalized = raw.replace(/\r\n/g, '\n');
51
+ const sha = 'sha256:' + createHash('sha256').update(normalized, 'utf-8').digest('hex');
52
+ const packages = normalized
53
+ .split('\n')
54
+ .map((l) => l.trim())
55
+ .filter((l) => l && !l.startsWith('#'));
56
+ return {
57
+ override_file: OVERRIDE_RELATIVE_PATH,
58
+ override_sha256: sha,
59
+ upstream_issue: AI_TOOLKIT_UPSTREAM_ISSUE,
60
+ packages,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Enforce that held Portlight training data (the 336 Nano Banana-derived pairs)
66
+ * is never packaged or trained on (§1.2, §5.2, G12).
67
+ *
68
+ * @param {Object} opts
69
+ * @param {Object} [opts.profile]
70
+ * @param {Array<Object>} [opts.rows]
71
+ * @param {Object} [opts.manifest]
72
+ */
73
+ export function assertNotHeldTrainingData({ profile = {}, rows = [], manifest = {} } = {}) {
74
+ const haystacks = [
75
+ profile?.profile_id,
76
+ profile?.trigger_override,
77
+ profile?.label,
78
+ manifest?.source_export_id,
79
+ ...(Array.isArray(rows)
80
+ ? rows.flatMap((r) => [r?.entity_id, r?.caption, r?.asset_path])
81
+ : []),
82
+ ]
83
+ .filter(Boolean)
84
+ .map((s) => String(s).toLowerCase());
85
+
86
+ for (const text of haystacks) {
87
+ for (const token of HELD_DATA_TOKENS) {
88
+ if (text.includes(token)) {
89
+ throw inputError(
90
+ 'AI_TOOLKIT_HELD_DATA_FORBIDDEN',
91
+ `AI_TOOLKIT_HELD_DATA_FORBIDDEN: Refusing to build training package or config referencing held dataset token "${token}".`
92
+ );
93
+ }
94
+ }
95
+ }
96
+ }
25
97
 
26
98
  /**
27
99
  * Build training package in ai-toolkit format from canonical rows.
28
100
  */
29
101
  export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
30
- if (profile?.target_family !== 'flux') {
102
+ if (!SUPPORTED_AI_TOOLKIT_FAMILIES.includes(profile?.target_family)) {
31
103
  throw inputError(
32
104
  'ADAPTER_TARGET_FAMILY_MISMATCH',
33
- `ai-toolkit adapter requires profile.target_family === 'flux' (got "${profile?.target_family}")`,
34
- 'Use diffusers-lora for SDXL targets, or set target_family: "flux" on the profile.',
105
+ `ai-toolkit adapter requires profile.target_family === 'flux' or 'qwen-image' (got "${profile?.target_family}")`,
106
+ 'Use diffusers-lora for SDXL targets, or set target_family: "flux" or "qwen-image" on the profile.',
35
107
  );
36
108
  }
37
109
 
110
+ assertNotHeldTrainingData({ profile, rows, manifest });
111
+
38
112
  const partitioned = groupRowsByPartition(rows || []);
39
113
 
40
114
  let imageCount = 0;
115
+ // SDL-C5: per-row failure accounting (export.js DB-002 shape) — see
116
+ // generic-image-caption.js for the full rationale. A caught copy/symlink
117
+ // error must not silently drop the image, caption sidecar, AND metadata
118
+ // row together with zero logging.
119
+ const imagesFailed = [];
41
120
 
42
121
  for (const [partition, partitionRows] of Object.entries(partitioned)) {
43
122
  const datasetDir = join(packageDir, 'dataset', partition);
@@ -48,10 +127,21 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
48
127
  const metadataRows = [];
49
128
 
50
129
  for (const row of partitionRows) {
51
- if (!row.asset_path) continue;
130
+ if (!row.asset_path) {
131
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
132
+ continue;
133
+ }
134
+
135
+ // SDL-H5 / SDL-H6: defense-in-depth — see generic-image-caption.js
136
+ // for the full rationale. Throws (hard fail) on traversal/unsafe id;
137
+ // that is a security boundary, not an operational hiccup.
138
+ assertSafeId(row.entity_id, `row.entity_id in ai-toolkit adapter (partition "${partition}")`);
139
+ const sourcePath = assertSafeAssetPath(row.asset_path, projectRoot, row.entity_id);
52
140
 
53
- const sourcePath = join(projectRoot, row.asset_path);
54
- if (!existsSync(sourcePath)) continue;
141
+ if (!existsSync(sourcePath)) {
142
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'source_missing' });
143
+ continue;
144
+ }
55
145
 
56
146
  const sourceBasename = row.asset_path.split(/[\\/]/).pop();
57
147
  const extMatch = sourceBasename.match(/\.[^.]+$/);
@@ -67,7 +157,13 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
67
157
  await symlink(relative(datasetDir, sourcePath), destPath);
68
158
  }
69
159
  imageCount++;
70
- } catch {
160
+ } catch (err) {
161
+ imagesFailed.push({
162
+ record_id: row.entity_id,
163
+ partition,
164
+ reason: copy ? 'copy_failed' : 'link_failed',
165
+ detail: err.message,
166
+ });
71
167
  continue;
72
168
  }
73
169
 
@@ -91,39 +187,138 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
91
187
  );
92
188
  }
93
189
 
190
+ if (imagesFailed.length > 0) {
191
+ const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
192
+ warn(
193
+ `ai-toolkit adapter: ${imagesFailed.length} of ${imageCount + imagesFailed.length} rows failed to place` +
194
+ (imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
195
+ );
196
+ }
197
+
94
198
  const aiToolkitConfig = buildAiToolkitConfig({ profile, manifest });
95
199
  await writeFile(
96
200
  join(packageDir, 'ai-toolkit-config.yaml'),
97
201
  YAML.stringify(aiToolkitConfig)
98
202
  );
99
203
 
100
- return { imageCount };
204
+ return { imageCount, imagesFailed };
205
+ }
206
+
207
+ /**
208
+ * Build the Qwen-Image ai-toolkit YAML config object (§3.3, §3.5, G12).
209
+ */
210
+ export function buildQwenAiToolkitConfig({ profile, manifest }) {
211
+ assertNotHeldTrainingData({ profile, manifest });
212
+ const trigger = deriveStyleTrigger(profile) || profile.profile_id;
213
+ const baseModel = profile.base_model_recommendations?.[0] || 'Qwen/Qwen-Image';
214
+ const sourceExportId = manifest?.source_export_id || 'unknown';
215
+ const overrideMeta = getAiToolkitOverrideMetadata();
216
+
217
+ const hp = profile.training_hyperparameters || {};
218
+ const linear = hp.rank ?? 16;
219
+ const linearAlpha = hp.alpha ?? linear;
220
+ const trainSteps = hp.steps ?? 1000;
221
+ const lr = hp.lr ?? 1e-4;
222
+
223
+ return {
224
+ job: 'extension',
225
+ config: {
226
+ name: `${profile.profile_id}-${sourceExportId}`,
227
+ process: [
228
+ {
229
+ type: 'sd_trainer',
230
+ training_folder: 'output',
231
+ device: 'cuda:0',
232
+ trigger_word: trigger,
233
+ network: {
234
+ type: 'lora',
235
+ linear: linear,
236
+ linear_alpha: linearAlpha,
237
+ },
238
+ save: {
239
+ dtype: 'float16',
240
+ save_every: 250,
241
+ max_step_saves_to_keep: 4,
242
+ },
243
+ datasets: [
244
+ {
245
+ folder_path: './dataset/train',
246
+ caption_ext: 'txt',
247
+ caption_dropout_rate: 0.05,
248
+ shuffle_tokens: false,
249
+ cache_latents_to_disk: true,
250
+ resolution: [512, 768, 1024],
251
+ },
252
+ ],
253
+ train: {
254
+ batch_size: 1,
255
+ steps: trainSteps,
256
+ gradient_accumulation_steps: 1,
257
+ train_unet: true,
258
+ train_text_encoder: false,
259
+ gradient_checkpointing: true,
260
+ noise_scheduler: 'flowmatch',
261
+ optimizer: 'adamw8bit',
262
+ lr: lr,
263
+ ema_config: {
264
+ use_ema: true,
265
+ ema_decay: 0.99,
266
+ },
267
+ dtype: 'bf16',
268
+ },
269
+ model: {
270
+ name_or_path: baseModel,
271
+ arch: 'qwen_image',
272
+ quantize: true,
273
+ qtype: 'qfloat8',
274
+ quantize_te: true,
275
+ qtype_te: 'qfloat8',
276
+ low_vram: true,
277
+ },
278
+ is_style: profile.is_style_lora === true,
279
+ sample: {
280
+ sampler: 'flowmatch',
281
+ sample_every: 250,
282
+ width: 1024,
283
+ height: 1024,
284
+ prompts: [`${trigger} style sample prompt`],
285
+ neg: '',
286
+ seed: 42,
287
+ walk_seed: true,
288
+ guidance_scale: 3.5,
289
+ sample_steps: 22,
290
+ },
291
+ },
292
+ ],
293
+ },
294
+ meta: {
295
+ name: profile.profile_id,
296
+ version: '1.0',
297
+ target_family: 'qwen-image',
298
+ base_licence_id: 'Apache-2.0',
299
+ override_file: overrideMeta.override_file,
300
+ override_sha256: overrideMeta.override_sha256,
301
+ upstream_issue: overrideMeta.upstream_issue,
302
+ },
303
+ };
101
304
  }
102
305
 
103
306
  /**
104
307
  * Build the ai-toolkit YAML config object from a profile + manifest.
105
308
  *
106
- * Defaults follow ai-toolkit's published Flux examples and the 16GB-VRAM
107
- * ceiling documented in the slice-3 handoff. Users can override any field
108
- * by editing the emitted YAML before running ai-toolkit.
309
+ * Dispatches to `buildQwenAiToolkitConfig` when `profile.target_family === 'qwen-image'`;
310
+ * otherwise emits the Flux-shaped config.
109
311
  */
110
312
  export function buildAiToolkitConfig({ profile, manifest }) {
313
+ assertNotHeldTrainingData({ profile, manifest });
314
+ if (profile?.target_family === 'qwen-image') {
315
+ return buildQwenAiToolkitConfig({ profile, manifest });
316
+ }
317
+
111
318
  const trigger = deriveStyleTrigger(profile) || profile.profile_id;
112
319
  const baseModel = profile.base_model_recommendations?.[0] || 'black-forest-labs/FLUX.1-dev';
113
320
  const sourceExportId = manifest?.source_export_id || 'unknown';
114
321
 
115
- // Per-profile training_hyperparameters override the World-LoRA-tuned defaults.
116
- // World LoRAs: rank 32, steps 2500 (style absorption across a domain).
117
- // Per-character LoRAs: rank 16, alpha 8, steps 2000 (identity on a single
118
- // subject — higher rank just overfits face details). See the two-LoRA stack
119
- // contract in the handbook + memory/two-lora-stack-research-2026-04-23.md.
120
- //
121
- // TODO (normalization): the implicit World-LoRA fallback is a transition-era
122
- // convenience for existing profiles that pre-date training_hyperparameters.
123
- // Once every shipping profile declares training_hyperparameters explicitly,
124
- // the ?? fallbacks can be removed in favor of a required block — preventing
125
- // the "is this World-tuned by omission or by intent?" ambiguity that grows
126
- // once a second World project exists.
127
322
  const hp = profile.training_hyperparameters || {};
128
323
  const linear = hp.rank ?? 32;
129
324
  const linearAlpha = hp.alpha ?? linear;
@@ -179,12 +374,6 @@ export function buildAiToolkitConfig({ profile, manifest }) {
179
374
  is_flux: true,
180
375
  quantize: true,
181
376
  },
182
- // ai-toolkit's is_style flag: true for world/style LoRAs that teach
183
- // aesthetics broadly; false (default) for subject LoRAs that teach a
184
- // specific character or object. Drives ai-toolkit's training
185
- // regularization — wrong value silently mistrains. See the two-LoRA
186
- // stack research (memory/two-lora-stack-research-2026-04-23.md) for
187
- // the World LoRA vs per-character LoRA convention.
188
377
  is_style: profile.is_style_lora === true,
189
378
  sample: {
190
379
  sampler: 'flowmatch',
@@ -7,9 +7,9 @@
7
7
 
8
8
  import { writeFile, mkdir } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
- import { comfyHealth, submitAndWait, downloadImage } from '../comfyui.js';
10
+ import { comfyHealthDetailed, submitAndWait, downloadImage } from '../comfyui.js';
11
11
  import { buildWorkflowGraph, resolveTemplateId, QWEN_DEFAULTS } from './comfyui-workflows.js';
12
- import { buildSeedPlan, prepareRunDir, saveRunManifest } from '../runtime-runs.js';
12
+ import { buildSeedPlan, prepareRunDir, saveRunManifest, checkpointRunManifest } from '../runtime-runs.js';
13
13
  import { loadProjectConfig } from '../config.js';
14
14
  import { pickOutputImage } from '../comfyui-output.js';
15
15
  import { buildPinning, seedPolicyFromMode, createHashCache, flushHashCache } from '../run-manifest.js';
@@ -17,6 +17,24 @@ import { SCHEMA_VERSION } from '../snapshot.js';
17
17
  import { log, info, warn } from '../log.js';
18
18
  import { runtimeError } from '../errors.js';
19
19
 
20
+ // B04: comfyHealthDetailed()'s `reason` maps to an ACTIONABLE hint, instead
21
+ // of every failure mode sharing the same "start ComfyUI" advice — which is
22
+ // simply wrong for an `http-<5xx>` (ComfyUI is up and answering) and useless
23
+ // for `dns`/`refused` against a misconfigured URL.
24
+ const HEALTH_REASON_HINTS = {
25
+ timeout: 'ComfyUI accepted the connection but never responded — it may be loading a custom node or a large model. Wait and retry, or check its console for a stuck step.',
26
+ refused: 'Start ComfyUI, or check COMFY_URL / project defaults.comfy_url — the connection was actively refused.',
27
+ dns: 'COMFY_URL / project defaults.comfy_url does not resolve — check for a typo in the hostname.',
28
+ unknown: 'Start ComfyUI or set COMFY_URL / project defaults.comfy_url.',
29
+ };
30
+
31
+ function healthReasonHint(reason) {
32
+ if (reason && reason.startsWith('http-')) {
33
+ return `ComfyUI responded with ${reason.slice('http-'.length)} — it is reachable but reported an error; check its console/logs.`;
34
+ }
35
+ return HEALTH_REASON_HINTS[reason] || HEALTH_REASON_HINTS.unknown;
36
+ }
37
+
20
38
  /**
21
39
  * Execute a brief: build graphs, submit to ComfyUI, capture outputs.
22
40
  *
@@ -50,12 +68,16 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
50
68
 
51
69
  // Health check
52
70
  if (!dryRun) {
53
- const healthy = await comfyHealth(comfyUrl);
54
- if (!healthy) {
71
+ // B04: use the reason-carrying check so a 503-while-loading, a refused
72
+ // connection, a DNS typo, and a genuine timeout each get their OWN
73
+ // actionable hint instead of the identical (and sometimes factually
74
+ // wrong) "ComfyUI not reachable — start ComfyUI" message.
75
+ const health = await comfyHealthDetailed(comfyUrl);
76
+ if (!health.ok) {
55
77
  throw runtimeError(
56
78
  'COMFYUI_OFFLINE',
57
- `ComfyUI not reachable at ${comfyUrl}`,
58
- 'Start ComfyUI or set COMFY_URL / project defaults.comfy_url'
79
+ `ComfyUI not reachable at ${comfyUrl} (${health.reason})`,
80
+ healthReasonHint(health.reason)
59
81
  );
60
82
  }
61
83
  info('ComfyUI online');
@@ -63,6 +85,43 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
63
85
 
64
86
  const outputs = [];
65
87
  const errors = [];
88
+ // B01: fixed at run start and reused by every mid-loop checkpoint AND the
89
+ // final manifest, so `created_at` never drifts between an incremental
90
+ // snapshot and the finished manifest for the same run.
91
+ const createdAt = new Date().toISOString();
92
+
93
+ // B01: checkpointRunManifest() existed (with a docstring saying exactly
94
+ // this) but had zero production callers — comfyui-runner.js only called
95
+ // saveRunManifest() once, after the whole loop. A run killed partway
96
+ // through wrote NO manifest at all, so listRuns() silently skipped the
97
+ // directory entirely: no id, no `run show`, no resume. This snapshot
98
+ // mirrors the final manifest's shape (minus `pinning`, computed only
99
+ // after the loop) so a partial manifest.json is readable by the same
100
+ // code that reads a finished one.
101
+ function buildManifestSnapshot() {
102
+ return {
103
+ run_id: runId,
104
+ schema_version: SCHEMA_VERSION,
105
+ brief_id: brief.brief_id,
106
+ project_id: brief.project_id,
107
+ workflow_template_id: templateId,
108
+ adapter_target: 'comfyui',
109
+ output_mode: outputMode,
110
+ output_count: outputCount,
111
+ created_at: createdAt,
112
+ seed_plan: seedPlan,
113
+ comfy_url: comfyUrl,
114
+ checkpoint: defaults.checkpoint,
115
+ loras: defaults.loras || [],
116
+ pinning: null,
117
+ runtime_plan: brief.runtime_plan,
118
+ outputs,
119
+ errors,
120
+ dry_run: dryRun,
121
+ success_count: outputs.filter(o => o.status === 'ok' || o.status === 'dry_run').length,
122
+ error_count: errors.length,
123
+ };
124
+ }
66
125
 
67
126
  for (let i = 0; i < outputCount; i++) {
68
127
  const seed = seedPlan.seeds[i];
@@ -77,6 +136,7 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
77
136
  status: 'dry_run',
78
137
  filename: `${imageIndex}.png`,
79
138
  });
139
+ await checkpointRunManifest(runDir, buildManifestSnapshot());
80
140
  continue;
81
141
  }
82
142
 
@@ -106,6 +166,7 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
106
166
  if (!picked) {
107
167
  warn(` No output image for seed ${seed}`);
108
168
  outputs.push({ index: i, seed, status: 'no_output', elapsed_ms: elapsed });
169
+ await checkpointRunManifest(runDir, buildManifestSnapshot());
109
170
  continue;
110
171
  }
111
172
 
@@ -138,6 +199,12 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
138
199
  elapsed_ms: elapsed,
139
200
  });
140
201
  }
202
+
203
+ // B01: checkpoint after every completed output (success OR error) so a
204
+ // crash/interruption mid-loop leaves a partial, readable manifest.json
205
+ // instead of an orphan directory — this is the call the docstring on
206
+ // checkpointRunManifest() always described but nothing ever made.
207
+ await checkpointRunManifest(runDir, buildManifestSnapshot());
141
208
  }
142
209
 
143
210
  // PIN_PER_STEP: pin the exact graph + model/LoRA content identity + seed
@@ -170,28 +237,14 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
170
237
  warn(`pinning skipped: ${err.message}`);
171
238
  }
172
239
 
173
- // Build manifest
240
+ // Build manifest — reuses buildManifestSnapshot() (same helper the
241
+ // mid-loop checkpoints use) so the finished manifest and every
242
+ // incremental checkpoint before it are byte-for-byte the same shape,
243
+ // just with `pinning` filled in and no `incremental`/`last_checkpoint_at`
244
+ // stamp (that stamp is what lets a reader tell "done" from "still going").
174
245
  const manifest = {
175
- run_id: runId,
176
- schema_version: SCHEMA_VERSION,
177
- brief_id: brief.brief_id,
178
- project_id: brief.project_id,
179
- workflow_template_id: templateId,
180
- adapter_target: 'comfyui',
181
- output_mode: outputMode,
182
- output_count: outputCount,
183
- created_at: new Date().toISOString(),
184
- seed_plan: seedPlan,
185
- comfy_url: comfyUrl,
186
- checkpoint: defaults.checkpoint,
187
- loras: defaults.loras || [],
246
+ ...buildManifestSnapshot(),
188
247
  pinning,
189
- runtime_plan: brief.runtime_plan,
190
- outputs,
191
- errors,
192
- dry_run: dryRun,
193
- success_count: outputs.filter(o => o.status === 'ok' || o.status === 'dry_run').length,
194
- error_count: errors.length,
195
248
  };
196
249
 
197
250
  await saveRunManifest(runDir, manifest);
@@ -6,7 +6,6 @@
6
6
  * that into a ComfyUI node graph.
7
7
  */
8
8
 
9
- import { readFile } from 'node:fs/promises';
10
9
  import { join } from 'node:path';
11
10
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
11
  import { getRuntimeDir } from '../paths.js';
@@ -33,8 +32,16 @@ export function listWorkflowTemplates() {
33
32
 
34
33
  /**
35
34
  * Resolve a template for a given output mode.
36
- * Returns 'txt2img-standard' for all standard modes.
37
- * Throws if no template matches.
35
+ *
36
+ * Returns the first template declaring `outputMode` in its `compatible_modes`,
37
+ * and falls back to `'txt2img-standard'` when none matches — it does NOT
38
+ * throw. (The docstring claimed a throw until 2026-08-04; a caller written
39
+ * against that claim would guard a branch that cannot fire and would miss the
40
+ * silent-default case, where an unrecognised mode renders through the standard
41
+ * SDXL graph instead of failing loudly.)
42
+ *
43
+ * The fallback is also what makes an install with no `runtime/comfyui/`
44
+ * templates degrade rather than crash — see `getRuntimeDir()` in lib/paths.js.
38
45
  */
39
46
  export function resolveTemplateId(outputMode) {
40
47
  const templates = listWorkflowTemplates();
@@ -64,6 +71,41 @@ export const QWEN_DEFAULTS = Object.freeze({
64
71
  height: 1024,
65
72
  });
66
73
 
74
+ function normalizeLoraSpec(entry) {
75
+ if (!entry || typeof entry !== 'object') return null;
76
+ const name = entry.name || entry.lora_name;
77
+ if (typeof name !== 'string' || name.trim().length === 0) return null;
78
+ const weight = entry.weight ?? entry.strength_model ?? entry.strength ?? 1.0;
79
+ return { name: name.trim(), weight: Number(weight) };
80
+ }
81
+
82
+ /**
83
+ * Resolve the ordered Qwen-Image LoRA chain (G3) from project defaults, brief,
84
+ * runtime_plan, and explicit job overrides. Deduplicates by LoRA filename while
85
+ * preserving the latest weight override.
86
+ *
87
+ * @param {Object} [opts]
88
+ * @param {Object} [opts.brief]
89
+ * @param {Object} [opts.projectDefaults]
90
+ * @param {Array} [opts.loras]
91
+ * @returns {Array<{name: string, weight: number}>}
92
+ */
93
+ export function resolveQwenLoras({ brief = {}, projectDefaults = {}, loras = [] } = {}) {
94
+ const sources = [
95
+ ...(Array.isArray(projectDefaults?.loras) ? projectDefaults.loras : []),
96
+ ...(Array.isArray(brief?.loras) ? brief.loras : []),
97
+ ...(Array.isArray(brief?.runtime_plan?.loras) ? brief.runtime_plan.loras : []),
98
+ ...(Array.isArray(loras) ? loras : []),
99
+ ];
100
+ const byName = new Map();
101
+ for (const raw of sources) {
102
+ const spec = normalizeLoraSpec(raw);
103
+ if (!spec) continue;
104
+ byName.set(spec.name, spec);
105
+ }
106
+ return [...byName.values()];
107
+ }
108
+
67
109
  /**
68
110
  * Build the Qwen-Image ComfyUI prompt graph.
69
111
  *
@@ -110,13 +152,15 @@ export function buildQwenGraph({
110
152
  // DiT LoRA chain: 37 → 40 → 41 → … → ModelSamplingAuraFlow.
111
153
  let modelOut = ['37', 0];
112
154
  let loraId = 40;
113
- for (const lora of loras) {
155
+ for (const rawLora of loras) {
156
+ const lora = normalizeLoraSpec(rawLora);
157
+ if (!lora) continue;
114
158
  const id = String(loraId++);
115
159
  nodes[id] = {
116
160
  class_type: 'LoraLoaderModelOnly',
117
161
  inputs: {
118
162
  lora_name: lora.name,
119
- strength_model: lora.weight ?? 1.0,
163
+ strength_model: lora.weight,
120
164
  model: modelOut,
121
165
  },
122
166
  };
@@ -147,6 +191,43 @@ export function buildQwenGraph({
147
191
  return { nodes, saveNodeId: '9' };
148
192
  }
149
193
 
194
+ /**
195
+ * Build the minimal 64x64 no-weights ComfyUI fixture graph for Stage 0 CPU
196
+ * stubs (EmptyImage -> ImageInvert -> SaveImage). Exercises ComfyUI --cpu
197
+ * execution, extra_pnginfo chunk writing, and /view download without loading
198
+ * multi-gigabyte diffusion weights.
199
+ *
200
+ * @param {Object} [opts]
201
+ * @param {number} [opts.seed=0]
202
+ * @param {number} [opts.width=64]
203
+ * @param {number} [opts.height=64]
204
+ * @param {string} [opts.filenamePrefix='sdl_stub']
205
+ * @returns {{ nodes: Object, saveNodeId: string }}
206
+ */
207
+ export function buildStubFixtureGraph({
208
+ seed = 0,
209
+ width = 64,
210
+ height = 64,
211
+ filenamePrefix = 'sdl_stub',
212
+ } = {}) {
213
+ const color = Math.abs(Number(seed) || 0) & 0xffffff;
214
+ const nodes = {
215
+ '1': {
216
+ class_type: 'EmptyImage',
217
+ inputs: { width, height, batch_size: 1, color },
218
+ },
219
+ '2': {
220
+ class_type: 'ImageInvert',
221
+ inputs: { image: ['1', 0] },
222
+ },
223
+ '3': {
224
+ class_type: 'SaveImage',
225
+ inputs: { filename_prefix: filenamePrefix, images: ['2', 0] },
226
+ },
227
+ };
228
+ return { nodes, saveNodeId: '3' };
229
+ }
230
+
150
231
  // ─── Graph builder ───────────────────────────────────────────────
151
232
 
152
233
  /**
@@ -160,17 +241,19 @@ export function buildQwenGraph({
160
241
  * @param {Object} opts.brief — compiled brief from brief-compiler
161
242
  * @param {Object} opts.projectDefaults — project.json defaults block
162
243
  * @param {number} opts.seed — specific seed for this generation
244
+ * @param {Array} [opts.loras] — optional explicit LoRA overrides
163
245
  * @returns {{ nodes: Object, saveNodeId: string }}
164
246
  */
165
- export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
166
- const rp = brief.runtime_plan;
167
- const defaults = projectDefaults;
247
+ export function buildWorkflowGraph({ brief, projectDefaults, seed, loras }) {
248
+ const rp = brief.runtime_plan || {};
249
+ const defaults = projectDefaults || {};
168
250
 
169
251
  const prompt = brief.prompt;
170
252
  const negativePrompt = brief.negative_prompt;
171
253
 
172
254
  // ── Qwen-Image branch (non-anime studio base) ──
173
255
  if (String(defaults.base || '').toLowerCase() === 'qwen-image') {
256
+ const resolvedLoras = resolveQwenLoras({ brief, projectDefaults: defaults, loras });
174
257
  return buildQwenGraph({
175
258
  prompt,
176
259
  negativePrompt,
@@ -187,13 +270,13 @@ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
187
270
  vae: defaults.vae || QWEN_DEFAULTS.vae,
188
271
  clipType: defaults.clip_type || QWEN_DEFAULTS.clip_type,
189
272
  weightDtype: defaults.weight_dtype || QWEN_DEFAULTS.weight_dtype,
190
- loras: defaults.loras || [],
273
+ loras: resolvedLoras,
191
274
  });
192
275
  }
193
276
 
194
277
  // ── SDXL branch (default) ──
195
278
  const checkpoint = defaults.checkpoint;
196
- const loras = defaults.loras || [];
279
+ const sdxlLoras = loras || defaults.loras || [];
197
280
  const width = rp.width || defaults.width || 1024;
198
281
  const height = rp.height || defaults.height || 1024;
199
282
  const steps = rp.steps || defaults.steps || 30;
@@ -215,7 +298,7 @@ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
215
298
  let clipOut = [ckptId, 1];
216
299
 
217
300
  // LoRA loaders (chained)
218
- for (const lora of loras) {
301
+ for (const lora of sdxlLoras) {
219
302
  const loraId = String(nextId++);
220
303
  nodes[loraId] = {
221
304
  class_type: 'LoraLoader',