@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.
- package/CHANGELOG.md +125 -0
- package/README.md +36 -8
- package/bin/sdlab.js +129 -0
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/batch-compiler.js +118 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +168 -0
- package/lib/batch-sheet-render.js +206 -0
- package/lib/brief-compiler.js +598 -0
- package/lib/brief-render.js +205 -0
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/generated-provenance.js +87 -0
- package/lib/paths.js +31 -0
- package/lib/refine-briefs.js +329 -0
- package/lib/reingest-selected.js +173 -0
- package/lib/run-summary.js +131 -0
- package/lib/runtime-runs.js +168 -0
- package/lib/selections.js +323 -0
- package/lib/workflow-profiles.js +161 -0
- package/package.json +6 -3
- package/scripts/batch-generate.js +167 -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 +52 -0
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/doctor.js +169 -1
- package/scripts/init.js +22 -0
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +7 -1
- 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 +118 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +96 -0
- 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/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/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/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/workflows/profiles/variant-pack.json +60 -0
- 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": "
|
|
4
|
-
"description": "
|
|
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
|
+
}
|