@mcptoolshop/style-dataset-lab 3.1.0 → 3.3.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 (61) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.es.md +96 -63
  3. package/README.fr.md +97 -64
  4. package/README.hi.md +108 -77
  5. package/README.it.md +97 -64
  6. package/README.ja.md +105 -72
  7. package/README.md +33 -0
  8. package/README.pt-BR.md +94 -61
  9. package/README.zh.md +98 -65
  10. package/bin/sdlab.js +71 -0
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-runner.js +36 -2
  13. package/lib/adapters/comfyui-workflows.js +133 -2
  14. package/lib/adapters/diffusers-lora.js +46 -66
  15. package/lib/adapters/generic-image-caption.js +22 -50
  16. package/lib/canon-build/build.js +450 -0
  17. package/lib/canon-build/emitters/base.js +122 -0
  18. package/lib/canon-build/emitters/character.js +167 -0
  19. package/lib/canon-build/emitters/deity.js +165 -0
  20. package/lib/canon-build/emitters/faction.js +161 -0
  21. package/lib/canon-build/emitters/index.js +38 -0
  22. package/lib/canon-build/emitters/location.js +153 -0
  23. package/lib/canon-build/emitters/monster.js +131 -0
  24. package/lib/canon-build/emitters/relic.js +142 -0
  25. package/lib/canon-build/emitters/ship.js +154 -0
  26. package/lib/canon-build/emitters/species.js +165 -0
  27. package/lib/canon-build/frontmatter.js +76 -0
  28. package/lib/canon-build/lane-resolve.js +64 -0
  29. package/lib/canon-build/load-config.js +117 -0
  30. package/lib/canon-build/load-entry.js +62 -0
  31. package/lib/canon-build/load-schema.js +46 -0
  32. package/lib/captions.js +197 -0
  33. package/lib/config.js +19 -0
  34. package/lib/critique-engine.js +46 -1
  35. package/lib/critique-render.js +45 -0
  36. package/lib/freeze-events.js +109 -0
  37. package/lib/freeze-gate.js +208 -0
  38. package/lib/freeze-stamp.js +161 -0
  39. package/lib/rows.js +228 -0
  40. package/lib/run-manifest.js +308 -0
  41. package/lib/snapshot.js +7 -1
  42. package/lib/training-adapters.js +5 -0
  43. package/lib/training-packages.js +24 -18
  44. package/lib/training-profiles.js +33 -0
  45. package/lib/verifier.js +67 -0
  46. package/package.json +12 -3
  47. package/scripts/_batch_cut.py +71 -0
  48. package/scripts/_seam_cutout.py +74 -0
  49. package/scripts/canon-build.js +88 -0
  50. package/scripts/canon-drift.js +193 -0
  51. package/scripts/canon-freeze-status.js +85 -0
  52. package/scripts/canon-freeze.js +211 -0
  53. package/scripts/canon-unfreeze.js +137 -0
  54. package/scripts/critique.js +32 -7
  55. package/scripts/curate.js +33 -0
  56. package/scripts/generate-controlnet.js +12 -0
  57. package/scripts/generate-identity.js +20 -4
  58. package/scripts/generate-ipadapter.js +11 -0
  59. package/scripts/generate.js +262 -102
  60. package/scripts/qwen_generate.py +327 -0
  61. package/scripts/run-generate.js +3 -0
