@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.
- package/CHANGELOG.md +27 -0
- package/README.es.md +111 -59
- package/README.fr.md +117 -67
- package/README.hi.md +109 -70
- package/README.it.md +111 -60
- package/README.ja.md +116 -76
- package/README.md +58 -2
- package/README.pt-BR.md +119 -64
- package/README.zh.md +108 -67
- package/bin/sdlab.js +50 -1
- package/lib/adapters/ai-toolkit.js +187 -30
- package/lib/adapters/comfyui-workflows.js +84 -9
- package/lib/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- package/lib/canon-author.js +350 -0
- package/lib/canon-review-gate.js +205 -0
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/export.js +1 -1
- package/lib/freeze-gate.js +3 -0
- package/lib/generated-provenance.js +178 -5
- package/lib/lane1-render.js +668 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +15 -1
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +59 -11
- package/lib/train-runs.js +214 -0
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/package.json +2 -2
- package/scripts/asset-ingest.js +160 -0
- package/scripts/canon-draft.js +82 -0
- package/scripts/canon-freeze.js +4 -0
- 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/doctor.js +1 -0
- package/scripts/generate.js +38 -12
- 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'
|
|
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';
|
|
@@ -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
|
|
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
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
273
|
+
loras: resolvedLoras,
|
|
199
274
|
});
|
|
200
275
|
}
|
|
201
276
|
|
|
202
277
|
// ── SDXL branch (default) ──
|
|
203
278
|
const checkpoint = defaults.checkpoint;
|
|
204
|
-
const
|
|
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
|
|
301
|
+
for (const lora of sdxlLoras) {
|
|
227
302
|
const loraId = String(nextId++);
|
|
228
303
|
nodes[loraId] = {
|
|
229
304
|
class_type: 'LoraLoader',
|