@mcptoolshop/style-dataset-lab 3.0.1 → 3.1.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/README.zh.md CHANGED
@@ -8,14 +8,16 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@mcptoolshop/style-dataset-lab"><img src="https://img.shields.io/npm/v/@mcptoolshop/style-dataset-lab" alt="npm"></a>
11
+ <a href="https://github.com/mcp-tool-shop-org/style-dataset-lab/actions/workflows/ci.yml"><img src="https://github.com/mcp-tool-shop-org/style-dataset-lab/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
12
+ <a href="https://codecov.io/gh/mcp-tool-shop-org/style-dataset-lab"><img src="https://codecov.io/gh/mcp-tool-shop-org/style-dataset-lab/branch/main/graph/badge.svg" alt="codecov"></a>
11
13
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
12
14
  </p>
13
15
 
14
- 编写您的视觉规则。生成艺术作品。根据这些规则评估每张图像。将结果作为版本控制的、可审计的训练数据发布。
16
+ 编写您的视觉规则。生成图像。根据这些规则评估每张图像。将结果作为版本控制的、可追溯的训练数据发布,然后将训练好的模型应用于实际的生产流程,并将最佳输出反馈回您的数据集。
15
17
 
16
18
  Style Dataset Lab 将您关于艺术风格的描述与实际用于训练的数据集联系起来。您定义一套规范,包括轮廓规则、调色板限制、材质语言,以及对您的项目而言重要的任何内容。流水线生成候选作品,根据这些规则对其进行评分,并将通过的成果打包成可重复的数据集,其中每个记录都解释了它被包含的原因。
17
19
 
18
- 循环闭合:训练一个模型,生成新的输出,根据相同的标准对其进行评分,并将通过的作品重新导入。数据集不断增长,规则始终有效。
20
+ 然后,生产工作台接管:从项目基础信息中提取生成指令,通过 ComfyUI 运行这些指令,评估输出结果,批量生成表情图和环境板,选择最佳结果,并将它们作为新的候选对象重新导入。循环完成:生成、选择、审查、改进。
19
21
 
20
22
  ## 流水线
21
23
 
@@ -28,25 +30,37 @@ sdlab generate inputs/prompts/wave1.json --project my-project
28
30
  sdlab curate <id> approved "Strong silhouette, correct faction palette"
29
31
 
30
32
  # Bind approved work to constitution rules
31
- sdlab bind --project my-project
33
+ # (`sdlab bind` is a shorter alias for `canon-bind`)
34
+ sdlab canon-bind --project my-project
32
35
 
33
36
  # Freeze a versioned dataset
34
37
  sdlab snapshot create --project my-project
35
38
  sdlab split build
36
39
  sdlab export build
37
40
 
38
- # Build a training package and close the loop
41
+ # Build a training package
39
42
  sdlab training-manifest create --profile character-style-lora
40
43
  sdlab training-package build
