@mcptoolshop/style-dataset-lab 3.4.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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.es.md +111 -59
  3. package/README.fr.md +117 -67
  4. package/README.hi.md +109 -70
  5. package/README.it.md +111 -60
  6. package/README.ja.md +116 -76
  7. package/README.md +58 -2
  8. package/README.pt-BR.md +119 -64
  9. package/README.zh.md +108 -67
  10. package/bin/sdlab.js +50 -1
  11. package/lib/adapters/ai-toolkit.js +187 -30
  12. package/lib/adapters/comfyui-workflows.js +84 -9
  13. package/lib/asset-ingest.js +575 -0
  14. package/lib/asset-masks.js +323 -0
  15. package/lib/asset-source.js +1176 -0
  16. package/lib/canon-author.js +350 -0
  17. package/lib/canon-review-gate.js +205 -0
  18. package/lib/colab-launcher-guards.js +285 -0
  19. package/lib/export.js +1 -1
  20. package/lib/freeze-gate.js +3 -0
  21. package/lib/generated-provenance.js +178 -5
  22. package/lib/lane1-render.js +668 -0
  23. package/lib/memory-store.js +663 -0
  24. package/lib/model-licences.js +305 -0
  25. package/lib/npy-meta.js +130 -0
  26. package/lib/png-meta.js +512 -0
  27. package/lib/records.js +54 -8
  28. package/lib/reingest-selected.js +15 -1
  29. package/lib/reingest.js +27 -3
  30. package/lib/run-manifest.js +59 -11
  31. package/lib/train-runs.js +214 -0
  32. package/lib/verifier.js +3 -0
  33. package/lib/verify-chain.js +367 -0
  34. package/package.json +2 -2
  35. package/scripts/asset-ingest.js +160 -0
  36. package/scripts/canon-draft.js +82 -0
  37. package/scripts/canon-freeze.js +4 -0
  38. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  39. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  40. package/scripts/colab/colab_window.sh +587 -0
  41. package/scripts/colab/lane1_render.py +970 -0
  42. package/scripts/colab/launch_guards.py +564 -0
  43. package/scripts/colab/launch_lane1_stub.sh +309 -0
  44. package/scripts/colab/launch_lane3_stub.sh +366 -0
  45. package/scripts/colab/remote_runner.py +59 -0
  46. package/scripts/doctor.js +1 -0
  47. package/scripts/generate.js +38 -12
  48. package/scripts/verify.js +67 -0
@@ -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,14 +10,15 @@
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';
@@ -25,18 +26,89 @@ import { inputError } from '../errors.js';
25
26
  import { assertSafeAssetPath, assertSafeId } from '../export.js';
26
27
  import { warn } from '../log.js';
27
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
+ }
97
+
28
98
  /**
29
99
  * Build training package in ai-toolkit format from canonical rows.
30
100
  */
31
101
  export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
32
- if (profile?.target_family !== 'flux') {
102
+ if (!SUPPORTED_AI_TOOLKIT_FAMILIES.includes(profile?.target_family)) {
33
103
  throw inputError(
34
104
  'ADAPTER_TARGET_FAMILY_MISMATCH',
35
- `ai-toolkit adapter requires profile.target_family === 'flux' (got "${profile?.target_family}")`,
36
- '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.',
37
107
  );
38
108
  }
39
109
 
110
+ assertNotHeldTrainingData({ profile, rows, manifest });
111
+
40
112
  const partitioned = groupRowsByPartition(rows || []);
41
113
 
42
114
  let imageCount = 0;
@@ -132,30 +204,121 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
132
204
  return { imageCount, imagesFailed };
133
205
  }
134
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
+ };
304
+ }
305
+
135
306
  /**
136
307
  * Build the ai-toolkit YAML config object from a profile + manifest.
137
308
  *
138
- * Defaults follow ai-toolkit's published Flux examples and the 16GB-VRAM
139
- * ceiling documented in the slice-3 handoff. Users can override any field
140
- * 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.
141
311
  */
