@mcptoolshop/style-dataset-lab 3.0.0 → 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.
Files changed (99) hide show
  1. package/CHANGELOG.md +108 -15
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +71 -0
  4. package/README.es.md +99 -8
  5. package/README.fr.md +100 -9
  6. package/README.hi.md +102 -9
  7. package/README.it.md +99 -8
  8. package/README.ja.md +100 -9
  9. package/README.md +64 -1
  10. package/README.pt-BR.md +99 -8
  11. package/README.zh.md +100 -9
  12. package/SECURITY.md +6 -5
  13. package/bin/sdlab.js +289 -62
  14. package/lib/adapters/comfyui-runner.js +11 -15
  15. package/lib/adapters/diffusers-lora.js +7 -2
  16. package/lib/adapters/generic-image-caption.js +6 -1
  17. package/lib/args.js +200 -13
  18. package/lib/batch-compiler.js +7 -3
  19. package/lib/batch-runs.js +162 -31
  20. package/lib/batch-sheet-render.js +5 -2
  21. package/lib/brief-compiler.js +38 -11
  22. package/lib/card.js +11 -4
  23. package/lib/comfyui-output.js +70 -0
  24. package/lib/comfyui.js +100 -6
  25. package/lib/config.js +68 -3
  26. package/lib/deprecation.js +1 -1
  27. package/lib/eval-pack.js +94 -4
  28. package/lib/eval-runs.js +42 -3
  29. package/lib/export.js +144 -34
  30. package/lib/implementation-packs.js +15 -1
  31. package/lib/log.js +41 -4
  32. package/lib/paths.js +41 -3
  33. package/lib/refine-briefs.js +16 -9
  34. package/lib/reingest-selected.js +14 -3
  35. package/lib/reingest.js +15 -1
  36. package/lib/run-summary.js +3 -2
  37. package/lib/runtime-runs.js +59 -9
  38. package/lib/selections.js +54 -2
  39. package/lib/snapshot.js +100 -7
  40. package/lib/split.js +175 -17
  41. package/lib/training-adapters.js +79 -0
  42. package/lib/training-manifests.js +17 -3
  43. package/lib/training-packages.js +29 -28
  44. package/lib/training-profiles.js +10 -0
  45. package/package.json +27 -3
  46. package/scripts/batch-generate.js +72 -20
  47. package/scripts/batch-sheet.js +3 -3
  48. package/scripts/batch-show.js +3 -3
  49. package/scripts/brief-compile.js +8 -8
  50. package/scripts/brief-show.js +4 -3
  51. package/scripts/canon-bind.js +7 -9
  52. package/scripts/card.js +22 -25
  53. package/scripts/compare.js +38 -33
  54. package/scripts/critique-show.js +3 -3
  55. package/scripts/critique.js +3 -3
  56. package/scripts/curate.js +91 -46
  57. package/scripts/doctor.js +3 -5
  58. package/scripts/eligibility.js +17 -14
  59. package/scripts/eval-pack.js +13 -11
  60. package/scripts/eval-run.js +30 -29
  61. package/scripts/export.js +27 -31
  62. package/scripts/generate-controlnet.js +31 -17
  63. package/scripts/generate-identity.js +37 -20
  64. package/scripts/generate-ipadapter.js +22 -16
  65. package/scripts/generate.js +96 -29
  66. package/scripts/implementation-pack.js +19 -14
  67. package/scripts/init.js +115 -17
  68. package/scripts/painterly.js +8 -10
  69. package/scripts/refine.js +3 -3
  70. package/scripts/reingest-selected.js +3 -3
  71. package/scripts/reingest.js +33 -20
  72. package/scripts/run-generate.js +4 -4
  73. package/scripts/run-list.js +3 -3
  74. package/scripts/run-show.js +3 -3
  75. package/scripts/select.js +5 -4
  76. package/scripts/selection-show.js +3 -3
  77. package/scripts/snapshot.js +22 -24
  78. package/scripts/split.js +26 -31
  79. package/scripts/training-manifest.js +29 -24
  80. package/scripts/training-package.js +24 -20
  81. package/scripts/training-profile.js +13 -8
  82. package/scripts/workflow-list.js +2 -2
  83. package/scripts/workflow-show.js +4 -3
  84. package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
  85. package/templates/domains/architecture/terminology.json +10 -8
  86. package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
  87. package/templates/domains/character-design/project.json +4 -2
  88. package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
  89. package/templates/domains/creature-design/project.json +4 -2
  90. package/templates/domains/creature-design/terminology.json +1 -0
  91. package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
  92. package/templates/domains/game-art/project.json +4 -2
  93. package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
  94. package/templates/domains/vehicle-mech/project.json +4 -2
  95. package/scripts/bulk-curate-wave2-5.js +0 -138
  96. package/scripts/bulk-curate-waves11-18.js +0 -413
  97. package/scripts/curate-wave25.js +0 -209
  98. package/scripts/migrate-records.js +0 -141
  99. package/templates/inputs/prompts/example-wave.json +0 -27
