@mcptoolshop/style-dataset-lab 3.2.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 (95) hide show
  1. package/CHANGELOG.md +58 -1
  2. package/README.es.md +40 -19
  3. package/README.fr.md +74 -53
  4. package/README.hi.md +67 -46
  5. package/README.it.md +42 -21
  6. package/README.ja.md +44 -23
  7. package/README.md +25 -4
  8. package/README.pt-BR.md +42 -21
  9. package/README.zh.md +66 -45
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +114 -27
  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 +82 -3
  28. package/lib/critique-render.js +45 -0
  29. package/lib/eligibility.js +121 -9
  30. package/lib/eval-pack.js +14 -14
  31. package/lib/eval-runs.js +33 -0
  32. package/lib/export.js +122 -34
  33. package/lib/freeze-gate.js +69 -16
  34. package/lib/freeze-stamp.js +28 -3
  35. package/lib/generated-provenance.js +16 -1
  36. package/lib/implementation-packs.js +95 -17
  37. package/lib/ingest.js +396 -0
  38. package/lib/judgment-provenance.js +220 -0
  39. package/lib/measure.js +422 -0
  40. package/lib/paths.js +196 -7
  41. package/lib/reingest-selected.js +98 -16
  42. package/lib/run-manifest.js +394 -0
  43. package/lib/run-summary.js +12 -6
  44. package/lib/runtime-runs.js +45 -4
  45. package/lib/snapshot.js +121 -16
  46. package/lib/split.js +364 -48
  47. package/lib/training-manifests.js +114 -21
  48. package/lib/training-packages.js +147 -15
  49. package/lib/verifier.js +67 -0
  50. package/lib/workflow-profiles.js +24 -3
  51. package/package.json +3 -2
  52. package/runtime/comfyui/txt2img-qwen.json +38 -0
  53. package/runtime/comfyui/txt2img-standard.json +21 -0
  54. package/scripts/_resume.js +62 -0
  55. package/scripts/_shared-args.js +44 -0
  56. package/scripts/brief-compile.js +2 -1
  57. package/scripts/brief-show.js +2 -1
  58. package/scripts/canon-bind.js +2 -2
  59. package/scripts/canon-build.js +27 -20
  60. package/scripts/canon-drift.js +16 -7
  61. package/scripts/canon-freeze-status.js +14 -6
  62. package/scripts/canon-freeze.js +27 -11
  63. package/scripts/canon-unfreeze.js +17 -9
  64. package/scripts/card.js +2 -2
  65. package/scripts/compare.js +2 -2
  66. package/scripts/critique.js +48 -8
  67. package/scripts/curate.js +23 -2
  68. package/scripts/doctor.js +181 -55
  69. package/scripts/eligibility.js +42 -2
  70. package/scripts/eval-pack.js +2 -2
  71. package/scripts/eval-run.js +2 -2
  72. package/scripts/export.js +2 -2
  73. package/scripts/generate-controlnet.js +45 -25
  74. package/scripts/generate-identity.js +53 -23
  75. package/scripts/generate-ipadapter.js +44 -23
  76. package/scripts/generate.js +72 -5
  77. package/scripts/implementation-pack.js +2 -2
  78. package/scripts/ingest.js +145 -0
  79. package/scripts/init.js +73 -13
  80. package/scripts/measure.js +170 -0
  81. package/scripts/measure_image.py +458 -0
  82. package/scripts/migrate-records.js +142 -0
  83. package/scripts/painterly-test.js +4 -3
  84. package/scripts/painterly.js +7 -3
  85. package/scripts/qwen_generate.py +334 -13
  86. package/scripts/reingest.js +2 -2
  87. package/scripts/run-generate.js +3 -0
  88. package/scripts/sheet.js +142 -0
  89. package/scripts/snapshot.js +2 -2
  90. package/scripts/split.js +2 -2
  91. package/scripts/training-manifest.js +2 -2
  92. package/scripts/training-package.js +2 -2
  93. package/scripts/training-profile.js +2 -2
  94. package/scripts/workflow-list.js +2 -1
  95. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ingest.js — Bring a directory of pre-existing images into the pipeline
