@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.
- package/CHANGELOG.md +60 -0
- package/README.es.md +117 -55
- package/README.fr.md +125 -65
- package/README.hi.md +117 -68
- package/README.it.md +119 -58
- package/README.ja.md +124 -74
- package/README.md +73 -7
- package/README.pt-BR.md +128 -63
- package/README.zh.md +116 -65
- package/bin/sdlab.js +100 -4
- package/lib/adapters/ai-toolkit.js +224 -35
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +94 -11
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-author.js +350 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/canon-review-gate.js +205 -0
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +16 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +194 -6
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/lane1-render.js +668 -0
- package/lib/measure.js +422 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/paths.js +196 -7
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +113 -17
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +159 -25
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/train-runs.js +214 -0
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +4 -3
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/asset-ingest.js +160 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-draft.js +82 -0
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +31 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +182 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +86 -17
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/verify.js +67 -0
- package/scripts/workflow-list.js +2 -1
- 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'
|
|
14
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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 {
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
273
|
+
loras: resolvedLoras,
|
|
191
274
|
});
|
|
192
275
|
}
|
|
193
276
|
|
|
194
277
|
// ── SDXL branch (default) ──
|
|
195
278
|
const checkpoint = defaults.checkpoint;
|
|
196
|
-
const
|
|
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
|
|
301
|
+
for (const lora of sdxlLoras) {
|
|
219
302
|
const loraId = String(nextId++);
|
|
220
303
|
nodes[loraId] = {
|
|
221
304
|
class_type: 'LoraLoader',
|