package/bin/sdlab.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { enableDebug, handleCliError, inputError } from '../lib/errors.js';
11
11
  import { setLogLevel } from '../lib/log.js';
12
+ import { findClosest } from '../lib/args.js';
12
13
 
13
14
  const COMMANDS = {
14
15
  'init': '../scripts/init.js',
@@ -79,6 +80,212 @@ const SELECTION_COMMANDS = {
79
80
  'show': '../scripts/selection-show.js',
80
81
  };
81
82
 
83
+ // ─── Per-command help ─────────────────────────────────────────────────
84
+ //
85
+ // Keyed by the user-facing command (including two-word forms joined by
86
+ // a space). Help text is plain — no color codes — to stay readable in
87
+ // dumb terminals and when piped to files.
88
+
89
+ const HELP_TEXT = {
90
+ 'init': `sdlab init <project-name> [--domain <domain>]
91
+
92
+ Scaffold a new project from a domain template.
93
+
94
+ Positional:
95
+ project-name Lowercase, hyphen-separated (e.g. "my-project")
96
+
97
+ Flags:
98
+ --domain <id> Domain template (default: generic)
99
+
100
+ Examples:
101
+ sdlab init my-project
102
+ sdlab init heist-crew --domain character-design
103
+
104
+ Run "sdlab init" with no arguments to see the available domains.`,
105
+
106
+ 'project doctor': `sdlab project doctor --project <name>
107
+
108
+ Validate project config and directory structure. Reports missing
109
+ required files, unmigrated record schemas, and orphaned outputs.
110
+
111
+ Flags:
112
+ --project <name> Project to audit (required in practice)
113
+ --verbose Show per-check detail
114
+ --quiet Only print PASS / FAIL summary`,
115
+
116
+ 'generate': `sdlab generate [<pack-path>] --project <name> [--dry-run] [--resume]
117
+
118
+ Generate candidates from a prompt pack (JSON file under
119
+ projects/<name>/inputs/prompts/).
120
+
121
+ Positional:
122
+ pack-path Path to the prompt pack (default: inputs/prompts/rpg-icons-lane1.json)
123
+
124
+ Flags:
125
+ --project <name> Project to operate on
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.
131
+
132
+ Env:
133
+ COMFY_URL ComfyUI HTTP endpoint (default: http://127.0.0.1:8188)
134
+
135
+ Precedence: CLI flag > env COMFY_URL > project defaults > built-in default.`,
136
+
137
+ 'batch generate': `sdlab batch generate --mode <id> [--subject <id>] [--theme <label>] --project <name>
138
+ sdlab batch generate --resume <batch_id> --project <name>
139
+
140
+ Execute a batch production mode (expression sheet, silhouette pack,
141
+ environment board, etc).
142
+
143
+ Flags:
144
+ --mode <id> Batch mode id (required unless --resume is given)
145
+ --subject <id> Subject for subject-driven modes
146
+ --theme <label> Theme label for environment modes
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.
151
+ --project <name> Project to operate on
152
+ --dry-run Prepare batch without submitting to ComfyUI
153
+ --json Output manifest as JSON`,
154
+
155
+ 'brief compile': `sdlab brief compile --profile <id> --project <name>
156
+
157
+ Compile a generation brief from project canon (constitution, lanes,
158
+ workflow profile).
159
+
160
+ Flags:
161
+ --profile <id> Workflow profile id to compile against
162
+ --project <name> Project to operate on
163
+ --out <path> Optional explicit output path`,
164
+
165
+ 'run generate': `sdlab run generate --brief <id> --project <name> [--dry-run]
166
+
167
+ Execute a compiled brief through ComfyUI.
168
+
169
+ Flags:
170
+ --brief <id> Brief id to execute (required)
171
+ --project <name> Project to operate on
172
+ --dry-run Plan without submitting to ComfyUI`,
173
+
174
+ 'critique': `sdlab critique --run <id> --project <name>
175
+
176
+ Critique a completed run. Writes a critique record alongside the run.
177
+
178
+ Flags:
179
+ --run <id> Run id to critique (required)
180
+ --project <name> Project to operate on
181
+ --json Emit critique as JSON on stdout`,
182
+
183
+ 'refine': `sdlab refine --run <id> --pick <file> --project <name>
184
+
185
+ Generate a refined next-pass brief based on a specific pick from a
186
+ prior run.
187
+
188
+ Flags:
189
+ --run <id> Source run id (required)
190
+ --pick <file> Picked output filename inside the run (required)
191
+ --project <name> Project to operate on`,
192
+
193
+ 'batch sheet': `sdlab batch sheet <batch-id> --project <name>
194
+
195
+ Re-render a batch's contact sheet from its manifest.
196
+
197
+ Positional:
198
+ batch-id Batch id to re-render
199
+
200
+ Flags:
201
+ --project <name> Project to operate on`,
202
+
203
+ 'select': `sdlab select [--run <id>|--batch <id>] --approve <files> --project <name>
204
+
205
+ Select approved outputs from a run or batch.
206
+
207
+ Flags:
208
+ --run <id> Source run id
209
+ --batch <id> Source batch id
210
+ --approve <files> Comma-separated output filenames to approve
211
+ --project <name> Project to operate on`,
212
+
213
+ 'curate': `sdlab curate <asset_id> <approved|rejected|borderline> <explanation> [--scores k:v,...] [--failures f1,f2] --project <name>
214
+
215
+ Record a judgment on a candidate and move it to the matching outputs
216
+ subdirectory.
217
+
218
+ Positional:
219
+ asset_id Candidate asset id
220
+ status approved | rejected | borderline
221
+ explanation Short reason (quoted)
222
+
223
+ Flags:
224
+ --scores k:v,... Per-dimension scores (e.g. silhouette:0.9,palette:0.8)
225
+ --failures f1,f2 Named failure tags (comma-separated)
226
+ --notes "…" Optional free-form note
227
+ --list Print uncurated candidates and exit
228
+ --dry-run Preview without moving files
229
+ --project <name> Project to operate on`,
230
+
231
+ 'canon-bind': `sdlab canon-bind --project <name> [--lane <id>]
232
+
233
+ Bind approved records to constitution rules.
234
+
235
+ Flags:
236
+ --project <name> Project to operate on
237
+ --lane <id> Restrict binding to a single lane`,
238
+
239
+ 'snapshot': `sdlab snapshot <create|list|show|diff> [args] --project <name>
240
+
241
+ Manage frozen dataset snapshots.
242
+
243
+ Subcommands:
244
+ create Create a new snapshot from approved records
245
+ list List all snapshots (default subcommand)
246
+ show <id> Show snapshot details
247
+ diff <a> <b> Compare two snapshots
248
+
249
+ Flags:
250
+ --profile <name> Selection profile (create only)
251
+ --project <name> Project to operate on
252
+ --dry-run Preview snapshot contents without writing`,
253
+
254
+ 'split': `sdlab split <build|list|show|audit> [args] --project <name>
255
+
256
+ Manage train/val/test splits from a snapshot.
257
+
258
+ Subcommands:
259
+ build Build a split from a snapshot
260
+ list List all splits
261
+ show <id> Show split details
262
+ audit <id> Audit a split for leakage and balance
263
+
264
+ Flags:
265
+ --project <name> Project to operate on`,
266
+
267
+ 'export': `sdlab export <build|list> [args] --project <name>
268
+
269
+ Build and list self-contained export packages.
270
+
271
+ Subcommands:
272
+ build Build a new export package
273
+ list List all export packages
274
+
275
+ Flags:
276
+ --project <name> Project to operate on`,
277
+ };
278
+
279
+ // "bind" is a short alias for "canon-bind" — share help.
280
+ HELP_TEXT['bind'] = HELP_TEXT['canon-bind'];
281
+
282
+ function printCommandHelp(key) {
283
+ const text = HELP_TEXT[key];
284
+ if (!text) return false;
285
+ console.log(text);
286
+ return true;
287
+ }
288
+
82
289
  function printHelp() {
83
290
  console.log(`\x1b[1msdlab\x1b[0m — Style Dataset Lab CLI\n`);
84
291
  console.log('Usage: sdlab <command> [options]\n');
@@ -94,9 +301,10 @@ function printHelp() {
94
301
  console.log(' generate:ipadapter IP-Adapter reference-guided generation');
95
302
  console.log(' curate Move candidate to approved/rejected/borderline');
96
303
  console.log(' compare Record pairwise A-vs-B comparison');
97
- console.log(' bind Bind approved records to constitution rules');
304
+ console.log(' canon-bind Bind approved records to constitution rules');
305
+ console.log(' ("bind" is a short alias for canon-bind)');
98
306
  console.log(' painterly Post-processing painterly style pass');
99
- console.log(' painterly:test Test denoise levels on reference images');
307
+ console.log(' painterly:test Calibrate painterly denoise level on sample images');
100
308
  console.log('');
101
309
  console.log('Dataset:');
102
310
  console.log(' snapshot create Create a frozen dataset snapshot');
@@ -132,7 +340,8 @@ function printHelp() {
132
340
  console.log(' implementation-pack build Build implementation example pack');
133
341
  console.log(' implementation-pack show <id> Show pack details');
134
342
  console.log(' implementation-pack list List all packs');
135
- console.log(' reingest generated --source <dir> Re-ingest generated outputs');
343
+ console.log(' reingest generated --source <dir> --manifest <id> Re-ingest generated outputs');
344
+ console.log(' reingest selected --selection <id> Re-ingest selected outputs as records');
136
345
  console.log(' reingest audit Audit re-ingested record status');
137
346
  console.log('');
138
347
  console.log('Production:');
@@ -155,22 +364,59 @@ function printHelp() {
155
364
  console.log(' reingest selected --selection <id> Re-ingest selected outputs as records');
156
365
  console.log('');
157
366
  console.log('Options:');
158
- console.log(' --project <name> Project to operate on (default: star-freight)');
367
+ console.log(' --project <name> Project to operate on (no default — fallback to "star-freight" with warning)');
159
368
  console.log(' --game <name> Deprecated alias for --project');
160
369
  console.log(' --debug Show stack traces on error');
161
370
  console.log(' --verbose Verbose output');
162
371
  console.log(' --quiet Suppress non-essential output');
163
372
  console.log(' --dry-run Preview changes without writing (where supported)');
164
- console.log(' --help Show this help');
373
+ console.log(' --help Show this help (or per-command help with "sdlab <cmd> --help")');
165
374
  console.log('');
166
375
  console.log('Examples:');
167
376
  console.log(' sdlab init my-project --domain character-design');
377
+ console.log(' sdlab generate --help');
168
378
  console.log(' sdlab snapshot create --project star-freight');
169
379
  console.log(' sdlab split build --dry-run --project star-freight');
170
380
  console.log(' sdlab training-package build --debug --project star-freight');
171
381
  }
172
382
 
383
+ // All top-level command tokens users can type (including two-word heads).
384
+ function allKnownCommands() {
385
+ const oneWord = Object.keys(COMMANDS);
386
+ const heads = ['project', 'workflow', 'brief', 'run', 'critique', 'refine', 'batch', 'select', 'selection'];
387
+ return [...new Set([...oneWord, ...heads])];
388
+ }
389
+
390
+ function argvHasHelp(argv) {
391
+ return argv.includes('--help') || argv.includes('-h');
392
+ }
393
+
394
+ // ─── Signal + fatal-error handlers ────────────────────────────────────
395
+
396
+ function installProcessHandlers() {
397
+ const onSignal = (sig) => {
398
+ const code = sig === 'SIGINT' ? 130 : 143;
399
+ process.stderr.write(`\n\x1b[31m✗ Interrupted by ${sig}. Partial output may exist.\x1b[0m\n`);
400
+ process.exit(code);
401
+ };
402
+ process.on('SIGINT', () => onSignal('SIGINT'));
403
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
404
+
405
+ process.on('uncaughtException', (err) => {
406
+ handleCliError(err);
407
+ // handleCliError exits, but guard anyway.
408
+ process.exit(2);
409
+ });
410
+ process.on('unhandledRejection', (reason) => {
411
+ const err = reason instanceof Error ? reason : new Error(String(reason));
412
+ handleCliError(err);
413
+ process.exit(2);
414
+ });
415
+ }
416
+
173
417
  async function main() {
418
+ installProcessHandlers();
419
+
174
420
  const args = process.argv.slice(2);
175
421
 
176
422
  // Global flags
@@ -186,94 +432,75 @@ async function main() {
186
432
  let command = args[0];
187
433
  let commandArgs = args.slice(1);
188
434
 
189
- // Handle "project <subcommand>" two-word form
190
- if (command === 'project' && args[1] && PROJECT_COMMANDS[args[1]]) {
191
- commandArgs = args.slice(2);
192
- const modulePath = PROJECT_COMMANDS[args[1]];
193
- const mod = await import(modulePath);
194
- await mod.run(commandArgs);
195
- return;
196
- }
197
-
198
- // Handle "workflow <subcommand>" two-word form
199
- if (command === 'workflow' && args[1] && WORKFLOW_COMMANDS[args[1]]) {
200
- commandArgs = args.slice(2);
201
- const modulePath = WORKFLOW_COMMANDS[args[1]];
202
- const mod = await import(modulePath);
203
- await mod.run(commandArgs);
204
- return;
205
- }
435
+ // ── Two-word dispatch tables and --help interception ──
436
+ // For every two-word namespace, we check "cmd sub --help" first,
437
+ // then the single-word "cmd --help", then fall through to dispatch.
206
438
 
207
- // Handle "brief <subcommand>" two-word form
208
- if (command === 'brief' && args[1] && BRIEF_COMMANDS[args[1]]) {
209
- commandArgs = args.slice(2);
210
- const modulePath = BRIEF_COMMANDS[args[1]];
211
- const mod = await import(modulePath);
212
- await mod.run(commandArgs);
213
- return;
214
- }
439
+ const twoWord = (head, table) => ({ head, table });
440
+ const namespaces = [
441
+ twoWord('project', PROJECT_COMMANDS),
442
+ twoWord('workflow', WORKFLOW_COMMANDS),
443
+ twoWord('brief', BRIEF_COMMANDS),
444
+ twoWord('run', RUN_COMMANDS),
445
+ twoWord('batch', BATCH_COMMANDS),
446
+ twoWord('selection', SELECTION_COMMANDS),
447
+ ];
215
448
 
216
- // Handle "run <subcommand>" two-word form
217
- if (command === 'run' && args[1] && RUN_COMMANDS[args[1]]) {
218
- commandArgs = args.slice(2);
219
- const modulePath = RUN_COMMANDS[args[1]];
220
- const mod = await import(modulePath);
221
- await mod.run(commandArgs);
222
- return;
449
+ // "project", "workflow", "brief", "run", "batch", "selection" two-word forms
450
+ for (const { head, table } of namespaces) {
451
+ if (command === head && args[1] && table[args[1]]) {
452
+ commandArgs = args.slice(2);
453
+ const twoWordKey = `${head} ${args[1]}`;
454
+ if (argvHasHelp(commandArgs) && printCommandHelp(twoWordKey)) return;
455
+ const mod = await import(table[args[1]]);
456
+ await mod.run(commandArgs);
457
+ return;
458
+ }
223
459
  }
224
460
 
225
- // Handle "critique" — bare or "critique show" two-word form
461
+ // "critique" — bare or "critique show"
226
462
  if (command === 'critique') {
227
463
  if (args[1] && CRITIQUE_COMMANDS[args[1]]) {
228
464
  commandArgs = args.slice(2);
229
- const modulePath = CRITIQUE_COMMANDS[args[1]];
230
- const mod = await import(modulePath);
465
+ if (argvHasHelp(commandArgs) && printCommandHelp(`critique ${args[1]}`)) return;
466
+ const mod = await import(CRITIQUE_COMMANDS[args[1]]);
231
467
  await mod.run(commandArgs);
232
468
  return;
233
469
  }
234
- // Bare "critique" → generate critique
470
+ if (argvHasHelp(commandArgs) && printCommandHelp('critique')) return;
235
471
  const mod = await import('../scripts/critique.js');
236
472
  await mod.run(commandArgs);
237
473
  return;
238
474
  }
239
475
 
240
- // Handle "refine" — single command
476
+ // "refine" — single command
241
477
  if (command === 'refine') {
478
+ if (argvHasHelp(commandArgs) && printCommandHelp('refine')) return;
242
479
  const mod = await import('../scripts/refine.js');
243
480
  await mod.run(commandArgs);
244
481
  return;
245
482
  }
246
483
 
247
- // Handle "batch <subcommand>" two-word form
248
- if (command === 'batch' && args[1] && BATCH_COMMANDS[args[1]]) {
249
- commandArgs = args.slice(2);
250
- const modulePath = BATCH_COMMANDS[args[1]];
251
- const mod = await import(modulePath);
252
- await mod.run(commandArgs);
253
- return;
254
- }
255
-
256
- // Handle "select" — single command
484
+ // "select" — single command
257
485
  if (command === 'select') {
486
+ if (argvHasHelp(commandArgs) && printCommandHelp('select')) return;
258
487
  const mod = await import('../scripts/select.js');
259
488
  await mod.run(commandArgs);
260
489
  return;
261
490
  }
262
491
 
263
- // Handle "selection <subcommand>" two-word form
264
- if (command === 'selection' && args[1] && SELECTION_COMMANDS[args[1]]) {
265
- commandArgs = args.slice(2);
266
- const modulePath = SELECTION_COMMANDS[args[1]];
267
- const mod = await import(modulePath);
268
- await mod.run(commandArgs);
269
- return;
270
- }
271
-
272
492
  const modulePath = COMMANDS[command];
273
493
  if (!modulePath) {
274
- throw inputError('INPUT_UNKNOWN_COMMAND', `Unknown command: ${command}`, 'Run "sdlab --help" for available commands.');
494
+ const suggestion = findClosest(command, allKnownCommands());
495
+ const hint = suggestion
496
+ ? `Did you mean "${suggestion}"? (Run "sdlab --help" for all commands.)`
497
+ : 'Run "sdlab --help" for available commands.';
498
+ throw inputError('INPUT_UNKNOWN_COMMAND', `Unknown command: ${command}`, hint);
275
499
  }
276
500
 
501
+ // Per-command --help for one-word commands
502
+ if (argvHasHelp(commandArgs) && printCommandHelp(command)) return;
503
+
277
504
  const mod = await import(modulePath);
278
505
  await mod.run(commandArgs);
279
506
  }
@@ -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)`);
@@ -68,8 +68,13 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
68
68
  const sourcePath = join(projectRoot, record.asset_path);
69
69
  if (!existsSync(sourcePath)) continue;
70
70
 
71
- const filename = record.asset_path.split('/').pop();
72
- const stem = filename.replace(/\.[^.]+$/, '');
71
+ // D-009: Name output files by record.id (not source basename) to
72
+ // avoid silent collisions when two records share a filename stem.
73
+ const sourceBasename = record.asset_path.split(/[\\/]/).pop();
74
+ const extMatch = sourceBasename.match(/\.[^.]+$/);
75
+ const ext = extMatch ? extMatch[0] : '';
76
+ const filename = `${record.id}${ext}`;
77
+ const stem = record.id;
73
78
  const destPath = join(datasetDir, filename);
74
79
 
75
80
  try {
@@ -57,7 +57,12 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
57
57
  const sourcePath = join(projectRoot, record.asset_path);
58
58
  if (!existsSync(sourcePath)) continue;
59
59
 
60
- const filename = record.asset_path.split('/').pop();
60
+ // D-009: Name output files by record.id (not source basename) to
61
+ // avoid silent collisions when two records share a filename stem.
62
+ const sourceBasename = record.asset_path.split(/[\\/]/).pop();
63
+ const extMatch = sourceBasename.match(/\.[^.]+$/);
64
+ const ext = extMatch ? extMatch[0] : '';
65
+ const filename = `${record.id}${ext}`;
61
66
  const destPath = join(datasetDir, filename);
62
67
 
63
68
  try {