5
+ * as bare, uncurated candidate records.
6
+ *
7
+ * Closes the gap where generation happened OUTSIDE sdlab entirely (a hand
8
+ * batch, external Python against Comfy Cloud, salvage from somewhere else)
9
+ * and there was no CLI path to get those images into records/ for review.
10
+ * Unlike `sdlab reingest generated` (which requires a trained LoRA's
11
+ * training manifest) or `sdlab reingest selected` (which requires a
12
+ * selection id only the production loop can mint), this takes any folder
13
+ * of images and any project — no pipeline history required.
14
+ *
15
+ * Usage:
16
+ * sdlab ingest <dir> --project <name>
17
+ * sdlab ingest <dir> --project <name> --wave inputs/prompts/wave-v2.json
18
+ * sdlab ingest <dir> --project <name> --dry-run
19
+ * sdlab ingest <dir> --project <name> --json
20
+ */
21
+
22
+ import { parseArgs, getProjectName } from '../lib/args.js';
23
+ import { REPO_ROOT, getProjectRoot, resolveSafeProjectPath } from '../lib/paths.js';
24
+ import { inputError, handleCliError } from '../lib/errors.js';
25
+ import { ingestDirectory } from '../lib/ingest.js';
26
+ import { result } from '../lib/log.js';
27
+
28
+ const USAGE = `Usage: sdlab ingest <dir> --project <name> [--wave <wave.json>] [--dry-run] [--json]
29
+
30
+ Scan <dir> for images and create bare candidate records (judgment: null,
31
+ canon: null, provenance.source: "external") ready for "sdlab curate".
32
+
33
+ Positional:
34
+ dir Directory of images to ingest (png/jpg/jpeg/webp)
35
+
36
+ Flags:
37
+ --project <name> Project to operate on
38
+ --wave <path> Optional wave JSON (inputs/prompts/*.json shape) to
39
+ attach real generation provenance (seed, prompt, ...)
40
+ by matching each image's filename stem to an item id.
41
+ A sibling receipts.jsonl next to <dir> is picked up
42
+ automatically when present (prompt_id, seconds, status).
43
+ --dry-run Preview what would be created — no files written
44
+ --json Emit the result as JSON on stdout
45
+
46
+ Next steps after ingesting:
47
+ 1. Review: sdlab curate <id> approved/rejected "explanation"
48
+ 2. Bind: sdlab bind --project <name>`;
49
+
50
+ export async function run(argv = process.argv.slice(2)) {
51
+ if (argv.includes('--help') || argv.includes('-h')) {
52
+ console.log(USAGE);
53
+ return;
54
+ }
55
+
56
+ const { flags, positionals } = parseArgs(argv, {
57
+ flags: {
58
+ project: { type: 'string' },
59
+ wave: { type: 'string' },
60
+ 'dry-run': { type: 'boolean', default: false },
61
+ json: { type: 'boolean', default: false },
62
+ },
63
+ deprecated: { game: 'project' },
64
+ });
65
+
66
+ const dirArg = positionals[0];
67
+ if (!dirArg) {
68
+ throw inputError(
69
+ 'INPUT_MISSING_ARGS',
70
+ 'A source directory is required.',
71
+ USAGE
72
+ );
73
+ }
74
+
75
+ const projectName = flags.project || getProjectName(argv);
76
+ const projectRoot = getProjectRoot(projectName);
77
+ const dryRun = flags['dry-run'] || flags.dryRun;
78
+
79
+ // Mirrors scripts/reingest.js's --source handling: the source directory
80
+ // of pre-existing images is allowed to live anywhere under the workspace
81
+ // root, not just inside this specific project (e.g. a shared staging
82
+ // inbox), while still refusing to escape the workspace entirely.
83
+ const safeSourceDir = resolveSafeProjectPath(projectRoot, dirArg, { baseRoot: REPO_ROOT, flagName: 'dir' });
84
+
85
+ // Mirrors scripts/generate.js's pack-path handling: a --wave file is
86
+ // expected to live inside THIS project (inputs/prompts/*.json), so its
87
+ // containment root defaults to the project root, not the whole workspace.
88
+ const safeWavePath = flags.wave
89
+ ? resolveSafeProjectPath(projectRoot, flags.wave, { flagName: 'wave' })
90
+ : null;
91
+
92
+ if (!flags.json) {
93
+ console.log(`\x1b[1msdlab ingest\x1b[0m — ${projectName}`);
94
+ console.log(` Source: ${dirArg}`);
95
+ if (flags.wave) console.log(` Wave: ${flags.wave}`);
96
+ if (dryRun) console.log(` Mode: DRY RUN (no files written)`);
97
+ console.log('');
98
+ }
99
+
100
+ const { created, skipped, rejected, receiptsFile } = await ingestDirectory(projectRoot, safeSourceDir, {
101
+ wavePath: safeWavePath,
102
+ dryRun,
103
+ });
104
+
105
+ if (flags.json) {
106
+ console.log(JSON.stringify({ created, skipped, rejected, receiptsFile, dryRun }, null, 2));
107
+ return;
108
+ }
109
+
110
+ if (receiptsFile) {
111
+ console.log(` \x1b[2mreceipts: ${receiptsFile}\x1b[0m`);
112
+ }
113
+
114
+ console.log(` \x1b[32m✓\x1b[0m Created: ${created.length} record(s)${dryRun ? ' (dry run — nothing written)' : ''}`);
115
+ if (created.length > 0 && created.length <= 25) {
116
+ for (const id of created) console.log(` ✓ ${id}`);
117
+ }
118
+
119
+ if (skipped.length > 0) {
120
+ console.log(` Skipped: ${skipped.length} (record already exists)`);
121
+ if (skipped.length <= 25) {
122
+ for (const id of skipped) console.log(` ○ ${id}`);
123
+ }
124
+ }
125
+
126
+ if (rejected.length > 0) {
127
+ console.log(` \x1b[33m⚠\x1b[0m Rejected: ${rejected.length} (unsafe id derived from filename — rename and re-run)`);
128
+ for (const r of rejected) console.log(` ✗ ${r.file}: ${r.reason}`);
129
+ }
130
+
131
+ if (!dryRun && created.length > 0) {
132
+ console.log('');
133
+ result(`Candidates: outputs/candidates/`);
134
+ result(`Records: records/`);
135
+ console.log('');
136
+ console.log(' Next steps:');
137
+ console.log(' 1. Review: sdlab curate <id> approved/rejected "explanation"');
138
+ console.log(' 2. Bind: sdlab bind --project ' + projectName);
139
+ }
140
+ }
141
+
142
+ // Direct execution guard
143
+ if (process.argv[1] && (process.argv[1].endsWith('ingest.js') || process.argv[1].endsWith('ingest'))) {
144
+ run().catch(handleCliError);
145
+ }
package/scripts/init.js CHANGED
@@ -12,10 +12,16 @@
12
12
  import { mkdir, readdir, readFile, writeFile, copyFile } from 'node:fs/promises';
