@mcptoolshop/style-dataset-lab 2.2.1 → 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 (65) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.es.md +76 -144
  3. package/README.fr.md +75 -143
  4. package/README.hi.md +75 -143
  5. package/README.it.md +76 -144
  6. package/README.ja.md +75 -143
  7. package/README.md +36 -8
  8. package/README.pt-BR.md +75 -143
  9. package/README.zh.md +76 -144
  10. package/SECURITY.md +3 -1
  11. package/bin/sdlab.js +146 -17
  12. package/lib/adapters/comfyui-runner.js +170 -0
  13. package/lib/adapters/comfyui-workflows.js +163 -0
  14. package/lib/batch-compiler.js +118 -0
  15. package/lib/batch-modes.js +111 -0
  16. package/lib/batch-runs.js +168 -0
  17. package/lib/batch-sheet-render.js +206 -0
  18. package/lib/brief-compiler.js +598 -0
  19. package/lib/brief-render.js +205 -0
  20. package/lib/critique-engine.js +413 -0
  21. package/lib/critique-render.js +171 -0
  22. package/lib/errors.js +82 -0
  23. package/lib/generated-provenance.js +87 -0
  24. package/lib/log.js +36 -0
  25. package/lib/paths.js +31 -0
  26. package/lib/refine-briefs.js +329 -0
  27. package/lib/reingest-selected.js +173 -0
  28. package/lib/run-summary.js +131 -0
  29. package/lib/runtime-runs.js +168 -0
  30. package/lib/selections.js +323 -0
  31. package/lib/snapshot.js +5 -2
  32. package/lib/split.js +5 -2
  33. package/lib/workflow-profiles.js +161 -0
  34. package/package.json +8 -6
  35. package/scripts/batch-generate.js +167 -0
  36. package/scripts/batch-sheet.js +46 -0
  37. package/scripts/batch-show.js +86 -0
  38. package/scripts/brief-compile.js +134 -0
  39. package/scripts/brief-show.js +52 -0
  40. package/scripts/critique-show.js +50 -0
  41. package/scripts/critique.js +70 -0
  42. package/scripts/doctor.js +169 -1
  43. package/scripts/init.js +22 -0
  44. package/scripts/refine.js +119 -0
  45. package/scripts/reingest-selected.js +78 -0
  46. package/scripts/reingest.js +7 -1
  47. package/scripts/run-generate.js +76 -0
  48. package/scripts/run-list.js +58 -0
  49. package/scripts/run-show.js +63 -0
  50. package/scripts/select.js +118 -0
  51. package/scripts/selection-show.js +88 -0
  52. package/scripts/snapshot.js +27 -23
  53. package/scripts/split.js +3 -1
  54. package/scripts/workflow-list.js +61 -0
  55. package/scripts/workflow-show.js +96 -0
  56. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  57. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  58. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  59. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  60. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  61. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  62. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  63. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  64. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  65. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * ComfyUI runner — executes a brief through ComfyUI and captures outputs.
