@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +36 -8
  3. package/bin/sdlab.js +129 -0
  4. package/lib/adapters/comfyui-runner.js +170 -0
  5. package/lib/adapters/comfyui-workflows.js +163 -0
  6. package/lib/batch-compiler.js +118 -0
  7. package/lib/batch-modes.js +111 -0
  8. package/lib/batch-runs.js +168 -0
  9. package/lib/batch-sheet-render.js +206 -0
  10. package/lib/brief-compiler.js +598 -0
  11. package/lib/brief-render.js +205 -0
  12. package/lib/critique-engine.js +413 -0
  13. package/lib/critique-render.js +171 -0
  14. package/lib/generated-provenance.js +87 -0
  15. package/lib/paths.js +31 -0
  16. package/lib/refine-briefs.js +329 -0
  17. package/lib/reingest-selected.js +173 -0
  18. package/lib/run-summary.js +131 -0
  19. package/lib/runtime-runs.js +168 -0
  20. package/lib/selections.js +323 -0
  21. package/lib/workflow-profiles.js +161 -0
  22. package/package.json +6 -3
  23. package/scripts/batch-generate.js +167 -0
  24. package/scripts/batch-sheet.js +46 -0
  25. package/scripts/batch-show.js +86 -0
  26. package/scripts/brief-compile.js +134 -0
  27. package/scripts/brief-show.js +52 -0
  28. package/scripts/critique-show.js +50 -0
  29. package/scripts/critique.js +70 -0
  30. package/scripts/doctor.js +169 -1
  31. package/scripts/init.js +22 -0
  32. package/scripts/refine.js +119 -0
  33. package/scripts/reingest-selected.js +78 -0
  34. package/scripts/reingest.js +7 -1
  35. package/scripts/run-generate.js +76 -0
  36. package/scripts/run-list.js +58 -0
  37. package/scripts/run-show.js +63 -0
  38. package/scripts/select.js +118 -0
  39. package/scripts/selection-show.js +88 -0
  40. package/scripts/workflow-list.js +61 -0
  41. package/scripts/workflow-show.js +96 -0
  42. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  43. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  44. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  45. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  46. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  47. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  48. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  49. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  50. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  51. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Selection engine — approve outputs from runs or batches into
