@mcptoolshop/style-dataset-lab 3.1.0 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.es.md +84 -62
  3. package/README.fr.md +85 -63
  4. package/README.hi.md +97 -77
  5. package/README.it.md +86 -64
  6. package/README.ja.md +94 -72
  7. package/README.md +22 -0
  8. package/README.pt-BR.md +83 -61
  9. package/README.zh.md +88 -66
  10. package/bin/sdlab.js +71 -0
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-workflows.js +133 -2
  13. package/lib/adapters/diffusers-lora.js +46 -66
  14. package/lib/adapters/generic-image-caption.js +22 -50
  15. package/lib/canon-build/build.js +450 -0
  16. package/lib/canon-build/emitters/base.js +122 -0
  17. package/lib/canon-build/emitters/character.js +167 -0
  18. package/lib/canon-build/emitters/deity.js +165 -0
  19. package/lib/canon-build/emitters/faction.js +161 -0
  20. package/lib/canon-build/emitters/index.js +38 -0
  21. package/lib/canon-build/emitters/location.js +153 -0
  22. package/lib/canon-build/emitters/monster.js +131 -0
  23. package/lib/canon-build/emitters/relic.js +142 -0
  24. package/lib/canon-build/emitters/ship.js +154 -0
  25. package/lib/canon-build/emitters/species.js +165 -0
  26. package/lib/canon-build/frontmatter.js +76 -0
  27. package/lib/canon-build/lane-resolve.js +64 -0
  28. package/lib/canon-build/load-config.js +117 -0
  29. package/lib/canon-build/load-entry.js +62 -0
  30. package/lib/canon-build/load-schema.js +46 -0
  31. package/lib/captions.js +197 -0
  32. package/lib/config.js +19 -0
  33. package/lib/freeze-events.js +109 -0
  34. package/lib/freeze-gate.js +165 -0
  35. package/lib/freeze-stamp.js +161 -0
  36. package/lib/rows.js +228 -0
  37. package/lib/training-adapters.js +5 -0
  38. package/lib/training-packages.js +24 -18
  39. package/lib/training-profiles.js +33 -0
  40. package/package.json +12 -3
  41. package/scripts/_batch_cut.py +71 -0
  42. package/scripts/_seam_cutout.py +74 -0
  43. package/scripts/canon-build.js +88 -0
  44. package/scripts/canon-drift.js +193 -0
  45. package/scripts/canon-freeze-status.js +85 -0
  46. package/scripts/canon-freeze.js +211 -0
  47. package/scripts/canon-unfreeze.js +137 -0
  48. package/scripts/curate.js +12 -0
  49. package/scripts/generate-controlnet.js +12 -0
  50. package/scripts/generate-identity.js +20 -4
  51. package/scripts/generate-ipadapter.js +11 -0
  52. package/scripts/generate.js +238 -102
  53. 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
@@ -264,6 +273,60 @@ Subcommands:
264
273
  Flags:
265
274
  --project <name> Project to operate on`,
266
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
+
267
330
  'export': `sdlab export <build|list> [args] --project <name>
268
331
 
269
332
  Build and list self-contained export packages.
@@ -306,6 +369,13 @@ function printHelp() {
306
369
  console.log(' painterly Post-processing painterly style pass');
307
370
  console.log(' painterly:test Calibrate painterly denoise level on sample images');
308
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('');
309
379
  console.log('Dataset:');
310
380
  console.log(' snapshot create Create a frozen dataset snapshot');
311
381
  console.log(' snapshot list List all snapshots');
@@ -444,6 +514,7 @@ async function main() {
444
514
  twoWord('run', RUN_COMMANDS),
445
515
  twoWord('batch', BATCH_COMMANDS),
446
516
  twoWord('selection', SELECTION_COMMANDS),
517
+ twoWord('canon', CANON_COMMANDS),
447
518
  ];
448
519
 
449
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
+ }
@@ -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;
@@ -1,60 +1,40 @@
1
1
  /**
2
2
  * Diffusers LoRA adapter.
3
3
  *
4
- * Produces a layout compatible with diffusers fine-tuning scripts:
5
- * - dataset/<partition>/ with image files
6
- * - dataset/<partition>/<image>.txt caption sidecar files
7
- * - metadata/<partition>.jsonl with structured rows
4
+ * Consumes canonical rows (per D6) — either produced in memory by
5
+ * training-packages.js from records, or read from a canon-build
6
+ * dataset.jsonl. The row shape is defined in lib/rows.js and pinned by
7
+ * ROW_SCHEMA_VERSION.
8
8
  *
9
- * Does not mutate inclusion or split truth.
9
+ * Output layout (unchanged from pre-refactor):
10
+ * dataset/<partition>/<entity_id>.<ext> — image (copied or symlinked)
11
+ * dataset/<partition>/<entity_id>.txt — caption sidecar
12
+ * metadata/<partition>.jsonl — per-row audit
10
13
  */
11
14
 
12
15
  import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
13
- import { join, relative, basename } from 'node:path';
16
+ import { join, relative } from 'node:path';
14
17
  import { existsSync } from 'node:fs';
18
+ import { groupRowsByPartition } from '../rows.js';
15
19
 