@@ -15,10 +15,12 @@ import { existsSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { parseArgs, getProjectName } from "../lib/args.js";
17
17
  import { REPO_ROOT, resolveSafeProjectPath } from "../lib/paths.js";
18
- import { readJsonFile } from "../lib/config.js";
19
- import { runtimeError, handleCliError } from "../lib/errors.js";
18
+ import { readJsonFile, loadProjectMeta, resolveGpuModel } from "../lib/config.js";
19
+ import { runtimeError, inputError, handleCliError } from "../lib/errors.js";
20
20
  import { comfyHealth, submitAndWait, downloadImage } from "../lib/comfyui.js";
21
21
  import { pickOutputImage } from "../lib/comfyui-output.js";
22
+ import { buildQwenGraph, QWEN_DEFAULTS } from "../lib/adapters/comfyui-workflows.js";
23
+ import { buildPinning, SEED_POLICIES, createHashCache, flushHashCache } from "../lib/run-manifest.js";
22
24
  import { info, result } from "../lib/log.js";
23
25
 
24
26
  /**
@@ -177,6 +179,89 @@ function buildWorkflow(prompt, negativePrompt, checkpoint, loras, seed, steps, c
177
179
  return { nodes, saveNodeId: saveId };
178
180
  }
179
181
 
182
+ /**
183
+ * Detect the input schema and normalize to a unified shape:
184
+ * { lane, style_prefix, negative, defaults, base, jobs:[{assetId, subjectId, variationId, prompt, seed, loras}] }
185
+ *
186
+ * Two accepted schemas (both produce jobs in subject-major order, so the legacy
187
+ * seed sequence stays byte-identical):
188
+ *
189
+ * • Legacy pack — top-level `lane` + `variations` (array) applied to every
190
+ * subject. seed = base_seed + jobIndex + (variation.seed_offset||0);
191
+ * assetId = `${subject.id}_${variation.id}`; `negative` is top-level.
192
+ *
193
+ * • Wave (what `sdlab init` scaffolds, e.g. example-wave.json) — each subject
194
+ * carries `variations` as an integer count; `negative` lives in `defaults`.
195
+ * seed = base_seed + jobIndex; assetId = `${subject.id}_v${v}`. This matches
196
+ * scripts/qwen_generate.py's seed/filename scheme.
197
+ *
198
+ * `base` resolves: input.base → input.defaults.base → project.json defaults.base
199
+ * → "sdxl". `"qwen-image"` routes to the non-anime Qwen graph.
200
+ */
201
+ function normalizeGenerationInput(raw, projectMeta) {
202
+ if (!Array.isArray(raw.subjects) || raw.subjects.length === 0) {
203
+ throw inputError(
204
+ 'INPUT_MISSING_FIELDS',
205
+ 'Prompt pack/wave must have a non-empty "subjects" array.',
206
+ 'See inputs/prompts/example-wave.json for the expected shape.'
207
+ );
208
+ }
209
+ const defaults = raw.defaults || {};
210
+ const base = String(
211
+ raw.base || defaults.base || projectMeta?.defaults?.base || 'sdxl'
212
+ ).toLowerCase();
213
+ const baseSeed = Number.isFinite(defaults.base_seed) ? defaults.base_seed : 0;
214
+ const stylePrefix = raw.style_prefix || '';
215
+ const compose = (p) => (stylePrefix ? `${stylePrefix}, ${p}` : p);
216
+
217
+ const jobs = [];
218
+ let k = 0;
219
+
220
+ if (Array.isArray(raw.variations)) {
221
+ // Legacy pack schema
222
+ if (!raw.lane) {
223
+ throw inputError(
224
+ 'INPUT_MISSING_FIELDS',
225
+ 'Legacy pack (top-level "variations" array) requires a "lane".',
226
+ 'Add a "lane" string, or use the wave schema (per-subject integer "variations").'
227
+ );
228
+ }
229
+ const negative = raw.negative ?? '';
230
+ for (const subject of raw.subjects) {
231
+ for (const variation of raw.variations) {
232
+ jobs.push({
233
+ assetId: `${subject.id}_${variation.id}`,
234
+ subjectId: subject.id,
235
+ variationId: variation.id,
236
+ prompt: compose(subject.prompt),
237
+ seed: baseSeed + k + (variation.seed_offset || 0),
238
+ loras: variation.loras || [],
239
+ });
240
+ k++;
241
+ }
242
+ }
243
+ return { lane: raw.lane, style_prefix: stylePrefix, negative, defaults, base, jobs, schema: 'pack' };
244
+ }
245
+
246
+ // Wave schema
247
+ const negative = raw.negative ?? defaults.negative ?? '';
248
+ for (const subject of raw.subjects) {
249
+ const nvar = Number.isFinite(subject.variations) ? subject.variations : 1;
250
+ for (let v = 0; v < nvar; v++) {
251
+ jobs.push({
252
+ assetId: `${subject.id}_v${v}`,
253
+ subjectId: subject.id,
254
+ variationId: `v${v}`,
255
+ prompt: compose(subject.prompt),
256
+ seed: baseSeed + k,
257
+ loras: [],
258
+ });
259
+ k++;
260
+ }
261
+ }
262
+ return { lane: raw.wave || raw.lane || 'default', style_prefix: stylePrefix, negative, defaults, base, jobs, schema: 'wave' };
263
+ }
264
+
180
265
  export async function run(argv = process.argv.slice(2)) {
181
266
  const projectName = getProjectName(argv);
182
267
  const GAME_ROOT = join(REPO_ROOT, 'projects', projectName);
@@ -188,21 +273,54 @@ export async function run(argv = process.argv.slice(2)) {
188
273
  game: { type: 'string' },
189
274
  'dry-run': { type: 'boolean' },
190
275
  resume: { type: 'boolean' },
276
+ 'hash-models': { type: 'boolean' },
191
277
  },
192
278
  deprecated: { game: 'project' },
193
279
  });
