@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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 (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
package/README.zh.md CHANGED
@@ -27,8 +27,15 @@ sdlab init my-project --domain character-design
27
27
 
28
28
  # Generate candidates via ComfyUI, then review them
29
29
  sdlab generate inputs/prompts/wave1.json --project my-project
30
+ sdlab sheet outputs/candidates --project my-project # HTML contact sheet to triage
30
31
  sdlab curate <id> approved "Strong silhouette, correct faction palette"
31
32
 
33
+ # Already have images? Bring them in without generating anything
34
+ sdlab ingest ~/renders/wave1 --project my-project
35
+
36
+ # Measure what the pixels actually are — palette and texture, as numbers
37
+ sdlab measure outputs/candidates --project my-project
38
+
32
39
  # Bind approved work to constitution rules
33
40
  # (`sdlab bind` is a shorter alias for `canon-bind`)
34
41
  sdlab canon-bind --project my-project
@@ -60,7 +67,7 @@ sdlab reingest selected --selection selection_2026-04-16_001
60
67
 
61
68
  ## 规范创作
62
69
 
63
- 在数据集流水线运行之前,“sdlab canon *”命名空间会将你的项目的规范实体存储转换为三个投影,这些投影是训练和生产实际使用的——并且锁定那些不应发生变化的项目。
70
+ Before the dataset pipeline runs, the `sdlab canon *` namespace turns your project's canon entity store into the three projections training and production actually consume — and locks the entries that must not drift.
64
71
 
65
72
  ```bash
66
73
  # Build three projections from the canon entity store:
@@ -76,7 +83,7 @@ sdlab canon freeze kael_maren --project my-project --reason "prologue portrait l
76
83
  sdlab canon drift --project my-project
77
84
  ```
78
85
 
79
- “canon build”(构建规范)是基于内容的——它的输出由一个“canon_sha”标识并进行缓存,因此未更改的规范可以立即重建。“canon freeze”(冻结规范)会针对特定的构建记录每次冻结操作,并将结果附加到“freeze-events.jsonl”审计跟踪中: “frozen”(已冻结)条目完全拒绝重新生成,“soft-advisory”(软提示)条目默认情况下拒绝(通过使用“--i-know”选项可以绕过)。“canon drift”(规范漂移)会重新计算每个受监控条目的哈希值,并标记自上次干净构建以来发生更改的任何内容。
86
+ `canon build` 是基于内容的——它的输出由一个 `canon_sha` 确定,并进行缓存,因此未更改的规范可以立即重建。`canon freeze` 会针对特定的构建记录每次冻结状态,并将结果附加到 `freeze-events.jsonl` 审计跟踪中:`frozen` 条目会直接拒绝重新生成,`soft-advisory` 条目默认情况下会拒绝(通过 `--i-know` 可以绕过)。`canon drift` 会重新计算每个已监视条目的哈希值,并标记自上次完整构建以来发生更改的任何内容。
80
87
 
81
88
  完整的流程在手册中:[构建规范](handbook/canon-build/)、[冻结规范](handbook/canon-freeze/)和[双 LoRA 堆叠](handbook/two-lora-stacking/)。
82
89
 
@@ -90,7 +97,7 @@ sdlab canon drift --project my-project
90
97
  | **Split** | 训练/验证/测试分区,其中主题系列不会跨越边界。 |
91
98
  | **Export package** | 自包含数据集:清单、元数据、图像、分割、数据集卡、校验和。 |
92
99
  | **Eval pack** | 考虑规范的测试任务:车道覆盖率、禁止漂移、锚点/黄金标准、主题连续性。 |
93
- | **Training package** | 通过适配器(“diffusers-lora”、“generic-image-caption”)实现的,可用于训练器的布局。相同的真实数据,不同的格式。 |
100
+ | **Training package** | 通过适配器实现训练器友好的布局(`diffusers-lora`、`generic-image-caption`)。相同的真相,不同的格式。 |
94
101
  | **Eval scorecard** | 根据评估包对生成的输出进行评分后的每个任务的通过/失败结果。 |
95
102
  | **Implementation pack** | 提示示例、已知错误、连续性测试和重新导入指南。 |
96
103
 
@@ -117,8 +124,8 @@ sdlab canon drift --project my-project
117
124
 
118
125
  这不是一个演示流水线。两个真实的风格 LoRA 都通过它完成了端到端的流程——相同的规范 → 策划 → 训练 → 发布循环,位于策划频谱的两端。
119
126
 
120
- - **[Tallow Fen](handbook/case-study-tallow-fen/)**(生物设计)——一个从头开始构建的奇幻生物典籍规范,在 293 个策划记录中,大约有 **34% 的批准率**(169 个被拒绝——审核严格)。发布了 `tallow_fen_style_v3.safetensors`,权重为 1.5,用于 `qwen-image`。
121
- - **[Rustline](handbook/case-study-rustline/)**(概念设计)——密集、预先构建的规范,在 180 个记录中,大约有 **96% 的批准率**。发布了 `rustline_v3ckpt_1500.safetensors`,权重为 1.0,用于 `qwen-image`,并在后续项目中被第二个项目重用。
127
+ - **[Tallow Fen](handbook/case-study-tallow-fen/)**(生物设计)——一个从头开始构建的生物图鉴规范,在 293 个精选记录中获得约 **34% 的认可度**(169 个被拒绝——审核非常严格)。以 `tallow_fen_style_v3.safetensors` @ 1.5 的形式发布到 `qwen-image`。
128
+ - **[Rustline](handbook/case-study-rustline/)**(概念设计)——一个密集、预先构建的规范,在 180 个记录中获得约 **96% 的认可度**。以 `rustline_v3ckpt_1500.safetensors` @ 1.0 的形式发布到 `qwen-image`,并在第二个项目中重复使用。
122
129
 
123
130
  相同的流水线,两种生产配置:策划审核是真实的(它会严格拒绝开放主题),并且严谨的规范可以产生很高的接受率。
124
131
 
@@ -162,13 +169,16 @@ projects/my-project/
162
169
 
163
170
  这些不是理想化的,而是强制执行的。
164
171
 
165
- - **快照是不可变的。** 配置指纹(SHA-256)证明没有任何更改。
166
- - **分割可以防止数据泄露。** 主题组(按身份、血统或 ID 后缀划分)永远不会跨越分区边界。
167
- - **清单是冻结的合同。** 导出哈希 + 配置指纹。如果发生任何更改,请创建一个新的。
168
- - **运行会固定其确切的图。** 每次生成都会记录 `comfy_workflow_sha` + 模型/LoRA 内容哈希 + 种子策略,因此一个批次可以完全重现——在 JS 和 Python 运行器中都是相同的。模型哈希是可选的(`--hash-models`),并且绝不会被伪造。
169
- - **没有模型会验证自己的输出。** 评估会记录 `judged_by_model` 和 `generator_model`;如果它们曾经是同一个模型,则会发出警告。
170
- - **适配器不能改变真相。** 不同的布局,相同的数据。没有添加、删除或重新分类。
171
- - **生成的输出通过审核后重新进入。** 没有绕过。像其他所有内容一样进行策划和绑定。
172
+ - **快照是不可变的。** 配置指纹(SHA-256)证明没有任何更改。ID 是原子性地声明的,因此两个并发运行不能相互交错到一个目录中。
173
+ - **分割可以防止泄漏——并且验证这一点的代码是独立的。** 主题系列(按身份、血统或规范化的 ID 词干)永远不会跨越分区边界,并且第二个检查会从头开始重新推导主题身份,而不是重新读取分割本身使用的映射。数据集卡仅在两个检查都运行并通过时声明“泄漏:无(已验证)”;如果分割早于第二次检查,则会明确说明这一点。
174
+ - **清单是冻结的协议。** 导出哈希 + 配置指纹,并且 `validate` 会重新计算 `checksums.txt` 列出的每个文件——因此,替换完成导出中的图像会被捕获,包括对于在进行此检查之前创建的清单。
175
+ - **运行会固定其确切的图。** 每个生成都会记录 `comfy_workflow_sha` + 模型/LoRA 内容哈希 + 种子策略,因此可以逐字节地重现一个批次。JS 和 Python 运行器通过一个测试来确保它们具有完全相同的哈希值,该测试会同时启动两者。模型哈希是可选的(`--hash-models`),并且绝不会伪造——如果无法解析某个文件,则会记录 `sha256: null` 并附带说明。
176
+ - **没有模型会验证自己的输出。** 判断会记录 `judged_by_model` 和 `generator_model`;如果它们曾经是同一个模型,则会发出警告。
177
+ - **判断会说明是谁做出的判断。** `eligibility audit` 会区分某人编写的判断和批量脚本生成的判断,因此描述一个类别而不是图像的理由不能被视为审核。
178
+ - **测量不是结论。** `sdlab measure` 将数字附加到记录中。它绝不会设置判断、拟合或认可——并且如果某个图像没有定义度量值,则会记录 `null` 而不是一个合理的数字。
179
+ - **适配器无法更改真相。** 不同的布局,相同的记录。没有添加、删除或重新分类。
180
+ - **生成的输出会通过审核重新进入。** 没有绕过。像其他内容一样进行审核和绑定。外部生成的图像以相同的方式通过 `sdlab ingest` 进入,未经审核。
181
+ - **失败是可见的。** 缺少记录、无法放置的图像或无法构建的标题会导致导出或训练包停止,而不是悄悄地缩小它。
172
182
 
173
183
  ## 星际货运
174
184
 
@@ -184,7 +194,7 @@ sdlab split build --project star-freight # zero subject leakage
184
194
 
185
195
  ## 下游格式
186
196
 
187
- `sdlab` 拥有数据集。格式转换由 [`repo-dataset`](https://github.com/mcp-tool-shop-org/repo-dataset) 处理:TRL、LLaVA、Qwen2-VL、JSONL、Parquet 等。`repo-dataset` 进行渲染;它不会决定是否包含。
197
+ `sdlab` 拥有数据集。格式转换由 [`repo-dataset`](https://github.com/mcp-tool-shop-org/repo-dataset) 处理:TRL、LLaVA、Qwen2-VL、JSONL、Parquet 等。`repo-dataset` 进行渲染;它绝不会决定是否包含。
188
198
 
189
199
  ## 安装
190
200
 
@@ -209,7 +219,7 @@ sdlab project doctor --project test
209
219
  sdlab snapshot create --dry-run --project star-freight
210
220
  ```
211
221
 
212
- `sdlab project doctor` 会验证每个项目配置(宪法、通道、评分标准、术语),并在不触及 GPU 的情况下报告资格。任何会更改生成状态的命令都接受 `--dry-run`,以便首先预览效果。
222
+ `sdlab project doctor` validates every project config (constitution, lanes, rubric, terminology) and reports eligibility without touching the GPU. Any command that mutates generated state accepts `--dry-run` to preview the effect first.
213
223
 
214
224
  如果您忘记了 `--project`,CLI 将回退到在 `projects/` 下找到的第一个项目并打印警告——明确传递 `--project` 以使其停止发出警告。
215
225
 
@@ -233,26 +243,26 @@ sdlab batch generate --resume batch_2026-04-22_001 --project my-project
233
243
 
234
244
  常见的失败模式和解决方法:
235
245
 
236
- **任何 `sdlab generate` / `sdlab run generate` / `sdlab batch generate` 上的 `ECONNREFUSED 127.0.0.1:8188`**
237
- 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`。
246
+ **`ECONNREFUSED 127.0.0.1:8188` 在任何 `sdlab generate` / `sdlab run generate` / `sdlab batch generate` 上**
247
+ 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`。
238
248
 
239
249
  **`missing checkpoint` / `LoRA weight not found`**
240
- 您的工作流程配置文件指定了一个不在 ComfyUI 的 `models/checkpoints/` 或 `models/loras/` 文件夹中的模型文件。打开 `projects/<project>/workflows/profiles/<profile>.json`,找到 `checkpoint` 或 `lora` 字段,然后下载引用的权重或将其替换为您已有的权重。重新运行 `sdlab project doctor --project <project>` 以确认修复。
250
+ 您的工作流程配置文件指定了一个不在 ComfyUI 的 `models/checkpoints/` 或 `models/loras/` 文件夹中的模型文件。打开 `projects/<project>/workflows/profiles/<profile>.json`,找到 `checkpoint` 或 `lora` 字段,然后下载引用的权重或将其替换为您已经拥有的权重。重新运行 `sdlab project doctor --project <project>` 以确认修复。
241
251
 
242
252
  **`sdlab project doctor` 错误**
243
253
  Doctor 返回结构化的错误代码。常见的错误:
244
- - `E_PROJECT_NOT_FOUND`——项目目录在 `projects/` 下不存在。检查拼写。
254
+ - `E_PROJECT_NOT_FOUND`——项目目录不存在于 `projects/` 下。检查拼写。
245
255
  - `E_CONFIG_INVALID`——五个 JSON 配置文件中的一个未能通过模式验证。`hint` 字段指定了错误的 文件和字段。
246
- - `E_RECORD_DRIFT`——记录的配置指纹不再与其来源匹配。如提示所述,重新策划或重新绑定。
256
+ - `E_RECORD_DRIFT`——某个记录的配置指纹不再与其来源匹配。如提示所述,重新审核或重新绑定。
247
257
 
248
258
  **`No --project specified, falling back to <name>`**
249
259
  一个软警告。明确传递 `--project <name>` 以选择正确的项目并停止发出警告。
250
260
 
251
- **绘画风格 / VRAM 内存不足问题**
252
- 请参阅 `docs/internal/HANDOFF.md`,了解有关绘画风格降噪调整说明。简而言之:降低降噪强度、减少批处理大小或在工作流程配置文件中切换到较小的检查点。
261
+ **Painterly / VRAM out-of-memory issues**
262
+ See `docs/internal/HANDOFF.md` for the painterly denoise tuning notes. In short: lower the denoise strength, reduce batch size, or switch to a smaller checkpoint in your workflow profile.
253
263
 
254
- **报告错误**
255
- 在 https://github.com/mcp-tool-shop-org/style-dataset-lab/issues 上提交问题,并提供您的 sdlab 版本(`sdlab --version`)、Node 版本(`node -v`)、完整的命令以及结构化的错误输出。一个错误报告模板会预填充这些字段。
264
+ **Reporting bugs**
265
+ File an issue at https://github.com/mcp-tool-shop-org/style-dataset-lab/issues with your sdlab version (`sdlab --version`), Node version (`node -v`), the full command, and the structured error output. A bug-report template prefills the fields.
256
266
 
257
267
  ## 安全性
258
268
 
package/bin/sdlab.js CHANGED
@@ -18,6 +18,21 @@ const COMMANDS = {
18
18
  'generate:controlnet': '../scripts/generate-controlnet.js',
19
19
  'generate:ipadapter': '../scripts/generate-ipadapter.js',
20
20
  'curate': '../scripts/curate.js',
21
+ // Bring images that were generated OUTSIDE sdlab into the pipeline. The
22
+ // documented production loop (brief→run→critique→batch→select→reingest) has
23
+ // never run on any project in this repo — real generation happens in
24
+ // external Python against Comfy Cloud, and until now those images had no
25
+ // CLI path in. `reingest generated` needs a trained LoRA's manifest;
26
+ // `reingest selected` needs a selection only the loop can mint.
27
+ 'ingest': '../scripts/ingest.js',
28
+ // Contact sheet over ANY directory of candidates. The renderer already
29
+ // existed (lib/batch-sheet-render.js) but was reachable only via
30
+ // `batch sheet`, which needs a batch manifest nothing has ever produced —
31
+ // so five hand-rolled PIL contact-sheet scripts grew up in projects/ instead.
32
+ 'sheet': '../scripts/sheet.js',
33
+ // Deterministic pixel measurement. Nothing in this tool opened an image
34
+ // before this: critique scores brief TEXT, not pixels.
35
+ 'measure': '../scripts/measure.js',
21
36
  'compare': '../scripts/compare.js',
22
37
  'bind': '../scripts/canon-bind.js',
23
38
  'canon-bind': '../scripts/canon-bind.js',
@@ -362,7 +377,10 @@ function printHelp() {
362
377
  console.log(' generate:identity Generate named-subject identity images');
363
378
  console.log(' generate:controlnet ControlNet-guided structural generation');
364
379
  console.log(' generate:ipadapter IP-Adapter reference-guided generation');
380
+ console.log(' ingest <dir> Ingest externally-generated images as candidates');
365
381
  console.log(' curate Move candidate to approved/rejected/borderline');
382
+ console.log(' sheet [<dir>] Render an HTML contact sheet for visual review');
383
+ console.log(' measure <target> Measure candidate pixels (palette, texture)');
366
384
  console.log(' compare Record pairwise A-vs-B comparison');
367
385
  console.log(' canon-bind Bind approved records to constitution rules');
368
386
  console.log(' ("bind" is a short alias for canon-bind)');
@@ -445,15 +463,19 @@ function printHelp() {
445
463
  console.log('Examples:');
446
464
  console.log(' sdlab init my-project --domain character-design');
447
465
  console.log(' sdlab generate --help');
448
- console.log(' sdlab snapshot create --project star-freight');
449
- console.log(' sdlab split build --dry-run --project star-freight');
450
- console.log(' sdlab training-package build --debug --project star-freight');
466
+ console.log(' sdlab snapshot create --project my-project');
467
+ console.log(' sdlab split build --dry-run --project my-project');
468
+ console.log(' sdlab training-package build --debug --project my-project');
451
469
  }
452
470
 
453
471
  // All top-level command tokens users can type (including two-word heads).
454
472
  function allKnownCommands() {
455
473
  const oneWord = Object.keys(COMMANDS);
456
- const heads = ['project', 'workflow', 'brief', 'run', 'critique', 'refine', 'batch', 'select', 'selection'];
474
+ // SDL-L8: 'canon' is a real two-word namespace (see CANON_COMMANDS / the
475
+ // `namespaces` table in main()) but was missing from this list, so a typo
476
+ // like "sdlab cannon build" had no candidate to suggest against — every
477
+ // other namespace head got a "Did you mean ...?" hint, canon did not.
478
+ const heads = ['project', 'workflow', 'brief', 'run', 'critique', 'refine', 'batch', 'select', 'selection', 'canon'];
457
479
  return [...new Set([...oneWord, ...heads])];
458
480
  }
459
481
 
@@ -529,6 +551,31 @@ async function main() {
529
551
  }
530
552
  }
531
553
 
554
+ // SDL-L2: a bare or invalid namespace verb ("sdlab workflow", "sdlab batch
555
+ // bogus"). These heads only exist as two-word commands — falling through to
556
+ // the generic COMMANDS[command] lookup below used to hand `command` itself
557
+ // to findClosest() against a corpus that includes that same head, so the
558
+ // suggester matched itself at distance 0 and printed "Unknown command:
559
+ // workflow" / 'Did you mean "workflow"?' — a hint that echoes the very
560
+ // thing the user just typed. List the real subcommands instead.
561
+ for (const { head, table } of namespaces) {
562
+ if (command === head) {
563
+ const subs = Object.keys(table);
564
+ if (!args[1] || args[1] === '--help' || args[1] === '-h') {
565
+ console.log(`sdlab ${head} <${subs.join('|')}>\n`);
566
+ console.log('Subcommands:');
567
+ for (const s of subs) console.log(` ${s}`);
568
+ console.log(`\nRun "sdlab ${head} <subcommand> --help" for details.`);
569
+ return;
570
+ }
571
+ const suggestion = findClosest(args[1], subs);
572
+ const hint = suggestion
573
+ ? `Did you mean "sdlab ${head} ${suggestion}"?`
574
+ : `Try: ${subs.map(s => `sdlab ${head} ${s}`).join(', ')}`;
575
+ throw inputError('INPUT_UNKNOWN_SUBCOMMAND', `Unknown ${head} subcommand: ${args[1]}`, hint);
576
+ }
577
+ }
578
+
532
579
  // "critique" — bare or "critique show"
533
580
  if (command === 'critique') {
534
581
  if (args[1] && CRITIQUE_COMMANDS[args[1]]) {
@@ -22,6 +22,8 @@ import YAML from 'yaml';
22
22
  import { deriveStyleTrigger } from '../captions.js';
23
23
  import { groupRowsByPartition } from '../rows.js';
24
24
  import { inputError } from '../errors.js';
25
+ import { assertSafeAssetPath, assertSafeId } from '../export.js';
26
+ import { warn } from '../log.js';
25
27
 
26
28
  /**
27
29
  * Build training package in ai-toolkit format from canonical rows.
@@ -38,6 +40,11 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
38
40
  const partitioned = groupRowsByPartition(rows || []);
39
41
 
40
42
  let imageCount = 0;
43
+ // SDL-C5: per-row failure accounting (export.js DB-002 shape) — see
44
+ // generic-image-caption.js for the full rationale. A caught copy/symlink
45
+ // error must not silently drop the image, caption sidecar, AND metadata
46
+ // row together with zero logging.
47
+ const imagesFailed = [];
41
48
 
42
49
  for (const [partition, partitionRows] of Object.entries(partitioned)) {
43
50
  const datasetDir = join(packageDir, 'dataset', partition);
@@ -48,10 +55,21 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
48
55
  const metadataRows = [];
49
56
 
50
57
  for (const row of partitionRows) {
51
- if (!row.asset_path) continue;
58
+ if (!row.asset_path) {
59
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
60
+ continue;
61
+ }
52
62
 
53
- const sourcePath = join(projectRoot, row.asset_path);
54
- if (!existsSync(sourcePath)) continue;
63
+ // SDL-H5 / SDL-H6: defense-in-depth — see generic-image-caption.js
64
+ // for the full rationale. Throws (hard fail) on traversal/unsafe id;
65
+ // that is a security boundary, not an operational hiccup.
66
+ assertSafeId(row.entity_id, `row.entity_id in ai-toolkit adapter (partition "${partition}")`);
67
+ const sourcePath = assertSafeAssetPath(row.asset_path, projectRoot, row.entity_id);
68
+
69
+ if (!existsSync(sourcePath)) {
70
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'source_missing' });
71
+ continue;
72
+ }
55
73
 
56
74
  const sourceBasename = row.asset_path.split(/[\\/]/).pop();
57
75
  const extMatch = sourceBasename.match(/\.[^.]+$/);
@@ -67,7 +85,13 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
67
85
  await symlink(relative(datasetDir, sourcePath), destPath);
68
86
  }
69
87
  imageCount++;
70
- } catch {
88
+ } catch (err) {
89
+ imagesFailed.push({
90
+ record_id: row.entity_id,
91
+ partition,
92
+ reason: copy ? 'copy_failed' : 'link_failed',
93
+ detail: err.message,
94
+ });
71
95
  continue;
72
96
  }
73
97
 
@@ -91,13 +115,21 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
91
115
  );
92
116
  }
93
117
 
118
+ if (imagesFailed.length > 0) {
119
+ const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
120
+ warn(
121
+ `ai-toolkit adapter: ${imagesFailed.length} of ${imageCount + imagesFailed.length} rows failed to place` +
122
+ (imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
123
+ );
124
+ }
125
+
94
126
  const aiToolkitConfig = buildAiToolkitConfig({ profile, manifest });
95
127
  await writeFile(
96
128
  join(packageDir, 'ai-toolkit-config.yaml'),
97
129
  YAML.stringify(aiToolkitConfig)
98
130
  );
99
131
 
100
- return { imageCount };
132
+ return { imageCount, imagesFailed };
101
133
  }
102
134
 
103
135
  /**
@@ -7,9 +7,9 @@
7
7
 
8
8
  import { writeFile, mkdir } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
- import { comfyHealth, submitAndWait, downloadImage } from '../comfyui.js';
10
+ import { comfyHealthDetailed, submitAndWait, downloadImage } from '../comfyui.js';
11
11
  import { buildWorkflowGraph, resolveTemplateId, QWEN_DEFAULTS } from './comfyui-workflows.js';
12
- import { buildSeedPlan, prepareRunDir, saveRunManifest } from '../runtime-runs.js';
12
+ import { buildSeedPlan, prepareRunDir, saveRunManifest, checkpointRunManifest } from '../runtime-runs.js';
13
13
  import { loadProjectConfig } from '../config.js';
14
14
  import { pickOutputImage } from '../comfyui-output.js';
15
15
  import { buildPinning, seedPolicyFromMode, createHashCache, flushHashCache } from '../run-manifest.js';
@@ -17,6 +17,24 @@ import { SCHEMA_VERSION } from '../snapshot.js';
17
17
  import { log, info, warn } from '../log.js';
18
18
  import { runtimeError } from '../errors.js';
19
19
 
20
+ // B04: comfyHealthDetailed()'s `reason` maps to an ACTIONABLE hint, instead
21
+ // of every failure mode sharing the same "start ComfyUI" advice — which is
22
+ // simply wrong for an `http-<5xx>` (ComfyUI is up and answering) and useless
23
+ // for `dns`/`refused` against a misconfigured URL.
24
+ const HEALTH_REASON_HINTS = {
25
+ timeout: 'ComfyUI accepted the connection but never responded — it may be loading a custom node or a large model. Wait and retry, or check its console for a stuck step.',
26
+ refused: 'Start ComfyUI, or check COMFY_URL / project defaults.comfy_url — the connection was actively refused.',
27
+ dns: 'COMFY_URL / project defaults.comfy_url does not resolve — check for a typo in the hostname.',
28
+ unknown: 'Start ComfyUI or set COMFY_URL / project defaults.comfy_url.',
29
+ };
30
+
31
+ function healthReasonHint(reason) {
32
+ if (reason && reason.startsWith('http-')) {
33
+ return `ComfyUI responded with ${reason.slice('http-'.length)} — it is reachable but reported an error; check its console/logs.`;
34
+ }
35
+ return HEALTH_REASON_HINTS[reason] || HEALTH_REASON_HINTS.unknown;
36
+ }
37
+
20
38
  /**
21
39
  * Execute a brief: build graphs, submit to ComfyUI, capture outputs.
22
40
  *
@@ -50,12 +68,16 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
50
68
 
51
69
  // Health check
52
70
  if (!dryRun) {
53
- const healthy = await comfyHealth(comfyUrl);
54
- if (!healthy) {
71
+ // B04: use the reason-carrying check so a 503-while-loading, a refused
72
+ // connection, a DNS typo, and a genuine timeout each get their OWN
73
+ // actionable hint instead of the identical (and sometimes factually
74
+ // wrong) "ComfyUI not reachable — start ComfyUI" message.
75
+ const health = await comfyHealthDetailed(comfyUrl);
76
+ if (!health.ok) {
55
77
  throw runtimeError(
56
78
  'COMFYUI_OFFLINE',
57
- `ComfyUI not reachable at ${comfyUrl}`,
58
- 'Start ComfyUI or set COMFY_URL / project defaults.comfy_url'
79
+ `ComfyUI not reachable at ${comfyUrl} (${health.reason})`,
80
+ healthReasonHint(health.reason)
59
81
  );
60
82
  }
61
83
  info('ComfyUI online');
@@ -63,6 +85,43 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
63
85
 
64
86
  const outputs = [];
65
87
  const errors = [];
88
+ // B01: fixed at run start and reused by every mid-loop checkpoint AND the
89
+ // final manifest, so `created_at` never drifts between an incremental
90
+ // snapshot and the finished manifest for the same run.
91
+ const createdAt = new Date().toISOString();
92
+
93
+ // B01: checkpointRunManifest() existed (with a docstring saying exactly
94
+ // this) but had zero production callers — comfyui-runner.js only called
95
+ // saveRunManifest() once, after the whole loop. A run killed partway
96
+ // through wrote NO manifest at all, so listRuns() silently skipped the
97
+ // directory entirely: no id, no `run show`, no resume. This snapshot
98
+ // mirrors the final manifest's shape (minus `pinning`, computed only
99
+ // after the loop) so a partial manifest.json is readable by the same
100
+ // code that reads a finished one.
101
+ function buildManifestSnapshot() {
102
+ return {
103
+ run_id: runId,
104
+ schema_version: SCHEMA_VERSION,
105
+ brief_id: brief.brief_id,
106
+ project_id: brief.project_id,
107
+ workflow_template_id: templateId,
108
+ adapter_target: 'comfyui',
109
+ output_mode: outputMode,
110
+ output_count: outputCount,
111
+ created_at: createdAt,
112
+ seed_plan: seedPlan,
113
+ comfy_url: comfyUrl,
114
+ checkpoint: defaults.checkpoint,
115
+ loras: defaults.loras || [],
116
+ pinning: null,
117
+ runtime_plan: brief.runtime_plan,
118
+ outputs,
119
+ errors,
120
+ dry_run: dryRun,
121
+ success_count: outputs.filter(o => o.status === 'ok' || o.status === 'dry_run').length,
122
+ error_count: errors.length,
123
+ };
124
+ }
66
125
 
67
126
  for (let i = 0; i < outputCount; i++) {
68
127
  const seed = seedPlan.seeds[i];
@@ -77,6 +136,7 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
77
136
  status: 'dry_run',
78
137
  filename: `${imageIndex}.png`,
79
138
  });
139
+ await checkpointRunManifest(runDir, buildManifestSnapshot());
80
140
  continue;
81
141
  }
82
142
 
@@ -106,6 +166,7 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
106
166
  if (!picked) {
107
167
  warn(` No output image for seed ${seed}`);
108
168
  outputs.push({ index: i, seed, status: 'no_output', elapsed_ms: elapsed });
169
+ await checkpointRunManifest(runDir, buildManifestSnapshot());
109
170
  continue;
110
171
  }
111
172
 
@@ -138,6 +199,12 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
138
199
  elapsed_ms: elapsed,
139
200
  });
140
201
  }
202
+
203
+ // B01: checkpoint after every completed output (success OR error) so a
204
+ // crash/interruption mid-loop leaves a partial, readable manifest.json
205
+ // instead of an orphan directory — this is the call the docstring on
206
+ // checkpointRunManifest() always described but nothing ever made.
207
+ await checkpointRunManifest(runDir, buildManifestSnapshot());
141
208
  }
142
209
 
143
210
  // PIN_PER_STEP: pin the exact graph + model/LoRA content identity + seed
@@ -170,28 +237,14 @@ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dr
170
237
  warn(`pinning skipped: ${err.message}`);
171
238
  }
172
239
 
173
- // Build manifest
240
+ // Build manifest — reuses buildManifestSnapshot() (same helper the
241
+ // mid-loop checkpoints use) so the finished manifest and every
242
+ // incremental checkpoint before it are byte-for-byte the same shape,
243
+ // just with `pinning` filled in and no `incremental`/`last_checkpoint_at`
244
+ // stamp (that stamp is what lets a reader tell "done" from "still going").
174
245
  const manifest = {
175
- run_id: runId,
176
- schema_version: SCHEMA_VERSION,
177
- brief_id: brief.brief_id,
178
- project_id: brief.project_id,
179
- workflow_template_id: templateId,
180
- adapter_target: 'comfyui',
181
- output_mode: outputMode,
182
- output_count: outputCount,
183
- created_at: new Date().toISOString(),
184
- seed_plan: seedPlan,
185
- comfy_url: comfyUrl,
186
- checkpoint: defaults.checkpoint,
187
- loras: defaults.loras || [],
246
+ ...buildManifestSnapshot(),
188
247
  pinning,
189
- runtime_plan: brief.runtime_plan,
190
- outputs,
191
- errors,
192
- dry_run: dryRun,
193
- success_count: outputs.filter(o => o.status === 'ok' || o.status === 'dry_run').length,
194
- error_count: errors.length,
195
248
  };
196
249
 
197
250
  await saveRunManifest(runDir, manifest);
@@ -33,8 +33,16 @@ export function listWorkflowTemplates() {
33
33
 
34
34
  /**
35
35
  * Resolve a template for a given output mode.
36
- * Returns 'txt2img-standard' for all standard modes.
37
- * Throws if no template matches.
36
+ *
37
+ * Returns the first template declaring `outputMode` in its `compatible_modes`,
38
+ * and falls back to `'txt2img-standard'` when none matches — it does NOT
39
+ * throw. (The docstring claimed a throw until 2026-08-04; a caller written
40
+ * against that claim would guard a branch that cannot fire and would miss the
41
+ * silent-default case, where an unrecognised mode renders through the standard
42
+ * SDXL graph instead of failing loudly.)
43
+ *
44
+ * The fallback is also what makes an install with no `runtime/comfyui/`
45
+ * templates degrade rather than crash — see `getRuntimeDir()` in lib/paths.js.
38
46
  */
39
47
  export function resolveTemplateId(outputMode) {
40
48
  const templates = listWorkflowTemplates();
@@ -16,6 +16,8 @@ import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
16
16
  import { join, relative } from 'node:path';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { groupRowsByPartition } from '../rows.js';
19
+ import { assertSafeAssetPath, assertSafeId } from '../export.js';
20
+ import { warn } from '../log.js';
19
21
 
20
22
  /**
21
23
  * Build training package in diffusers LoRA format from canonical rows.
@@ -33,6 +35,11 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
33
35
  const partitioned = groupRowsByPartition(rows || []);
34
36
 
35
37
  let imageCount = 0;
38
+ // SDL-C5: per-row failure accounting (export.js DB-002 shape) — see
39
+ // generic-image-caption.js for the full rationale. A caught copy/symlink
40
+ // error must not silently drop the image, caption sidecar, AND metadata
41
+ // row together with zero logging.
42
+ const imagesFailed = [];
36
43
 
37
44
  for (const [partition, partitionRows] of Object.entries(partitioned)) {
38
45
  const datasetDir = join(packageDir, 'dataset', partition);
@@ -43,10 +50,21 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
43
50
  const metadataRows = [];
44
51
 
45
52
  for (const row of partitionRows) {
46
- if (!row.asset_path) continue;
53
+ if (!row.asset_path) {
54
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
55
+ continue;
56
+ }
47
57
 
48
- const sourcePath = join(projectRoot, row.asset_path);
49
- if (!existsSync(sourcePath)) continue;
58
+ // SDL-H5 / SDL-H6: defense-in-depth — see generic-image-caption.js
59
+ // for the full rationale. Throws (hard fail) on traversal/unsafe id;
60
+ // that is a security boundary, not an operational hiccup.
61
+ assertSafeId(row.entity_id, `row.entity_id in diffusers-lora adapter (partition "${partition}")`);
62
+ const sourcePath = assertSafeAssetPath(row.asset_path, projectRoot, row.entity_id);
63
+
64
+ if (!existsSync(sourcePath)) {
65
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'source_missing' });
66
+ continue;
67
+ }
50
68
 
51
69
  const sourceBasename = row.asset_path.split(/[\\/]/).pop();
52
70
  const extMatch = sourceBasename.match(/\.[^.]+$/);
@@ -62,7 +80,13 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
62
80
  await symlink(relative(datasetDir, sourcePath), destPath);
63
81
  }
64
82
  imageCount++;
65
- } catch {
83
+ } catch (err) {
84
+ imagesFailed.push({
85
+ record_id: row.entity_id,
86
+ partition,
87
+ reason: copy ? 'copy_failed' : 'link_failed',
88
+ detail: err.message,
89
+ });
66
90
  continue;
67
91
  }
68
92
 
@@ -77,7 +101,15 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
77
101
  );
78
102
  }
79
103
 
80
- return { imageCount };
104
+ if (imagesFailed.length > 0) {
105
+ const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
106
+ warn(
107
+ `diffusers-lora adapter: ${imagesFailed.length} of ${imageCount + imagesFailed.length} rows failed to place` +
108
+ (imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
109
+ );
110
+ }
111
+
112
+ return { imageCount, imagesFailed };
81
113
  }
82
114
 
83
115
  function buildMetadataRow(row, partition, filename, stem) {
@@ -14,11 +14,20 @@ import { writeFile, mkdir, symlink, copyFile } from 'node:fs/promises';
14
14
  import { join, relative } from 'node:path';
15
15
  import { existsSync } from 'node:fs';
16
16
  import { groupRowsByPartition } from '../rows.js';
17
+ import { assertSafeAssetPath, assertSafeId } from '../export.js';
18
+ import { warn } from '../log.js';
17
19
 
18
20
  export async function buildPackage({ packageDir, rows, profile, manifest, config, projectRoot, copy }) {
19
21
  const partitioned = groupRowsByPartition(rows || []);
20
22
 
21
23
  let imageCount = 0;
24
+ // SDL-C5: per-row failure accounting (export.js DB-002 shape). The old
25
+ // bare `catch { continue; }` silently dropped the image, the caption
26
+ // sidecar, AND the metadata row together with no logging — on Windows
27
+ // without Developer Mode this fired on every row, so imageCount stayed
28
+ // 0 and the build still reported success. Every drop is now accounted
29
+ // for and returned to the caller.
30
+ const imagesFailed = [];
22
31
 
23
32
  for (const [partition, partitionRows] of Object.entries(partitioned)) {
24
33
  const datasetDir = join(packageDir, 'dataset', partition);
@@ -29,10 +38,25 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
29
38
  const metadataRows = [];
30
39
 
31
40
  for (const row of partitionRows) {
32
- if (!row.asset_path) continue;
41
+ if (!row.asset_path) {
42
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
43
+ continue;
44
+ }
33
45
 
34
- const sourcePath = join(projectRoot, row.asset_path);
35
- if (!existsSync(sourcePath)) continue;
46
+ // SDL-H5 / SDL-H6: defense-in-depth. buildTrainingPackage validates
47
+ // every row before dispatching here, but this adapter is also
48
+ // unit-tested (and documented in lib/rows.js as a future direct
49
+ // canon-build target) independent of that caller, so the guard
50
+ // lives here too. A traversal / unsafe id is a security boundary,
51
+ // not an operational hiccup — it throws (hard fail) rather than
52
+ // joining the soft imagesFailed accounting below.
53
+ assertSafeId(row.entity_id, `row.entity_id in generic-image-caption adapter (partition "${partition}")`);
54
+ const sourcePath = assertSafeAssetPath(row.asset_path, projectRoot, row.entity_id);
55
+
56
+ if (!existsSync(sourcePath)) {
57
+ imagesFailed.push({ record_id: row.entity_id, partition, reason: 'source_missing' });
58
+ continue;
59
+ }
36
60
 
37
61
  const sourceBasename = row.asset_path.split(/[\\/]/).pop();
38
62
  const extMatch = sourceBasename.match(/\.[^.]+$/);
@@ -47,7 +71,13 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
47
71
  await symlink(relative(datasetDir, sourcePath), destPath);
48
72
  }
49
73
  imageCount++;
50
- } catch {
74
+ } catch (err) {
75
+ imagesFailed.push({
76
+ record_id: row.entity_id,
77
+ partition,
78
+ reason: copy ? 'copy_failed' : 'link_failed',
79
+ detail: err.message,
80
+ });
51
81
  continue;
52
82
  }
53
83
 
@@ -67,5 +97,13 @@ export async function buildPackage({ packageDir, rows, profile, manifest, config
67
97
  );
68
98
  }
69
99
 
70
- return { imageCount };
100
+ if (imagesFailed.length > 0) {
101
+ const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
102
+ warn(
103
+ `generic-image-caption adapter: ${imagesFailed.length} of ${imageCount + imagesFailed.length} rows failed to place` +
104
+ (imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`),
105
+ );
106
+ }
107
+
108
+ return { imageCount, imagesFailed };
71
109
  }