3
+ *
4
+ * Orchestrates: seed plan → graph build → submit → poll → download → manifest.
5
+ * Each image in the seed plan becomes one submission to ComfyUI.
6
+ */
7
+
8
+ import { writeFile, mkdir } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { comfyHealth, submitAndWait, downloadImage } from '../comfyui.js';
11
+ import { buildWorkflowGraph, resolveTemplateId } from './comfyui-workflows.js';
12
+ import { buildSeedPlan, prepareRunDir, saveRunManifest } from '../runtime-runs.js';
13
+ import { loadProjectConfig } from '../config.js';
14
+ import { log, info, warn } from '../log.js';
15
+ import { runtimeError } from '../errors.js';
16
+
17
+ /**
18
+ * Execute a brief: build graphs, submit to ComfyUI, capture outputs.
19
+ *
20
+ * @param {Object} opts
21
+ * @param {string} opts.projectRoot — absolute project path
22
+ * @param {string} opts.projectName — project name
23
+ * @param {Object} opts.brief — compiled brief object
24
+ * @param {number} [opts.baseSeed] — override base seed
25
+ * @param {boolean} [opts.dryRun] — if true, prepare run dir but do not submit
26
+ * @returns {Promise<Object>} — manifest
27
+ */
28
+ export async function executeRun({ projectRoot, projectName, brief, baseSeed, dryRun = false }) {
29
+ const config = loadProjectConfig(projectRoot);
30
+ const defaults = config.meta?.defaults ?? {};
31
+ const comfyUrl = defaults.comfy_url || process.env.COMFY_URL || 'http://127.0.0.1:8188';
32
+
33
+ // Resolve template
34
+ const outputMode = brief.expected_outputs?.output_mode || 'portrait_set';
35
+ const templateId = resolveTemplateId(outputMode);
36
+ const outputCount = brief.expected_outputs?.output_count || brief.runtime_plan?.output_count || 4;
37
+
38
+ // Build seed plan
39
+ const seedMode = brief.runtime_plan?.seed_mode || 'increment';
40
+ const seedPlan = buildSeedPlan({ seedMode, outputCount, baseSeed });
41
+
42
+ // Prepare run directory
43
+ const { runId, runDir } = await prepareRunDir(projectRoot, brief);
44
+
45
+ info(`Run ${runId} — ${outputCount} images, seed mode: ${seedMode}, base: ${seedPlan.base_seed}`);
46
+ info(`Template: ${templateId}, ComfyUI: ${comfyUrl}`);
47
+
48
+ // Health check
49
+ if (!dryRun) {
50
+ const healthy = await comfyHealth(comfyUrl);
51
+ if (!healthy) {
52
+ throw runtimeError(
53
+ 'COMFYUI_OFFLINE',
54
+ `ComfyUI not reachable at ${comfyUrl}`,
55
+ 'Start ComfyUI or set COMFY_URL / project defaults.comfy_url'
56
+ );
57
+ }
58
+ info('ComfyUI online');
59
+ }
60
+
61
+ const outputs = [];
62
+ const errors = [];
63
+
64
+ for (let i = 0; i < outputCount; i++) {
65
+ const seed = seedPlan.seeds[i];
66
+ const imageIndex = String(i + 1).padStart(3, '0');
67
+
68
+ log(` [${i + 1}/${outputCount}] seed: ${seed}`);
69
+
70
+ if (dryRun) {
71
+ outputs.push({
72
+ index: i,
73
+ seed,
74
+ status: 'dry_run',
75
+ filename: `${imageIndex}.png`,
76
+ });
77
+ continue;
78
+ }
79
+
80
+ const startMs = Date.now();
81
+
82
+ try {
83
+ // Build graph
84
+ const { nodes } = buildWorkflowGraph({
85
+ brief,
86
+ projectDefaults: defaults,
87
+ seed,
88
+ });
89
+
90
+ // Submit and wait
91
+ const result = await submitAndWait(nodes, comfyUrl, {
92
+ clientPrefix: `sdl-run-${runId}`,
93
+ });
94
+
95
+ const elapsed = Date.now() - startMs;
96
+
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
+ }
108
+
109
+ if (!imageFile) {
110
+ warn(` No output image for seed ${seed}`);
111
+ outputs.push({ index: i, seed, status: 'no_output', elapsed_ms: elapsed });
112
+ continue;
113
+ }
114
+
115
+ // Download and save
116
+ const imgData = await downloadImage(imageFile, imageSubfolder, comfyUrl);
117
+ const destFilename = `${imageIndex}.png`;
118
+ await writeFile(join(runDir, 'outputs', destFilename), imgData);
119
+
120
+ outputs.push({
121
+ index: i,
122
+ seed,
123
+ status: 'ok',
124
+ filename: destFilename,
125
+ bytes: imgData.length,
126
+ elapsed_ms: elapsed,
127
+ comfy_filename: imageFile,
128
+ });
129
+
130
+ log(` ✓ ${destFilename} (${imgData.length} bytes, ${(elapsed / 1000).toFixed(1)}s)`);
131
+ } catch (err) {
132
+ const elapsed = Date.now() - startMs;
133
+ warn(` ✗ seed ${seed}: ${err.message}`);
134
+ errors.push({ index: i, seed, error: err.message });
135
+ outputs.push({
136
+ index: i,
137
+ seed,
138
+ status: 'error',
139
+ error: err.message,
140
+ elapsed_ms: elapsed,
141
+ });
142
+ }
143
+ }
144
+
145
+ // Build manifest
146
+ const manifest = {
147
+ run_id: runId,
148
+ brief_id: brief.brief_id,
149
+ project_id: brief.project_id,
150
+ workflow_template_id: templateId,
151
+ adapter_target: 'comfyui',
152
+ output_mode: outputMode,
153
+ output_count: outputCount,
154
+ created_at: new Date().toISOString(),
155
+ seed_plan: seedPlan,
156
+ comfy_url: comfyUrl,
157
+ checkpoint: defaults.checkpoint,
158
+ loras: defaults.loras || [],
159
+ runtime_plan: brief.runtime_plan,
160
+ outputs,
161
+ errors,
162
+ dry_run: dryRun,
163
+ success_count: outputs.filter(o => o.status === 'ok' || o.status === 'dry_run').length,
164
+ error_count: errors.length,
165
+ };
166
+
167
+ await saveRunManifest(runDir, manifest);
168
+
169
+ return manifest;
170
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ComfyUI workflow adapter — builds executable ComfyUI prompt graphs from briefs.
3
+ *
4
+ * The brief carries everything needed: prompt, negative, runtime plan,
5
+ * plus project defaults for checkpoint/LoRAs. This module translates
6
+ * that into a ComfyUI node graph.
7
+ */
8
+
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
+ import { getRuntimeDir } from '../paths.js';
13
+
14
+ // ─── Template registry ───────────────────────────────────────────
15
+
16
+ /**
17
+ * List available workflow templates from runtime/comfyui/.
18
+ */
19
+ export function listWorkflowTemplates() {
20
+ const dir = join(getRuntimeDir(), 'comfyui');
21
+ if (!existsSync(dir)) return [];
22
+ return readdirSync(dir)
23
+ .filter(f => f.endsWith('.json'))
24
+ .map(f => {
25
+ const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
26
+ return {
27
+ template_id: data.template_id,
28
+ label: data.label,
29
+ compatible_modes: data.compatible_modes || [],
30
+ };
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Resolve a template for a given output mode.
36
+ * Returns 'txt2img-standard' for all standard modes.
37
+ * Throws if no template matches.
38
+ */
39
+ export function resolveTemplateId(outputMode) {
40
+ const templates = listWorkflowTemplates();
41
+ const match = templates.find(t => t.compatible_modes.includes(outputMode));
42
+ if (match) return match.template_id;
43
+ // Default fallback for unknown modes
44
+ return 'txt2img-standard';
45
+ }
46
+
47
+ // ─── Graph builder ───────────────────────────────────────────────
48
+
49
+ /**
50
+ * Build a ComfyUI prompt graph from a compiled brief + project config.
51
+ *
52
+ * @param {Object} opts
53
+ * @param {Object} opts.brief — compiled brief from brief-compiler
54
+ * @param {Object} opts.projectDefaults — project.json defaults block
55
+ * @param {number} opts.seed — specific seed for this generation
56
+ * @returns {{ nodes: Object, saveNodeId: string }}
57
+ */
58
+ export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
59
+ const rp = brief.runtime_plan;
60
+ const defaults = projectDefaults;
61
+
62
+ const checkpoint = defaults.checkpoint;
63
+ const loras = defaults.loras || [];
64
+ const prompt = brief.prompt;
65
+ const negativePrompt = brief.negative_prompt;
66
+ const width = rp.width || defaults.width || 1024;
67
+ const height = rp.height || defaults.height || 1024;
68
+ const steps = rp.steps || defaults.steps || 30;
69
+ const cfg = rp.cfg || defaults.cfg || 6.5;
70
+ const sampler = rp.sampler || defaults.sampler || 'dpmpp_2m';
71
+ const scheduler = rp.scheduler || defaults.scheduler || 'karras';
72
+
73
+ const nodes = {};
74
+ let nextId = 1;
75
+
76
+ // Checkpoint loader
77
+ const ckptId = String(nextId++);
78
+ nodes[ckptId] = {
79
+ class_type: 'CheckpointLoaderSimple',
80
+ inputs: { ckpt_name: checkpoint },
81
+ };
82
+
83
+ let modelOut = [ckptId, 0];
84
+ let clipOut = [ckptId, 1];
85
+
86
+ // LoRA loaders (chained)
87
+ for (const lora of loras) {
88
+ const loraId = String(nextId++);
89
+ nodes[loraId] = {
90
+ class_type: 'LoraLoader',
91
+ inputs: {
92
+ model: modelOut,
93
+ clip: clipOut,
94
+ lora_name: lora.name,
95
+ strength_model: lora.weight,
96
+ strength_clip: lora.clip_weight ?? lora.weight,
97
+ },
98
+ };
99
+ modelOut = [loraId, 0];
100
+ clipOut = [loraId, 1];
101
+ }
102
+
103
+ // CLIP Text Encode — positive
104
+ const posId = String(nextId++);
105
+ nodes[posId] = {
106
+ class_type: 'CLIPTextEncode',
107
+ inputs: { text: prompt, clip: clipOut },
108
+ };
109
+
110
+ // CLIP Text Encode — negative
111
+ const negId = String(nextId++);
112
+ nodes[negId] = {
113
+ class_type: 'CLIPTextEncode',
114
+ inputs: { text: negativePrompt, clip: clipOut },
115
+ };
116
+
117
+ // Empty latent image
118
+ const latentId = String(nextId++);
119
+ nodes[latentId] = {
120
+ class_type: 'EmptyLatentImage',
121
+ inputs: { width, height, batch_size: 1 },
122
+ };
123
+
124
+ // KSampler
125
+ const samplerId = String(nextId++);
126
+ nodes[samplerId] = {
127
+ class_type: 'KSampler',
128
+ inputs: {
129
+ model: modelOut,
130
+ positive: [posId, 0],
131
+ negative: [negId, 0],
132
+ latent_image: [latentId, 0],
133
+ seed,
134
+ steps,
135
+ cfg,
136
+ sampler_name: sampler,
137
+ scheduler,
138
+ denoise: 1.0,
139
+ },
140
+ };
141
+
142
+ // VAE Decode
143
+ const vaeId = String(nextId++);
144
+ nodes[vaeId] = {
145
+ class_type: 'VAEDecode',
146
+ inputs: {
147
+ samples: [samplerId, 0],
148
+ vae: [ckptId, 2],
149
+ },
150
+ };
151
+
152
+ // Save Image
153
+ const saveId = String(nextId++);
154
+ nodes[saveId] = {
155
+ class_type: 'SaveImage',
156
+ inputs: {
157
+ images: [vaeId, 0],
158
+ filename_prefix: 'sdl_run',
159
+ },
160
+ };
161
+
162
+ return { nodes, saveNodeId: saveId };
163
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Batch compiler — expand a batch mode into multiple coordinated briefs.
3
+ *
4
+ * Takes a batch mode + subject/theme inputs and produces one brief per
5
+ * variant slot, each with controlled prompt/negative/runtime deltas
6
+ * on top of a base compiled brief from 4A.
7
+ */
8
+
9
+ import { compileBrief, saveCompiledBrief } from './brief-compiler.js';
10
+ import { getBatchMode } from './batch-modes.js';
11
+ import { getWorkflowProfile } from './workflow-profiles.js';
12
+ import { inputError } from './errors.js';
13
+
14
+ /**
15
+ * Apply slot-level deltas to a base brief.
16
+ *
17
+ * @param {Object} baseBrief — compiled brief from brief-compiler
18
+ * @param {Object} slot — variant_plan slot definition
19
+ * @returns {Object} — modified brief (new object, base untouched)
20
+ */
21
+ export function applySlotDelta(baseBrief, slot) {
22
+ const modified = JSON.parse(JSON.stringify(baseBrief));
23
+
24
+ // Prompt additions — append to the end
25
+ if (slot.prompt_additions?.length > 0) {
26
+ modified.prompt = `${modified.prompt}, ${slot.prompt_additions.join(', ')}`;
27
+ }
28
+
29
+ // Negative additions — append without duplicating
30
+ if (slot.negative_additions?.length > 0) {
31
+ const existingLower = modified.negative_prompt.toLowerCase().split(',').map(s => s.trim());
32
+ const additions = slot.negative_additions.filter(
33
+ n => !existingLower.some(e => e.includes(n.toLowerCase()))
34
+ );
35
+ if (additions.length > 0) {
36
+ modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
37
+ }
38
+ }
39
+
40
+ // Runtime overrides
41
+ if (slot.runtime_overrides) {
42
+ for (const [key, val] of Object.entries(slot.runtime_overrides)) {
43
+ if (modified.runtime_plan && val !== undefined) {
44
+ modified.runtime_plan[key] = val;
45
+ }
46
+ }
47
+ }
48
+
49
+ // Tag the slot
50
+ modified._batch_slot_id = slot.slot_id;
51
+ modified._batch_slot_label = slot.label;
52
+
53
+ return modified;
54
+ }
55
+
56
+ /**
57
+ * Compile a full batch — one brief per variant slot.
58
+ *
59
+ * @param {Object} opts
60
+ * @param {string} opts.projectRoot — absolute project path
61
+ * @param {string} opts.projectId — project name
62
+ * @param {string} opts.modeId — batch mode ID
63
+ * @param {string} [opts.subjectId] — subject for subject-driven modes
64
+ * @param {string} [opts.theme] — theme label for environment modes
65
+ * @param {string} [opts.assetRef] — optional training asset reference
66
+ * @param {Object} [opts.overrides] — CLI runtime overrides
67
+ * @returns {Promise<{ mode: Object, baseBrief: Object, slotBriefs: Object[] }>}
68
+ */
69
+ export async function compileBatch({
70
+ projectRoot,
71
+ projectId,
72
+ modeId,
73
+ subjectId,
74
+ theme,
75
+ assetRef,
76
+ overrides = {},
77
+ }) {
78
+ // Load batch mode
79
+ const mode = getBatchMode(projectRoot, modeId);
80
+
81
+ // Enforce subject mode
82
+ const subjectMode = mode.subject_mode || 'optional';
83
+ if (subjectMode === 'required' && !subjectId) {
84
+ throw inputError(
85
+ 'BATCH_SUBJECT_REQUIRED',
86
+ `Batch mode "${modeId}" requires a subject`,
87
+ 'Add --subject <id> to the command.'
88
+ );
89
+ }
90
+ if (subjectMode === 'forbidden' && subjectId) {
91
+ throw inputError(
92
+ 'BATCH_SUBJECT_FORBIDDEN',
93
+ `Batch mode "${modeId}" does not accept a subject`,
94
+ 'Remove --subject from the command.'
95
+ );
96
+ }
97
+
98
+ // Compile the base brief using the mode's base workflow
99
+ // Force output_count to 1 — each slot produces one primary candidate
100
+ const baseBrief = await compileBrief({
101
+ projectRoot,
102
+ projectId,
103
+ workflowId: mode.base_workflow_id,
104
+ subjectId,
105
+ assetRef,
106
+ overrides: { ...overrides, output_count: 1 },
107
+ });
108
+
109
+ // Add theme to prompt if provided
110
+ if (theme) {
111
+ baseBrief.prompt = `${baseBrief.prompt}, ${theme}`;
112
+ }
113
+
114
+ // Expand into slot briefs
115
+ const slotBriefs = mode.variant_plan.map(slot => applySlotDelta(baseBrief, slot));
116
+
117
+ return { mode, baseBrief, slotBriefs };
118
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Batch modes — load, validate, list batch production mode definitions.
3
+ *
4
+ * A batch mode is a production recipe above the single-brief level.
5
+ * It defines what deliverable is being made, how many coordinated briefs
6
+ * are needed, and how results should be assembled.
7
+ */
8
+
9
+ import { join } from 'node:path';
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { getProjectRoot } from './paths.js';
12
+ import { inputError } from './errors.js';
13
+
14
+ const VALID_BATCH_TYPES = [
15
+ 'expression_sheet', 'variant_board', 'moodboard',
16
+ 'silhouette_grid', 'continuity_pack',
17
+ ];
18
+ const VALID_LAYOUTS = ['grid', 'moodboard', 'sheet', 'strip'];
19
+ const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
20
+
21
+ /**
22
+ * Return the batch modes directory for a project.
23
+ */
24
+ export function getBatchModesDir(projectRoot) {
25
+ return join(projectRoot, 'workflows', 'batch-modes');
26
+ }
27
+
28
+ /**
29
+ * List all batch modes for a project.
30
+ */
31
+ export function listBatchModes(projectRoot) {
32
+ const dir = getBatchModesDir(projectRoot);
33
+ if (!existsSync(dir)) return [];
34
+
35
+ return readdirSync(dir)
36
+ .filter(f => f.endsWith('.json'))
37
+ .map(f => {
38
+ const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
39
+ return {
40
+ mode_id: data.mode_id,
41
+ label: data.label,
42
+ batch_type: data.batch_type,
43
+ base_workflow_id: data.base_workflow_id,
44
+ subject_mode: data.subject_mode || 'optional',
45
+ slot_count: data.variant_plan?.length || 0,
46
+ };
47
+ })
48
+ .sort((a, b) => a.mode_id.localeCompare(b.mode_id));
49
+ }
50
+
51
+ /**
52
+ * Load a batch mode by ID.
53
+ */
54
+ export function getBatchMode(projectRoot, modeId) {
55
+ const dir = getBatchModesDir(projectRoot);
56
+ const filePath = join(dir, `${modeId}.json`);
57
+
58
+ if (!existsSync(filePath)) {
59
+ throw inputError(
60
+ 'BATCH_MODE_NOT_FOUND',
61
+ `Batch mode "${modeId}" not found`,
62
+ `Check workflows/batch-modes/ or run: sdlab batch list`
63
+ );
64
+ }
65
+
66
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
67
+ }
68
+
69
+ /**
70
+ * Validate a batch mode definition.
71
+ * @returns {{ valid: boolean, errors: string[] }}
72
+ */
73
+ export function validateBatchMode(mode) {
74
+ const errors = [];
75
+
76
+ if (!mode.mode_id) errors.push('missing mode_id');
77
+ if (!mode.label) errors.push('missing label');
78
+ if (!mode.base_workflow_id) errors.push('missing base_workflow_id');
79
+
80
+ if (!mode.batch_type || !VALID_BATCH_TYPES.includes(mode.batch_type)) {
81
+ errors.push(`invalid batch_type "${mode.batch_type}" — must be one of: ${VALID_BATCH_TYPES.join(', ')}`);
82
+ }
83
+
84
+ if (mode.subject_mode && !VALID_SUBJECT_MODES.includes(mode.subject_mode)) {
85
+ errors.push(`invalid subject_mode "${mode.subject_mode}"`);
86
+ }
87
+
88
+ if (!Array.isArray(mode.variant_plan) || mode.variant_plan.length === 0) {
89
+ errors.push('variant_plan must be a non-empty array');
90
+ } else {
91
+ const slotIds = new Set();
92
+ for (const slot of mode.variant_plan) {
93
+ if (!slot.slot_id) errors.push('variant slot missing slot_id');
94
+ if (!slot.label) errors.push('variant slot missing label');
95
+ if (slot.slot_id && slotIds.has(slot.slot_id)) {
96
+ errors.push(`duplicate slot_id "${slot.slot_id}"`);
97
+ }
98
+ slotIds.add(slot.slot_id);
99
+ }
100
+ }
101
+
102
+ if (!mode.assembly) {
103
+ errors.push('missing assembly');
104
+ } else {
105
+ if (!mode.assembly.layout || !VALID_LAYOUTS.includes(mode.assembly.layout)) {
106
+ errors.push(`invalid assembly.layout "${mode.assembly?.layout}" — must be one of: ${VALID_LAYOUTS.join(', ')}`);
107
+ }
108
+ }
109
+
110
+ return { valid: errors.length === 0, errors };
111
+ }