194
280
  const packPath = parsed.positionals[0] || "inputs/prompts/rpg-icons-lane1.json";
195
281
  const dryRun = parsed.flags['dry-run'] === true;
196
282
  const resume = parsed.flags['resume'] === true;
283
+ // PIN_PER_STEP: --hash-models opts into content hashing of the checkpoint/
284
+ // unet/clip/vae/LoRA files (best-effort, cached). Without it the pinning block
285
+ // still records comfy_workflow_sha + model name/size + seed_policy.
286
+ const hashModels = parsed.flags['hash-models'] === true;
287
+ const hashCache = hashModels ? createHashCache() : undefined;
197
288
 
198
289
  const fullPackPath = resolveSafeProjectPath(GAME_ROOT, packPath, { flagName: 'pack-path' });
199
- const pack = await readJsonFile(fullPackPath, { requiredFields: ['lane', 'subjects', 'variations', 'defaults'] });
290
+ const raw = await readJsonFile(fullPackPath);
291
+ const projectMeta = loadProjectMeta(GAME_ROOT);
292
+ const gpuModel = resolveGpuModel(projectMeta?.defaults);
293
+ const pack = normalizeGenerationInput(raw, projectMeta);
294
+
295
+ const d = pack.defaults;
296
+ const base = pack.base;
297
+ const isQwen = base === 'qwen-image';
298
+
299
+ // Resolve effective sampler params once. Qwen applies its own defaults when a
300
+ // wave omits them; SDXL uses the pack defaults exactly as authored (unchanged).
301
+ const resolved = isQwen
302
+ ? {
303
+ width: d.width || QWEN_DEFAULTS.width,
304
+ height: d.height || QWEN_DEFAULTS.height,
305
+ steps: d.steps || QWEN_DEFAULTS.steps,
306
+ cfg: d.cfg || QWEN_DEFAULTS.cfg,
307
+ sampler: d.sampler || QWEN_DEFAULTS.sampler,
308
+ scheduler: d.scheduler || QWEN_DEFAULTS.scheduler,
309
+ }
310
+ : {
311
+ width: d.width,
312
+ height: d.height,
313
+ steps: d.steps,
314
+ cfg: d.cfg,
315
+ sampler: d.sampler,
316
+ scheduler: d.scheduler,
317
+ };
200
318
 
201
319
  console.log(`\x1b[1mstyle-dataset-lab\x1b[0m generate`);
202
320
  console.log(` Lane: ${pack.lane}`);
203
- console.log(` Subjects: ${pack.subjects.length}`);
204
- console.log(` Variations: ${pack.variations.length}`);
205
- console.log(` Total candidates: ${pack.subjects.length * pack.variations.length}`);
321
+ console.log(` Base: ${base}${isQwen ? ' \x1b[2m(non-anime)\x1b[0m' : ''}`);
322
+ console.log(` Subjects: ${raw.subjects.length}`);
323
+ console.log(` Total candidates: ${pack.jobs.length}`);
206
324
  console.log("");
