@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.es.md +154 -41
  3. package/README.fr.md +153 -40
  4. package/README.hi.md +160 -47
  5. package/README.it.md +155 -42
  6. package/README.ja.md +159 -46
  7. package/README.md +40 -0
  8. package/README.pt-BR.md +154 -41
  9. package/README.zh.md +154 -41
  10. package/bin/sdlab.js +81 -2
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-runner.js +11 -15
  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/batch-runs.js +62 -0
  17. package/lib/canon-build/build.js +450 -0
  18. package/lib/canon-build/emitters/base.js +122 -0
  19. package/lib/canon-build/emitters/character.js +167 -0
  20. package/lib/canon-build/emitters/deity.js +165 -0
  21. package/lib/canon-build/emitters/faction.js +161 -0
  22. package/lib/canon-build/emitters/index.js +38 -0
  23. package/lib/canon-build/emitters/location.js +153 -0
  24. package/lib/canon-build/emitters/monster.js +131 -0
  25. package/lib/canon-build/emitters/relic.js +142 -0
  26. package/lib/canon-build/emitters/ship.js +154 -0
  27. package/lib/canon-build/emitters/species.js +165 -0
  28. package/lib/canon-build/frontmatter.js +76 -0
  29. package/lib/canon-build/lane-resolve.js +64 -0
  30. package/lib/canon-build/load-config.js +117 -0
  31. package/lib/canon-build/load-entry.js +62 -0
  32. package/lib/canon-build/load-schema.js +46 -0
  33. package/lib/captions.js +197 -0
  34. package/lib/comfyui-output.js +70 -0
  35. package/lib/config.js +19 -0
  36. package/lib/freeze-events.js +109 -0
  37. package/lib/freeze-gate.js +165 -0
  38. package/lib/freeze-stamp.js +161 -0
  39. package/lib/rows.js +228 -0
  40. package/lib/training-adapters.js +84 -0
  41. package/lib/training-packages.js +25 -32
  42. package/lib/training-profiles.js +43 -0
  43. package/package.json +29 -2
  44. package/scripts/_batch_cut.py +71 -0
  45. package/scripts/_seam_cutout.py +74 -0
  46. package/scripts/batch-generate.js +56 -13
  47. package/scripts/canon-build.js +88 -0
  48. package/scripts/canon-drift.js +193 -0
  49. package/scripts/canon-freeze-status.js +85 -0
  50. package/scripts/canon-freeze.js +211 -0
  51. package/scripts/canon-unfreeze.js +137 -0
  52. package/scripts/curate.js +12 -0
  53. package/scripts/generate-controlnet.js +12 -0
  54. package/scripts/generate-identity.js +20 -4
  55. package/scripts/generate-ipadapter.js +11 -0
  56. package/scripts/generate.js +250 -102
  57. package/scripts/qwen_generate.py +124 -0
@@ -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
+ }
@@ -1,49 +1,26 @@
1
1
  /**
2
2
  * Generic image+caption adapter.
3
3
  *
4
- * Simple, inspectable, durable baseline:
5
- * - dataset/<partition>/ with image files
6
- * - metadata/<partition>.jsonl with one row per image
4
+ * Consumes canonical rows (per D6) and emits:
5
+ * dataset/<partition>/<entity_id>.<ext> — image (copied or symlinked)
6
+ * metadata/<partition>.jsonl — per-row audit (caption inline)
7
7
  *
8
- * Does not mutate inclusion or split truth.
8
+ * No caption sidecar files — this adapter keeps metadata flat and
9
+ * machine-readable. Use diffusers-lora or ai-toolkit for trainer-specific
10
+ * caption-file conventions.
9
11
  */
10
12
 
11
13
  import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
12
14
  import { join, relative } from 'node:path';
13
15
  import { existsSync } from 'node:fs';
16
+ import { groupRowsByPartition } from '../rows.js';
14
17
 