142
312
  export function buildAiToolkitConfig({ profile, manifest }) {
313
+ assertNotHeldTrainingData({ profile, manifest });
314
+ if (profile?.target_family === 'qwen-image') {
315
+ return buildQwenAiToolkitConfig({ profile, manifest });
316
+ }
317
+
143
318
  const trigger = deriveStyleTrigger(profile) || profile.profile_id;
144
319
  const baseModel = profile.base_model_recommendations?.[0] || 'black-forest-labs/FLUX.1-dev';
145
320
  const sourceExportId = manifest?.source_export_id || 'unknown';
146
321
 
147
- // Per-profile training_hyperparameters override the World-LoRA-tuned defaults.
148
- // World LoRAs: rank 32, steps 2500 (style absorption across a domain).
149
- // Per-character LoRAs: rank 16, alpha 8, steps 2000 (identity on a single
150
- // subject — higher rank just overfits face details). See the two-LoRA stack
151
- // contract in the handbook + memory/two-lora-stack-research-2026-04-23.md.
152
- //
153
- // TODO (normalization): the implicit World-LoRA fallback is a transition-era
154
- // convenience for existing profiles that pre-date training_hyperparameters.
155
- // Once every shipping profile declares training_hyperparameters explicitly,
156
- // the ?? fallbacks can be removed in favor of a required block — preventing
157
- // the "is this World-tuned by omission or by intent?" ambiguity that grows
158
- // once a second World project exists.
159
322
  const hp = profile.training_hyperparameters || {};
160
323
  const linear = hp.rank ?? 32;
161
324
  const linearAlpha = hp.alpha ?? linear;
@@ -211,12 +374,6 @@ export function buildAiToolkitConfig({ profile, manifest }) {
211
374
  is_flux: true,
212
375
  quantize: true,
213
376
  },
214
- // ai-toolkit's is_style flag: true for world/style LoRAs that teach
215
- // aesthetics broadly; false (default) for subject LoRAs that teach a
216
- // specific character or object. Drives ai-toolkit's training
217
- // regularization — wrong value silently mistrains. See the two-LoRA
218
- // stack research (memory/two-lora-stack-research-2026-04-23.md) for
219
- // the World LoRA vs per-character LoRA convention.
220
377
  is_style: profile.is_style_lora === true,
221
378
  sample: {
222
379
  sampler: 'flowmatch',
@@ -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';
@@ -72,6 +71,41 @@ export const QWEN_DEFAULTS = Object.freeze({
72
71
  height: 1024,
73
72
  });
74
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
+
75
109
  /**
76
110
  * Build the Qwen-Image ComfyUI prompt graph.
77
111
  *
@@ -118,13 +152,15 @@ export function buildQwenGraph({
118
152
  // DiT LoRA chain: 37 → 40 → 41 → … → ModelSamplingAuraFlow.
119
153
  let modelOut = ['37', 0];
120
154
  let loraId = 40;
121
- for (const lora of loras) {
155
+ for (const rawLora of loras) {
156
+ const lora = normalizeLoraSpec(rawLora);
157
+ if (!lora) continue;
122
158
  const id = String(loraId++);
123
159
  nodes[id] = {
124
160
  class_type: 'LoraLoaderModelOnly',
125
161
  inputs: {
126
162
  lora_name: lora.name,
127
- strength_model: lora.weight ?? 1.0,
163
+ strength_model: lora.weight,
128
164
  model: modelOut,
129
165
  },
130
166
  };
@@ -155,6 +191,43 @@ export function buildQwenGraph({
155
191
  return { nodes, saveNodeId: '9' };
156
192
  }
157
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
+
158
231
  // ─── Graph builder ───────────────────────────────────────────────
159
232
 
160
233
  /**
@@ -168,17 +241,19 @@ export function buildQwenGraph({
168
241
  * @param {Object} opts.brief — compiled brief from brief-compiler
169
242
  * @param {Object} opts.projectDefaults — project.json defaults block
170
243
  * @param {number} opts.seed — specific seed for this generation
244
+ * @param {Array} [opts.loras] — optional explicit LoRA overrides
171
245
  * @returns {{ nodes: Object, saveNodeId: string }}
172
246
  */
173
- export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
174
- const rp = brief.runtime_plan;
175
- const defaults = projectDefaults;
247
+ export function buildWorkflowGraph({ brief, projectDefaults, seed, loras }) {
248
+ const rp = brief.runtime_plan || {};
249
+ const defaults = projectDefaults || {};
176
250
 
177
251
  const prompt = brief.prompt;
178
252
  const negativePrompt = brief.negative_prompt;
179
253
 
180
254
  // ── Qwen-Image branch (non-anime studio base) ──
181
255
  if (String(defaults.base || '').toLowerCase() === 'qwen-image') {
256
+ const resolvedLoras = resolveQwenLoras({ brief, projectDefaults: defaults, loras });
182
257
  return buildQwenGraph({
183
258
  prompt,
184
259
  negativePrompt,
@@ -195,13 +270,13 @@ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
195
270
  vae: defaults.vae || QWEN_DEFAULTS.vae,
196
271
  clipType: defaults.clip_type || QWEN_DEFAULTS.clip_type,
197
272
  weightDtype: defaults.weight_dtype || QWEN_DEFAULTS.weight_dtype,
198
- loras: defaults.loras || [],
273
+ loras: resolvedLoras,
199
274
  });
200
275
  }
201
276
 
202
277
  // ── SDXL branch (default) ──
203
278
  const checkpoint = defaults.checkpoint;
204
- const loras = defaults.loras || [];
279
+ const sdxlLoras = loras || defaults.loras || [];
205
280
  const width = rp.width || defaults.width || 1024;
206
281
  const height = rp.height || defaults.height || 1024;
207
282
  const steps = rp.steps || defaults.steps || 30;
@@ -223,7 +298,7 @@ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
223
298
  let clipOut = [ckptId, 1];
224
299
 
225
300
  // LoRA loaders (chained)
226
- for (const lora of loras) {
301
+ for (const lora of sdxlLoras) {
227
302
  const loraId = String(nextId++);
228
303
  nodes[loraId] = {
229
304
  class_type: 'LoraLoader',