207
325
 
208
326
  if (!dryRun) {
@@ -216,121 +334,163 @@ export async function run(argv = process.argv.slice(2)) {
216
334
  await mkdir(join(GAME_ROOT, "outputs/candidates"), { recursive: true });
217
335
  await mkdir(join(GAME_ROOT, "records"), { recursive: true });
218
336
 
219
- const d = pack.defaults;
220
337
  let generated = 0;
221
338
  let errors = 0;
222
339
  let skipped = 0;
223
- const totalExpected = pack.subjects.length * pack.variations.length;
340
+ const totalExpected = pack.jobs.length;
224
341
  const runStartMs = Date.now();
225
342
 
226
- for (const subject of pack.subjects) {
227
- for (const variation of pack.variations) {
228
- const assetId = `${subject.id}_${variation.id}`;
229
- const seed = (d.base_seed + generated) + (variation.seed_offset || 0);
230
- const fullPrompt = `${pack.style_prefix}, ${subject.prompt}`;
231
- const loras = variation.loras || [];
232
-
233
- // --resume: skip subjects whose record + image are both already on disk.
234
- // Seeds are deterministic per (base_seed, generated, variation), so
235
- // increment `generated` for skipped slots to keep downstream seeds stable.
236
- if (resume && !dryRun) {
237
- const recordPath = join(GAME_ROOT, `records/${assetId}.json`);
238
- const imagePath = join(GAME_ROOT, `outputs/candidates/${assetId}.png`);
239
- if (existsSync(recordPath) && existsSync(imagePath)) {
240
- console.log(` [${generated + 1}/${totalExpected}] ${assetId} \x1b[2m(resumed — skipped)\x1b[0m`);
241
- generated++;
242
- skipped++;
243
- continue;
244
- }
245
- }
246
-
247
- console.log(` [${generated + 1}/${totalExpected}] ${assetId} (seed: ${seed}, loras: ${loras.length})`);
343
+ for (const job of pack.jobs) {
344
+ const { assetId, prompt: fullPrompt, seed, loras } = job;
248
345
 
249
- if (dryRun) {
346
+ // --resume: skip jobs whose record + image are both already on disk.
347
+ // Seeds are precomputed per job index, so skipping never shifts later seeds.
348
+ if (resume && !dryRun) {
349
+ const recordPath = join(GAME_ROOT, `records/${assetId}.json`);
350
+ const imagePath = join(GAME_ROOT, `outputs/candidates/${assetId}.png`);
351
+ if (existsSync(recordPath) && existsSync(imagePath)) {
352
+ console.log(` [${generated + 1}/${totalExpected}] ${assetId} \x1b[2m(resumed — skipped)\x1b[0m`);
250
353
  generated++;
354
+ skipped++;
251
355
  continue;
252
356
  }
357
+ }
253
358
 
254
- const startMs = Date.now();
255
- const { nodes, saveNodeId } = buildWorkflow(
256
- fullPrompt, pack.negative,
257
- d.checkpoint, loras, seed,
258
- d.steps, d.cfg, d.sampler, d.scheduler,
259
- d.width, d.height,
260
- );
359
+ console.log(` [${generated + 1}/${totalExpected}] ${assetId} (seed: ${seed}, loras: ${isQwen ? (d.loras || []).length : loras.length})`);
261
360
 
262
- try {
263
- const submitResult = await submitAndWait(nodes, COMFY_URL);
264
- const elapsed = Date.now() - startMs;
265
-
266
- // Prefer the explicit SaveImage node from buildWorkflow; fall back
267
- // to highest numeric node id (see lib/comfyui-output.js).
268
- const picked = pickOutputImage(submitResult.outputs, { preferNodeId: saveNodeId });
269
- if (!picked) {
270
- console.log(` \x1b[33m⚠\x1b[0m No output image found`);
271
- generated++;
272
- continue;
273
- }
274
-
275
- // Download and save
276
- const imgData = await downloadImage(picked.filename, picked.subfolder, COMFY_URL);
277
- const destPath = `outputs/candidates/${assetId}.png`;
278
- await writeFile(join(GAME_ROOT, destPath), imgData);
279
-
280
- // Write provenance record
281
- const record = {
282
- id: assetId,
283
- schema_version: "1.0.0",
284
- created_at: new Date().toISOString(),
285
- asset_path: destPath,
286
- image: { format: "png", width: d.width, height: d.height, bytes: imgData.length },
287
- provenance: {
288
- workflow_id: pack.lane,
289
- workflow_version: "1.0.0",
290
- checkpoint: d.checkpoint,
291
- loras,
292
- prompt: fullPrompt,
293
- negative_prompt: pack.negative,
294
- seed,
295
- steps: d.steps,
296
- cfg: d.cfg,
297
- sampler: d.sampler,
298
- scheduler: d.scheduler,
299
- width: d.width,
300
- height: d.height,
301
- generation_time_ms: elapsed,
302
- gpu_model: "RTX 5080",
303
- batch_index: generated,
304
- },
305
- judgment: null,
306
- canon: null,
307
- iteration: null,
308
- };
309
-
310
- await writeFile(
311
- join(GAME_ROOT, `records/${assetId}.json`),
312
- JSON.stringify(record, null, 2),
361
+ if (dryRun) {
362
+ generated++;
363
+ continue;
364
+ }
365
+
366
+ const startMs = Date.now();
367
+ const { nodes, saveNodeId } = isQwen
368
+ ? buildQwenGraph({
369
+ prompt: fullPrompt,
370
+ negativePrompt: pack.negative,
371
+ seed,
372
+ width: resolved.width,
373
+ height: resolved.height,
374
+ steps: resolved.steps,
375
+ cfg: resolved.cfg,
376
+ sampler: resolved.sampler,
377
+ scheduler: resolved.scheduler,
378
+ shift: d.shift ?? QWEN_DEFAULTS.shift,
379
+ unet: d.unet || QWEN_DEFAULTS.unet,
380
+ clip: d.clip || QWEN_DEFAULTS.clip,
381
+ vae: d.vae || QWEN_DEFAULTS.vae,
382
+ clipType: d.clip_type || QWEN_DEFAULTS.clip_type,
383
+ weightDtype: d.weight_dtype || QWEN_DEFAULTS.weight_dtype,
384
+ loras: d.loras || [],
385
+ filenamePrefix: 'sdl',
386
+ })
387
+ : buildWorkflow(
388
+ fullPrompt, pack.negative,
389
+ d.checkpoint, loras, seed,
390
+ resolved.steps, resolved.cfg, resolved.sampler, resolved.scheduler,
391
+ resolved.width, resolved.height,
313
392
  );
314
393
 
315
- console.log(` \x1b[32m✓\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
316
- } catch (err) {
317
- console.log(` \x1b[31m✗\x1b[0m ${err.message}`);
318
- errors++;
319
- }
394
+ try {
395
+ const submitResult = await submitAndWait(nodes, COMFY_URL);
396
+ const elapsed = Date.now() - startMs;
320
397
 
321
- generated++;
398
+ // Prefer the explicit SaveImage node from the builder; fall back
399
+ // to highest numeric node id (see lib/comfyui-output.js).
400
+ const picked = pickOutputImage(submitResult.outputs, { preferNodeId: saveNodeId });
401
+ if (!picked) {
402
+ console.log(` \x1b[33m⚠\x1b[0m No output image found`);
403
+ generated++;
404
+ continue;
405
+ }
322
406
 
323
- // ETA every 5 items (not every item — too noisy for long runs).
324
- if (!dryRun && generated > 0 && generated % 5 === 0 && generated < totalExpected) {
325
- const elapsedTotal = Date.now() - runStartMs;
326
- const avgMs = elapsedTotal / generated;
327
- const remaining = totalExpected - generated;
328
- const etaMs = avgMs * remaining;
329
- info('generate', `progress ${generated}/${totalExpected} — avg ${formatEta(avgMs)}/item, ETA ~${formatEta(etaMs)}`);
407
+ // Download and save
408
+ const imgData = await downloadImage(picked.filename, picked.subfolder, COMFY_URL);
409
+ const destPath = `outputs/candidates/${assetId}.png`;
410
+ await writeFile(join(GAME_ROOT, destPath), imgData);
411
+
412
+ // Write provenance record (base-aware: Qwen records unet/clip/vae/shift,
413
+ // SDXL records checkpoint/loras).
414
+ const provenance = {
415
+ workflow_id: pack.lane,
416
+ workflow_version: "1.0.0",
417
+ base,
418
+ prompt: fullPrompt,
419
+ negative_prompt: pack.negative,
420
+ seed,
421
+ steps: resolved.steps,
422
+ cfg: resolved.cfg,
423
+ sampler: resolved.sampler,
424
+ scheduler: resolved.scheduler,
425
+ width: resolved.width,
426
+ height: resolved.height,
427
+ generation_time_ms: elapsed,
428
+ gpu_model: gpuModel,
429
+ batch_index: generated,
430
+ };
431
+ if (isQwen) {
432
+ provenance.unet = d.unet || QWEN_DEFAULTS.unet;
433
+ provenance.clip = d.clip || QWEN_DEFAULTS.clip;
434
+ provenance.vae = d.vae || QWEN_DEFAULTS.vae;
435
+ provenance.shift = d.shift ?? QWEN_DEFAULTS.shift;
436
+ provenance.loras = d.loras || [];
437
+ } else {
438
+ provenance.checkpoint = d.checkpoint;
439
+ provenance.loras = loras;
330
440
  }
441
+
442
+ // PIN_PER_STEP: pin the exact graph + model/LoRA content identity + seed
443
+ // policy via the shared contract (lib/run-manifest.js). Model hashing is
444
+ // wave-constant; the shared cache means it runs at most once per wave.
445
+ provenance.pinning = buildPinning({
446
+ graph: nodes,
447
+ models: isQwen
448
+ ? { unet: provenance.unet, clip: provenance.clip, vae: provenance.vae }
449
+ : { checkpoint: d.checkpoint },
450
+ loras: isQwen ? (d.loras || []) : loras,
451
+ seedPolicy: SEED_POLICIES.BASE_INCREMENT,
452
+ hashModels,
453
+ cache: hashCache,
454
+ });
455
+
456
+ const record = {
457
+ id: assetId,
458
+ schema_version: "1.0.0",
459
+ created_at: new Date().toISOString(),
460
+ asset_path: destPath,
461
+ image: { format: "png", width: resolved.width, height: resolved.height, bytes: imgData.length },
462
+ provenance,
463
+ judgment: null,
464
+ canon: null,
465
+ iteration: null,
466
+ };
467
+
468
+ await writeFile(
469
+ join(GAME_ROOT, `records/${assetId}.json`),
470
+ JSON.stringify(record, null, 2),
471
+ );
472
+
473
+ console.log(` \x1b[32m✓\x1b[0m ${destPath} (${elapsed}ms, ${imgData.length} bytes)`);
474
+ } catch (err) {
475
+ console.log(` \x1b[31m✗\x1b[0m ${err.message}`);
476
+ errors++;
477
+ }
478
+
479
+ generated++;
480
+
481
+ // ETA every 5 items (not every item — too noisy for long runs).
482
+ if (!dryRun && generated > 0 && generated % 5 === 0 && generated < totalExpected) {
483
+ const elapsedTotal = Date.now() - runStartMs;
484
+ const avgMs = elapsedTotal / generated;
485
+ const remaining = totalExpected - generated;
486
+ const etaMs = avgMs * remaining;
487
+ info('generate', `progress ${generated}/${totalExpected} — avg ${formatEta(avgMs)}/item, ETA ~${formatEta(etaMs)}`);
331
488
  }
332
489
  }
333
490
 
491
+ // Persist any newly computed model hashes so the next wave reuses them.
492
+ flushHashCache(hashCache);
493
+
334
494
  console.log("");
335
495
  const succeeded = generated - errors - skipped;
336
496
  const totalElapsedMs = Date.now() - runStartMs;