41
- sdlab eval-run create && sdlab eval-run score <id> --outputs results.jsonl
42
- sdlab reingest generated --source ./outputs --manifest <id>
44
+
45
+ # Compile a production brief and run it
46
+ sdlab brief compile --workflow character-portrait-set --subject kael_maren
47
+ sdlab run generate --brief brief_2026-04-16_001
48
+
49
+ # Critique, refine, batch-produce
50
+ sdlab critique --run run_2026-04-16_001
51
+ sdlab refine --run run_2026-04-16_001 --pick 001.png
52
+ sdlab batch generate --mode expression-sheet --subject kael_maren
53
+
54
+ # Select the best outputs and bring them back
55
+ sdlab select --run run_2026-04-16_001 --approve 001.png,003.png
56
+ sdlab reingest selected --selection selection_2026-04-16_001
43
57
  ```
44
58
 
45
- 那个最后的命令至关重要。生成的输出会经过与所有其他内容相同的审查流程。循环闭合。
59
+ 这个最后的命令至关重要。选定的输出会经过与所有其他内容相同的审查流程。数据集不断增长,规则始终有效。
46
60
 
47
61
  ## 它产生的内容
48
62
 
49
- 七个版本控制的、具有校验和的成果。每个成果都链接到其前身,因此您可以追溯任何训练记录,了解它是根据哪个规则被批准的。
63
+ 包含七个数据集文件和一套完整的生产工作台。每个文件都链接到其前驱文件,因此您可以追溯任何训练记录,直到找到最初批准它的规则。
50
64
 
51
65
  | 成果 | 它的定义 |
52
66
  |----------|-----------|
@@ -58,6 +72,17 @@ sdlab reingest generated --source ./outputs --manifest <id>
58
72
  | **Eval scorecard** | 每个任务的通过/失败结果,基于生成的输出与评估包的比较。 |
59
73
  | **Implementation pack** | 提示示例、已知的失败案例、连续性测试以及重新导入指南。 |
60
74
 
75
+ 生产工作台包含以下功能:
76
+
77
+ | 表面功能 | 功能描述 |
78
+ |---------|-------------|
79
+ | **Compiled brief** | 从工作流程配置文件和项目基础信息中获取确定性的生成指令。 |
80
+ | **Run** | 冻结的执行文件:包含指令、种子、ComfyUI 输出和清单。 |
81
+ | **Critique** | 对生成结果进行结构化的多维度评估,与标准进行比较。 |
82
+ | **Batch** | 协调的多通道生产(表情图、环境板、人物轮廓图)。 |
83
+ | **Selection** | 创意决策文件:记录选择了哪些输出,原因以及它们的来源。 |
84
+ | **Re-ingest** | 选定的输出作为包含完整生成信息的候选记录返回。 |
85
+
61
86
  ## 存在的理由
62
87
 
63
88
  训练数据是任何视觉人工智能流水线中最具价值的资源。但大多数训练数据只是一个包含图像的文件夹,没有任何历史记录、评估记录,以及与它应该遵循的风格规则的联系。
@@ -94,6 +119,12 @@ projects/my-project/
94
119
  splits/ Train/val/test partitions
95
120
  exports/ Versioned export packages
96
121
  training/ Profiles, manifests, packages, eval runs, implementations
122
+ workflows/ Workflow profiles + batch mode definitions
123
+ briefs/ Compiled generation briefs
124
+ runs/ Execution artifacts (brief + outputs + manifest)
125
+ batches/ Coordinated multi-slot productions
126
+ selections/ Chosen outputs with reasons and provenance
127
+ inbox/generated/ Re-ingested images awaiting review
97
128
  ```
98
129
 
99
130
  ## 信任属性
@@ -130,13 +161,73 @@ npm install -g @mcptoolshop/style-dataset-lab
130
161
 
