@mcptoolshop/style-dataset-lab 3.0.1 → 3.2.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 (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.es.md +154 -41
  3. package/README.fr.md +153 -40
  4. package/README.hi.md +160 -47
  5. package/README.it.md +155 -42
  6. package/README.ja.md +159 -46
  7. package/README.md +40 -0
  8. package/README.pt-BR.md +154 -41
  9. package/README.zh.md +154 -41
  10. package/bin/sdlab.js +81 -2
  11. package/lib/adapters/ai-toolkit.js +209 -0
  12. package/lib/adapters/comfyui-runner.js +11 -15
  13. package/lib/adapters/comfyui-workflows.js +133 -2
  14. package/lib/adapters/diffusers-lora.js +46 -66
  15. package/lib/adapters/generic-image-caption.js +22 -50
  16. package/lib/batch-runs.js +62 -0
  17. package/lib/canon-build/build.js +450 -0
  18. package/lib/canon-build/emitters/base.js +122 -0
  19. package/lib/canon-build/emitters/character.js +167 -0
  20. package/lib/canon-build/emitters/deity.js +165 -0
  21. package/lib/canon-build/emitters/faction.js +161 -0
  22. package/lib/canon-build/emitters/index.js +38 -0
  23. package/lib/canon-build/emitters/location.js +153 -0
  24. package/lib/canon-build/emitters/monster.js +131 -0
  25. package/lib/canon-build/emitters/relic.js +142 -0
  26. package/lib/canon-build/emitters/ship.js +154 -0
  27. package/lib/canon-build/emitters/species.js +165 -0
  28. package/lib/canon-build/frontmatter.js +76 -0
  29. package/lib/canon-build/lane-resolve.js +64 -0
  30. package/lib/canon-build/load-config.js +117 -0
  31. package/lib/canon-build/load-entry.js +62 -0
  32. package/lib/canon-build/load-schema.js +46 -0
  33. package/lib/captions.js +197 -0
  34. package/lib/comfyui-output.js +70 -0
  35. package/lib/config.js +19 -0
  36. package/lib/freeze-events.js +109 -0
  37. package/lib/freeze-gate.js +165 -0
  38. package/lib/freeze-stamp.js +161 -0
  39. package/lib/rows.js +228 -0
  40. package/lib/training-adapters.js +84 -0
  41. package/lib/training-packages.js +25 -32
  42. package/lib/training-profiles.js +43 -0
  43. package/package.json +29 -2
  44. package/scripts/_batch_cut.py +71 -0
  45. package/scripts/_seam_cutout.py +74 -0
  46. package/scripts/batch-generate.js +56 -13
  47. package/scripts/canon-build.js +88 -0
  48. package/scripts/canon-drift.js +193 -0
  49. package/scripts/canon-freeze-status.js +85 -0
  50. package/scripts/canon-freeze.js +211 -0
  51. package/scripts/canon-unfreeze.js +137 -0
  52. package/scripts/curate.js +12 -0
  53. package/scripts/generate-controlnet.js +12 -0
  54. package/scripts/generate-identity.js +20 -4
  55. package/scripts/generate-ipadapter.js +11 -0
  56. package/scripts/generate.js +250 -102
  57. package/scripts/qwen_generate.py +124 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Training adapter registry.
3
+ *
4
+ * Single source of truth for training-package adapters. Each adapter
5
+ * lives in `lib/adapters/<id>.js` and exports `buildPackage(opts)`.
6
+ *
7
+ * To add a new adapter (e.g. `kohya-lora`, `onedtrainer`):
8
+ * 1. Add `lib/adapters/<id>.js` exporting `buildPackage`.
9
+ * 2. Add an entry to ADAPTER_REGISTRY below with a one-line description.
10
+ *
11
+ * Why explicit registration: the available adapter list shows up in error
12
+ * messages, training-profile validation, and (eventually) CLI completion.
13
+ * A static map keeps those views consistent and makes new adapters a
14
+ * one-line surface change rather than a directory-listing side effect.
15
+ */
16
+
17
+ import { inputError } from './errors.js';
18
+
19
+ export const ADAPTER_REGISTRY = Object.freeze({
20
+ 'generic-image-caption': {
21
+ id: 'generic-image-caption',
22
+ description: 'Image folders + caption JSONL — broadest trainer compatibility',
23
+ module: './adapters/generic-image-caption.js',
24
+ },
25
+ 'diffusers-lora': {
26
+ id: 'diffusers-lora',
27
+ description: 'Diffusers fine-tuning layout: image + caption .txt sidecars + per-partition JSONL',
28
+ module: './adapters/diffusers-lora.js',
29
+ },
30
+ 'ai-toolkit': {
31
+ id: 'ai-toolkit',
32
+ description: 'Ostris ai-toolkit Flux training package: image + .txt sidecars + ai-toolkit-config.yaml at root',
33
+ module: './adapters/ai-toolkit.js',
34
+ },
35
+ });
36
+
37
+ /**
38
+ * Names of all registered adapters.
39
+ * @returns {string[]}
40
+ */
41
+ export function listAdapters() {
42
+ return Object.keys(ADAPTER_REGISTRY);
43
+ }
44
+
45
+ /**
46
+ * Whether `id` names a registered adapter.
47
+ * @param {string} id
48
+ * @returns {boolean}
49
+ */
50
+ export function isRegisteredAdapter(id) {
51
+ return Object.prototype.hasOwnProperty.call(ADAPTER_REGISTRY, id);
52
+ }
53
+
54
+ /**
55
+ * Resolve and dynamically import an adapter module.
56
+ *
57
+ * Throws `ADAPTER_NOT_REGISTERED` (input error, exit 1) for unknown ids
58
+ * — distinct from `MODULE_LOAD_FAILED` (runtime, exit 2) for the rare
59
+ * case where the file vanished after registration.
60
+ *
61
+ * @param {string} id
62
+ * @returns {Promise<{ buildPackage: Function }>}
63
+ */
64
+ export async function loadAdapter(id) {
65
+ if (!isRegisteredAdapter(id)) {
66
+ throw inputError(
67
+ 'ADAPTER_NOT_REGISTERED',
68
+ `Adapter "${id}" is not registered. Available: ${listAdapters().join(', ')}`,
69
+ 'Pick one of the registered adapters or add a new one to lib/training-adapters.js.'
70
+ );
71
+ }
72
+ const entry = ADAPTER_REGISTRY[id];
73
+ try {
74
+ return await import(entry.module);
75
+ } catch (err) {
76
+ // The registry says it should exist but import failed — surface the
77
+ // underlying error so it doesn't get masked as a typo.
78
+ throw inputError(
79
+ 'ADAPTER_MODULE_LOAD_FAILED',
80
+ `Adapter "${id}" is registered but its module failed to load: ${err.message}`,
81
+ `Check that ${entry.module} exists and exports buildPackage().`
82
+ );
83
+ }
84
+ }
@@ -17,6 +17,8 @@ import { loadRecord } from './records.js';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { inputError } from './errors.js';
19
19
  import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
+ import { loadAdapter } from './training-adapters.js';
21
+ import { recordToRow, filterRowsForProfile } from './rows.js';
20
22
 
21
23
  /**
22
24
  * Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
@@ -33,20 +35,6 @@ async function sha256File(filePath) {
33
35
  return createHash('sha256').update(data).digest('hex');
34
36
  }
35
37
 
36
- /**
37
- * Load an adapter module by name.
38
- */
39
- async function loadAdapter(adapterTarget) {
40
- const adapterPath = `./adapters/${adapterTarget}.js`;
41
- try {
42
- return await import(adapterPath);
43
- } catch {
44
- throw new Error(
45
- `Adapter "${adapterTarget}" not found. Available: generic-image-caption, diffusers-lora`
46
- );
47
- }
48
- }
49
-
50
38
  /**
51
39
  * Build a training package from a manifest.
52
40
  *
@@ -74,38 +62,43 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
74
62
  );
75
63
  }
76
64
 
77
- // Load split partitions
78
- const partitions = {};
79
- let totalRecords = 0;
80
- let totalImages = 0;
65
+ // Convert split partitions → canonical rows (D6 adapter input shape).
66
+ // Adapters only understand rows — records-flow callers go through this
67
+ // converter, canon-build-flow callers produce rows directly. One input
68
+ // contract, two producers.
69
+ const rows = [];
70
+ const partitionCounts = { train: 0, val: 0, test: 0 };
81
71
 
82
72
  for (const partition of ['train', 'val', 'test']) {
83
73
  const entries = await loadSplitPartition(projectRoot, manifest.source_split_id, partition);
84
- const records = [];
85
74
 
86
75
  for (const entry of entries) {
87
76
  const record = await loadRecord(recordsDir, entry.record_id);
88
77
  if (!record) continue;
89
78
 
90
- // Filter by profile's eligible lanes
91
79
  const prompt = record.provenance?.prompt || '';
92
80
  const lane = detectLane(record.id, prompt, config.lanes);
93
- if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) {
94
- continue;
95
- }
96
-
97
81
  const group = detectGroup(record.id, prompt, config.terminology);
98
- records.push({ record, lane, group });
82
+
83
+ const row = recordToRow(record, lane, group, profile, partition);
84
+ if (row) rows.push(row);
99
85
  }
86
+ }
100
87
 
101
- partitions[partition] = records;
102
- totalRecords += records.length;
88
+ // Apply profile filters (eligible_lanes + entity_id_scope — D8).
89
+ // filterRowsForProfile returns rows where lane ∈ eligible_lanes AND
90
+ // entity_id === entity_id_scope (when set). Zero filters = identity.
91
+ const filteredRows = filterRowsForProfile(rows, profile);
92
+ for (const row of filteredRows) {
93
+ if (partitionCounts[row.partition] !== undefined) partitionCounts[row.partition]++;
103
94
  }
95
+ const totalRecords = filteredRows.length;
96
+ let totalImages = 0;
104
97
 
105
- // Let the adapter build the package layout
98
+ // Let the adapter build the package layout from the canonical rows.
106
99
  const adapterResult = await adapter.buildPackage({
107
100
  packageDir,
108
- partitions,
101
+ rows: filteredRows,
109
102
  profile,
110
103
  manifest,
111
104
  config,
@@ -131,9 +124,9 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
131
124
  config_fingerprint: manifest.config_fingerprint,
132
125
  counts: {
133
126
  total_records: totalRecords,
134
- train: partitions.train.length,
135
- val: partitions.val.length,
136
- test: partitions.test.length,
127
+ train: partitionCounts.train,
128
+ val: partitionCounts.val,
129
+ test: partitionCounts.test,
137
130
  images: totalImages,
138
131
  checksums: 0, // updated after checksum pass below
139
132
  },
@@ -9,9 +9,17 @@
9
9
  import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
  import { existsSync } from 'node:fs';
12
+ import { isRegisteredAdapter, listAdapters } from './training-adapters.js';
12
13
 
13
14
  const REQUIRED_FIELDS = ['profile_id', 'label', 'target_family', 'asset_type', 'eligible_lanes', 'adapter_targets'];
14
15
 
16
+ // trigger_override constraints (research ref: two-LoRA stack D3, 2026-04-24).
17
+ // T5 tokenizes on SentencePiece Unigram with `_` as the space sentinel; hyphens
18
+ // fragment unpredictably and uppercase is not canonicalized. Generic suffixes
19
+ // alone collide across projects — a game-slug prefix is the documented defense.
20
+ const TRIGGER_OVERRIDE_RE = /^[a-z0-9_]+$/;
21
+ const TRIGGER_GENERIC_SUFFIX_DENYLIST = ['style', 'character', 'anime', 'realistic'];
22
+
15
23
  /**
16
24
  * Validate a training profile object.
17
25
  * @returns {{ valid: boolean, errors: string[] }}
@@ -32,6 +40,41 @@ export function validateProfile(profile) {
32
40
  if (profile.adapter_targets?.length === 0) {
33
41
  errors.push('adapter_targets must have at least one target');
34
42
  }
43
+ // Reject unregistered adapter targets early — surfaces typos at profile
44
+ // load time instead of at package-build time.
45
+ if (Array.isArray(profile.adapter_targets)) {
46
+ for (const target of profile.adapter_targets) {
47
+ if (!isRegisteredAdapter(target)) {
48
+ errors.push(`adapter_targets contains unregistered adapter "${target}". Available: ${listAdapters().join(', ')}`);
49
+ }
50
+ }
51
+ }
52
+ // trigger_override is optional. When present, enforce SentencePiece-safe
53
+ // format and reject bare generic suffixes that collide across projects.
54
+ if (profile.trigger_override !== undefined && profile.trigger_override !== null) {
55
+ if (typeof profile.trigger_override !== 'string') {
56
+ errors.push('trigger_override must be a string');
57
+ } else if (profile.trigger_override.length === 0) {
58
+ errors.push('trigger_override must be a non-empty string (or omit the field)');
59
+ } else if (!TRIGGER_OVERRIDE_RE.test(profile.trigger_override)) {
60
+ errors.push(`trigger_override "${profile.trigger_override}" must match ^[a-z0-9_]+$ (lowercase, digits, underscores — no hyphens, no uppercase)`);
61
+ } else if (TRIGGER_GENERIC_SUFFIX_DENYLIST.includes(profile.trigger_override)) {
62
+ errors.push(`trigger_override "${profile.trigger_override}" is a generic suffix; prefix with a game slug (e.g. "sf_${profile.trigger_override}")`);
63
+ }
64
+ }
65
+ // entity_id_scope is optional (post-research D8). Same SentencePiece-safe
66
+ // format as trigger_override — no hyphens, no uppercase, lowercase slug.
67
+ // Used by per-character LoRA profiles to row-filter canonical dataset.jsonl
68
+ // to a single subject at packaging time.
69
+ if (profile.entity_id_scope !== undefined && profile.entity_id_scope !== null) {
70
+ if (typeof profile.entity_id_scope !== 'string') {
71
+ errors.push('entity_id_scope must be a string');
72
+ } else if (profile.entity_id_scope.length === 0) {
73
+ errors.push('entity_id_scope must be a non-empty string (or omit the field)');
74
+ } else if (!TRIGGER_OVERRIDE_RE.test(profile.entity_id_scope)) {
75
+ errors.push(`entity_id_scope "${profile.entity_id_scope}" must match ^[a-z0-9_]+$ (lowercase, digits, underscores — no hyphens, no uppercase)`);
76
+ }
77
+ }
35
78
  return { valid: errors.length === 0, errors };
36
79
  }
37
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "Canon-aligned dataset production and generation workbench — define visual rules, build versioned training data, compile production briefs, run local workflows, batch-produce, select winners, and re-ingest into your corpus",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test tests/**/*.test.js",
11
+ "coverage": "c8 npm test",
11
12
  "verify": "node bin/sdlab.js --help && node bin/sdlab.js project doctor --project star-freight",
12
13
  "generate": "node scripts/generate.js",
13
14
  "curate": "node scripts/curate.js",
@@ -16,6 +17,26 @@
16
17
  "painterly": "node scripts/painterly.js",
17
18
  "generate:identity": "node scripts/generate-identity.js"
18
19
  },
