@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1
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.
- package/CHANGELOG.md +178 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +82 -9
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +359 -11
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +122 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +237 -0
- package/lib/batch-sheet-render.js +209 -0
- package/lib/brief-compiler.js +625 -0
- package/lib/brief-render.js +205 -0
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/generated-provenance.js +87 -0
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +72 -3
- package/lib/refine-briefs.js +336 -0
- package/lib/reingest-selected.js +184 -0
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +132 -0
- package/lib/runtime-runs.js +218 -0
- package/lib/selections.js +375 -0
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +28 -14
- package/lib/workflow-profiles.js +161 -0
- package/package.json +13 -4
- package/scripts/batch-generate.js +176 -0
- package/scripts/batch-sheet.js +46 -0
- package/scripts/batch-show.js +86 -0
- package/scripts/brief-compile.js +134 -0
- package/scripts/brief-show.js +53 -0
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +172 -6
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +68 -13
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +137 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +37 -18
- package/scripts/run-generate.js +76 -0
- package/scripts/run-list.js +58 -0
- package/scripts/run-show.js +63 -0
- package/scripts/select.js +119 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +97 -0
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
- package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
- package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
- package/templates/domains/creature-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -0
- package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
- package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
- package/templates/domains/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
- package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
- package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- package/templates/inputs/prompts/example-wave.json +0 -27
|
@@ -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
|
+
}
|
|
@@ -68,8 +68,13 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
|
|
|
68
68
|
const sourcePath = join(projectRoot, record.asset_path);
|
|
69
69
|
if (!existsSync(sourcePath)) continue;
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// D-009: Name output files by record.id (not source basename) to
|
|
72
|
+
// avoid silent collisions when two records share a filename stem.
|
|
73
|
+
const sourceBasename = record.asset_path.split(/[\\/]/).pop();
|
|
74
|
+
const extMatch = sourceBasename.match(/\.[^.]+$/);
|
|
75
|
+
const ext = extMatch ? extMatch[0] : '';
|
|
76
|
+
const filename = `${record.id}${ext}`;
|
|
77
|
+
const stem = record.id;
|
|
73
78
|
const destPath = join(datasetDir, filename);
|
|
74
79
|
|
|
75
80
|
try {
|
|
@@ -57,7 +57,12 @@ export async function buildPackage({ packageDir, partitions, profile, manifest,
|
|
|
57
57
|
const sourcePath = join(projectRoot, record.asset_path);
|
|
58
58
|
if (!existsSync(sourcePath)) continue;
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// D-009: Name output files by record.id (not source basename) to
|
|
61
|
+
// avoid silent collisions when two records share a filename stem.
|
|
62
|
+
const sourceBasename = record.asset_path.split(/[\\/]/).pop();
|
|
63
|
+
const extMatch = sourceBasename.match(/\.[^.]+$/);
|
|
64
|
+
const ext = extMatch ? extMatch[0] : '';
|
|
65
|
+
const filename = `${record.id}${ext}`;
|
|
61
66
|
const destPath = join(datasetDir, filename);
|
|
62
67
|
|
|
63
68
|
try {
|
package/lib/args.js
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { warnDeprecated } from './deprecation.js';
|
|
13
|
+
import { inputError } from './errors.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* @typedef {Object} ArgSpec
|
|
16
17
|
* @property {Object<string, {type: 'string'|'boolean', default?: any, alias?: string}>} flags
|
|
17
18
|
* @property {string[]} [positionals] - names for positional args (in order)
|
|
18
19
|
* @property {Object<string, string>} [deprecated] - old flag name → new flag name
|
|
20
|
+
* @property {boolean} [allowUnknown] - permit unknown flags (default false)
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -28,6 +30,22 @@ export function parseArgs(argv, spec = {}) {
|
|
|
28
30
|
const flagDefs = spec.flags || {};
|
|
29
31
|
const deprecated = spec.deprecated || {};
|
|
30
32
|
const positionalNames = spec.positionals || [];
|
|
33
|
+
const allowUnknown = spec.allowUnknown === true;
|
|
34
|
+
|
|
35
|
+
// Build alias index once; detect duplicate aliases.
|
|
36
|
+
const aliasIndex = {};
|
|
37
|
+
for (const [name, def] of Object.entries(flagDefs)) {
|
|
38
|
+
if (def && def.alias) {
|
|
39
|
+
if (aliasIndex[def.alias]) {
|
|
40
|
+
throw inputError(
|
|
41
|
+
'INPUT_DUPLICATE_ALIAS',
|
|
42
|
+
`Duplicate CLI alias "-${def.alias}" (on both "${aliasIndex[def.alias]}" and "${name}")`,
|
|
43
|
+
'Fix the flag spec: each alias must be unique.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
aliasIndex[def.alias] = name;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
31
49
|
|
|
32
50
|
const flags = {};
|
|
33
51
|
const positionals = [];
|
|
@@ -61,22 +79,44 @@ export function parseArgs(argv, spec = {}) {
|
|
|
61
79
|
key = deprecated[key];
|
|
62
80
|
}
|
|
63
81
|
|
|
64
|
-
// Handle short aliases defined in flagDefs
|
|
65
|
-
|
|
66
|
-
if (canonical) key = canonical[0];
|
|
82
|
+
// Handle short aliases defined in flagDefs (O(1) via prebuilt index)
|
|
83
|
+
if (aliasIndex[key]) key = aliasIndex[key];
|
|
67
84
|
|
|
68
85
|
// Normalize kebab-case to camelCase for lookup
|
|
69
86
|
const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
70
87
|
const def = flagDefs[key] || flagDefs[camelKey];
|
|
71
88
|
|
|
89
|
+
if (!def && !allowUnknown) {
|
|
90
|
+
const known = Object.keys(flagDefs);
|
|
91
|
+
const suggestion = findClosest(key, known);
|
|
92
|
+
const hint = suggestion
|
|
93
|
+
? `Did you mean --${suggestion}? (Run with --help to see all supported flags.)`
|
|
94
|
+
: 'Run with --help to see supported flags.';
|
|
95
|
+
throw inputError(
|
|
96
|
+
'UNKNOWN_FLAG',
|
|
97
|
+
`Unknown flag: --${key}`,
|
|
98
|
+
hint
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const storeKey = key in flagDefs ? key : camelKey;
|
|
103
|
+
|
|
72
104
|
if (def?.type === 'boolean') {
|
|
73
|
-
flags[
|
|
105
|
+
flags[storeKey] = true;
|
|
74
106
|
} else if (value !== undefined) {
|
|
75
|
-
flags[
|
|
107
|
+
flags[storeKey] = value;
|
|
76
108
|
} else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
77
|
-
flags[
|
|
109
|
+
flags[storeKey] = argv[++i];
|
|
110
|
+
} else if (def && def.type === 'string') {
|
|
111
|
+
// String flag with no value — that's a user error.
|
|
112
|
+
throw inputError(
|
|
113
|
+
'INPUT_MISSING_VALUE',
|
|
114
|
+
`Flag --${key} requires a value.`,
|
|
115
|
+
`Pass a value after --${key}.`
|
|
116
|
+
);
|
|
78
117
|
} else {
|
|
79
|
-
|
|
118
|
+
// Unknown flag in allowUnknown mode (no def), treat as boolean-style.
|
|
119
|
+
flags[storeKey] = true;
|
|
80
120
|
}
|
|
81
121
|
} else {
|
|
82
122
|
positionals.push(arg);
|
|
@@ -95,12 +135,159 @@ export function parseArgs(argv, spec = {}) {
|
|
|
95
135
|
|
|
96
136
|
/**
|
|
97
137
|
* Extract the --project (or deprecated --game) value from argv.
|
|
98
|
-
*
|
|
138
|
+
*
|
|
139
|
+
* Returns the explicit value if given. If missing, falls back to
|
|
140
|
+
* 'star-freight' with a LOUD stderr warning — the fallback exists for
|
|
141
|
+
* backward compatibility with v2.x scripts but should never be relied on
|
|
142
|
+
* in new code or documentation.
|
|
143
|
+
*
|
|
144
|
+
* Set SDLAB_QUIET_FALLBACK=1 to silence the warning in scripted pipelines.
|
|
99
145
|
*/
|
|
100
146
|
export function getProjectName(argv) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
147
|
+
// Manual parse (avoids triggering UNKNOWN_FLAG on caller's sibling flags)
|
|
148
|
+
let explicit = null;
|
|
149
|
+
for (let i = 0; i < argv.length; i++) {
|
|
150
|
+
const a = argv[i];
|
|
151
|
+
if (a === '--project' || a === '--game') {
|
|
152
|
+
if (a === '--game') warnDeprecated('game', 'project');
|
|
153
|
+
explicit = argv[i + 1];
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (a.startsWith('--project=')) { explicit = a.slice('--project='.length); break; }
|
|
157
|
+
if (a.startsWith('--game=')) { warnDeprecated('game', 'project'); explicit = a.slice('--game='.length); break; }
|
|
158
|
+
}
|
|
159
|
+
if (explicit) return explicit;
|
|
160
|
+
|
|
161
|
+
// Backward compat: fall back to star-freight but warn LOUDLY.
|
|
162
|
+
if (process.env.SDLAB_QUIET_FALLBACK !== '1') {
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
'\x1b[33m⚠ No --project specified, falling back to "star-freight".\x1b[0m\n' +
|
|
165
|
+
'\x1b[33m Use --project <name> to be explicit (set SDLAB_QUIET_FALLBACK=1 to silence).\x1b[0m\n'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return 'star-freight';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compute Levenshtein edit distance between two strings.
|
|
173
|
+
* Small hand-rolled implementation — O(m*n) time, O(min(m,n)) space.
|
|
174
|
+
*/
|
|
175
|
+
export function levenshtein(a, b) {
|
|
176
|
+
if (a === b) return 0;
|
|
177
|
+
if (!a) return b.length;
|
|
178
|
+
if (!b) return a.length;
|
|
179
|
+
// Keep the shorter string in the inner loop for better memory.
|
|
180
|
+
if (a.length > b.length) { const t = a; a = b; b = t; }
|
|
181
|
+
const prev = new Array(a.length + 1);
|
|
182
|
+
const curr = new Array(a.length + 1);
|
|
183
|
+
for (let i = 0; i <= a.length; i++) prev[i] = i;
|
|
184
|
+
for (let j = 1; j <= b.length; j++) {
|
|
185
|
+
curr[0] = j;
|
|
186
|
+
for (let i = 1; i <= a.length; i++) {
|
|
187
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
188
|
+
curr[i] = Math.min(
|
|
189
|
+
curr[i - 1] + 1, // insertion
|
|
190
|
+
prev[i] + 1, // deletion
|
|
191
|
+
prev[i - 1] + cost // substitution
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
for (let i = 0; i <= a.length; i++) prev[i] = curr[i];
|
|
195
|
+
}
|
|
196
|
+
return prev[a.length];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Find the nearest match to `needle` in `candidates` within `maxDistance`.
|
|
201
|
+
* Returns the closest candidate, or null if nothing qualifies.
|
|
202
|
+
* Used for "did you mean?" hints on unknown flags / commands.
|
|
203
|
+
*/
|
|
204
|
+
export function findClosest(needle, candidates, maxDistance = 2) {
|
|
205
|
+
if (!needle || !Array.isArray(candidates) || candidates.length === 0) return null;
|
|
206
|
+
// Short inputs have little tolerance.
|
|
207
|
+
const cap = needle.length <= 3 ? 1 : maxDistance;
|
|
208
|
+
let best = null;
|
|
209
|
+
let bestDist = Infinity;
|
|
210
|
+
for (const c of candidates) {
|
|
211
|
+
if (typeof c !== 'string') continue;
|
|
212
|
+
const d = levenshtein(needle.toLowerCase(), c.toLowerCase());
|
|
213
|
+
if (d < bestDist && d <= cap) {
|
|
214
|
+
bestDist = d;
|
|
215
|
+
best = c;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return best;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse a CLI value as a number with NaN validation.
|
|
223
|
+
* @param {string} name - flag name, for error messages
|
|
224
|
+
* @param {string|number|undefined} value - the raw flag value
|
|
225
|
+
* @param {{min?:number,max?:number,int?:boolean,default?:number}} opts
|
|
226
|
+
*/
|
|
227
|
+
export function parseNumberFlag(name, value, opts = {}) {
|
|
228
|
+
if (value === undefined || value === null || value === '') {
|
|
229
|
+
if (opts.default !== undefined) return opts.default;
|
|
230
|
+
throw inputError(
|
|
231
|
+
'INPUT_MISSING_VALUE',
|
|
232
|
+
`Flag --${name} requires a numeric value.`,
|
|
233
|
+
`Example: --${name} 4`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const n = opts.int ? Number.parseInt(String(value), 10) : Number.parseFloat(String(value));
|
|
237
|
+
if (!Number.isFinite(n)) {
|
|
238
|
+
throw inputError(
|
|
239
|
+
'INPUT_BAD_NUMBER',
|
|
240
|
+
`Flag --${name} expected a number, got "${value}".`,
|
|
241
|
+
`Pass a ${opts.int ? 'whole number' : 'number'} instead.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (opts.min !== undefined && n < opts.min) {
|
|
245
|
+
throw inputError('INPUT_OUT_OF_RANGE', `--${name} must be ≥ ${opts.min}, got ${n}.`);
|
|
246
|
+
}
|
|
247
|
+
if (opts.max !== undefined && n > opts.max) {
|
|
248
|
+
throw inputError('INPUT_OUT_OF_RANGE', `--${name} must be ≤ ${opts.max}, got ${n}.`);
|
|
249
|
+
}
|
|
250
|
+
return n;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read an indexed-next-arg value from argv but refuse to consume a flag-like token.
|
|
255
|
+
* Useful for legacy scripts still using indexOf/argv[idx+1] patterns — wraps the access
|
|
256
|
+
* with a safety check so `--source --manifest` doesn't silently treat `--manifest` as a path.
|
|
257
|
+
*/
|
|
258
|
+
export function takeFlagValue(argv, flagName) {
|
|
259
|
+
const idx = argv.indexOf(`--${flagName}`);
|
|
260
|
+
if (idx < 0) {
|
|
261
|
+
// Try --flag=value form
|
|
262
|
+
const eq = argv.find(a => a.startsWith(`--${flagName}=`));
|
|
263
|
+
if (eq) return eq.slice(`--${flagName}=`.length);
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const nxt = argv[idx + 1];
|
|
267
|
+
if (nxt === undefined) {
|
|
268
|
+
throw inputError('INPUT_MISSING_VALUE', `Flag --${flagName} requires a value.`);
|
|
269
|
+
}
|
|
270
|
+
if (typeof nxt === 'string' && nxt.startsWith('--')) {
|
|
271
|
+
throw inputError(
|
|
272
|
+
'INPUT_MISSING_VALUE',
|
|
273
|
+
`Flag --${flagName} is missing its value (got another flag: ${nxt}).`,
|
|
274
|
+
`Pass a value after --${flagName}.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return nxt;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Strip the subcommand token at position argv[firstPositionalIndex] by slicing,
|
|
282
|
+
* avoiding the value-based filter bug where a project/id that matches the
|
|
283
|
+
* subcommand string would be stripped too.
|
|
284
|
+
*
|
|
285
|
+
* Returns the args AFTER the first non-flag token. If no non-flag token is
|
|
286
|
+
* found, returns a copy of argv.
|
|
287
|
+
*/
|
|
288
|
+
export function sliceAfterSubcommand(argv, subcommand) {
|
|
289
|
+
if (subcommand == null) return argv.slice();
|
|
290
|
+
const idx = argv.indexOf(subcommand);
|
|
291
|
+
if (idx < 0) return argv.slice();
|
|
292
|
+
return argv.slice(idx + 1);
|
|
106
293
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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 (token-exact match)
|
|
30
|
+
if (slot.negative_additions?.length > 0) {
|
|
31
|
+
const existingLower = modified.negative_prompt
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.split(',')
|
|
34
|
+
.map(s => s.trim())
|
|
35
|
+
.filter(s => s.length > 0);
|
|
36
|
+
const additions = slot.negative_additions.filter(
|
|
37
|
+
n => !existingLower.includes(n.toLowerCase().trim())
|
|
38
|
+
);
|
|
39
|
+
if (additions.length > 0) {
|
|
40
|
+
modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Runtime overrides
|
|
45
|
+
if (slot.runtime_overrides) {
|
|
46
|
+
for (const [key, val] of Object.entries(slot.runtime_overrides)) {
|
|
47
|
+
if (modified.runtime_plan && val !== undefined) {
|
|
48
|
+
modified.runtime_plan[key] = val;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Tag the slot
|
|
54
|
+
modified._batch_slot_id = slot.slot_id;
|
|
55
|
+
modified._batch_slot_label = slot.label;
|
|
56
|
+
|
|
57
|
+
return modified;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compile a full batch — one brief per variant slot.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} opts
|
|
64
|
+
* @param {string} opts.projectRoot — absolute project path
|
|
65
|
+
* @param {string} opts.projectId — project name
|
|
66
|
+
* @param {string} opts.modeId — batch mode ID
|
|
67
|
+
* @param {string} [opts.subjectId] — subject for subject-driven modes
|
|
68
|
+
* @param {string} [opts.theme] — theme label for environment modes
|
|
69
|
+
* @param {string} [opts.assetRef] — optional training asset reference
|
|
70
|
+
* @param {Object} [opts.overrides] — CLI runtime overrides
|
|
71
|
+
* @returns {Promise<{ mode: Object, baseBrief: Object, slotBriefs: Object[] }>}
|
|
72
|
+
*/
|
|
73
|
+
export async function compileBatch({
|
|
74
|
+
projectRoot,
|
|
75
|
+
projectId,
|
|
76
|
+
modeId,
|
|
77
|
+
subjectId,
|
|
78
|
+
theme,
|
|
79
|
+
assetRef,
|
|
80
|
+
overrides = {},
|
|
81
|
+
}) {
|
|
82
|
+
// Load batch mode
|
|
83
|
+
const mode = getBatchMode(projectRoot, modeId);
|
|
84
|
+
|
|
85
|
+
// Enforce subject mode
|
|
86
|
+
const subjectMode = mode.subject_mode || 'optional';
|
|
87
|
+
if (subjectMode === 'required' && !subjectId) {
|
|
88
|
+
throw inputError(
|
|
89
|
+
'BATCH_SUBJECT_REQUIRED',
|
|
90
|
+
`Batch mode "${modeId}" requires a subject`,
|
|
91
|
+
'Add --subject <id> to the command.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (subjectMode === 'forbidden' && subjectId) {
|
|
95
|
+
throw inputError(
|
|
96
|
+
'BATCH_SUBJECT_FORBIDDEN',
|
|
97
|
+
`Batch mode "${modeId}" does not accept a subject`,
|
|
98
|
+
'Remove --subject from the command.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Compile the base brief using the mode's base workflow
|
|
103
|
+
// Force output_count to 1 — each slot produces one primary candidate
|
|
104
|
+
const baseBrief = await compileBrief({
|
|
105
|
+
projectRoot,
|
|
106
|
+
projectId,
|
|
107
|
+
workflowId: mode.base_workflow_id,
|
|
108
|
+
subjectId,
|
|
109
|
+
assetRef,
|
|
110
|
+
overrides: { ...overrides, output_count: 1 },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Add theme to prompt if provided
|
|
114
|
+
if (theme) {
|
|
115
|
+
baseBrief.prompt = `${baseBrief.prompt}, ${theme}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Expand into slot briefs
|
|
119
|
+
const slotBriefs = mode.variant_plan.map(slot => applySlotDelta(baseBrief, slot));
|
|
120
|
+
|
|
121
|
+
return { mode, baseBrief, slotBriefs };
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|