3
+ * durable selection artifacts.
4
+ *
5
+ * A selection is a creative decision: what was chosen, why, and
6
+ * where it came from. Re-ingest consumes selections; this module
7
+ * only builds and stores the artifact.
8
+ */
9
+
10
+ import { readFileSync, existsSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs';
11
+ import { writeFile, mkdir, readFile } from 'node:fs/promises';
12
+ import { join, basename } from 'node:path';
13
+ import { getRunsDir } from './paths.js';
14
+ import { getBatchesDir } from './batch-runs.js';
15
+ import { inputError } from './errors.js';
16
+
17
+ // ─── Selection ID generation ─────────────────────────────────────
18
+
19
+ export function getSelectionsDir(projectRoot) {
20
+ return join(projectRoot, 'selections');
21
+ }
22
+
23
+ export function generateSelectionId(selectionsDir) {
24
+ const today = new Date().toISOString().slice(0, 10);
25
+ const prefix = `selection_${today}_`;
26
+
27
+ let maxSeq = 0;
28
+ if (existsSync(selectionsDir)) {
29
+ const existing = readdirSync(selectionsDir, { withFileTypes: true })
30
+ .filter(e => e.isDirectory() && e.name.startsWith(prefix));
31
+ for (const e of existing) {
32
+ const seqStr = e.name.slice(prefix.length);
33
+ const seq = parseInt(seqStr, 10);
34
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
35
+ }
36
+ }
37
+
38
+ return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
39
+ }
40
+
41
+ // ─── Run selection ───────────────────────────────────────────────
42
+
43
+ /**
44
+ * Create a selection from a single run.
45
+ *
46
+ * @param {Object} opts
47
+ * @param {string} opts.projectRoot
48
+ * @param {string} opts.projectId
49
+ * @param {string} opts.runId
50
+ * @param {string[]} opts.approvedFiles — filenames to approve (e.g. ['001.png', '003.png'])
51
+ * @param {string} [opts.reason]
52
+ * @param {string[]} [opts.tags]
53
+ * @returns {Promise<Object>} selection manifest
54
+ */
55
+ export async function createSelectionFromRun({
56
+ projectRoot, projectId, runId, approvedFiles, reason, tags,
57
+ }) {
58
+ const runsDir = getRunsDir(projectRoot);
59
+ const runDir = join(runsDir, runId);
60
+ const manifestPath = join(runDir, 'manifest.json');
61
+
62
+ if (!existsSync(manifestPath)) {
63
+ throw inputError('RUN_NOT_FOUND', `Run "${runId}" not found`, 'Check the run ID.');
64
+ }
65
+
66
+ const runManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
67
+ const outputsDir = join(runDir, 'outputs');
68
+
69
+ // Validate all approved files exist
70
+ for (const file of approvedFiles) {
71
+ if (!existsSync(join(outputsDir, file))) {
72
+ throw inputError(
73
+ 'OUTPUT_NOT_FOUND',
74
+ `Output "${file}" not found in ${runId}/outputs/`,
75
+ `Available: ${readdirSync(outputsDir).filter(f => f.endsWith('.png')).join(', ') || '(none)'}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ // Create selection directory
81
+ const selectionsDir = getSelectionsDir(projectRoot);
82
+ const selectionId = generateSelectionId(selectionsDir);
83
+ const selectionDir = join(selectionsDir, selectionId);
84
+ await mkdir(join(selectionDir, 'chosen'), { recursive: true });
85
+
86
+ // Copy chosen files
87
+ const items = [];
88
+ for (let i = 0; i < approvedFiles.length; i++) {
89
+ const file = approvedFiles[i];
90
+ const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
91
+ copyFileSync(join(outputsDir, file), join(selectionDir, 'chosen', destName));
92
+
93
+ // Find seed from run manifest
94
+ const outputEntry = runManifest.outputs?.find(o => o.filename === file);
95
+ items.push({
96
+ slot_or_output: file,
97
+ filename: destName,
98
+ seed: outputEntry?.seed,
99
+ reason: reason || 'selected',
100
+ tags: tags || [],
101
+ });
102
+ }
103
+
104
+ // Build selection manifest
105
+ const manifest = {
106
+ selection_id: selectionId,
107
+ project_id: projectId,
108
+ source_type: 'run',
109
+ source_id: runId,
110
+ workflow_id: runManifest.workflow_template_id,
111
+ created_at: new Date().toISOString(),
112
+ items,
113
+ reingest_ready: true,
114
+ };
115
+
116
+ // Attach subject from brief if available
117
+ const briefPath = join(runDir, 'brief.json');
118
+ if (existsSync(briefPath)) {
119
+ try {
120
+ const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
121
+ if (brief.subject_id) manifest.subject_id = brief.subject_id;
122
+ } catch { /* skip */ }
123
+ }
124
+
125
+ return { selectionId, selectionDir, manifest, runManifest };
126
+ }
127
+
128
+ // ─── Batch selection ─────────────────────────────────────────────
129
+
130
+ /**
131
+ * Create a selection from a batch.
132
+ *
133
+ * @param {Object} opts
134
+ * @param {string} opts.projectRoot
135
+ * @param {string} opts.projectId
136
+ * @param {string} opts.batchId
137
+ * @param {Array<{slotId: string, filename: string}>} opts.approvedSlots
138
+ * @param {string} [opts.reason]
139
+ * @param {string[]} [opts.tags]
140
+ * @returns {Promise<Object>} selection manifest
141
+ */
142
+ export async function createSelectionFromBatch({
143
+ projectRoot, projectId, batchId, approvedSlots, reason, tags,
144
+ }) {
145
+ const batchesDir = getBatchesDir(projectRoot);
146
+ const batchDir = join(batchesDir, batchId);
147
+ const batchManifestPath = join(batchDir, 'manifest.json');
148
+
149
+ if (!existsSync(batchManifestPath)) {
150
+ throw inputError('BATCH_NOT_FOUND', `Batch "${batchId}" not found`, 'Check the batch ID.');
151
+ }
152
+
153
+ const batchManifest = JSON.parse(readFileSync(batchManifestPath, 'utf-8'));
154
+ const runsDir = getRunsDir(projectRoot);
155
+
156
+ // Build slot → run lookup
157
+ const slotRunMap = new Map();
158
+ for (const slot of batchManifest.slots || []) {
159
+ slotRunMap.set(slot.slot_id, slot);
160
+ }
161
+
162
+ // Validate each approved slot
163
+ const runManifests = new Map();
164
+ for (const { slotId, filename } of approvedSlots) {
165
+ const slot = slotRunMap.get(slotId);
166
+ if (!slot) {
167
+ throw inputError(
168
+ 'SLOT_NOT_FOUND',
169
+ `Slot "${slotId}" not found in batch ${batchId}`,
170
+ `Available: ${[...slotRunMap.keys()].join(', ')}`,
171
+ );
172
+ }
173
+
174
+ const runDir = join(runsDir, slot.run_id);
175
+ const outputPath = join(runDir, 'outputs', filename);
176
+ if (!existsSync(outputPath)) {
177
+ throw inputError(
178
+ 'OUTPUT_NOT_FOUND',
179
+ `Output "${filename}" not found in ${slot.run_id}/outputs/`,
180
+ `Check the filename.`,
181
+ );
182
+ }
183
+
184
+ // Cache run manifests
185
+ if (!runManifests.has(slot.run_id)) {
186
+ const rmPath = join(runDir, 'manifest.json');
187
+ if (existsSync(rmPath)) {
188
+ runManifests.set(slot.run_id, JSON.parse(readFileSync(rmPath, 'utf-8')));
189
+ }
190
+ }
191
+ }
192
+
193
+ // Create selection directory
194
+ const selectionsDir = getSelectionsDir(projectRoot);
195
+ const selectionId = generateSelectionId(selectionsDir);
196
+ const selectionDir = join(selectionsDir, selectionId);
197
+ await mkdir(join(selectionDir, 'chosen'), { recursive: true });
198
+
199
+ // Copy chosen files and build items
200
+ const items = [];
201
+ for (let i = 0; i < approvedSlots.length; i++) {
202
+ const { slotId, filename } = approvedSlots[i];
203
+ const slot = slotRunMap.get(slotId);
204
+ const runDir = join(runsDir, slot.run_id);
205
+ const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
206
+
207
+ copyFileSync(join(runDir, 'outputs', filename), join(selectionDir, 'chosen', destName));
208
+
209
+ const rm = runManifests.get(slot.run_id);
210
+ const outputEntry = rm?.outputs?.find(o => o.filename === filename);
211
+
212
+ items.push({
213
+ slot_or_output: `${slotId}:${filename}`,
214
+ filename: destName,
215
+ seed: outputEntry?.seed,
216
+ reason: reason || `selected from slot ${slot.label || slotId}`,
217
+ tags: tags || [],
218
+ });
219
+ }
220
+
221
+ const manifest = {
222
+ selection_id: selectionId,
223
+ project_id: projectId,
224
+ source_type: 'batch',
225
+ source_id: batchId,
226
+ workflow_id: batchManifest.mode_id,
227
+ created_at: new Date().toISOString(),
228
+ items,
229
+ reingest_ready: true,
230
+ };
231
+
232
+ if (batchManifest.subject_id) manifest.subject_id = batchManifest.subject_id;
233
+
234
+ return { selectionId, selectionDir, manifest, batchManifest, runManifests };
235
+ }
236
+
237
+ // ─── Save / Load / List ──────────────────────────────────────────
238
+
239
+ /**
240
+ * Save a selection manifest + summary to disk.
241
+ */
242
+ export async function saveSelection(selectionDir, manifest) {
243
+ await writeFile(
244
+ join(selectionDir, 'manifest.json'),
245
+ JSON.stringify(manifest, null, 2) + '\n',
246
+ );
247
+
248
+ // Summary JSON
249
+ await writeFile(
250
+ join(selectionDir, 'summary.json'),
251
+ JSON.stringify({
252
+ selection_id: manifest.selection_id,
253
+ source_type: manifest.source_type,
254
+ source_id: manifest.source_id,
255
+ workflow_id: manifest.workflow_id,
256
+ subject_id: manifest.subject_id,
257
+ items: manifest.items.length,
258
+ reingest_ready: manifest.reingest_ready,
259
+ created_at: manifest.created_at,
260
+ }, null, 2) + '\n',
261
+ );
262
+
263
+ // Summary markdown
264
+ const lines = [
265
+ `# Selection: ${manifest.selection_id}`,
266
+ '',
267
+ `**Source:** ${manifest.source_type} \`${manifest.source_id}\``,
268
+ `**Workflow:** ${manifest.workflow_id || '—'}`,
269
+ ];
270
+ if (manifest.subject_id) lines.push(`**Subject:** ${manifest.subject_id}`);
271
+ lines.push(`**Created:** ${manifest.created_at}`);
272
+ lines.push(`**Re-ingest ready:** ${manifest.reingest_ready ? 'yes' : 'no'}`);
273
+ lines.push('');
274
+ lines.push('## Chosen outputs');
275
+ lines.push('');
276
+ lines.push('| # | Source | File | Seed | Reason |');
277
+ lines.push('|---|--------|------|------|--------|');
278
+ for (let i = 0; i < manifest.items.length; i++) {
279
+ const item = manifest.items[i];
280
+ lines.push(
281
+ `| ${i + 1} | ${item.slot_or_output} | ${item.filename} | ${item.seed ?? '—'} | ${item.reason} |`,
282
+ );
283
+ }
284
+ lines.push('');
285
+ if (manifest.items.some(it => it.tags?.length)) {
286
+ lines.push('## Tags');
287
+ for (const item of manifest.items) {
288
+ if (item.tags?.length) {
289
+ lines.push(`- ${item.filename}: ${item.tags.join(', ')}`);
290
+ }
291
+ }
292
+ lines.push('');
293
+ }
294
+ await writeFile(join(selectionDir, 'summary.md'), lines.join('\n'));
295
+ }
296
+
297
+ /**
298
+ * Load a selection manifest by ID.
299
+ */
300
+ export function loadSelection(projectRoot, selectionId) {
301
+ const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
302
+ const manifestPath = join(selectionDir, 'manifest.json');
303
+ if (!existsSync(manifestPath)) {
304
+ throw inputError(
305
+ 'SELECTION_NOT_FOUND',
306
+ `Selection "${selectionId}" not found`,
307
+ 'Check the selection ID or run: sdlab select --run <id> --approve <files>',
308
+ );
309
+ }
310
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
311
+ }
312
+
313
+ /**
314
+ * List all selections in a project.
315
+ */
316
+ export function listSelections(projectRoot) {
317
+ const dir = getSelectionsDir(projectRoot);
318
+ if (!existsSync(dir)) return [];
319
+ return readdirSync(dir, { withFileTypes: true })
320
+ .filter(e => e.isDirectory() && e.name.startsWith('selection_'))
321
+ .map(e => e.name)
322
+ .sort();
323
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Workflow profile management.
3
+ *
4
+ * A workflow profile is a production recipe — it defines how to generate
5
+ * a specific kind of output set (portrait pack, expression sheet, moodboard, etc.)
6
+ * using project canon and trained assets.
7
+ *
8
+ * Workflow profiles are NOT training profiles. They drive generation, not training.
9
+ */
10
+
11
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { existsSync } from 'node:fs';
14
+ import { getWorkflowProfilesDir } from './paths.js';
15
+
16
+ const REQUIRED_FIELDS = [
17
+ 'workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
18
+ 'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards',
19
+ 'runtime_defaults',
20
+ ];
21
+
22
+ const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
23
+
24
+ const VALID_OUTPUT_MODES = [
25
+ 'portrait_set', 'expression_sheet', 'variant_pack',
26
+ 'moodboard', 'silhouette_sheet', 'turnaround',
27
+ ];
28
+
29
+ const VALID_CANON_FOCUS = [
30
+ 'silhouette', 'material_language', 'palette', 'anatomy',
31
+ 'costume_logic', 'era_logic', 'scale_logic', 'lighting',
32
+ 'surface_wear', 'gesture', 'composition', 'faction_read',
33
+ ];
34
+
35
+ /**
36
+ * Validate a workflow profile object.
37
+ * @returns {{ valid: boolean, errors: string[] }}
38
+ */
39
+ export function validateWorkflowProfile(profile) {
40
+ const errors = [];
41
+
42
+ for (const field of REQUIRED_FIELDS) {
43
+ if (profile[field] === undefined || profile[field] === null) {
44
+ errors.push(`missing required field: ${field}`);
45
+ }
46
+ }
47
+
48
+ if (profile.subject_mode && !VALID_SUBJECT_MODES.includes(profile.subject_mode)) {
49
+ errors.push(`invalid subject_mode "${profile.subject_mode}" — must be one of: ${VALID_SUBJECT_MODES.join(', ')}`);
50
+ }
51
+
52
+ if (profile.output_mode && !VALID_OUTPUT_MODES.includes(profile.output_mode)) {
53
+ errors.push(`invalid output_mode "${profile.output_mode}" — must be one of: ${VALID_OUTPUT_MODES.join(', ')}`);
54
+ }
55
+
56
+ if (!Number.isInteger(profile.output_count) || profile.output_count < 1) {
57
+ errors.push('output_count must be a positive integer');
58
+ }
59
+
60
+ // Prompt strategy structure
61
+ const ps = profile.prompt_strategy;
62
+ if (ps) {
63
+ if (!Array.isArray(ps.style_prefix)) errors.push('prompt_strategy.style_prefix must be an array');
64
+ if (typeof ps.structure !== 'string') errors.push('prompt_strategy.structure must be a string');
65
+ if (!Array.isArray(ps.must_include)) errors.push('prompt_strategy.must_include must be an array');
66
+ }
67
+
68
+ // Negative strategy structure
69
+ const ns = profile.negative_strategy;
70
+ if (ns) {
71
+ if (!Array.isArray(ns.must_avoid)) errors.push('negative_strategy.must_avoid must be an array');
72
+ }
73
+
74
+ // Canon focus items
75
+ if (Array.isArray(profile.canon_focus)) {
76
+ for (const item of profile.canon_focus) {
77
+ if (!VALID_CANON_FOCUS.includes(item)) {
78
+ errors.push(`invalid canon_focus item "${item}" — must be one of: ${VALID_CANON_FOCUS.join(', ')}`);
79
+ }
80
+ }
81
+ } else if (profile.canon_focus !== undefined) {
82
+ errors.push('canon_focus must be an array');
83
+ }
84
+
85
+ if (profile.drift_guards && !Array.isArray(profile.drift_guards)) {
86
+ errors.push('drift_guards must be an array');
87
+ }
88
+
89
+ // Runtime defaults
90
+ const rd = profile.runtime_defaults;
91
+ if (rd) {
92
+ if (!rd.adapter_target) errors.push('runtime_defaults.adapter_target is required');
93
+ if (rd.seed_mode && !['fixed', 'increment', 'random'].includes(rd.seed_mode)) {
94
+ errors.push(`invalid runtime_defaults.seed_mode "${rd.seed_mode}" — must be fixed, increment, or random`);
95
+ }
96
+ }
97
+
98
+ return { valid: errors.length === 0, errors };
99
+ }
100
+
101
+ /**
102
+ * Load a single workflow profile by ID.
103
+ */
104
+ export async function getWorkflowProfile(projectRoot, workflowId) {
105
+ const dir = getWorkflowProfilesDir(projectRoot);
106
+ const path = join(dir, `${workflowId}.json`);
107
+ if (!existsSync(path)) {
108
+ throw new Error(`Workflow profile "${workflowId}" not found at ${path}`);
109
+ }
110
+ const data = JSON.parse(await readFile(path, 'utf-8'));
111
+ const { valid, errors } = validateWorkflowProfile(data);
112
+ if (!valid) {
113
+ throw new Error(`Workflow profile "${workflowId}" is invalid:\n ${errors.join('\n ')}`);
114
+ }
115
+ return data;
116
+ }
117
+
118
+ /**
119
+ * List all workflow profiles in a project.
120
+ */
121
+ export async function listWorkflowProfiles(projectRoot) {
122
+ const dir = getWorkflowProfilesDir(projectRoot);
123
+ if (!existsSync(dir)) return [];
124
+
125
+ const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
126
+ const profiles = [];
127
+
128
+ for (const file of files) {
129
+ try {
130
+ const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
131
+ profiles.push({
132
+ workflow_id: data.workflow_id,
133
+ label: data.label,
134
+ lane_id: data.lane_id,
135
+ subject_mode: data.subject_mode || 'optional',
136
+ output_mode: data.output_mode,
137
+ output_count: data.output_count,
138
+ });
139
+ } catch {
140
+ // Skip malformed
141
+ }
142
+ }
143
+
144
+ return profiles;
145
+ }
146
+
147
+ /**
148
+ * Save a workflow profile to a project.
149
+ */
150
+ export async function saveWorkflowProfile(projectRoot, profile) {
151
+ const { valid, errors } = validateWorkflowProfile(profile);
152
+ if (!valid) {
153
+ throw new Error(`Invalid workflow profile:\n ${errors.join('\n ')}`);
154
+ }
155
+ const dir = getWorkflowProfilesDir(projectRoot);
156
+ await mkdir(dir, { recursive: true });
157
+ await writeFile(
158
+ join(dir, `${profile.workflow_id}.json`),
159
+ JSON.stringify(profile, null, 2) + '\n'
160
+ );
161
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mcptoolshop/style-dataset-lab",
3
- "version": "2.3.0",
4
- "description": "Visual canon and dataset production pipeline — snapshots, splits, versioned exports, eval packs, and canon-bound curation for any image production domain",
3
+ "version": "3.0.0",
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": {
7
7
  "sdlab": "./bin/sdlab.js"
@@ -38,7 +38,10 @@
38
38
  "style-guide",
39
39
  "lora",
40
40
  "multimodal",
41
- "comfyui"
41
+ "comfyui",
42
+ "generation-workflow",
43
+ "batch-production",
44
+ "reintegration"
42
45
  ],
43
46
  "homepage": "https://mcp-tool-shop-org.github.io/style-dataset-lab/",
44
47
  "repository": {
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * batch-generate.js — Execute a batch production mode.
5
+ *
6
+ * Usage:
7
+ * sdlab batch generate --mode expression-sheet --subject captain-orryn --project star-freight
8
+ * sdlab batch generate --mode environment-board --theme freeport-night --project star-freight
9
+ * sdlab batch generate --mode silhouette-pack --project star-freight --dry-run
10
+ */
11
+
12
+ import { parseArgs } from '../lib/args.js';
13
+ import { getProjectRoot, getRunsDir } from '../lib/paths.js';
14
+ import { compileBatch } from '../lib/batch-compiler.js';
15
+ import { getBatchMode } from '../lib/batch-modes.js';
16
+ import {
17
+ createBatchDir,
18
+ executeBatchRuns,
19
+ saveBatchManifest,
20
+ } from '../lib/batch-runs.js';
21
+ import { renderSheetHTML, saveSheet } from '../lib/batch-sheet-render.js';
22
+ import { info } from '../lib/log.js';
23
+ import { join } from 'node:path';
24
+ import { writeFile } from 'node:fs/promises';
25
+
26
+ export async function run(argv = process.argv.slice(2)) {
27
+ const { flags } = parseArgs(argv, {
28
+ flags: {
29
+ project: { type: 'string', default: 'star-freight' },
30
+ mode: { type: 'string' },
31
+ subject: { type: 'string' },
32
+ theme: { type: 'string' },
33
+ asset: { type: 'string' },
34
+ 'dry-run': { type: 'boolean' },
35
+ json: { type: 'boolean' },
36
+ },
37
+ deprecated: { game: 'project' },
38
+ });
39
+
40
+ if (!flags.mode) {
41
+ console.log('Usage: sdlab batch generate --mode <id> [--subject <id>] [--theme <label>] [--project <name>]');
42
+ console.log('');
43
+ console.log('Options:');
44
+ console.log(' --mode <id> Batch mode (required)');
45
+ console.log(' --subject <id> Subject for subject-driven modes');
46
+ console.log(' --theme <label> Theme label for environment modes');
47
+ console.log(' --asset <id> Training asset reference');
48
+ console.log(' --dry-run Prepare batch without submitting to ComfyUI');
49
+ console.log(' --json Output manifest as JSON');
50
+ return;
51
+ }
52
+
53
+ const projectName = flags.project;
54
+ const projectRoot = getProjectRoot(projectName);
55
+ const dryRun = flags['dry-run'] || false;
56
+
57
+ // Load mode for display
58
+ const mode = getBatchMode(projectRoot, flags.mode);
59
+
60
+ console.log(`\x1b[1mstyle-dataset-lab\x1b[0m batch generate`);
61
+ console.log(` Project: ${projectName}`);
62
+ console.log(` Mode: ${mode.label} (${mode.mode_id})`);
63
+ console.log(` Type: ${mode.batch_type}`);
64
+ 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}`);
67
+ if (dryRun) console.log(' ⚠ DRY RUN');
68
+ console.log('');
69
+
70
+ // 1. Compile batch briefs
71
+ const { baseBrief, slotBriefs } = await compileBatch({
72
+ projectRoot,
73
+ projectId: projectName,
74
+ modeId: flags.mode,
75
+ subjectId: flags.subject,
76
+ theme: flags.theme,
77
+ assetRef: flags.asset,
78
+ });
79
+
80
+ // 2. Create batch directory
81
+ const { batchId, batchDir } = await createBatchDir(projectRoot);
82
+ info(`Batch: ${batchId}`);
83
+
84
+ // 3. Execute runs
85
+ const results = await executeBatchRuns({
86
+ projectRoot,
87
+ projectName,
88
+ batchDir,
89
+ mode,
90
+ slotBriefs,
91
+ dryRun,
92
+ });
93
+
94
+ // 4. Build manifest
95
+ const manifest = {
96
+ batch_id: batchId,
97
+ project_id: projectName,
98
+ mode_id: mode.mode_id,
99
+ created_at: new Date().toISOString(),
100
+ brief_ids: results.map(r => r.brief_id),
101
+ run_ids: results.map(r => r.run_id),
102
+ slots: results.map(r => ({
103
+ slot_id: r.slot_id,
104
+ label: r.label,
105
+ brief_id: r.brief_id,
106
+ run_id: r.run_id,
107
+ selected_output: r.selected_output,
108
+ })),
109
+ sheet_paths: [],
110
+ summary: {
111
+ total_slots: results.length,
112
+ successful_slots: results.filter(r => r.selected_output).length,
113
+ dry_run: dryRun,
114
+ },
115
+ };
116
+
117
+ if (flags.subject) manifest.subject_id = flags.subject;
118
+ if (flags.theme) manifest.theme = flags.theme;
119
+
120
+ // 5. Render sheet
121
+ const sheetHtml = renderSheetHTML({ manifest, mode, projectRoot, batchDir });
122
+ const sheetPath = await saveSheet(batchDir, 'primary-sheet.html', sheetHtml);
123
+ manifest.sheet_paths.push('sheets/primary-sheet.html');
124
+
125
+ // 6. Save manifest + summary
126
+ await saveBatchManifest(batchDir, manifest);
127
+
128
+ // Write summary.md
129
+ const summaryLines = [
130
+ `# Batch: ${batchId}`,
131
+ '',
132
+ `**Mode:** ${mode.label} (${mode.mode_id})`,
133
+ `**Type:** ${mode.batch_type}`,
134
+ `**Project:** ${projectName}`,
135
+ ];
136
+ if (manifest.subject_id) summaryLines.push(`**Subject:** ${manifest.subject_id}`);
137
+ if (manifest.theme) summaryLines.push(`**Theme:** ${manifest.theme}`);
138
+ summaryLines.push(`**Created:** ${manifest.created_at}`);
139
+ if (dryRun) summaryLines.push('**⚠ DRY RUN**');
140
+ summaryLines.push('');
141
+ summaryLines.push('## Slots');
142
+ summaryLines.push('');
143
+ summaryLines.push('| Slot | Run | Output |');
144
+ summaryLines.push('|------|-----|--------|');
145
+ for (const r of results) {
146
+ summaryLines.push(`| ${r.label} | ${r.run_id} | ${r.selected_output || '—'} |`);
147
+ }
148
+ summaryLines.push('');
149
+ await writeFile(join(batchDir, 'summary.md'), summaryLines.join('\n'));
150
+
151
+ // 7. Output
152
+ console.log('');
153
+ if (flags.json) {
154
+ console.log(JSON.stringify(manifest, null, 2));
155
+ } else {
156
+ console.log(`Batch: ${batchId}`);
157
+ console.log(`Slots: ${results.length}`);
158
+ console.log('');
159
+ for (const r of results) {
160
+ const status = r.selected_output ? '✓' : '○';
161
+ console.log(` ${status} ${r.label.padEnd(24)} → ${r.run_id} ${r.selected_output || '(dry run)'}`);
162
+ }
163
+ console.log('');
164
+ info(`Sheet: batches/${batchId}/sheets/primary-sheet.html`);
165
+ info(`Manifest: batches/${batchId}/manifest.json`);
166
+ }
167
+ }