20
+ "c8": {
21
+ "include": [
22
+ "lib/**/*.js"
23
+ ],
24
+ "exclude": [
25
+ "tests/**",
26
+ "scripts/**",
27
+ "site/**",
28
+ "projects/**",
29
+ "templates/**",
30
+ "**/*.test.js"
31
+ ],
32
+ "reporter": [
33
+ "text",
34
+ "lcov",
35
+ "text-summary"
36
+ ],
37
+ "report-dir": "coverage",
38
+ "all": true
39
+ },
19
40
  "files": [
20
41
  "bin/",
21
42
  "lib/",
@@ -58,5 +79,11 @@
58
79
  "node": ">=20"
59
80
  },
60
81
  "author": "mcp-tool-shop",
61
- "license": "MIT"
82
+ "license": "MIT",
83
+ "devDependencies": {
84
+ "c8": "^10.1.3"
85
+ },
86
+ "dependencies": {
87
+ "yaml": "^2.8.3"
88
+ }
62
89
  }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """Batch alpha-cut cast turnaround views via ComfyUI's native BiRefNet (RGBA out).
3
+ For each character, cuts its 8 restylized views -> out/<char>/<view>.png on transparency.
4
+ Free Qwen (POST /free) before running so BiRefNet doesn't co-reside with the 28 GB transformer.
5
+
6
+ python _batch_cut.py --restylized <dir-of-per-char> --out <dir> --chars rustgrave,hearthframe,dustwhisper
7
+ """
8
+ from __future__ import annotations
9
+ import argparse, json, shutil, time, urllib.request, uuid
10
+ from pathlib import Path
11
+
12
+ COMFY = "http://127.0.0.1:8188"
13
+ COMFY_OUT = Path(r"E:/AI-Models/ComfyUI_windows_portable/ComfyUI/output")
14
+ COMFY_IN = Path(r"E:/AI-Models/ComfyUI_windows_portable/ComfyUI/input")
15
+ MODEL = "birefnet.safetensors"
16
+
17
+
18
+ def graph(image_filename: str, out_prefix: str) -> dict:
19
+ return {
20
+ "1": {"class_type": "LoadImage", "inputs": {"image": image_filename}},
21
+ "2": {"class_type": "LoadBackgroundRemovalModel", "inputs": {"bg_removal_name": MODEL}},
22
+ "3": {"class_type": "RemoveBackground", "inputs": {"image": ["1", 0], "bg_removal_model": ["2", 0]}},
23
+ "4": {"class_type": "InvertMask", "inputs": {"mask": ["3", 0]}},
24
+ "5": {"class_type": "JoinImageWithAlpha", "inputs": {"image": ["1", 0], "alpha": ["4", 0]}},
25
+ "6": {"class_type": "SaveImage", "inputs": {"filename_prefix": out_prefix, "images": ["5", 0]}},
26
+ }
27
+
28
+
29
+ def post(path: str, payload: dict):
30
+ return json.loads(urllib.request.urlopen(urllib.request.Request(
31
+ f"{COMFY}{path}", json.dumps(payload).encode(), {"Content-Type": "application/json"}), timeout=120).read())
32
+
33
+
34
+ def cut_one(src: Path, staged_name: str, dst: Path):
35
+ shutil.copyfile(src, COMFY_IN / f"{staged_name}.png")
36
+ g = graph(f"{staged_name}.png", f"cut_{staged_name}")
37
+ pid = post("/prompt", {"prompt": g, "client_id": uuid.uuid4().hex})["prompt_id"]
38
+ for _ in range(600):
39
+ hist = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=30).read())
40
+ if pid in hist and hist[pid].get("outputs"):
41
+ img = hist[pid]["outputs"]["6"]["images"][0]
42
+ srcp = COMFY_OUT / img["subfolder"] / img["filename"]
43
+ for _w in range(120):
44
+ if srcp.exists():
45
+ dst.parent.mkdir(parents=True, exist_ok=True)
46
+ shutil.copyfile(srcp, dst)
47
+ return
48
+ time.sleep(0.5)
49
+ time.sleep(0.5)
50
+ raise RuntimeError(f"cut timed out: {src}")
51
+
52
+
53
+ def main():
54
+ ap = argparse.ArgumentParser()
55
+ ap.add_argument("--restylized", required=True)
56
+ ap.add_argument("--out", required=True)
57
+ ap.add_argument("--chars", required=True)
58
+ args = ap.parse_args()
59
+ rdir = Path(args.restylized); odir = Path(args.out)
60
+ n = 0
61
+ for c in args.chars.split(","):
62
+ c = c.strip()
63
+ for v in sorted((rdir / c).glob("*.png")):
64
+ cut_one(v, f"{c}_{v.stem}", odir / c / f"{v.stem}.png")
65
+ n += 1
66
+ print(f" cut {c}/{v.stem}", flush=True)
67
+ print(f"BATCH CUT DONE: {n} sprites -> {odir}", flush=True)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Alpha-cut sprites via ComfyUI's native BiRefNet background removal, for the A1 seam-test.
3
+
4
+ Replicates the shipped "Remove Background (BiRefNet)" subgraph:
5
+ LoadImage -> LoadBackgroundRemovalModel -> RemoveBackground -> InvertMask -> JoinImageWithAlpha -> SaveImage
6
+ Outputs RGBA PNGs (figure on transparency) and copies them to the Godot seam-test sprites dir.
7
+
8
+ Inputs must already be in ComfyUI/input. Run with ComfyUI's embedded python:
9
+ python _seam_cutout.py seam_front seam_pitch30 seam_pitch40
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ import sys
16
+ import time
17
+ import urllib.request
18
+ import uuid
19
+ from pathlib import Path
20
+
21
+ COMFY = "http://127.0.0.1:8188"
22
+ COMFY_OUT = Path(r"E:/AI-Models/ComfyUI_windows_portable/ComfyUI/output")
23
+ GODOT_SPRITES = Path(r"E:/AI/hesperia/game/seam_test/sprites")
24
+ MODEL = "birefnet.safetensors"
25
+
26
+
27
+ def graph(image_filename: str, out_prefix: str) -> dict:
28
+ return {
29
+ "1": {"class_type": "LoadImage", "inputs": {"image": image_filename}},
30
+ "2": {"class_type": "LoadBackgroundRemovalModel", "inputs": {"bg_removal_name": MODEL}},
31
+ "3": {"class_type": "RemoveBackground", "inputs": {"image": ["1", 0], "bg_removal_model": ["2", 0]}},
32
+ "4": {"class_type": "InvertMask", "inputs": {"mask": ["3", 0]}},
33
+ "5": {"class_type": "JoinImageWithAlpha", "inputs": {"image": ["1", 0], "alpha": ["4", 0]}},
34
+ "6": {"class_type": "SaveImage", "inputs": {"filename_prefix": out_prefix, "images": ["5", 0]}},
35
+ }
36
+
37
+
38
+ def post(path: str, payload: dict):
39
+ return json.loads(urllib.request.urlopen(urllib.request.Request(
40
+ f"{COMFY}{path}", json.dumps(payload).encode(), {"Content-Type": "application/json"}), timeout=120).read())
41
+
42
+
43
+ def run_one(name: str) -> Path:
44
+ g = graph(f"{name}.png", f"seamcut_{name}")
45
+ pid = post("/prompt", {"prompt": g, "client_id": uuid.uuid4().hex})["prompt_id"]
46
+ for _ in range(600):
47
+ hist = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=30).read())
48
+ if pid in hist and hist[pid].get("outputs"):
49
+ img = hist[pid]["outputs"]["6"]["images"][0]
50
+ src = COMFY_OUT / img["subfolder"] / img["filename"]
51
+ for _w in range(120):
52
+ if src.exists():
53
+ GODOT_SPRITES.mkdir(parents=True, exist_ok=True)
54
+ dst = GODOT_SPRITES / f"{name}.png"
55
+ shutil.copyfile(src, dst)
56
+ return dst
57
+ time.sleep(0.5)
58
+ time.sleep(0.5)
59
+ raise RuntimeError(f"cutout timed out for {name}")
60
+
61
+
62
+ def main() -> None:
63
+ names = sys.argv[1:]
64
+ if not names:
65
+ print("usage: _seam_cutout.py <name> [<name> ...] (names are ComfyUI/input stems, no .png)")
66
+ sys.exit(2)
67
+ for n in names:
68
+ dst = run_one(n)
69
+ print(f" cut {n} -> {dst}", flush=True)
70
+ print(f"\nSEAMCUT DONE: {len(names)} sprite(s) -> {GODOT_SPRITES}")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -15,6 +15,7 @@ import { compileBatch } from '../lib/batch-compiler.js';
15
15
  import { getBatchMode } from '../lib/batch-modes.js';