15
- /**
16
- * Build a caption string from a record and profile.
17
- */
18
- function buildCaption(record, lane, group, profile) {
19
- const parts = [];
20
-
21
- if (profile.prompt_strategy === 'trigger-word' && profile.profile_id) {
22
- parts.push(profile.profile_id.replace(/-/g, '_'));
23
- }
24
-
25
- if (group) parts.push(group);
26
- if (lane) parts.push(lane);
27
-
28
- // Add key provenance if available
29
- const prompt = record.provenance?.prompt;
30
- if (prompt) {
31
- // Use first 200 chars of prompt as caption body
32
- parts.push(prompt.slice(0, 200));
33
- } else {
34
- parts.push(record.id.replace(/_/g, ' '));
35
- }
36
-
37
- return parts.join(', ');
38
- }
18
+ export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
19
+ const partitioned = groupRowsByPartition(rows || []);
39
20
 
40
- /**
41
- * Build training package in generic image+caption format.
42
- */
43
- export async function buildPackage({ packageDir, partitions, profile, manifest, config, projectRoot, copy }) {
44
21
  let imageCount = 0;
45
22
 
46
- for (const [partition, records] of Object.entries(partitions)) {
23
+ for (const [partition, partitionRows] of Object.entries(partitioned)) {
47
24
  const datasetDir = join(packageDir, 'dataset', partition);
48
25
  const metadataDir = join(packageDir, 'metadata');
49
26
  await mkdir(datasetDir, { recursive: true });
@@ -51,18 +28,16 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
51
28
 
52
29
  const metadataRows = [];
53
30
 
54
- for (const { record, lane, group } of records) {
55
- if (!record.asset_path) continue;
31
+ for (const row of partitionRows) {
32
+ if (!row.asset_path) continue;
56
33
 
57
- const sourcePath = join(projectRoot, record.asset_path);
34
+ const sourcePath = join(projectRoot, row.asset_path);
58
35
  if (!existsSync(sourcePath)) continue;
59
36
 
60
- // D-009: Name output files by record.id (not source basename) to
61
- // avoid silent collisions when two records share a filename stem.
62
- const sourceBasename = record.asset_path.split(/[\\/]/).pop();
37
+ const sourceBasename = row.asset_path.split(/[\\/]/).pop();
63
38
  const extMatch = sourceBasename.match(/\.[^.]+$/);
64
39
  const ext = extMatch ? extMatch[0] : '';
65
- const filename = `${record.id}${ext}`;
40
+ const filename = `${row.entity_id}${ext}`;
66
41
  const destPath = join(datasetDir, filename);
67
42
 
68
43
  try {
@@ -73,25 +48,22 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
73
48
  }
74
49
  imageCount++;
75
50
  } catch {
76
- continue; // skip duplicates or broken links
51
+ continue;
77
52
  }
78
53
 
79
- const caption = buildCaption(record, lane, group, profile);
80
-
81
54
  metadataRows.push({
82
55
  file: `dataset/${partition}/${filename}`,
83
- record_id: record.id,
84
- caption,
85
- lane,
86
- group,
87
- pass_ratio: record.canon?.assertion_count > 0
88
- ? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : null,
56
+ record_id: row.entity_id,
57
+ caption: row.caption,
58
+ lane: row.lane,
59
+ group: row.group ?? null,
60
+ pass_ratio: row.pass_ratio ?? null,
89
61
  });
90
62
  }
91
63
 
92
64
  await writeFile(
93
65
  join(metadataDir, `${partition}.jsonl`),
94
- metadataRows.map(r => JSON.stringify(r)).join('\n') + '\n'
66
+ metadataRows.map(r => JSON.stringify(r)).join('\n') + (metadataRows.length ? '\n' : '')
95
67
  );
96
68
  }
97
69
 
package/lib/batch-runs.js CHANGED
@@ -85,6 +85,57 @@ export async function createBatchDir(projectRoot) {
85
85
  return { batchId, batchDir };
86
86
  }
87
87
 
88
+ /**
89
+ * Compute which prior slot_ids are eligible to skip on resume.
90
+ * Truth source: status === 'ok'. For pre-status manifests we infer success
91
+ * from a truthy selected_output. Returns a Map<slot_id, prior_result>.
92
+ */
93
+ export function buildCompletedSlotMap(priorResults) {
94
+ const completed = new Map();
95
+ if (!Array.isArray(priorResults)) return completed;
96
+ for (const r of priorResults) {
97
+ if (!r || !r.slot_id) continue;
98
+ const ok = r.status === 'ok' || (r.status === undefined && !!r.selected_output);
99
+ if (ok) completed.set(r.slot_id, r);
100
+ }
101
+ return completed;
102
+ }
103
+
104
+ /**
105
+ * Resume an existing batch directory. Loads its prior manifest so the caller
106
+ * can replay only the incomplete slots while keeping the same batch_id.
107
+ *
108
+ * Throws BATCH_NOT_FOUND if the batch dir or manifest is missing.
109
+ * Throws BATCH_NO_PROGRESS if the manifest predates the incremental-checkpoint
110
+ * format (no `slots` array) — those batches can't be partially resumed.
111
+ */
112
+ export function openBatchDirForResume(projectRoot, batchId) {
113
+ const batchesDir = getBatchesDir(projectRoot);
114
+ const batchDir = join(batchesDir, batchId);
115
+ const manifestPath = join(batchDir, 'manifest.json');
116
+
117
+ if (!existsSync(manifestPath)) {
118
+ throw inputError(
119
+ 'BATCH_NOT_FOUND',
120
+ `Batch "${batchId}" not found at ${manifestPath}`,
121
+ 'Run `sdlab batch list` to see available batch IDs.'
122
+ );
123
+ }
124
+
125
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
126
+ const priorResults = Array.isArray(manifest.slots) ? manifest.slots : null;
127
+
128
+ if (!priorResults) {
129
+ throw inputError(
130
+ 'BATCH_NO_PROGRESS',
131
+ `Batch "${batchId}" has no slot-level progress to resume from`,
132
+ 'This batch was created before incremental checkpointing — re-run from scratch instead.'
133
+ );
134
+ }
135
+
136
+ return { batchId, batchDir, priorResults, priorManifest: manifest };
137
+ }
138
+
88
139
  /**
89
140
  * Execute all slot briefs as coordinated runs.
90
141
  *
@@ -95,6 +146,7 @@ export async function createBatchDir(projectRoot) {
95
146
  * @param {Object} opts.mode — batch mode definition
96
147
  * @param {Object[]} opts.slotBriefs — one brief per slot
97
148
  * @param {boolean} [opts.dryRun]
149
+ * @param {Object[]} [opts.priorResults] — previously-completed slot results to carry forward (resume)
98
150
  * @returns {Promise<Object[]>} — array of { slot, brief, manifest }
99
151
  */
100
152
  export async function executeBatchRuns({
@@ -104,6 +156,7 @@ export async function executeBatchRuns({
104
156
  mode,
105
157
  slotBriefs,
106
158
  dryRun = false,
159
+ priorResults = [],
107
160
  }) {
108
161
  // PB-005: fail fast on length mismatch instead of crashing deep in the loop.
109
162
  if (!Array.isArray(mode?.variant_plan) || mode.variant_plan.length !== slotBriefs.length) {
@@ -115,6 +168,8 @@ export async function executeBatchRuns({
115
168
  );
116
169
  }
117
170
 
171
+ const completed = buildCompletedSlotMap(priorResults);
172
+
118
173
  const results = [];
119
174
  // PB-005: incremental batch-progress manifest — written after every slot
120
175
  // so a crash never orphans the batch dir without a record of what ran.
@@ -125,6 +180,13 @@ export async function executeBatchRuns({
125
180
  const brief = slotBriefs[i];
126
181
  const slotLabel = slot.label || slot.slot_id;
127
182
 
183
+ if (completed.has(slot.slot_id)) {
184
+ const prior = completed.get(slot.slot_id);
185
+ info(`[${i + 1}/${slotBriefs.length}] Slot: ${slotLabel} \x1b[2m(resumed — already complete)\x1b[0m`);
186
+ results.push(prior);
187
+ continue;
188
+ }
189
+
128
190
  info(`[${i + 1}/${slotBriefs.length}] Slot: ${slotLabel}`);
129
191
 
130
192
  // Save brief into batch/briefs/