@mcptoolshop/style-dataset-lab 3.0.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 +68 -15
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +46 -1
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +281 -62
- 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 +7 -3
- package/lib/batch-runs.js +100 -31
- package/lib/batch-sheet-render.js +5 -2
- package/lib/brief-compiler.js +38 -11
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- 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/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +41 -3
- package/lib/refine-briefs.js +16 -9
- package/lib/reingest-selected.js +14 -3
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +3 -2
- package/lib/runtime-runs.js +59 -9
- package/lib/selections.js +54 -2
- 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/package.json +8 -2
- package/scripts/batch-generate.js +16 -7
- package/scripts/batch-sheet.js +3 -3
- package/scripts/batch-show.js +3 -3
- package/scripts/brief-compile.js +8 -8
- package/scripts/brief-show.js +4 -3
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +3 -3
- package/scripts/critique.js +3 -3
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +3 -5
- 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 +115 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +3 -3
- package/scripts/reingest-selected.js +3 -3
- package/scripts/reingest.js +33 -20
- package/scripts/run-generate.js +4 -4
- package/scripts/run-list.js +3 -3
- package/scripts/run-show.js +3 -3
- package/scripts/select.js +5 -4
- package/scripts/selection-show.js +3 -3
- 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 +2 -2
- package/scripts/workflow-show.js +4 -3
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- package/templates/domains/character-design/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- 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/game-art/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- package/templates/domains/vehicle-mech/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- 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
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
|
}
|
package/lib/batch-compiler.js
CHANGED
|
@@ -26,11 +26,15 @@ export function applySlotDelta(baseBrief, slot) {
|
|
|
26
26
|
modified.prompt = `${modified.prompt}, ${slot.prompt_additions.join(', ')}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Negative additions — append without duplicating
|
|
29
|
+
// Negative additions — append without duplicating (token-exact match)
|
|
30
30
|
if (slot.negative_additions?.length > 0) {
|
|
31
|
-
const existingLower = modified.negative_prompt
|
|
31
|
+
const existingLower = modified.negative_prompt
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.split(',')
|
|
34
|
+
.map(s => s.trim())
|
|
35
|
+
.filter(s => s.length > 0);
|
|
32
36
|
const additions = slot.negative_additions.filter(
|
|
33
|
-
n => !existingLower.
|
|
37
|
+
n => !existingLower.includes(n.toLowerCase().trim())
|
|
34
38
|
);
|
|
35
39
|
if (additions.length > 0) {
|
|
36
40
|
modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
|
package/lib/batch-runs.js
CHANGED
|
@@ -10,15 +10,32 @@
|
|
|
10
10
|
* briefs/ (slot briefs)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { writeFile, mkdir, copyFile } from 'node:fs/promises';
|
|
13
|
+
import { writeFile, mkdir, copyFile, rename, unlink } from 'node:fs/promises';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
16
|
import { getProjectRoot, getRunsDir } from './paths.js';
|
|
17
17
|
import { executeRun } from './adapters/comfyui-runner.js';
|
|
18
18
|
import { saveRunSummary } from './run-summary.js';
|
|
19
|
-
import { log, info, warn } from './log.js';
|
|
19
|
+
import { log, info, warn, verbose } from './log.js';
|
|
20
20
|
import { inputError } from './errors.js';
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* PB-002 / PB-005: atomic write helper — write temp + rename.
|
|
24
|
+
* Rename is atomic on a single filesystem, so a crash mid-write cannot
|
|
25
|
+
* corrupt an existing manifest.
|
|
26
|
+
*/
|
|
27
|
+
async function atomicWriteJson(targetPath, obj) {
|
|
28
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
29
|
+
const body = JSON.stringify(obj, null, 2) + '\n';
|
|
30
|
+
try {
|
|
31
|
+
await writeFile(tmpPath, body);
|
|
32
|
+
await rename(tmpPath, targetPath);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
try { await unlink(tmpPath); } catch { /* ignore */ }
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* Return the batches directory for a project.
|
|
24
41
|
*/
|
|
@@ -29,6 +46,10 @@ export function getBatchesDir(projectRoot) {
|
|
|
29
46
|
/**
|
|
30
47
|
* Generate batch ID: batch_YYYY-MM-DD_NNN
|
|
31
48
|
*/
|
|
49
|
+
// PB-008: tighten regex so stray directories like 'batch_<date>_001_wip'
|
|
50
|
+
// don't inflate the sequence via lenient parseInt.
|
|
51
|
+
const BATCH_SEQ_RE = /^(\d{3})$/;
|
|
52
|
+
|
|
32
53
|
export function generateBatchId(batchesDir) {
|
|
33
54
|
const today = new Date().toISOString().slice(0, 10);
|
|
34
55
|
const prefix = `batch_${today}_`;
|
|
@@ -39,8 +60,10 @@ export function generateBatchId(batchesDir) {
|
|
|
39
60
|
.filter(e => e.isDirectory() && e.name.startsWith(prefix));
|
|
40
61
|
for (const e of existing) {
|
|
41
62
|
const seqStr = e.name.slice(prefix.length);
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
63
|
+
const match = BATCH_SEQ_RE.exec(seqStr);
|
|
64
|
+
if (!match) continue;
|
|
65
|
+
const seq = parseInt(match[1], 10);
|
|
66
|
+
if (seq > maxSeq) maxSeq = seq;
|
|
44
67
|
}
|
|
45
68
|
}
|
|
46
69
|
|
|
@@ -82,7 +105,20 @@ export async function executeBatchRuns({
|
|
|
82
105
|
slotBriefs,
|
|
83
106
|
dryRun = false,
|
|
84
107
|
}) {
|
|
108
|
+
// PB-005: fail fast on length mismatch instead of crashing deep in the loop.
|
|
109
|
+
if (!Array.isArray(mode?.variant_plan) || mode.variant_plan.length !== slotBriefs.length) {
|
|
110
|
+
throw inputError(
|
|
111
|
+
'BATCH_SLOT_PLAN_MISMATCH',
|
|
112
|
+
`Batch variant_plan has ${mode?.variant_plan?.length ?? 0} slots but ` +
|
|
113
|
+
`${slotBriefs.length} brief(s) were compiled`,
|
|
114
|
+
'This is an internal invariant — rebuild the batch from the mode definition.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
const results = [];
|
|
119
|
+
// PB-005: incremental batch-progress manifest — written after every slot
|
|
120
|
+
// so a crash never orphans the batch dir without a record of what ran.
|
|
121
|
+
const progressPath = join(batchDir, 'manifest.json');
|
|
86
122
|
|
|
87
123
|
for (let i = 0; i < slotBriefs.length; i++) {
|
|
88
124
|
const slot = mode.variant_plan[i];
|
|
@@ -98,29 +134,63 @@ export async function executeBatchRuns({
|
|
|
98
134
|
JSON.stringify(brief, null, 2) + '\n'
|
|
99
135
|
);
|
|
100
136
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
137
|
+
// PB-005: per-slot try/catch — one slot failure no longer throws out of
|
|
138
|
+
// the whole batch. Record the error and keep going so the user doesn't
|
|
139
|
+
// lose already-completed slots.
|
|
140
|
+
try {
|
|
141
|
+
// Execute the run through the existing 4B pipeline
|
|
142
|
+
const manifest = await executeRun({
|
|
143
|
+
projectRoot,
|
|
144
|
+
projectName,
|
|
145
|
+
brief,
|
|
146
|
+
dryRun,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Save run summary
|
|
150
|
+
const runDir = join(getRunsDir(projectRoot), manifest.run_id);
|
|
151
|
+
await saveRunSummary(runDir, manifest);
|
|
152
|
+
|
|
153
|
+
results.push({
|
|
154
|
+
slot_id: slot.slot_id,
|
|
155
|
+
label: slotLabel,
|
|
156
|
+
brief_id: brief.brief_id,
|
|
157
|
+
run_id: manifest.run_id,
|
|
158
|
+
manifest,
|
|
159
|
+
status: 'ok',
|
|
160
|
+
// Pick the first successful output as the selected one
|
|
161
|
+
selected_output: manifest.outputs.find(o =>
|
|
162
|
+
o.status === 'ok' || o.status === 'dry_run'
|
|
163
|
+
)?.filename || null,
|
|
164
|
+
});
|
|
165
|
+
} catch (err) {
|
|
166
|
+
warn(` ✗ slot ${slotLabel} failed: ${err.message}`);
|
|
167
|
+
results.push({
|
|
168
|
+
slot_id: slot.slot_id,
|
|
169
|
+
label: slotLabel,
|
|
170
|
+
brief_id: brief.brief_id,
|
|
171
|
+
run_id: null,
|
|
172
|
+
manifest: null,
|
|
173
|
+
status: 'error',
|
|
174
|
+
error: err.message,
|
|
175
|
+
selected_output: null,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// PB-005: checkpoint after every slot (atomic write).
|
|
180
|
+
try {
|
|
181
|
+
await atomicWriteJson(progressPath, {
|
|
182
|
+
batch_dir: batchDir,
|
|
183
|
+
mode_id: mode?.mode_id ?? mode?.id ?? null,
|
|
184
|
+
incremental: true,
|
|
185
|
+
last_checkpoint_at: new Date().toISOString(),
|
|
186
|
+
completed: i + 1,
|
|
187
|
+
total: slotBriefs.length,
|
|
188
|
+
slots: results,
|
|
189
|
+
});
|
|
190
|
+
verbose(`batch checkpoint: ${i + 1}/${slotBriefs.length} slots`);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
warn(` batch checkpoint write failed: ${err.message}`);
|
|
193
|
+
}
|
|
124
194
|
}
|
|
125
195
|
|
|
126
196
|
return results;
|
|
@@ -130,10 +200,9 @@ export async function executeBatchRuns({
|
|
|
130
200
|
* Save a batch manifest.
|
|
131
201
|
*/
|
|
132
202
|
export async function saveBatchManifest(batchDir, manifest) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
);
|
|
203
|
+
// PB-002: atomic write — safe to overwrite an in-progress checkpoint
|
|
204
|
+
// because rename is atomic on a single filesystem.
|
|
205
|
+
await atomicWriteJson(join(batchDir, 'manifest.json'), manifest);
|
|
137
206
|
}
|
|
138
207
|
|
|
139
208
|
/**
|
|
@@ -41,12 +41,15 @@ function resolveImagePath(projectRoot, result, batchDir) {
|
|
|
41
41
|
export function renderSheetHTML({ manifest, mode, projectRoot, batchDir }) {
|
|
42
42
|
const assembly = mode.assembly || {};
|
|
43
43
|
const layout = assembly.layout || 'grid';
|
|
44
|
-
const
|
|
44
|
+
const slots = manifest.slots || [];
|
|
45
|
+
const rawColumns = assembly.columns;
|
|
46
|
+
const columns = (typeof rawColumns === 'number' && rawColumns > 0)
|
|
47
|
+
? rawColumns
|
|
48
|
+
: computeColumns(slots.length);
|
|
45
49
|
const showSlotLabels = assembly.annotate_slots !== false;
|
|
46
50
|
const showSeed = assembly.show_seed || false;
|
|
47
51
|
|
|
48
52
|
const title = `${manifest.mode_id} — ${manifest.subject_id || manifest.theme || manifest.project_id}`;
|
|
49
|
-
const slots = manifest.slots || [];
|
|
50
53
|
|
|
51
54
|
// Build slot cells
|
|
52
55
|
const cells = slots.map(slot => {
|
package/lib/brief-compiler.js
CHANGED
|
@@ -47,6 +47,24 @@ function generateBriefId(briefsDir) {
|
|
|
47
47
|
return `${prefix}${seq}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// ─── Canonical JSON ──────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Recursively canonicalize an object: sort keys at every nesting level.
|
|
54
|
+
* Arrays retain their order (order is semantically meaningful for arrays).
|
|
55
|
+
* Primitives pass through untouched.
|
|
56
|
+
*/
|
|
57
|
+
export function canonicalize(value) {
|
|
58
|
+
if (value === null || typeof value !== 'object') return value;
|
|
59
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
60
|
+
const sortedKeys = Object.keys(value).sort();
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const key of sortedKeys) {
|
|
63
|
+
out[key] = canonicalize(value[key]);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
// ─── Config fingerprint ──────────────────────────────────────────
|
|
51
69
|
|
|
52
70
|
/**
|
|
@@ -67,9 +85,9 @@ function computeBriefFingerprint(projectRoot, workflow, subjectRecords) {
|
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
|
|
70
|
-
// Workflow profile (serialized deterministically)
|
|
88
|
+
// Workflow profile (serialized deterministically, recursively key-sorted)
|
|
71
89
|
hash.update('--- workflow ---\n');
|
|
72
|
-
hash.update(JSON.stringify(
|
|
90
|
+
hash.update(JSON.stringify(canonicalize(workflow)));
|
|
73
91
|
hash.update('\n');
|
|
74
92
|
|
|
75
93
|
// Subject records (sorted by ID)
|
|
@@ -385,16 +403,16 @@ function buildDriftWarnings(workflow, config) {
|
|
|
385
403
|
/**
|
|
386
404
|
* Build the runtime plan — merge workflow defaults with CLI overrides.
|
|
387
405
|
*/
|
|
388
|
-
function buildRuntimePlan(workflow, overrides = {}) {
|
|
406
|
+
export function buildRuntimePlan(workflow, overrides = {}) {
|
|
389
407
|
const rd = workflow.runtime_defaults || {};
|
|
390
408
|
return {
|
|
391
|
-
adapter_target: overrides.adapter_target
|
|
392
|
-
width: overrides.width
|
|
393
|
-
height: overrides.height
|
|
394
|
-
steps: overrides.steps
|
|
395
|
-
cfg: overrides.cfg
|
|
396
|
-
sampler: overrides.sampler
|
|
397
|
-
seed_mode: overrides.seed_mode
|
|
409
|
+
adapter_target: overrides.adapter_target ?? rd.adapter_target ?? 'comfyui',
|
|
410
|
+
width: overrides.width ?? rd.width ?? 1024,
|
|
411
|
+
height: overrides.height ?? rd.height ?? 1024,
|
|
412
|
+
steps: overrides.steps ?? rd.steps ?? 30,
|
|
413
|
+
cfg: overrides.cfg ?? rd.cfg ?? 6.5,
|
|
414
|
+
sampler: overrides.sampler ?? rd.sampler ?? 'dpmpp_2m',
|
|
415
|
+
seed_mode: overrides.seed_mode ?? rd.seed_mode ?? 'increment',
|
|
398
416
|
};
|
|
399
417
|
}
|
|
400
418
|
|
|
@@ -427,6 +445,15 @@ export async function compileBrief({
|
|
|
427
445
|
// Load workflow profile
|
|
428
446
|
const workflow = await getWorkflowProfile(projectRoot, workflowId);
|
|
429
447
|
|
|
448
|
+
// Validate required workflow fields
|
|
449
|
+
if (typeof workflow.output_mode !== 'string' || workflow.output_mode.length === 0) {
|
|
450
|
+
throw inputError(
|
|
451
|
+
'WORKFLOW_INVALID',
|
|
452
|
+
`Workflow "${workflowId}" is missing a non-empty "output_mode"`,
|
|
453
|
+
`Check the workflow profile JSON — output_mode must be a non-empty string.`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
430
457
|
// Enforce subject_mode
|
|
431
458
|
const subjectMode = workflow.subject_mode || 'optional';
|
|
432
459
|
if (subjectMode === 'required' && !subjectId) {
|
|
@@ -486,7 +513,7 @@ export async function compileBrief({
|
|
|
486
513
|
// Expected outputs
|
|
487
514
|
const expectedOutputs = {
|
|
488
515
|
output_mode: workflow.output_mode,
|
|
489
|
-
output_count: overrides.output_count
|
|
516
|
+
output_count: overrides.output_count ?? workflow.output_count,
|
|
490
517
|
review_surface: workflow.output_mode.replace(/_/g, ' '),
|
|
491
518
|
};
|
|
492
519
|
|
package/lib/card.js
CHANGED
|
@@ -70,6 +70,13 @@ export async function generateCard(projectRoot, snapshotId, splitId) {
|
|
|
70
70
|
return { markdown, json };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// D-025: safe percentage formatter — renders '—' for zero-total cases
|
|
74
|
+
// instead of 'NaN%'.
|
|
75
|
+
function pct(numerator, denominator) {
|
|
76
|
+
if (!denominator || denominator <= 0) return '—';
|
|
77
|
+
return `${(numerator / denominator * 100).toFixed(1)}%`;
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
function renderMarkdown(card, config) {
|
|
74
81
|
const lines = [];
|
|
75
82
|
const add = (s = '') => lines.push(s);
|
|
@@ -87,7 +94,7 @@ function renderMarkdown(card, config) {
|
|
|
87
94
|
add(`- **Snapshot:** \`${card.snapshot.id}\``);
|
|
88
95
|
add(`- **Config fingerprint:** \`${card.snapshot.config_fingerprint.slice(0, 16)}...\``);
|
|
89
96
|
add(`- **Total records evaluated:** ${card.snapshot.total_records}`);
|
|
90
|
-
add(`- **Included:** ${card.snapshot.included} (${(card.snapshot.included
|
|
97
|
+
add(`- **Included:** ${card.snapshot.included} (${pct(card.snapshot.included, card.snapshot.total_records)})`);
|
|
91
98
|
add(`- **Excluded:** ${card.snapshot.excluded}`);
|
|
92
99
|
add();
|
|
93
100
|
|
|
@@ -111,9 +118,9 @@ function renderMarkdown(card, config) {
|
|
|
111
118
|
add('| Partition | Records | % |');
|
|
112
119
|
add('|-----------|---------|---|');
|
|
113
120
|
const total = card.split.counts.total_records;
|
|
114
|
-
add(`| Train | ${card.split.counts.train} | ${(card.split.counts.train
|
|
115
|
-
add(`| Val | ${card.split.counts.val} | ${(card.split.counts.val
|
|
116
|
-
add(`| Test | ${card.split.counts.test} | ${(card.split.counts.test
|
|
121
|
+
add(`| Train | ${card.split.counts.train} | ${pct(card.split.counts.train, total)} |`);
|
|
122
|
+
add(`| Val | ${card.split.counts.val} | ${pct(card.split.counts.val, total)} |`);
|
|
123
|
+
add(`| Test | ${card.split.counts.test} | ${pct(card.split.counts.test, total)} |`);
|
|
117
124
|
add();
|
|
118
125
|
|
|
119
126
|
add('## Lane Balance');
|