16
20
  /**
17
- * Build a diffusers-style caption from record metadata.
21
+ * Build training package in diffusers LoRA format from canonical rows.
22
+ *
23
+ * @param {Object} args
24
+ * @param {string} args.packageDir
25
+ * @param {Array<Object>} args.rows — canonical rows; partition field groups them
26
+ * @param {Object} args.profile
27
+ * @param {Object} args.manifest
28
+ * @param {Object} args.config
29
+ * @param {string} args.projectRoot
30
+ * @param {boolean} [args.copy]
18
31
  */
19
- function buildCaption(record, lane, group, profile) {
20
- const parts = [];
21
-
22
- // Trigger word for LoRA activation
23
- if (profile.prompt_strategy === 'trigger-word' && profile.profile_id) {
24
- parts.push(profile.profile_id.replace(/-/g, '_'));
25
- }
26
-
27
- // Subject identity if available
28
- if (record.identity?.subject_name) {
29
- parts.push(record.identity.subject_name.replace(/_/g, ' '));
30
- }
31
-
32
- // Faction / group
33
- if (group && group !== 'unknown') {
34
- parts.push(`${group} faction`);
35
- }
36
-
37
- // Lane context
38
- if (lane) parts.push(lane);
32
+ export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
33
+ const partitioned = groupRowsByPartition(rows || []);
39
34
 
40
- // Use original prompt if available, otherwise build from ID
41
- const prompt = record.provenance?.prompt;
42
- if (prompt) {
43
- parts.push(prompt.slice(0, 300));
44
- } else {
45
- parts.push(record.id.replace(/_/g, ' '));
46
- }
47
-
48
- return parts.join(', ');
49
- }
50
-
51
- /**
52
- * Build training package in diffusers LoRA format.
53
- */
54
- export async function buildPackage({ packageDir, partitions, profile, manifest, config, projectRoot, copy }) {
55
35
  let imageCount = 0;
56
36
 
57
- for (const [partition, records] of Object.entries(partitions)) {
37
+ for (const [partition, partitionRows] of Object.entries(partitioned)) {
58
38
  const datasetDir = join(packageDir, 'dataset', partition);
59
39
  const metadataDir = join(packageDir, 'metadata');
60
40
  await mkdir(datasetDir, { recursive: true });
@@ -62,19 +42,17 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
62
42
 
63
43
  const metadataRows = [];
64
44
 
65
- for (const { record, lane, group } of records) {
66
- if (!record.asset_path) continue;
45
+ for (const row of partitionRows) {
46
+ if (!row.asset_path) continue;
67
47
 
68
- const sourcePath = join(projectRoot, record.asset_path);
48
+ const sourcePath = join(projectRoot, row.asset_path);
69
49
  if (!existsSync(sourcePath)) continue;
70
50
 
71
- // D-009: Name output files by record.id (not source basename) to
72
- // avoid silent collisions when two records share a filename stem.
73
- const sourceBasename = record.asset_path.split(/[\\/]/).pop();
51
+ const sourceBasename = row.asset_path.split(/[\\/]/).pop();
74
52
  const extMatch = sourceBasename.match(/\.[^.]+$/);
75
53
  const ext = extMatch ? extMatch[0] : '';
76
- const filename = `${record.id}${ext}`;
77
- const stem = record.id;
54
+ const filename = `${row.entity_id}${ext}`;
55
+ const stem = row.entity_id;
78
56
  const destPath = join(datasetDir, filename);
79
57
 
80
58
  try {
@@ -88,28 +66,30 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
88
66
  continue;
89
67
  }
90
68
 
91
- // Write caption sidecar (.txt next to image)
92
- const caption = buildCaption(record, lane, group, profile);
93
- await writeFile(join(datasetDir, `${stem}.txt`), caption + '\n');
69
+ await writeFile(join(datasetDir, `${stem}.txt`), row.caption + '\n');
94
70
 
95
- metadataRows.push({
96
- file: `dataset/${partition}/${filename}`,
97
- caption_file: `dataset/${partition}/${stem}.txt`,
98
- record_id: record.id,
99
- caption,
100
- lane,
101
- group,
102
- subject: record.identity?.subject_name || null,
103
- pass_ratio: record.canon?.assertion_count > 0
104
- ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : null,
105
- });
71
+ metadataRows.push(buildMetadataRow(row, partition, filename, stem));
106
72
  }
107
73
 
108
74
  await writeFile(
109
75
  join(metadataDir, `${partition}.jsonl`),
110
- metadataRows.map(r => JSON.stringify(r)).join('\n') + '\n'
76
+ metadataRows.map(r => JSON.stringify(r)).join('\n') + (metadataRows.length ? '\n' : '')
111
77
  );
112
78
  }
113
79
 
114
80
  return { imageCount };
115
81
  }
82
+
83
+ function buildMetadataRow(row, partition, filename, stem) {
84
+ const out = {
85
+ file: `dataset/${partition}/${filename}`,
86
+ caption_file: `dataset/${partition}/${stem}.txt`,
87
+ record_id: row.entity_id,
88
+ caption: row.caption,
89
+ lane: row.lane,
90
+ group: row.group ?? null,
91
+ subject: row.subject_filter_key ?? null,
92
+ pass_ratio: row.pass_ratio ?? null,
93
+ };
94
+ return out;
95
+ }