@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.
- package/CHANGELOG.md +33 -0
- package/README.es.md +35 -25
- package/README.fr.md +38 -28
- package/README.hi.md +36 -26
- package/README.it.md +37 -27
- package/README.ja.md +40 -30
- package/README.md +15 -5
- package/README.pt-BR.md +35 -25
- package/README.zh.md +33 -23
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +13 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +102 -16
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +48 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- 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
|
-
|
|
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
|
-
|
|
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** |
|
|
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/)
|
|
121
|
-
- **[Rustline](handbook/case-study-rustline/)
|
|
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
|
-
-
|
|
167
|
-
-
|
|
168
|
-
- **运行会固定其确切的图。**
|
|
169
|
-
- **没有模型会验证自己的输出。**
|
|
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`
|
|
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
|
-
|
|
237
|
-
ComfyUI 没有运行。启动 ComfyUI(`python main.py --listen 127.0.0.1 --port 8188
|
|
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`
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|
449
|
-
console.log(' sdlab split build --dry-run --project
|
|
450
|
-
console.log(' sdlab training-package build --debug --project
|
|
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
|
-
|
|
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)
|
|
58
|
+
if (!row.asset_path) {
|
|
59
|
+
imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
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 {
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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)
|
|
53
|
+
if (!row.asset_path) {
|
|
54
|
+
imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
47
57
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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)
|
|
41
|
+
if (!row.asset_path) {
|
|
42
|
+
imagesFailed.push({ record_id: row.entity_id, partition, reason: 'missing_asset_path' });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
}
|