13
13
  import { join, basename } from 'node:path';
14
14
  import { existsSync } from 'node:fs';
15
- import { REPO_ROOT } from '../lib/paths.js';
15
+ import { REPO_ROOT, getPackageRoot } from '../lib/paths.js';
16
16
  import { inputError, handleCliError } from '../lib/errors.js';
17
+ import { takeFlagValue } from '../lib/args.js';
17
18
 
18
- const TEMPLATES_DIR = join(REPO_ROOT, 'templates');
19
+ // Two DIFFERENT roots — see getPackageRoot() in lib/paths.js.
20
+ // `templates/` ships inside the npm tarball, so it is package-relative;
21
+ // `projects/` is the user's own work, so it is workspace-relative. In a repo
22
+ // checkout these coincide, which is why resolving both against REPO_ROOT
23
+ // passed every in-repo test while breaking `init` for every installed user.
24
+ const TEMPLATES_DIR = join(getPackageRoot(), 'templates');
19
25
  const PROJECTS_DIR = join(REPO_ROOT, 'projects');
20
26
 
21
27
  const PROJECT_DIRS = [
@@ -64,11 +70,15 @@ async function listDomains() {
64
70
  export async function run(argv = process.argv.slice(2)) {
65
71
  const projectName = argv.find(a => !a.startsWith('--'));
66
72
 
67
- // Parse --domain
73
+ // Parse --domain. H4: previously a hand-rolled `argv.indexOf('--domain')`
74
+ // that only recognized the space-separated form — `sdlab init foo
75
+ // --domain=game-art` silently fell through to the 'generic' default
76
+ // because no argv token was EXACTLY "--domain". takeFlagValue() (already
77
+ // hardened for both `--flag value` and `--flag=value`) closes that gap.
68
78
  let domain = 'generic';
69
- const domainIdx = argv.indexOf('--domain');
70
- if (domainIdx >= 0 && argv[domainIdx + 1]) {
71
- domain = argv[domainIdx + 1];
79
+ const domainValue = takeFlagValue(argv, 'domain');
80
+ if (domainValue !== undefined) {
81
+ domain = domainValue;
72
82
  }
73
83
 
74
84
  if (!projectName) {
@@ -133,6 +143,34 @@ export async function run(argv = process.argv.slice(2)) {
133
143
  }
134
144
  console.log(` \x1b[32m✓\x1b[0m Created ${PROJECT_DIRS.length} directories`);
135
145
 
146
+ // SDL-M6: the generate → curate → reingest loop fills inbox/generated/ and
147
+ // the outputs/ subtrees with PNGs (see lib/reingest-selected.js), but this
148
+ // repo's OWN .gitignore (which happens to cover those paths in dev) is not
149
+ // in the npm `files` allow-list and ships to nobody. Without a per-project
150
+ // .gitignore, every downstream `sdlab init` user stages regenerable blobs
151
+ // by construction — measured at 55 MB of untracked PNGs in this repo before
152
+ // this fix. Never clobber a .gitignore the user already created by hand.
153
+ const gitignorePath = join(projectDir, '.gitignore');
154
+ if (!existsSync(gitignorePath)) {
155
+ const gitignoreLines = [
156
+ '# Generated by `sdlab init` — regenerable blobs for this project.',
157
+ '# Records/config JSON are tracked; only pixels and derived artifacts are not.',
158
+ '',
159
+ 'inbox/**/*.png',
160
+ 'inbox/**/*.jpg',
161
+ 'inbox/**/*.jpeg',
162
+ 'inbox/**/*.webp',
163
+ 'outputs/**',
164
+ 'exports/',
165
+ 'snapshots/',
166
+ 'splits/',
167
+ 'canon-build/*/',
168
+ '',
169
+ ];
170
+ await writeFile(gitignorePath, gitignoreLines.join('\n'));
171
+ console.log(` \x1b[32m✓\x1b[0m .gitignore (regenerable outputs/inbox/exports/snapshots/splits)`);
172
+ }
173
+
136
174
  // Copy config files from domain template or generate generic ones
137
175
  const configFiles = ['project.json', 'constitution.json', 'lanes.json', 'rubric.json', 'terminology.json'];
138
176
 
@@ -331,21 +369,43 @@ export async function run(argv = process.argv.slice(2)) {
331
369
  console.log('');
332
370
  console.log(`\x1b[32m✓\x1b[0m Project "${projectName}" initialized`);
333
371
  console.log('');
372
+ // Two DIFFERENT path bases are printed in this block, for two DIFFERENT
373
+ // audiences — do not collapse them back into one helper:
374
+ // - "Edit <file>" / "Full quick-start" lines are opened by the user in
375
+ // an editor/file-browser from wherever they ran `init` (the workspace
376
+ // root), so they need workspace-relative paths: projects/<name>/....
377
+ // - The `sdlab generate <pack>` line's argument is consumed by
378
+ // generate.js via resolveSafeProjectPath(GAME_ROOT, packPath) — GAME_ROOT
379
+ // IS projects/<name>, so that path must be PROJECT-relative
380
+ // (inputs/prompts/...), not workspace-relative. Printing the
381
+ // workspace-relative form here doubles the path
382
+ // (projects/<name>/projects/<name>/...) and generate.js 404s with
383
+ // INPUT_FILE_NOT_FOUND. Regression introduced in 27c42c6 while fixing
384
+ // the sibling "Edit" lines (which legitimately needed the workspace-
385
+ // relative switch) — see tests/cli-scripts/init-generate-path.test.js.
386
+ const rel = (...segments) => ['projects', projectName, ...segments].join('/');
387
+ const projectRel = (...segments) => segments.join('/');
388
+
334
389
  console.log('Next steps:');
335
390
  let step = 1;
336
391
  if (wroteCanon['constitution.md']) {
337
- console.log(` ${step++}. Edit ${projectName}/canon/constitution.md — define your visual rules`);
392
+ console.log(` ${step++}. Edit ${rel('canon', 'constitution.md')}`);
393
+ console.log(` Describe your visual rules in prose — this is the canon the pipeline binds to.`);
338
394
  }
339
- console.log(` ${step++}. Edit ${projectName}/constitution.json — encode rules for canon-bind`);
395
+ console.log(` ${step++}. Edit ${rel('constitution.json')}`);
396
+ console.log(` Encode those rules as machine-checkable entries for canon-bind.`);
340
397
  if (wroteExamplePack) {
341
- console.log(` ${step++}. Run: sdlab generate inputs/prompts/example-wave.json --project ${projectName}`);
398
+ console.log(` ${step++}. Generate your first candidates:`);
399
+ console.log(` sdlab generate ${projectRel('inputs', 'prompts', 'example-wave.json')} --project ${projectName}`);
342
400
  } else {
343
- console.log(` ${step++}. Create prompt packs under ${projectName}/inputs/prompts/`);
344
- console.log(` ${step++}. Run: sdlab generate <pack> --project ${projectName}`);
401
+ console.log(` ${step++}. Add a prompt pack under ${rel('inputs', 'prompts')}/`);
402
+ console.log(` ${step++}. Generate your first candidates:`);
403
+ console.log(` sdlab generate <pack> --project ${projectName}`);
345
404
  }
346
- console.log(` ${step++}. Run: sdlab project doctor --project ${projectName}`);
405
+ console.log(` ${step++}. Check the project is wired correctly:`);
406
+ console.log(` sdlab project doctor --project ${projectName}`);
347
407
  console.log('');
348
- console.log(`See ${projectName}/README.md for the full quick-start.`);
408
+ console.log(`Full quick-start: ${rel('README.md')}`);
349
409
  }
350
410
 
351
411
  // Direct execution guard
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * measure.js — Attach deterministic pixel measurements to candidate records.
5
+ *
6
+ * Until now nothing in this tool ever opened an image. `lib/critique-engine.js`
7
+ * builds its entire drift report from brief and workflow TEXT — keyword
8
+ * matching against prompt strings — and stamps every candidate
9
+ * `overall_fit: 'usable'` without looking at a single pixel. Every question
10
+ * about what an image actually looks like was answered by eyeballing it.
11
+ *
12
+ * The instruments here are a port, not an invention. They come from the
13
+ * measurement scripts written for the salt-road project
14
+ * (`projects/salt-road/inputs/prompts/measure-palette.py`, `measure-texture.py`),
15
+ * which were built and validated against real production waves and carry
16
+ * their own written rationale for why simpler proxies were rejected.
17
+ *
18
+ * MEASUREMENT, NOT VERDICT. This command attaches numbers. It never sets
19
+ * `judgment`, never sets `overall_fit`, never approves or rejects. That line
20
+ * is deliberate and load-bearing: this repo deleted three scripts in its
21
+ * Stage A health pass for minting judgments nobody made, and 948 of
22
+ * star-freight's 1,182 records still carry that damage. Numbers inform a
23
+ * human; they do not replace one.
24
+ *
25
+ * Usage:
26
+ * sdlab measure <dir-or-record-glob> --project <name>
27
+ * sdlab measure outputs/approved --project salt-road
28
+ * sdlab measure "styleset_p*" --project salt-road --anchors canon/anchors.json
29
+ * sdlab measure outputs/candidates --project salt-road --dry-run
30
+ */
31
+
32
+ import { parseArgs, getProjectName } from '../lib/args.js';
33
+ import { getProjectRoot } from '../lib/paths.js';
34
+ import { handleCliError } from '../lib/errors.js';
35
+ import { warn, result } from '../lib/log.js';
36
+ import {
37
+ assertMeasurementPrereqs,
38
+ resolveMeasureTargets,
39
+ loadAnchors,
40
+ runPythonMeasurement,
41
+ applyMeasurements,
42
+ } from '../lib/measure.js';
43
+
44
+ const USAGE = `Usage: sdlab measure <dir-or-record-glob> --project <name> [--anchors <path>] [--dry-run] [--json]
45
+
46
+ Measure candidate images (palette conformance, texture character) and attach
47
+ the numbers to their records. Measurement only — never sets a judgment.
48
+
49
+ Positional:
50
+ dir-or-record-glob A directory of images, or a record-id glob
51
+ (quote globs so your shell does not expand them)
52
+
53
+ Flags:
54
+ --project <name> Project to operate on
55
+ --anchors <path> Palette anchor definitions, relative to the project
56
+ root. Without it, palette measures that need anchors
57
+ are skipped rather than guessed.
58
+ --dry-run Measure and report, write nothing
59
+ --json Emit the raw result as JSON
60
+
61
+ Requires Python 3.9+ with Pillow, numpy and scipy (see scripts/measure_image.py).
62
+ Set SDLAB_PYTHON to pin an explicit interpreter.`;
63
+
64
+ export async function run(argv = process.argv.slice(2)) {
65
+ const { flags, positionals } = parseArgs(argv, {
66
+ flags: {
67
+ project: { type: 'string' },
68
+ anchors: { type: 'string' },
69
+ 'dry-run': { type: 'boolean' },
70
+ json: { type: 'boolean' },
71
+ help: { type: 'boolean' },
72
+ },
73
+ deprecated: { game: 'project' },
74
+ allowUnknown: true,
75
+ });
76
+
77
+ if (flags.help) {
78
+ console.log(USAGE);
79
+ return;
80
+ }
81
+
82
+ const projectName = flags.project || getProjectName(argv);
83
+ const projectRoot = getProjectRoot(projectName);
84
+ const dryRun = Boolean(flags['dry-run']);
85
+
86
+ // Fail on a missing interpreter or package BEFORE resolving targets, so an
87
+ // operator without the Python side gets one clear actionable error rather
88
+ // than a successful scan followed by a confusing failure.
89
+ const python = assertMeasurementPrereqs();
90
+
91
+ const { targets, skipped } = resolveMeasureTargets(projectRoot, positionals[0]);
92
+
93
+ console.log(`\x1b[1msdlab measure\x1b[0m — ${projectName}`);
94
+ console.log(` Target: ${positionals[0]}`);
95
+ console.log(` Images: ${targets.length}${dryRun ? ' (dry run — nothing will be written)' : ''}`);
96
+
97
+ const anchorSpec = loadAnchors(flags.anchors, projectRoot);
98
+ if (anchorSpec.source) {
99
+ console.log(` Anchors: ${anchorSpec.anchors.length} from ${flags.anchors}`);
100
+ } else {
101
+ // Say so plainly. A palette measure with no anchors is not a failed
102
+ // measurement, but it is a narrower one, and silently returning fewer
103
+ // fields than the operator expected is how a tool loses trust.
104
+ console.log(` Anchors: none — pass --anchors <path> for palette-conformance measures`);
105
+ }
106
+ console.log('');
107
+
108
+ if (skipped.length > 0) {
109
+ warn(`${skipped.length} target(s) skipped: ${skipped.slice(0, 3).map((s) => `${s.id} (${s.reason})`).join(', ')}${skipped.length > 3 ? ', …' : ''}`);
110
+ }
111
+
112
+ if (targets.length === 0) {
113
+ console.log(' Nothing to measure.');
114
+ return;
115
+ }
116
+
117
+ const pyResult = runPythonMeasurement(python, {
118
+ images: targets.map((t) => ({ id: t.id, path: t.imagePath })),
119
+ anchors: anchorSpec.anchors,
120
+ hue_tolerance_deg: anchorSpec.hue_tolerance_deg,
121
+ sat_min: anchorSpec.sat_min,
122
+ val_min: anchorSpec.val_min,
123
+ });
124
+
125
+ const { applied, failed } = await applyMeasurements(targets, pyResult, {
126
+ anchorsSource: anchorSpec.source,
127
+ dryRun,
128
+ });
129
+
130
+ if (flags.json) {
131
+ result(JSON.stringify({ applied, failed, skipped }, null, 2));
132
+ return;
133
+ }
134
+
135
+ console.log(` \x1b[32m✓\x1b[0m Measured: ${applied.length}`);
136
+ if (failed.length > 0) {
137
+ console.log(` \x1b[31m✗\x1b[0m Failed: ${failed.length}`);
138
+ for (const f of failed.slice(0, 5)) {
139
+ console.log(` ${f.id}: ${f.error}`);
140
+ }
141
+ if (failed.length > 5) console.log(` … and ${failed.length - 5} more`);
142
+ }
143
+
144
+ if (applied.length > 0) {
145
+ // Show one real measurement rather than only a count — a number the
146
+ // operator can sanity-check against the image in front of them is worth
147
+ // more than a success tally.
148
+ const sample = applied[0];
149
+ const keys = Object.keys(sample.measurements || {}).filter((k) => !k.startsWith('_'));
150
+ if (keys.length > 0) {
151
+ console.log('');
152
+ console.log(` Sample (${sample.id}):`);
153
+ for (const k of keys.slice(0, 6)) {
154
+ const v = sample.measurements[k];
155
+ const shown = typeof v === 'number' ? v.toFixed(4) : JSON.stringify(v);
156
+ console.log(` ${k.padEnd(28)} ${shown}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ if (dryRun) {
162
+ console.log('');
163
+ console.log(' (dry run — no records were modified)');
164
+ }
165
+ }
166
+
167
+ // Direct execution guard
168
+ if (process.argv[1] && (process.argv[1].endsWith('measure.js') || process.argv[1].endsWith('measure'))) {
169
+ run().catch(handleCliError);
170
+ }