131
162
  需要 Node.js 20+ 以及安装在本地主机 8188 端口上的 [ComfyUI](https://github.com/comfyanonymous/ComfyUI) 才能进行生成。
132
163
 
164
+ ### 尝试不使用 ComfyUI
165
+
166
+ 您可以使用捆绑的 Star Freight 项目,探索完整的非生成功能,例如检查、整理、快照、分割和导出,而无需安装 ComfyUI 或下载任何 SDXL 模型文件。
167
+
168
+ ```bash
169
+ # Scaffold a fresh project (no ComfyUI needed)
170
+ sdlab init test --domain game-art
171
+
172
+ # Run the canonical health check (no ComfyUI needed)
173
+ sdlab project doctor --project test
174
+
175
+ # Dry-run a snapshot against the bundled Star Freight corpus
176
+ sdlab snapshot create --dry-run --project star-freight
177
+ ```
178
+
179
+ `sdlab project doctor` 命令可以验证每个项目的配置(宪法、流程、评分标准、术语),并在不访问 GPU 的情况下报告资格。任何会修改生成状态的命令都支持 `--dry-run` 参数,以便在执行前预览效果。
180
+
181
+ 如果您忘记了 `--project` 参数,CLI 会自动选择 `projects/` 目录下找到的第一个项目,并打印一条警告。要强制指定项目,请明确使用 `--project` 参数。
182
+
183
+ ### 恢复中断的运行
184
+
185
+ 可以恢复长时间的生成运行,而无需重新执行已完成的工作:
186
+
187
+ ```bash
188
+ # Skip subjects whose record + image are already on disk.
189
+ # Seeds are preserved — resumed runs are bit-identical to fresh ones.
190
+ sdlab generate inputs/prompts/wave1.json --project my-project --resume
191
+
192
+ # Re-run only failed/missing slots in an existing batch.
193
+ # Inherits mode/subject/theme from the prior manifest.
194
+ sdlab batch generate --resume batch_2026-04-22_001 --project my-project
195
+ ```
196
+
197
+ 这两个命令都有效,因为每个通道在完成时都会原子性地写入其清单条目,即使在运行过程中崩溃,也不会损坏部分状态。
198
+
199
+ ## 故障排除
200
+
201
+ 常见的错误模式和解决方法:
202
+
203
+ **`ECONNREFUSED 127.0.0.1:8188` 错误,出现在任何 `sdlab generate` / `sdlab run generate` / `sdlab batch generate` 命令中**
204
+ ComfyUI 未运行。启动 ComfyUI (`python main.py --listen 127.0.0.1 --port 8188`),并使用 `curl http://127.0.0.1:8188/system_stats` 确认其是否正在运行。要指定不同的主机/端口,请设置 `COMFY_URL=http://host:port`。
205
+
206
+ **`missing checkpoint` / `LoRA weight not found` 错误**
207
+ 您的工作流程配置文件中指定了 ComfyUI 的 `models/checkpoints/` 或 `models/loras/` 文件夹中不存在的模型文件。打开 `projects/<project>/workflows/profiles/<profile>.json`,找到 `checkpoint` 或 `lora` 字段,然后要么下载指定的模型文件,要么将其替换为您已有的模型文件。重新运行 `sdlab project doctor --project <project>` 以确认修复。
208
+
209
+ **`sdlab project doctor` 错误**
210
+ Doctor 返回结构化的错误代码。常见的错误代码包括:
211
+ - `E_PROJECT_NOT_FOUND`:项目目录不存在于 `projects/` 目录下。检查拼写。
212
+ - `E_CONFIG_INVALID`:五个 JSON 配置文件之一未能通过模式验证。`hint` 字段会指出哪个文件和字段存在问题。
213
+ - `E_RECORD_DRIFT`:记录的配置指纹与其来源不再匹配。根据提示进行重新整理或重新绑定。
214
+
215
+ **`未指定 --project,将使用 <name> 作为默认项目`**
216
+ 这是一个提示信息。为了选择正确的项目并消除此提示,请明确使用 `--project <name>` 参数。
217
+
218
+ **绘画风格 / 显存不足问题**
219
+ 请参考 `docs/internal/HANDOFF.md` 中的关于绘画风格降噪的调整说明。 简而言之:降低降噪强度,减少批次大小,或者在工作流程配置文件中切换到较小的模型文件。
220
+
221
+ **报告错误**
222
+ 请在 https://github.com/mcp-tool-shop-org/style-dataset-lab/issues 上提交问题,并提供您的 sdlab 版本(`sdlab --version`)、Node 版本(`node -v`)、完整的命令以及结构化的错误输出。 错误报告模板会预先填充字段。
223
+
133
224
  ## 安全性
134
225
 
135
226
  仅在本地运行。没有遥测数据,没有分析,没有外部请求。图像保留在您的 GPU 和文件系统中。
136
227
 
137
228
  ## 许可证
138
229
 
139
- MIT
230
+ MIT 许可证
140
231
 
141
232
  ---
142
233
 
package/bin/sdlab.js CHANGED
@@ -113,7 +113,7 @@ Flags:
113
113
  --verbose Show per-check detail
114
114
  --quiet Only print PASS / FAIL summary`,
115
115
 
116
- 'generate': `sdlab generate [<pack-path>] --project <name> [--dry-run]
116
+ 'generate': `sdlab generate [<pack-path>] --project <name> [--dry-run] [--resume]
117
117
 
118
118
  Generate candidates from a prompt pack (JSON file under
119
119
  projects/<name>/inputs/prompts/).
@@ -124,6 +124,10 @@ Positional:
124
124
  Flags:
125
125
  --project <name> Project to operate on
126
126
  --dry-run Preview work without contacting ComfyUI
127
+ --resume Skip subjects whose record + image are already on disk.
128
+ Seeds remain stable — skipped slots still advance the
129
+ seed counter so downstream outputs are bit-identical to
130
+ a fresh run.
127
131
 
128
132
  Env:
129
133
  COMFY_URL ComfyUI HTTP endpoint (default: http://127.0.0.1:8188)
@@ -131,15 +135,19 @@ Env:
131
135
  Precedence: CLI flag > env COMFY_URL > project defaults > built-in default.`,
132
136
 
133
137
  'batch generate': `sdlab batch generate --mode <id> [--subject <id>] [--theme <label>] --project <name>
138
+ sdlab batch generate --resume <batch_id> --project <name>
134
139
 
135
140
  Execute a batch production mode (expression sheet, silhouette pack,
136
141
  environment board, etc).
137
142
 
138
143
  Flags:
139
- --mode <id> Batch mode id (required)
144
+ --mode <id> Batch mode id (required unless --resume is given)
140
145
  --subject <id> Subject for subject-driven modes
141
146
  --theme <label> Theme label for environment modes
142
147
  --asset <id> Training asset reference
148
+ --resume <id> Resume an existing batch — re-runs only the failed or
149
+ missing slots, keeps the same batch_id, and inherits
150
+ mode/subject/theme/asset from the prior manifest.
143
151
  --project <name> Project to operate on
144
152
  --dry-run Prepare batch without submitting to ComfyUI
145
153
  --json Output manifest as JSON`,
@@ -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
- const nodeOutputs = result.outputs || {};
99
- let imageFile = null;
100
- let imageSubfolder = '';
101
- for (const nodeOut of Object.values(nodeOutputs)) {
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 (!imageFile) {
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(imageFile, imageSubfolder, comfyUrl);
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: imageFile,
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)`);
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/
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pick the canonical output image from a ComfyUI run's `outputs` map.
3
+ *
4
+ * ComfyUI workflows can contain more than one SaveImage node (e.g. a
5
+ * preview save + a final save, or multiple intermediate saves). Picking
6
+ * the first via `Object.values()` is non-deterministic across builds —
7
+ * Node guarantees insertion order, but the order in which ComfyUI
8
+ * populates `outputs` depends on its execution scheduler.
9
+ *
10
+ * Selection precedence:
11
+ * 1. `preferNodeId` — explicit hint from the workflow builder
12
+ * (`buildWorkflowGraph` returns `{ saveNodeId }`) or from a
13
+ * workflow profile's `expected_outputs.save_node_id`.
14
+ * 2. Highest numeric node id among nodes that emitted images.
15
+ * This matches the typical convention where the final save node
16
+ * is appended last, and is deterministic regardless of map order.
17
+ * 3. First node in iteration order — last-resort fallback for the
18
+ * pathological case where node ids aren't numeric.
19
+ *
20
+ * @param {Object} nodeOutputs - the `outputs` field from a ComfyUI prompt result
21
+ * @param {Object} [opts]
22
+ * @param {string|number|null} [opts.preferNodeId] - hint to prefer
23
+ * @returns {{ filename: string, subfolder: string, nodeId: string } | null}
24
+ */
25
+ export function pickOutputImage(nodeOutputs, { preferNodeId = null } = {}) {
26
+ if (!nodeOutputs || typeof nodeOutputs !== 'object') return null;
27
+
28
+ const candidates = [];
29
+ for (const [nodeId, nodeOut] of Object.entries(nodeOutputs)) {
30
+ if (nodeOut?.images?.length > 0) {
31
+ candidates.push({ nodeId, image: nodeOut.images[0] });
32
+ }
33
+ }
34
+ if (candidates.length === 0) return null;
35
+
36
+ // 1. Honor explicit hint when the matching node actually produced an image.
37
+ if (preferNodeId !== null && preferNodeId !== undefined) {
38
+ const wanted = String(preferNodeId);
39
+ const match = candidates.find(c => c.nodeId === wanted);
40
+ if (match) {
41
+ return {
42
+ filename: match.image.filename,
43
+ subfolder: match.image.subfolder || '',
44
+ nodeId: match.nodeId,
45
+ };
46
+ }
47
+ }
48
+
49
+ // 2. Highest numeric node id wins (typical "final save" convention).
50
+ const numeric = candidates
51
+ .map(c => ({ ...c, n: Number(c.nodeId) }))
52
+ .filter(c => Number.isFinite(c.n));
53
+ if (numeric.length > 0) {
54
+ numeric.sort((a, b) => b.n - a.n);
55
+ const winner = numeric[0];
56
+ return {
57
+ filename: winner.image.filename,
58
+ subfolder: winner.image.subfolder || '',
59
+ nodeId: winner.nodeId,
60
+ };
61
+ }
62
+
63
+ // 3. Last resort: first iteration entry (non-numeric ids).
64
+ const first = candidates[0];
65
+ return {
66
+ filename: first.image.filename,
67
+ subfolder: first.image.subfolder || '',
68
+ nodeId: first.nodeId,
69
+ };
70
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Training adapter registry.
3
+ *
4
+ * Single source of truth for training-package adapters. Each adapter
5
+ * lives in `lib/adapters/<id>.js` and exports `buildPackage(opts)`.
6
+ *
7
+ * To add a new adapter (e.g. `kohya-lora`, `onedtrainer`):
8
+ * 1. Add `lib/adapters/<id>.js` exporting `buildPackage`.
9
+ * 2. Add an entry to ADAPTER_REGISTRY below with a one-line description.
10
+ *
11
+ * Why explicit registration: the available adapter list shows up in error
12
+ * messages, training-profile validation, and (eventually) CLI completion.
13
+ * A static map keeps those views consistent and makes new adapters a
14
+ * one-line surface change rather than a directory-listing side effect.
15
+ */
16
+
17
+ import { inputError } from './errors.js';
18
+
19
+ export const ADAPTER_REGISTRY = Object.freeze({
20
+ 'generic-image-caption': {
21
+ id: 'generic-image-caption',
22
+ description: 'Image folders + caption JSONL — broadest trainer compatibility',
23
+ module: './adapters/generic-image-caption.js',
24
+ },
25
+ 'diffusers-lora': {
26
+ id: 'diffusers-lora',
27
+ description: 'Diffusers fine-tuning layout: image + caption .txt sidecars + per-partition JSONL',
28
+ module: './adapters/diffusers-lora.js',
29
+ },
30
+ });
31
+
32
+ /**
33
+ * Names of all registered adapters.
34
+ * @returns {string[]}
35
+ */
36
+ export function listAdapters() {
37
+ return Object.keys(ADAPTER_REGISTRY);
38
+ }
39
+
40
+ /**
41
+ * Whether `id` names a registered adapter.
42
+ * @param {string} id
43
+ * @returns {boolean}
44
+ */
45
+ export function isRegisteredAdapter(id) {
46
+ return Object.prototype.hasOwnProperty.call(ADAPTER_REGISTRY, id);
47
+ }
48
+
49
+ /**
50
+ * Resolve and dynamically import an adapter module.
51
+ *
52
+ * Throws `ADAPTER_NOT_REGISTERED` (input error, exit 1) for unknown ids
53
+ * — distinct from `MODULE_LOAD_FAILED` (runtime, exit 2) for the rare
54
+ * case where the file vanished after registration.
55
+ *
56
+ * @param {string} id
57
+ * @returns {Promise<{ buildPackage: Function }>}
58
+ */
59
+ export async function loadAdapter(id) {
60
+ if (!isRegisteredAdapter(id)) {
61
+ throw inputError(
62
+ 'ADAPTER_NOT_REGISTERED',
63
+ `Adapter "${id}" is not registered. Available: ${listAdapters().join(', ')}`,
64
+ 'Pick one of the registered adapters or add a new one to lib/training-adapters.js.'
65
+ );
66
+ }
67
+ const entry = ADAPTER_REGISTRY[id];
68
+ try {
69
+ return await import(entry.module);
70
+ } catch (err) {
71
+ // The registry says it should exist but import failed — surface the
72
+ // underlying error so it doesn't get masked as a typo.
73
+ throw inputError(
74
+ 'ADAPTER_MODULE_LOAD_FAILED',
75
+ `Adapter "${id}" is registered but its module failed to load: ${err.message}`,
76
+ `Check that ${entry.module} exists and exports buildPackage().`
77
+ );
78
+ }
79
+ }
@@ -17,6 +17,7 @@ import { loadRecord } from './records.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { inputError } from './errors.js';
19
19
  import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { loadAdapter } from './training-adapters.js';
20
21
 
21
22
  /**
22
23
  * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
@@ -33,20 +34,6 @@ async function sha256File(filePath) {
33
34
  return createHash('sha256').update(data).digest('hex');
34
35
  }
35
36
 
36
- /**
37
- * Load an adapter module by name.
38
- */
39
- async function loadAdapter(adapterTarget) {
40
- const adapterPath = `./adapters/${adapterTarget}.js`;
41
- try {
42
- return await import(adapterPath);
43
- } catch {
44
- throw new Error(
45
- `Adapter "${adapterTarget}" not found. Available: generic-image-caption, diffusers-lora`
46
- );
47
- }
48
- }
49
-
50
37
  /**
51
38
  * Build a training package from a manifest.
52
39
  *
@@ -9,6 +9,7 @@
9
9
  import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { existsSync } from 'node:fs';
12
+ import { isRegisteredAdapter, listAdapters } from './training-adapters.js';
12
13
 
13
14
  const REQUIRED_FIELDS = ['profile_id', 'label', 'target_family', 'asset_type', 'eligible_lanes', 'adapter_targets'];
14
15
 
@@ -32,6 +33,15 @@ export function validateProfile(profile) {
32
33
  if (profile.adapter_targets?.length === 0) {
33
34
  errors.push('adapter_targets must have at least one target');
34
35
  }
36
+ // Reject unregistered adapter targets early — surfaces typos at profile
37
+ // load time instead of at package-build time.
38
+ if (Array.isArray(profile.adapter_targets)) {
39
+ for (const target of profile.adapter_targets) {
40
+ if (!isRegisteredAdapter(target)) {
41
+ errors.push(`adapter_targets contains unregistered adapter "${target}". Available: ${listAdapters().join(', ')}`);
42
+ }
43
+ }
44
+ }
35
45
  return { valid: errors.length === 0, errors };
36
46
  }
37
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Canon-aligned dataset production and generation workbench — define visual rules, build versioned training data, compile production briefs, run local workflows, batch-produce, select winners, and re-ingest into your corpus",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test tests/**/*.test.js",
11
+ "coverage": "c8 npm test",
11
12
  "verify": "node bin/sdlab.js --help && node bin/sdlab.js project doctor --project star-freight",
12
13
  "generate": "node scripts/generate.js",
13
14
  "curate": "node scripts/curate.js",
@@ -16,6 +17,20 @@
16
17
  "painterly": "node scripts/painterly.js",
17
18
  "generate:identity": "node scripts/generate-identity.js"
18
19
  },
20
+ "c8": {
21
+ "include": ["lib/**/*.js"],
22
+ "exclude": [
23
+ "tests/**",
24
+ "scripts/**",
25
+ "site/**",
26
+ "projects/**",
27
+ "templates/**",
28
+ "**/*.test.js"
29
+ ],
30
+ "reporter": ["text", "lcov", "text-summary"],
31
+ "report-dir": "coverage",
32
+ "all": true
33
+ },
19
34
  "files": [
20
35
  "bin/",
21
36
  "lib/",
@@ -58,5 +73,8 @@
58
73
  "node": ">=20"
59
74
  },
60
75
  "author": "mcp-tool-shop",
61
- "license": "MIT"
76
+ "license": "MIT",
77
+ "devDependencies": {
78
+ "c8": "^10.1.3"
79
+ }
62
80
  }