@mcptoolshop/style-dataset-lab 3.0.1 → 3.2.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 +77 -0
- package/README.es.md +154 -41
- package/README.fr.md +153 -40
- package/README.hi.md +160 -47
- package/README.it.md +155 -42
- package/README.ja.md +159 -46
- package/README.md +40 -0
- package/README.pt-BR.md +154 -41
- package/README.zh.md +154 -41
- package/bin/sdlab.js +81 -2
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-runner.js +11 -15
- package/lib/adapters/comfyui-workflows.js +133 -2
- package/lib/adapters/diffusers-lora.js +46 -66
- package/lib/adapters/generic-image-caption.js +22 -50
- package/lib/batch-runs.js +62 -0
- package/lib/canon-build/build.js +450 -0
- package/lib/canon-build/emitters/base.js +122 -0
- package/lib/canon-build/emitters/character.js +167 -0
- package/lib/canon-build/emitters/deity.js +165 -0
- package/lib/canon-build/emitters/faction.js +161 -0
- package/lib/canon-build/emitters/index.js +38 -0
- package/lib/canon-build/emitters/location.js +153 -0
- package/lib/canon-build/emitters/monster.js +131 -0
- package/lib/canon-build/emitters/relic.js +142 -0
- package/lib/canon-build/emitters/ship.js +154 -0
- package/lib/canon-build/emitters/species.js +165 -0
- package/lib/canon-build/frontmatter.js +76 -0
- package/lib/canon-build/lane-resolve.js +64 -0
- package/lib/canon-build/load-config.js +117 -0
- package/lib/canon-build/load-entry.js +62 -0
- package/lib/canon-build/load-schema.js +46 -0
- package/lib/captions.js +197 -0
- package/lib/comfyui-output.js +70 -0
- package/lib/config.js +19 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +165 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/training-adapters.js +84 -0
- package/lib/training-packages.js +25 -32
- package/lib/training-profiles.js +43 -0
- package/package.json +29 -2
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/batch-generate.js +56 -13
- package/scripts/canon-build.js +88 -0
- package/scripts/canon-drift.js +193 -0
- package/scripts/canon-freeze-status.js +85 -0
- package/scripts/canon-freeze.js +211 -0
- package/scripts/canon-unfreeze.js +137 -0
- package/scripts/curate.js +12 -0
- package/scripts/generate-controlnet.js +12 -0
- package/scripts/generate-identity.js +20 -4
- package/scripts/generate-ipadapter.js +11 -0
- package/scripts/generate.js +250 -102
- package/scripts/qwen_generate.py +124 -0
package/bin/sdlab.js
CHANGED
|
@@ -80,6 +80,15 @@ const SELECTION_COMMANDS = {
|
|
|
80
80
|
'show': '../scripts/selection-show.js',
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
// Two-word commands under "canon" namespace
|
|
84
|
+
const CANON_COMMANDS = {
|
|
85
|
+
'build': '../scripts/canon-build.js',
|
|
86
|
+
'freeze': '../scripts/canon-freeze.js',
|
|
87
|
+
'unfreeze': '../scripts/canon-unfreeze.js',
|
|
88
|
+
'freeze-status': '../scripts/canon-freeze-status.js',
|
|
89
|
+
'drift': '../scripts/canon-drift.js',
|
|
90
|
+
};
|
|
91
|
+
|
|
83
92
|
// ─── Per-command help ─────────────────────────────────────────────────
|
|
84
93
|
//
|
|
85
94
|
// Keyed by the user-facing command (including two-word forms joined by
|
|
@@ -113,7 +122,7 @@ Flags:
|
|
|
113
122
|
--verbose Show per-check detail
|
|
114
123
|
--quiet Only print PASS / FAIL summary`,
|
|
115
124
|
|
|
116
|
-
'generate': `sdlab generate [<pack-path>] --project <name> [--dry-run]
|
|
125
|
+
'generate': `sdlab generate [<pack-path>] --project <name> [--dry-run] [--resume]
|
|
117
126
|
|
|
118
127
|
Generate candidates from a prompt pack (JSON file under
|
|
119
128
|
projects/<name>/inputs/prompts/).
|
|
@@ -124,6 +133,10 @@ Positional:
|
|
|
124
133
|
Flags:
|
|
125
134
|
--project <name> Project to operate on
|
|
126
135
|
--dry-run Preview work without contacting ComfyUI
|
|
136
|
+
--resume Skip subjects whose record + image are already on disk.
|
|
137
|
+
Seeds remain stable — skipped slots still advance the
|
|
138
|
+
seed counter so downstream outputs are bit-identical to
|
|
139
|
+
a fresh run.
|
|
127
140
|
|
|
128
141
|
Env:
|
|
129
142
|
COMFY_URL ComfyUI HTTP endpoint (default: http://127.0.0.1:8188)
|
|
@@ -131,15 +144,19 @@ Env:
|
|
|
131
144
|
Precedence: CLI flag > env COMFY_URL > project defaults > built-in default.`,
|
|
132
145
|
|
|
133
146
|
'batch generate': `sdlab batch generate --mode <id> [--subject <id>] [--theme <label>] --project <name>
|
|
147
|
+
sdlab batch generate --resume <batch_id> --project <name>
|
|
134
148
|
|
|
135
149
|
Execute a batch production mode (expression sheet, silhouette pack,
|
|
136
150
|
environment board, etc).
|
|
137
151
|
|
|
138
152
|
Flags:
|
|
139
|
-
--mode <id> Batch mode id (required)
|
|
153
|
+
--mode <id> Batch mode id (required unless --resume is given)
|
|
140
154
|
--subject <id> Subject for subject-driven modes
|
|
141
155
|
--theme <label> Theme label for environment modes
|
|
142
156
|
--asset <id> Training asset reference
|
|
157
|
+
--resume <id> Resume an existing batch — re-runs only the failed or
|
|
158
|
+
missing slots, keeps the same batch_id, and inherits
|
|
159
|
+
mode/subject/theme/asset from the prior manifest.
|
|
143
160
|
--project <name> Project to operate on
|
|
144
161
|
--dry-run Prepare batch without submitting to ComfyUI
|
|
145
162
|
--json Output manifest as JSON`,
|
|
@@ -256,6 +273,60 @@ Subcommands:
|
|
|
256
273
|
Flags:
|
|
257
274
|
--project <name> Project to operate on`,
|
|
258
275
|
|
|
276
|
+
'canon freeze': `sdlab canon freeze <entity_id> --project <name> --reason "<text>" [--status frozen|soft-advisory] [--watch <fields>] [--build <sha>] [--by <name>] [--json]
|
|
277
|
+
|
|
278
|
+
Stamp a freeze block on a canon entry. Requires a canon-build output to
|
|
279
|
+
witness against (the "locked_at_build" field). Writes both the entry
|
|
280
|
+
frontmatter AND canon-build/freeze-events.jsonl.
|
|
281
|
+
|
|
282
|
+
--reason is REQUIRED — the audit record depends on it.
|
|
283
|
+
|
|
284
|
+
Statuses:
|
|
285
|
+
frozen Regen refused outright; unfreeze ceremony required.
|
|
286
|
+
soft-advisory Refused by default; bypassable with --i-know + --reason.`,
|
|
287
|
+
|
|
288
|
+
'canon unfreeze': `sdlab canon unfreeze <entity_id> --project <name> --reason "<text>" [--by <name>] [--json]
|
|
289
|
+
|
|
290
|
+
Lift a freeze back to status=auto. Preserves the freeze.overrides[] history
|
|
291
|
+
on the entry (append-only) and writes an "unfreeze" event.
|
|
292
|
+
|
|
293
|
+
--reason is REQUIRED — shipping studios (Sabotage, Supergiant, Pentiment)
|
|
294
|
+
most often regretted not capturing why an unfreeze happened.`,
|
|
295
|
+
|
|
296
|
+
'canon freeze-status': `sdlab canon freeze-status <entity_id> --project <name> [--json]
|
|
297
|
+
|
|
298
|
+
Read-only glance at an entry's freeze state. Shows status, locked_at_build,
|
|
299
|
+
frozen_by, frozen_reason, watch_fields, overrides count, and event count.`,
|
|
300
|
+
|
|
301
|
+
'canon drift': `sdlab canon drift --project <name> [--since <build_hash>] [--json]
|
|
302
|
+
|
|
303
|
+
Drift report. For every frozen or on-canon-change entry, computes the
|
|
304
|
+
current watch-field hash and compares against the hash stamped in the
|
|
305
|
+
latest canon-build manifest. Reports drifted entries and overrides since
|
|
306
|
+
a given build (default: since the latest clean build).`,
|
|
307
|
+
|
|
308
|
+
'canon build': `sdlab canon build --project <name> [--full] [--no-cache] [--dry-run] [--only <id[,id...]>] [--json]
|
|
309
|
+
|
|
310
|
+
Build the three canonical projections from a project's canon entity store:
|
|
311
|
+
- dataset.jsonl (for training adapters)
|
|
312
|
+
- prompts/<id>.j2 (Jinja2 templates for ComfyUI workflow invocation)
|
|
313
|
+
- context/<id>.md (narrative blocks for Role OS dispatch)
|
|
314
|
+
|
|
315
|
+
Reads canon-build/config.json under the project directory. Produces
|
|
316
|
+
<project>/canon-build/<canon_sha>/ and a content-addressable cache under
|
|
317
|
+
<project>/canon-build/.cache/.
|
|
318
|
+
|
|
319
|
+
Flags:
|
|
320
|
+
--project <name> Project to operate on (required)
|
|
321
|
+
--full Ignore cache hits; rebuild every entity
|
|
322
|
+
--no-cache Neither read nor write the cache this run
|
|
323
|
+
--dry-run Walk + resolve + plan; write nothing
|
|
324
|
+
--only <ids> Limit to specific entity ids (comma-separated)
|
|
325
|
+
--json Emit the result summary as JSON on stdout
|
|
326
|
+
--quiet Suppress the human-readable summary
|
|
327
|
+
|
|
328
|
+
Exit codes: 1 = user / config error; 2 = runtime error (context length cap, etc.).`,
|
|
329
|
+
|
|
259
330
|
'export': `sdlab export <build|list> [args] --project <name>
|
|
260
331
|
|
|
261
332
|
Build and list self-contained export packages.
|
|
@@ -298,6 +369,13 @@ function printHelp() {
|
|
|
298
369
|
console.log(' painterly Post-processing painterly style pass');
|
|
299
370
|
console.log(' painterly:test Calibrate painterly denoise level on sample images');
|
|
300
371
|
console.log('');
|
|
372
|
+
console.log('Canon:');
|
|
373
|
+
console.log(' canon build Build dataset.jsonl + prompts/*.j2 + context/*.md from canon entries');
|
|
374
|
+
console.log(' canon freeze <id> Stamp a freeze block on an entry (--reason required)');
|
|
375
|
+
console.log(' canon unfreeze <id> Lift a freeze back to status=auto (--reason required)');
|
|
376
|
+
console.log(' canon freeze-status <id> Glance at an entry\'s freeze state');
|
|
377
|
+
console.log(' canon drift Report drift on frozen entries + overrides since last build');
|
|
378
|
+
console.log('');
|
|
301
379
|
console.log('Dataset:');
|
|
302
380
|
console.log(' snapshot create Create a frozen dataset snapshot');
|
|
303
381
|
console.log(' snapshot list List all snapshots');
|
|
@@ -436,6 +514,7 @@ async function main() {
|
|
|
436
514
|
twoWord('run', RUN_COMMANDS),
|
|
437
515
|
twoWord('batch', BATCH_COMMANDS),
|
|
438
516
|
twoWord('selection', SELECTION_COMMANDS),
|
|
517
|
+
twoWord('canon', CANON_COMMANDS),
|
|
439
518
|
];
|
|
440
519
|
|
|
441
520
|
// "project", "workflow", "brief", "run", "batch", "selection" two-word forms
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ostris ai-toolkit adapter.
|
|
3
|
+
*
|
|
4
|
+
* Consumes canonical rows (per D6) and emits a training package ai-toolkit
|
|
5
|
+
* can consume directly:
|
|
6
|
+
*
|
|
7
|
+
* <package_dir>/
|
|
8
|
+
* dataset/<partition>/<entity_id>.png
|
|
9
|
+
* dataset/<partition>/<entity_id>.txt (caption sidecar)
|
|
10
|
+
* metadata/<partition>.jsonl (audit rows)
|
|
11
|
+
* ai-toolkit-config.yaml (training config)
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
|
|
19
|
+
import { join, relative } from 'node:path';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import YAML from 'yaml';
|
|
22
|
+
import { deriveStyleTrigger } from '../captions.js';
|
|
23
|
+
import { groupRowsByPartition } from '../rows.js';
|
|
24
|
+
import { inputError } from '../errors.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build training package in ai-toolkit format from canonical rows.
|
|
28
|
+
*/
|
|
29
|
+
export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
|
|
30
|
+
if (profile?.target_family !== 'flux') {
|
|
31
|
+
throw inputError(
|
|
32
|
+
'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.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const partitioned = groupRowsByPartition(rows || []);
|
|
39
|
+
|
|
40
|
+
let imageCount = 0;
|
|
41
|
+
|
|
42
|
+
for (const [partition, partitionRows] of Object.entries(partitioned)) {
|
|
43
|
+
const datasetDir = join(packageDir, 'dataset', partition);
|
|
44
|
+
const metadataDir = join(packageDir, 'metadata');
|
|
45
|
+
await mkdir(datasetDir, { recursive: true });
|
|
46
|
+
await mkdir(metadataDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const metadataRows = [];
|
|
49
|
+
|
|
50
|
+
for (const row of partitionRows) {
|
|
51
|
+
if (!row.asset_path) continue;
|
|
52
|
+
|
|
53
|
+
const sourcePath = join(projectRoot, row.asset_path);
|
|
54
|
+
if (!existsSync(sourcePath)) continue;
|
|
55
|
+
|
|
56
|
+
const sourceBasename = row.asset_path.split(/[\\/]/).pop();
|
|
57
|
+
const extMatch = sourceBasename.match(/\.[^.]+$/);
|
|
58
|
+
const ext = extMatch ? extMatch[0] : '';
|
|
59
|
+
const filename = `${row.entity_id}${ext}`;
|
|
60
|
+
const stem = row.entity_id;
|
|
61
|
+
const destPath = join(datasetDir, filename);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (copy) {
|
|
65
|
+
await copyFile(sourcePath, destPath);
|
|
66
|
+
} else {
|
|
67
|
+
await symlink(relative(datasetDir, sourcePath), destPath);
|
|
68
|
+
}
|
|
69
|
+
imageCount++;
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await writeFile(join(datasetDir, `${stem}.txt`), row.caption + '\n');
|
|
75
|
+
|
|
76
|
+
metadataRows.push({
|
|
77
|
+
file: `dataset/${partition}/${filename}`,
|
|
78
|
+
caption_file: `dataset/${partition}/${stem}.txt`,
|
|
79
|
+
record_id: row.entity_id,
|
|
80
|
+
caption: row.caption,
|
|
81
|
+
lane: row.lane,
|
|
82
|
+
group: row.group ?? null,
|
|
83
|
+
subject: row.subject_filter_key ?? null,
|
|
84
|
+
pass_ratio: row.pass_ratio ?? null,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await writeFile(
|
|
89
|
+
join(metadataDir, `${partition}.jsonl`),
|
|
90
|
+
metadataRows.map(r => JSON.stringify(r)).join('\n') + (metadataRows.length ? '\n' : '')
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const aiToolkitConfig = buildAiToolkitConfig({ profile, manifest });
|
|
95
|
+
await writeFile(
|
|
96
|
+
join(packageDir, 'ai-toolkit-config.yaml'),
|
|
97
|
+
YAML.stringify(aiToolkitConfig)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return { imageCount };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the ai-toolkit YAML config object from a profile + manifest.
|
|
105
|
+
*
|
|
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.
|
|
109
|
+
*/
|
|
110
|
+
export function buildAiToolkitConfig({ profile, manifest }) {
|
|
111
|
+
const trigger = deriveStyleTrigger(profile) || profile.profile_id;
|
|
112
|
+
const baseModel = profile.base_model_recommendations?.[0] || 'black-forest-labs/FLUX.1-dev';
|
|
113
|
+
const sourceExportId = manifest?.source_export_id || 'unknown';
|
|
114
|
+
|
|
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
|
+
const hp = profile.training_hyperparameters || {};
|
|
128
|
+
const linear = hp.rank ?? 32;
|
|
129
|
+
const linearAlpha = hp.alpha ?? linear;
|
|
130
|
+
const trainSteps = hp.steps ?? 2500;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
job: 'extension',
|
|
134
|
+
config: {
|
|
135
|
+
name: `${profile.profile_id}-${sourceExportId}`,
|
|
136
|
+
process: [
|
|
137
|
+
{
|
|
138
|
+
type: 'sd_trainer',
|
|
139
|
+
training_folder: 'output',
|
|
140
|
+
device: 'cuda:0',
|
|
141
|
+
network: {
|
|
142
|
+
type: 'lora',
|
|
143
|
+
linear: linear,
|
|
144
|
+
linear_alpha: linearAlpha,
|
|
145
|
+
},
|
|
146
|
+
save: {
|
|
147
|
+
dtype: 'float16',
|
|
148
|
+
save_every: 250,
|
|
149
|
+
max_step_saves_to_keep: 4,
|
|
150
|
+
},
|
|
151
|
+
datasets: [
|
|
152
|
+
{
|
|
153
|
+
folder_path: './dataset/train',
|
|
154
|
+
caption_ext: 'txt',
|
|
155
|
+
caption_dropout_rate: 0.05,
|
|
156
|
+
shuffle_tokens: false,
|
|
157
|
+
cache_latents_to_disk: true,
|
|
158
|
+
resolution: [512, 768, 1024],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
train: {
|
|
162
|
+
batch_size: 1,
|
|
163
|
+
steps: trainSteps,
|
|
164
|
+
gradient_accumulation_steps: 1,
|
|
165
|
+
train_unet: true,
|
|
166
|
+
train_text_encoder: false,
|
|
167
|
+
gradient_checkpointing: true,
|
|
168
|
+
noise_scheduler: 'flowmatch',
|
|
169
|
+
optimizer: 'adamw8bit',
|
|
170
|
+
lr: 4e-4,
|
|
171
|
+
ema_config: {
|
|
172
|
+
use_ema: true,
|
|
173
|
+
ema_decay: 0.99,
|
|
174
|
+
},
|
|
175
|
+
dtype: 'bf16',
|
|
176
|
+
},
|
|
177
|
+
model: {
|
|
178
|
+
name_or_path: baseModel,
|
|
179
|
+
is_flux: true,
|
|
180
|
+
quantize: true,
|
|
181
|
+
},
|
|
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
|
+
is_style: profile.is_style_lora === true,
|
|
189
|
+
sample: {
|
|
190
|
+
sampler: 'flowmatch',
|
|
191
|
+
sample_every: 250,
|
|
192
|
+
width: 1024,
|
|
193
|
+
height: 1024,
|
|
194
|
+
prompts: [`${trigger} style sample prompt`],
|
|
195
|
+
neg: '',
|
|
196
|
+
seed: 42,
|
|
197
|
+
walk_seed: true,
|
|
198
|
+
guidance_scale: 4,
|
|
199
|
+
sample_steps: 20,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
meta: {
|
|
205
|
+
name: profile.profile_id,
|
|
206
|
+
version: '1.0',
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -11,6 +11,7 @@ import { comfyHealth, submitAndWait, downloadImage } from '../comfyui.js';
|
|
|
11
11
|
import { buildWorkflowGraph, resolveTemplateId } from './comfyui-workflows.js';
|
|
12
12
|
import { buildSeedPlan, prepareRunDir, saveRunManifest } from '../runtime-runs.js';
|
|
13
13
|
import { loadProjectConfig } from '../config.js';
|
|
14
|
+
import { pickOutputImage } from '../comfyui-output.js';
|
|
14
15
|
import { log, info, warn } from '../log.js';
|
|
15
16
|
import { runtimeError } from '../errors.js';
|
|
16
17
|
|
|
@@ -81,7 +82,7 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
|
|
|
81
82
|
|
|
82
83
|
try {
|
|
83
84
|
// Build graph
|
|
84
|
-
const { nodes } = buildWorkflowGraph({
|
|
85
|
+
const { nodes, saveNodeId } = buildWorkflowGraph({
|
|
85
86
|
brief,
|
|
86
87
|
projectDefaults: defaults,
|
|
87
88
|
seed,
|
|
@@ -94,26 +95,20 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
|
|
|
94
95
|
|
|
95
96
|
const elapsed = Date.now() - startMs;
|
|
96
97
|
|
|
97
|
-
// Extract output image
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (nodeOut.images && nodeOut.images.length > 0) {
|
|
103
|
-
imageFile = nodeOut.images[0].filename;
|
|
104
|
-
imageSubfolder = nodeOut.images[0].subfolder || '';
|
|
105
|
-
break;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
98
|
+
// Extract output image — prefer the explicit save node from the
|
|
99
|
+
// workflow builder (or `expected_outputs.save_node_id` from the
|
|
100
|
+
// brief/profile), falling back to the highest numeric node id.
|
|
101
|
+
const preferNodeId = brief.expected_outputs?.save_node_id ?? saveNodeId;
|
|
102
|
+
const picked = pickOutputImage(result.outputs, { preferNodeId });
|
|
108
103
|
|
|
109
|
-
if (!
|
|
104
|
+
if (!picked) {
|
|
110
105
|
warn(` No output image for seed ${seed}`);
|
|
111
106
|
outputs.push({ index: i, seed, status: 'no_output', elapsed_ms: elapsed });
|
|
112
107
|
continue;
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
// Download and save
|
|
116
|
-
const imgData = await downloadImage(
|
|
111
|
+
const imgData = await downloadImage(picked.filename, picked.subfolder, comfyUrl);
|
|
117
112
|
const destFilename = `${imageIndex}.png`;
|
|
118
113
|
await writeFile(join(runDir, 'outputs', destFilename), imgData);
|
|
119
114
|
|
|
@@ -124,7 +119,8 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
|
|
|
124
119
|
filename: destFilename,
|
|
125
120
|
bytes: imgData.length,
|
|
126
121
|
elapsed_ms: elapsed,
|
|
127
|
-
comfy_filename:
|
|
122
|
+
comfy_filename: picked.filename,
|
|
123
|
+
comfy_node_id: picked.nodeId,
|
|
128
124
|
});
|
|
129
125
|
|
|
130
126
|
log(` ✓ ${destFilename} (${imgData.length} bytes, ${(elapsed / 1000).toFixed(1)}s)`);
|
|
@@ -44,11 +44,118 @@ export function resolveTemplateId(outputMode) {
|
|
|
44
44
|
return 'txt2img-standard';
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ─── Qwen-Image native defaults ──────────────────────────────────
|
|
48
|
+
// The studio's verified NON-ANIME base (Apache-2.0, on rig). See
|
|
49
|
+
// memory/studio-sprite-pipeline-2026-06.md + memory/feedback_no_anime.md.
|
|
50
|
+
// These mirror scripts/qwen_generate.py exactly so both runners emit an
|
|
51
|
+
// identical graph for a given (prompt, seed) — PIN_PER_STEP reproducibility.
|
|
52
|
+
export const QWEN_DEFAULTS = Object.freeze({
|
|
53
|
+
unet: 'qwen_image_fp8_e4m3fn.safetensors',
|
|
54
|
+
clip: 'qwen_2.5_vl_7b_fp8_scaled.safetensors',
|
|
55
|
+
vae: 'qwen_image_vae.safetensors',
|
|
56
|
+
clip_type: 'qwen_image',
|
|
57
|
+
weight_dtype: 'default',
|
|
58
|
+
shift: 3.1,
|
|
59
|
+
sampler: 'euler',
|
|
60
|
+
scheduler: 'simple',
|
|
61
|
+
steps: 22,
|
|
62
|
+
cfg: 3.5,
|
|
63
|
+
width: 1024,
|
|
64
|
+
height: 1024,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the Qwen-Image ComfyUI prompt graph.
|
|
69
|
+
*
|
|
70
|
+
* Diffusion-transformer base (UNETLoader + qwen_image CLIP + SD3 latent +
|
|
71
|
+
* AuraFlow model-sampling shift) — NOT an SDXL checkpoint. This is the studio's
|
|
72
|
+
* non-anime path; never wire an anime checkpoint here. Node ids and wiring
|
|
73
|
+
* match scripts/qwen_generate.py so output is reproducible across runners.
|
|
74
|
+
*
|
|
75
|
+
* `loras` chains DiT-only adapters (LoraLoaderModelOnly) between the
|
|
76
|
+
* UNETLoader and ModelSamplingAuraFlow. Model-only on purpose: studio Qwen
|
|
77
|
+
* style LoRAs train the transformer with the text encoder frozen, so there
|
|
78
|
+
* is no clip strength to wire (unlike the SDXL LoraLoader path). LoRA node
|
|
79
|
+
* ids start at '40' — also mirrored in qwen_generate.py.
|
|
80
|
+
*
|
|
81
|
+
* @returns {{ nodes: Object, saveNodeId: string }}
|
|
82
|
+
*/
|
|
83
|
+
export function buildQwenGraph({
|
|
84
|
+
prompt,
|
|
85
|
+
negativePrompt,
|
|
86
|
+
seed,
|
|
87
|
+
width = QWEN_DEFAULTS.width,
|
|
88
|
+
height = QWEN_DEFAULTS.height,
|
|
89
|
+
steps = QWEN_DEFAULTS.steps,
|
|
90
|
+
cfg = QWEN_DEFAULTS.cfg,
|
|
91
|
+
sampler = QWEN_DEFAULTS.sampler,
|
|
92
|
+
scheduler = QWEN_DEFAULTS.scheduler,
|
|
93
|
+
shift = QWEN_DEFAULTS.shift,
|
|
94
|
+
unet = QWEN_DEFAULTS.unet,
|
|
95
|
+
clip = QWEN_DEFAULTS.clip,
|
|
96
|
+
vae = QWEN_DEFAULTS.vae,
|
|
97
|
+
clipType = QWEN_DEFAULTS.clip_type,
|
|
98
|
+
weightDtype = QWEN_DEFAULTS.weight_dtype,
|
|
99
|
+
loras = [],
|
|
100
|
+
filenamePrefix = 'sdl_run',
|
|
101
|
+
}) {
|
|
102
|
+
const nodes = {
|
|
103
|
+
'37': { class_type: 'UNETLoader', inputs: { unet_name: unet, weight_dtype: weightDtype } },
|
|
104
|
+
'38': { class_type: 'CLIPLoader', inputs: { clip_name: clip, type: clipType } },
|
|
105
|
+
'39': { class_type: 'VAELoader', inputs: { vae_name: vae } },
|
|
106
|
+
'6': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['38', 0] } },
|
|
107
|
+
'7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['38', 0] } },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// DiT LoRA chain: 37 → 40 → 41 → … → ModelSamplingAuraFlow.
|
|
111
|
+
let modelOut = ['37', 0];
|
|
112
|
+
let loraId = 40;
|
|
113
|
+
for (const lora of loras) {
|
|
114
|
+
const id = String(loraId++);
|
|
115
|
+
nodes[id] = {
|
|
116
|
+
class_type: 'LoraLoaderModelOnly',
|
|
117
|
+
inputs: {
|
|
118
|
+
lora_name: lora.name,
|
|
119
|
+
strength_model: lora.weight ?? 1.0,
|
|
120
|
+
model: modelOut,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
modelOut = [id, 0];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Object.assign(nodes, {
|
|
127
|
+
'66': { class_type: 'ModelSamplingAuraFlow', inputs: { shift, model: modelOut } },
|
|
128
|
+
'5': { class_type: 'EmptySD3LatentImage', inputs: { width, height, batch_size: 1 } },
|
|
129
|
+
'3': {
|
|
130
|
+
class_type: 'KSampler',
|
|
131
|
+
inputs: {
|
|
132
|
+
seed,
|
|
133
|
+
steps,
|
|
134
|
+
cfg,
|
|
135
|
+
sampler_name: sampler,
|
|
136
|
+
scheduler,
|
|
137
|
+
denoise: 1.0,
|
|
138
|
+
model: ['66', 0],
|
|
139
|
+
positive: ['6', 0],
|
|
140
|
+
negative: ['7', 0],
|
|
141
|
+
latent_image: ['5', 0],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['39', 0] } },
|
|
145
|
+
'9': { class_type: 'SaveImage', inputs: { filename_prefix: filenamePrefix, images: ['8', 0] } },
|
|
146
|
+
});
|
|
147
|
+
return { nodes, saveNodeId: '9' };
|
|
148
|
+
}
|
|
149
|
+
|
|
47
150
|
// ─── Graph builder ───────────────────────────────────────────────
|
|
48
151
|
|
|
49
152
|
/**
|
|
50
153
|
* Build a ComfyUI prompt graph from a compiled brief + project config.
|
|
51
154
|
*
|
|
155
|
+
* Dispatches on `projectDefaults.base`: `"qwen-image"` emits the Qwen graph
|
|
156
|
+
* (non-anime studio base); anything else (default) emits the SDXL checkpoint →
|
|
157
|
+
* LoRA chain → KSampler graph. The SDXL path is unchanged.
|
|
158
|
+
*
|
|
52
159
|
* @param {Object} opts
|
|
53
160
|
* @param {Object} opts.brief — compiled brief from brief-compiler
|
|
54
161
|
* @param {Object} opts.projectDefaults — project.json defaults block
|
|
@@ -59,10 +166,34 @@ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
|
|
|
59
166
|
const rp = brief.runtime_plan;
|
|
60
167
|
const defaults = projectDefaults;
|
|
61
168
|
|
|
62
|
-
const checkpoint = defaults.checkpoint;
|
|
63
|
-
const loras = defaults.loras || [];
|
|
64
169
|
const prompt = brief.prompt;
|
|
65
170
|
const negativePrompt = brief.negative_prompt;
|
|
171
|
+
|
|
172
|
+
// ── Qwen-Image branch (non-anime studio base) ──
|
|
173
|
+
if (String(defaults.base || '').toLowerCase() === 'qwen-image') {
|
|
174
|
+
return buildQwenGraph({
|
|
175
|
+
prompt,
|
|
176
|
+
negativePrompt,
|
|
177
|
+
seed,
|
|
178
|
+
width: rp.width || defaults.width || QWEN_DEFAULTS.width,
|
|
179
|
+
height: rp.height || defaults.height || QWEN_DEFAULTS.height,
|
|
180
|
+
steps: rp.steps || defaults.steps || QWEN_DEFAULTS.steps,
|
|
181
|
+
cfg: rp.cfg || defaults.cfg || QWEN_DEFAULTS.cfg,
|
|
182
|
+
sampler: rp.sampler || defaults.sampler || QWEN_DEFAULTS.sampler,
|
|
183
|
+
scheduler: rp.scheduler || defaults.scheduler || QWEN_DEFAULTS.scheduler,
|
|
184
|
+
shift: defaults.shift ?? QWEN_DEFAULTS.shift,
|
|
185
|
+
unet: defaults.unet || QWEN_DEFAULTS.unet,
|
|
186
|
+
clip: defaults.clip || QWEN_DEFAULTS.clip,
|
|
187
|
+
vae: defaults.vae || QWEN_DEFAULTS.vae,
|
|
188
|
+
clipType: defaults.clip_type || QWEN_DEFAULTS.clip_type,
|
|
189
|
+
weightDtype: defaults.weight_dtype || QWEN_DEFAULTS.weight_dtype,
|
|
190
|
+
loras: defaults.loras || [],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── SDXL branch (default) ──
|
|
195
|
+
const checkpoint = defaults.checkpoint;
|
|
196
|
+
const loras = defaults.loras || [];
|
|
66
197
|
const width = rp.width || defaults.width || 1024;
|
|
67
198
|
const height = rp.height || defaults.height || 1024;
|
|
68
199
|
const steps = rp.steps || defaults.steps || 30;
|