16
16
  import {
17
17
  createBatchDir,
18
+ openBatchDirForResume,
18
19
  executeBatchRuns,
19
20
  saveBatchManifest,
20
21
  } from '../lib/batch-runs.js';
@@ -31,20 +32,23 @@ export async function run(argv = process.argv.slice(2)) {
31
32
  subject: { type: 'string' },
32
33
  theme: { type: 'string' },
33
34
  asset: { type: 'string' },
35
+ resume: { type: 'string' },
34
36
  'dry-run': { type: 'boolean' },
35
37
  json: { type: 'boolean' },
36
38
  },
37
39
  deprecated: { game: 'project' },
38
40
  });
39
41
 
40
- if (!flags.mode) {
42
+ if (!flags.mode && !flags.resume) {
41
43
  console.log('Usage: sdlab batch generate --mode <id> [--subject <id>] [--theme <label>] [--project <name>]');
44
+ console.log(' sdlab batch generate --resume <batch_id> [--project <name>]');
42
45
  console.log('');
43
46
  console.log('Options:');
44
- console.log(' --mode <id> Batch mode (required)');
47
+ console.log(' --mode <id> Batch mode (required unless --resume is given)');
45
48
  console.log(' --subject <id> Subject for subject-driven modes');
46
49
  console.log(' --theme <label> Theme label for environment modes');
47
50
  console.log(' --asset <id> Training asset reference');
51
+ console.log(' --resume <id> Resume an existing batch — re-runs only failed/missing slots');
48
52
  console.log(' --dry-run Prepare batch without submitting to ComfyUI');
49
53
  console.log(' --json Output manifest as JSON');
50
54
  return;
@@ -53,17 +57,47 @@ export async function run(argv = process.argv.slice(2)) {
53
57
  const projectName = flags.project || getProjectName(argv);
54
58
  const projectRoot = getProjectRoot(projectName);
55
59
  const dryRun = flags['dry-run'] || false;
60
+ const resumeId = flags.resume || null;
61
+
62
+ // Resume short-circuit: load prior manifest and reuse its mode_id + subject/theme
63
+ // so the user only needs to pass --resume <batch_id>.
64
+ let batchId;
65
+ let batchDir;
66
+ let priorResults = [];
67
+ let resumedMode = null;
68
+ let resumedSubject = null;
69
+ let resumedTheme = null;
70
+ let resumedAsset = null;
71
+ if (resumeId) {
72
+ const opened = openBatchDirForResume(projectRoot, resumeId);
73
+ batchId = opened.batchId;
74
+ batchDir = opened.batchDir;
75
+ priorResults = opened.priorResults;
76
+ resumedMode = opened.priorManifest.mode_id || null;
77
+ resumedSubject = opened.priorManifest.subject_id || null;
78
+ resumedTheme = opened.priorManifest.theme || null;
79
+ resumedAsset = opened.priorManifest.asset_ref || null;
80
+ }
81
+
82
+ const modeId = flags.mode || resumedMode;
83
+ const subjectId = flags.subject || resumedSubject;
84
+ const theme = flags.theme || resumedTheme;
85
+ const assetRef = flags.asset || resumedAsset;
56
86
 
57
87
  // Load mode for display
58
- const mode = getBatchMode(projectRoot, flags.mode);
88
+ const mode = getBatchMode(projectRoot, modeId);
59
89
 
60
90
  console.log(`\x1b[1mstyle-dataset-lab\x1b[0m batch generate`);
61
91
  console.log(` Project: ${projectName}`);
62
92
  console.log(` Mode: ${mode.label} (${mode.mode_id})`);
63
93
  console.log(` Type: ${mode.batch_type}`);
64
94
  console.log(` Slots: ${mode.variant_plan.length}`);
65
- if (flags.subject) console.log(` Subject: ${flags.subject}`);
66
- if (flags.theme) console.log(` Theme: ${flags.theme}`);
95
+ if (subjectId) console.log(` Subject: ${subjectId}`);
96
+ if (theme) console.log(` Theme: ${theme}`);
97
+ if (resumeId) {
98
+ const completed = priorResults.filter(r => r && r.status === 'ok').length;
99
+ console.log(` Resume: ${resumeId} (${completed}/${priorResults.length} slots already complete)`);
100
+ }
67
101
  if (dryRun) console.log(' ⚠ DRY RUN');
68
102
  console.log('');
69
103
 
@@ -71,14 +105,18 @@ export async function run(argv = process.argv.slice(2)) {
71
105
  const { baseBrief, slotBriefs } = await compileBatch({
72
106
  projectRoot,
73
107
  projectId: projectName,
74
- modeId: flags.mode,
75
- subjectId: flags.subject,
76
- theme: flags.theme,
77
- assetRef: flags.asset,
108
+ modeId,
109
+ subjectId,
110
+ theme,
111
+ assetRef,
78
112
  });
79
113
 
80
- // 2. Create batch directory
81
- const { batchId, batchDir } = await createBatchDir(projectRoot);
114
+ // 2. Create or reuse batch directory
115
+ if (!resumeId) {
116
+ const created = await createBatchDir(projectRoot);
117
+ batchId = created.batchId;
118
+ batchDir = created.batchDir;
119
+ }
82
120
  info('batch-generate', `Batch: ${batchId}`);
83
121
 
84
122
  // 3. Execute runs
@@ -90,6 +128,7 @@ export async function run(argv = process.argv.slice(2)) {
90
128
  mode,
91
129
  slotBriefs,
92
130
  dryRun,
131
+ priorResults,
93
132
  });
94
133
  const batchElapsedMs = Date.now() - batchStartMs;
95
134
  if (!dryRun && results.length > 0) {
@@ -111,7 +150,9 @@ export async function run(argv = process.argv.slice(2)) {
111
150
  label: r.label,
112
151
  brief_id: r.brief_id,
113
152
  run_id: r.run_id,
153
+ status: r.status,
114
154
  selected_output: r.selected_output,
155
+ ...(r.error ? { error: r.error } : {}),
115
156
  })),
116
157
  sheet_paths: [],
117
158
  summary: {
@@ -121,8 +162,10 @@ export async function run(argv = process.argv.slice(2)) {
121
162
  },
122
163
  };
123
164
 
124
- if (flags.subject) manifest.subject_id = flags.subject;
125
- if (flags.theme) manifest.theme = flags.theme;
165
+ if (subjectId) manifest.subject_id = subjectId;
166
+ if (theme) manifest.theme = theme;
167
+ if (assetRef) manifest.asset_ref = assetRef;
168
+ if (resumeId) manifest.resumed_from = resumeId;
126
169
 
127
170
  // 5. Render sheet
128
171
  const sheetHtml = renderSheetHTML({ manifest, mode, projectRoot, batchDir });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `sdlab canon build` — CLI entry point for the three-projection build.
3
+ *
4
+ * Delegates orchestration to lib/canon-build/build.js. Parses flags, prints
5
+ * a summary, exits with codes per the errors.js inputError/runtimeError
6
+ * model (1 = user error, 2 = runtime error).
7
+ */
8
+
9
+ import { parseArgs } from 'node:util';
10
+ import { join } from 'node:path';
11
+ import { existsSync } from 'node:fs';
12
+ import { runBuild } from '../lib/canon-build/build.js';
13
+ import { inputError } from '../lib/errors.js';
14
+
15
+ export async function run(argv) {
16
+ const parsed = parseArgs({
17
+ args: argv,
18
+ options: {
19
+ project: { type: 'string' },
20
+ game: { type: 'string' }, // deprecated alias
21
+ full: { type: 'boolean', default: false },
22
+ 'no-cache': { type: 'boolean', default: false },
23
+ 'dry-run': { type: 'boolean', default: false },
24
+ only: { type: 'string' },
25
+ json: { type: 'boolean', default: false },
26
+ quiet: { type: 'boolean', default: false },
27
+ },
28
+ allowPositionals: true,
29
+ strict: false,
30
+ });
31
+
32
+ const projectName = parsed.values.project || parsed.values.game;
33
+ if (!projectName) {
34
+ throw inputError(
35
+ 'INPUT_MISSING_FLAG',
36
+ 'sdlab canon build requires --project <name>',
37
+ 'Example: sdlab canon build --project greek-rpg',
38
+ );
39
+ }
40
+
41
+ const projectRoot = join(process.cwd(), 'projects', projectName);
42
+ if (!existsSync(projectRoot)) {
43
+ throw inputError(
44
+ 'INPUT_PROJECT_NOT_FOUND',
45
+ `Project directory not found: ${projectRoot}`,
46
+ `Run from the style-dataset-lab repo root, or scaffold the project with "sdlab init ${projectName}".`,
47
+ );
48
+ }
49
+
50
+ const only = parsed.values.only
51
+ ? parsed.values.only.split(',').map((s) => s.trim()).filter(Boolean)
52
+ : null;
53
+
54
+ const result = await runBuild({
55
+ projectRoot,
56
+ full: parsed.values.full,
57
+ noCache: parsed.values['no-cache'],
58
+ dryRun: parsed.values['dry-run'],
59
+ only,
60
+ });
61
+
62
+ if (parsed.values.json) {
63
+ console.log(JSON.stringify(result, null, 2));
64
+ return;
65
+ }
66
+
67
+ if (!parsed.values.quiet) {
68
+ if (result.dryRun) {
69
+ console.log(`[dry-run] canon build would process ${result.entities_total} entities (${result.entities_cached} cached, ${result.entities_rebuilt} would rebuild) → ${result.rows} dataset rows.`);
70
+ console.log(`[dry-run] output would land at: ${result.output_dir}`);
71
+ } else {
72
+ console.log(`canon build complete: ${result.entities_total} entities (${result.entities_cached} cached, ${result.entities_rebuilt} rebuilt), ${result.rows} dataset rows.`);
73
+ console.log(`output: ${result.output_dir}`);
74
+ console.log(`generated_from: ${result.generated_from}`);
75
+ if (result.frozen_entries > 0) {
76
+ console.log(`frozen entries: ${result.frozen_entries} stamped with witness hashes`);
77
+ }
78
+ if (Array.isArray(result.drift) && result.drift.length > 0) {
79
+ console.log('');
80
+ console.log(`drift detected on ${result.drift.length} frozen entr${result.drift.length === 1 ? 'y' : 'ies'} since the previous build:`);
81
+ for (const d of result.drift) {
82
+ console.log(` ${d.schema_kind}:${d.entity_id} (${d.status})`);
83
+ }
84
+ console.log('Run `sdlab canon drift` for the full report.');
85
+ }
86
+ }
87
+ }
88
+ }