@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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComfyUI workflow adapter — builds executable ComfyUI prompt graphs from briefs.
|
|
3
|
+
*
|
|
4
|
+
* The brief carries everything needed: prompt, negative, runtime plan,
|
|
5
|
+
* plus project defaults for checkpoint/LoRAs. This module translates
|
|
6
|
+
* that into a ComfyUI node graph.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { getRuntimeDir } from '../paths.js';
|
|
13
|
+
|
|
14
|
+
// ─── Template registry ───────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* List available workflow templates from runtime/comfyui/.
|
|
18
|
+
*/
|
|
19
|
+
export function listWorkflowTemplates() {
|
|
20
|
+
const dir = join(getRuntimeDir(), 'comfyui');
|
|
21
|
+
if (!existsSync(dir)) return [];
|
|
22
|
+
return readdirSync(dir)
|
|
23
|
+
.filter(f => f.endsWith('.json'))
|
|
24
|
+
.map(f => {
|
|
25
|
+
const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
|
|
26
|
+
return {
|
|
27
|
+
template_id: data.template_id,
|
|
28
|
+
label: data.label,
|
|
29
|
+
compatible_modes: data.compatible_modes || [],
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a template for a given output mode.
|
|
36
|
+
* Returns 'txt2img-standard' for all standard modes.
|
|
37
|
+
* Throws if no template matches.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveTemplateId(outputMode) {
|
|
40
|
+
const templates = listWorkflowTemplates();
|
|
41
|
+
const match = templates.find(t => t.compatible_modes.includes(outputMode));
|
|
42
|
+
if (match) return match.template_id;
|
|
43
|
+
// Default fallback for unknown modes
|
|
44
|
+
return 'txt2img-standard';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Graph builder ───────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a ComfyUI prompt graph from a compiled brief + project config.
|
|
51
|
+
*
|
|
52
|
+
* @param {Object} opts
|
|
53
|
+
* @param {Object} opts.brief — compiled brief from brief-compiler
|
|
54
|
+
* @param {Object} opts.projectDefaults — project.json defaults block
|
|
55
|
+
* @param {number} opts.seed — specific seed for this generation
|
|
56
|
+
* @returns {{ nodes: Object, saveNodeId: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function buildWorkflowGraph({ brief, projectDefaults, seed }) {
|
|
59
|
+
const rp = brief.runtime_plan;
|
|
60
|
+
const defaults = projectDefaults;
|
|
61
|
+
|
|
62
|
+
const checkpoint = defaults.checkpoint;
|
|
63
|
+
const loras = defaults.loras || [];
|
|
64
|
+
const prompt = brief.prompt;
|
|
65
|
+
const negativePrompt = brief.negative_prompt;
|
|
66
|
+
const width = rp.width || defaults.width || 1024;
|
|
67
|
+
const height = rp.height || defaults.height || 1024;
|
|
68
|
+
const steps = rp.steps || defaults.steps || 30;
|
|
69
|
+
const cfg = rp.cfg || defaults.cfg || 6.5;
|
|
70
|
+
const sampler = rp.sampler || defaults.sampler || 'dpmpp_2m';
|
|
71
|
+
const scheduler = rp.scheduler || defaults.scheduler || 'karras';
|
|
72
|
+
|
|
73
|
+
const nodes = {};
|
|
74
|
+
let nextId = 1;
|
|
75
|
+
|
|
76
|
+
// Checkpoint loader
|
|
77
|
+
const ckptId = String(nextId++);
|
|
78
|
+
nodes[ckptId] = {
|
|
79
|
+
class_type: 'CheckpointLoaderSimple',
|
|
80
|
+
inputs: { ckpt_name: checkpoint },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
let modelOut = [ckptId, 0];
|
|
84
|
+
let clipOut = [ckptId, 1];
|
|
85
|
+
|
|
86
|
+
// LoRA loaders (chained)
|
|
87
|
+
for (const lora of loras) {
|
|
88
|
+
const loraId = String(nextId++);
|
|
89
|
+
nodes[loraId] = {
|
|
90
|
+
class_type: 'LoraLoader',
|
|
91
|
+
inputs: {
|
|
92
|
+
model: modelOut,
|
|
93
|
+
clip: clipOut,
|
|
94
|
+
lora_name: lora.name,
|
|
95
|
+
strength_model: lora.weight,
|
|
96
|
+
strength_clip: lora.clip_weight ?? lora.weight,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
modelOut = [loraId, 0];
|
|
100
|
+
clipOut = [loraId, 1];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// CLIP Text Encode — positive
|
|
104
|
+
const posId = String(nextId++);
|
|
105
|
+
nodes[posId] = {
|
|
106
|
+
class_type: 'CLIPTextEncode',
|
|
107
|
+
inputs: { text: prompt, clip: clipOut },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// CLIP Text Encode — negative
|
|
111
|
+
const negId = String(nextId++);
|
|
112
|
+
nodes[negId] = {
|
|
113
|
+
class_type: 'CLIPTextEncode',
|
|
114
|
+
inputs: { text: negativePrompt, clip: clipOut },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Empty latent image
|
|
118
|
+
const latentId = String(nextId++);
|
|
119
|
+
nodes[latentId] = {
|
|
120
|
+
class_type: 'EmptyLatentImage',
|
|
121
|
+
inputs: { width, height, batch_size: 1 },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// KSampler
|
|
125
|
+
const samplerId = String(nextId++);
|
|
126
|
+
nodes[samplerId] = {
|
|
127
|
+
class_type: 'KSampler',
|
|
128
|
+
inputs: {
|
|
129
|
+
model: modelOut,
|
|
130
|
+
positive: [posId, 0],
|
|
131
|
+
negative: [negId, 0],
|
|
132
|
+
latent_image: [latentId, 0],
|
|
133
|
+
seed,
|
|
134
|
+
steps,
|
|
135
|
+
cfg,
|
|
136
|
+
sampler_name: sampler,
|
|
137
|
+
scheduler,
|
|
138
|
+
denoise: 1.0,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// VAE Decode
|
|
143
|
+
const vaeId = String(nextId++);
|
|
144
|
+
nodes[vaeId] = {
|
|
145
|
+
class_type: 'VAEDecode',
|
|
146
|
+
inputs: {
|
|
147
|
+
samples: [samplerId, 0],
|
|
148
|
+
vae: [ckptId, 2],
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Save Image
|
|
153
|
+
const saveId = String(nextId++);
|
|
154
|
+
nodes[saveId] = {
|
|
155
|
+
class_type: 'SaveImage',
|
|
156
|
+
inputs: {
|
|
157
|
+
images: [vaeId, 0],
|
|
158
|
+
filename_prefix: 'sdl_run',
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return { nodes, saveNodeId: saveId };
|
|
163
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch compiler — expand a batch mode into multiple coordinated briefs.
|
|
3
|
+
*
|
|
4
|
+
* Takes a batch mode + subject/theme inputs and produces one brief per
|
|
5
|
+
* variant slot, each with controlled prompt/negative/runtime deltas
|
|
6
|
+
* on top of a base compiled brief from 4A.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { compileBrief, saveCompiledBrief } from './brief-compiler.js';
|
|
10
|
+
import { getBatchMode } from './batch-modes.js';
|
|
11
|
+
import { getWorkflowProfile } from './workflow-profiles.js';
|
|
12
|
+
import { inputError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Apply slot-level deltas to a base brief.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} baseBrief — compiled brief from brief-compiler
|
|
18
|
+
* @param {Object} slot — variant_plan slot definition
|
|
19
|
+
* @returns {Object} — modified brief (new object, base untouched)
|
|
20
|
+
*/
|
|
21
|
+
export function applySlotDelta(baseBrief, slot) {
|
|
22
|
+
const modified = JSON.parse(JSON.stringify(baseBrief));
|
|
23
|
+
|
|
24
|
+
// Prompt additions — append to the end
|
|
25
|
+
if (slot.prompt_additions?.length > 0) {
|
|
26
|
+
modified.prompt = `${modified.prompt}, ${slot.prompt_additions.join(', ')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Negative additions — append without duplicating
|
|
30
|
+
if (slot.negative_additions?.length > 0) {
|
|
31
|
+
const existingLower = modified.negative_prompt.toLowerCase().split(',').map(s => s.trim());
|
|
32
|
+
const additions = slot.negative_additions.filter(
|
|
33
|
+
n => !existingLower.some(e => e.includes(n.toLowerCase()))
|
|
34
|
+
);
|
|
35
|
+
if (additions.length > 0) {
|
|
36
|
+
modified.negative_prompt = `${modified.negative_prompt}, ${additions.join(', ')}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Runtime overrides
|
|
41
|
+
if (slot.runtime_overrides) {
|
|
42
|
+
for (const [key, val] of Object.entries(slot.runtime_overrides)) {
|
|
43
|
+
if (modified.runtime_plan && val !== undefined) {
|
|
44
|
+
modified.runtime_plan[key] = val;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Tag the slot
|
|
50
|
+
modified._batch_slot_id = slot.slot_id;
|
|
51
|
+
modified._batch_slot_label = slot.label;
|
|
52
|
+
|
|
53
|
+
return modified;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compile a full batch — one brief per variant slot.
|
|
58
|
+
*
|
|
59
|
+
* @param {Object} opts
|
|
60
|
+
* @param {string} opts.projectRoot — absolute project path
|
|
61
|
+
* @param {string} opts.projectId — project name
|
|
62
|
+
* @param {string} opts.modeId — batch mode ID
|
|
63
|
+
* @param {string} [opts.subjectId] — subject for subject-driven modes
|
|
64
|
+
* @param {string} [opts.theme] — theme label for environment modes
|
|
65
|
+
* @param {string} [opts.assetRef] — optional training asset reference
|
|
66
|
+
* @param {Object} [opts.overrides] — CLI runtime overrides
|
|
67
|
+
* @returns {Promise<{ mode: Object, baseBrief: Object, slotBriefs: Object[] }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function compileBatch({
|
|
70
|
+
projectRoot,
|
|
71
|
+
projectId,
|
|
72
|
+
modeId,
|
|
73
|
+
subjectId,
|
|
74
|
+
theme,
|
|
75
|
+
assetRef,
|
|
76
|
+
overrides = {},
|
|
77
|
+
}) {
|
|
78
|
+
// Load batch mode
|
|
79
|
+
const mode = getBatchMode(projectRoot, modeId);
|
|
80
|
+
|
|
81
|
+
// Enforce subject mode
|
|
82
|
+
const subjectMode = mode.subject_mode || 'optional';
|
|
83
|
+
if (subjectMode === 'required' && !subjectId) {
|
|
84
|
+
throw inputError(
|
|
85
|
+
'BATCH_SUBJECT_REQUIRED',
|
|
86
|
+
`Batch mode "${modeId}" requires a subject`,
|
|
87
|
+
'Add --subject <id> to the command.'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (subjectMode === 'forbidden' && subjectId) {
|
|
91
|
+
throw inputError(
|
|
92
|
+
'BATCH_SUBJECT_FORBIDDEN',
|
|
93
|
+
`Batch mode "${modeId}" does not accept a subject`,
|
|
94
|
+
'Remove --subject from the command.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Compile the base brief using the mode's base workflow
|
|
99
|
+
// Force output_count to 1 — each slot produces one primary candidate
|
|
100
|
+
const baseBrief = await compileBrief({
|
|
101
|
+
projectRoot,
|
|
102
|
+
projectId,
|
|
103
|
+
workflowId: mode.base_workflow_id,
|
|
104
|
+
subjectId,
|
|
105
|
+
assetRef,
|
|
106
|
+
overrides: { ...overrides, output_count: 1 },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Add theme to prompt if provided
|
|
110
|
+
if (theme) {
|
|
111
|
+
baseBrief.prompt = `${baseBrief.prompt}, ${theme}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Expand into slot briefs
|
|
115
|
+
const slotBriefs = mode.variant_plan.map(slot => applySlotDelta(baseBrief, slot));
|
|
116
|
+
|
|
117
|
+
return { mode, baseBrief, slotBriefs };
|
|
118
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch modes — load, validate, list batch production mode definitions.
|
|
3
|
+
*
|
|
4
|
+
* A batch mode is a production recipe above the single-brief level.
|
|
5
|
+
* It defines what deliverable is being made, how many coordinated briefs
|
|
6
|
+
* are needed, and how results should be assembled.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { getProjectRoot } from './paths.js';
|
|
12
|
+
import { inputError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
const VALID_BATCH_TYPES = [
|
|
15
|
+
'expression_sheet', 'variant_board', 'moodboard',
|
|
16
|
+
'silhouette_grid', 'continuity_pack',
|
|
17
|
+
];
|
|
18
|
+
const VALID_LAYOUTS = ['grid', 'moodboard', 'sheet', 'strip'];
|
|
19
|
+
const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Return the batch modes directory for a project.
|
|
23
|
+
*/
|
|
24
|
+
export function getBatchModesDir(projectRoot) {
|
|
25
|
+
return join(projectRoot, 'workflows', 'batch-modes');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* List all batch modes for a project.
|
|
30
|
+
*/
|
|
31
|
+
export function listBatchModes(projectRoot) {
|
|
32
|
+
const dir = getBatchModesDir(projectRoot);
|
|
33
|
+
if (!existsSync(dir)) return [];
|
|
34
|
+
|
|
35
|
+
return readdirSync(dir)
|
|
36
|
+
.filter(f => f.endsWith('.json'))
|
|
37
|
+
.map(f => {
|
|
38
|
+
const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
|
|
39
|
+
return {
|
|
40
|
+
mode_id: data.mode_id,
|
|
41
|
+
label: data.label,
|
|
42
|
+
batch_type: data.batch_type,
|
|
43
|
+
base_workflow_id: data.base_workflow_id,
|
|
44
|
+
subject_mode: data.subject_mode || 'optional',
|
|
45
|
+
slot_count: data.variant_plan?.length || 0,
|
|
46
|
+
};
|
|
47
|
+
})
|
|
48
|
+
.sort((a, b) => a.mode_id.localeCompare(b.mode_id));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load a batch mode by ID.
|
|
53
|
+
*/
|
|
54
|
+
export function getBatchMode(projectRoot, modeId) {
|
|
55
|
+
const dir = getBatchModesDir(projectRoot);
|
|
56
|
+
const filePath = join(dir, `${modeId}.json`);
|
|
57
|
+
|
|
58
|
+
if (!existsSync(filePath)) {
|
|
59
|
+
throw inputError(
|
|
60
|
+
'BATCH_MODE_NOT_FOUND',
|
|
61
|
+
`Batch mode "${modeId}" not found`,
|
|
62
|
+
`Check workflows/batch-modes/ or run: sdlab batch list`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate a batch mode definition.
|
|
71
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
72
|
+
*/
|
|
73
|
+
export function validateBatchMode(mode) {
|
|
74
|
+
const errors = [];
|
|
75
|
+
|
|
76
|
+
if (!mode.mode_id) errors.push('missing mode_id');
|
|
77
|
+
if (!mode.label) errors.push('missing label');
|
|
78
|
+
if (!mode.base_workflow_id) errors.push('missing base_workflow_id');
|
|
79
|
+
|
|
80
|
+
if (!mode.batch_type || !VALID_BATCH_TYPES.includes(mode.batch_type)) {
|
|
81
|
+
errors.push(`invalid batch_type "${mode.batch_type}" — must be one of: ${VALID_BATCH_TYPES.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (mode.subject_mode && !VALID_SUBJECT_MODES.includes(mode.subject_mode)) {
|
|
85
|
+
errors.push(`invalid subject_mode "${mode.subject_mode}"`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!Array.isArray(mode.variant_plan) || mode.variant_plan.length === 0) {
|
|
89
|
+
errors.push('variant_plan must be a non-empty array');
|
|
90
|
+
} else {
|
|
91
|
+
const slotIds = new Set();
|
|
92
|
+
for (const slot of mode.variant_plan) {
|
|
93
|
+
if (!slot.slot_id) errors.push('variant slot missing slot_id');
|
|
94
|
+
if (!slot.label) errors.push('variant slot missing label');
|
|
95
|
+
if (slot.slot_id && slotIds.has(slot.slot_id)) {
|
|
96
|
+
errors.push(`duplicate slot_id "${slot.slot_id}"`);
|
|
97
|
+
}
|
|
98
|
+
slotIds.add(slot.slot_id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!mode.assembly) {
|
|
103
|
+
errors.push('missing assembly');
|
|
104
|
+
} else {
|
|
105
|
+
if (!mode.assembly.layout || !VALID_LAYOUTS.includes(mode.assembly.layout)) {
|
|
106
|
+
errors.push(`invalid assembly.layout "${mode.assembly?.layout}" — must be one of: ${VALID_LAYOUTS.join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { valid: errors.length === 0, errors };
|
|
111
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch runs — create batch directories, execute coordinated runs, save manifests.
|
|
3
|
+
*
|
|
4
|
+
* A batch groups multiple runs under one artifact:
|
|
5
|
+
* batch_YYYY-MM-DD_NNN/
|
|
6
|
+
* manifest.json
|
|
7
|
+
* summary.json / summary.md
|
|
8
|
+
* sheets/
|
|
9
|
+
* runs/ (symlinks or copies to project-level runs)
|
|
10
|
+
* briefs/ (slot briefs)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { writeFile, mkdir, copyFile } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { getProjectRoot, getRunsDir } from './paths.js';
|
|
17
|
+
import { executeRun } from './adapters/comfyui-runner.js';
|
|
18
|
+
import { saveRunSummary } from './run-summary.js';
|
|
19
|
+
import { log, info, warn } from './log.js';
|
|
20
|
+
import { inputError } from './errors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Return the batches directory for a project.
|
|
24
|
+
*/
|
|
25
|
+
export function getBatchesDir(projectRoot) {
|
|
26
|
+
return join(projectRoot, 'batches');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate batch ID: batch_YYYY-MM-DD_NNN
|
|
31
|
+
*/
|
|
32
|
+
export function generateBatchId(batchesDir) {
|
|
33
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
34
|
+
const prefix = `batch_${today}_`;
|
|
35
|
+
|
|
36
|
+
let maxSeq = 0;
|
|
37
|
+
if (existsSync(batchesDir)) {
|
|
38
|
+
const existing = readdirSync(batchesDir, { withFileTypes: true })
|
|
39
|
+
.filter(e => e.isDirectory() && e.name.startsWith(prefix));
|
|
40
|
+
for (const e of existing) {
|
|
41
|
+
const seqStr = e.name.slice(prefix.length);
|
|
42
|
+
const seq = parseInt(seqStr, 10);
|
|
43
|
+
if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const seq = String(maxSeq + 1).padStart(3, '0');
|
|
48
|
+
return `${prefix}${seq}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a batch directory and prepare its structure.
|
|
53
|
+
*/
|
|
54
|
+
export async function createBatchDir(projectRoot) {
|
|
55
|
+
const batchesDir = getBatchesDir(projectRoot);
|
|
56
|
+
const batchId = generateBatchId(batchesDir);
|
|
57
|
+
const batchDir = join(batchesDir, batchId);
|
|
58
|
+
|
|
59
|
+
await mkdir(join(batchDir, 'sheets'), { recursive: true });
|
|
60
|
+
await mkdir(join(batchDir, 'briefs'), { recursive: true });
|
|
61
|
+
|
|
62
|
+
return { batchId, batchDir };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Execute all slot briefs as coordinated runs.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} opts
|
|
69
|
+
* @param {string} opts.projectRoot
|
|
70
|
+
* @param {string} opts.projectName
|
|
71
|
+
* @param {string} opts.batchDir
|
|
72
|
+
* @param {Object} opts.mode — batch mode definition
|
|
73
|
+
* @param {Object[]} opts.slotBriefs — one brief per slot
|
|
74
|
+
* @param {boolean} [opts.dryRun]
|
|
75
|
+
* @returns {Promise<Object[]>} — array of { slot, brief, manifest }
|
|
76
|
+
*/
|
|
77
|
+
export async function executeBatchRuns({
|
|
78
|
+
projectRoot,
|
|
79
|
+
projectName,
|
|
80
|
+
batchDir,
|
|
81
|
+
mode,
|
|
82
|
+
slotBriefs,
|
|
83
|
+
dryRun = false,
|
|
84
|
+
}) {
|
|
85
|
+
const results = [];
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < slotBriefs.length; i++) {
|
|
88
|
+
const slot = mode.variant_plan[i];
|
|
89
|
+
const brief = slotBriefs[i];
|
|
90
|
+
const slotLabel = slot.label || slot.slot_id;
|
|
91
|
+
|
|
92
|
+
info(`[${i + 1}/${slotBriefs.length}] Slot: ${slotLabel}`);
|
|
93
|
+
|
|
94
|
+
// Save brief into batch/briefs/
|
|
95
|
+
const briefFilename = `${slot.slot_id}.json`;
|
|
96
|
+
await writeFile(
|
|
97
|
+
join(batchDir, 'briefs', briefFilename),
|
|
98
|
+
JSON.stringify(brief, null, 2) + '\n'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Execute the run through the existing 4B pipeline
|
|
102
|
+
const manifest = await executeRun({
|
|
103
|
+
projectRoot,
|
|
104
|
+
projectName,
|
|
105
|
+
brief,
|
|
106
|
+
dryRun,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Save run summary
|
|
110
|
+
const runDir = join(getRunsDir(projectRoot), manifest.run_id);
|
|
111
|
+
await saveRunSummary(runDir, manifest);
|
|
112
|
+
|
|
113
|
+
results.push({
|
|
114
|
+
slot_id: slot.slot_id,
|
|
115
|
+
label: slotLabel,
|
|
116
|
+
brief_id: brief.brief_id,
|
|
117
|
+
run_id: manifest.run_id,
|
|
118
|
+
manifest,
|
|
119
|
+
// Pick the first successful output as the selected one
|
|
120
|
+
selected_output: manifest.outputs.find(o =>
|
|
121
|
+
o.status === 'ok' || o.status === 'dry_run'
|
|
122
|
+
)?.filename || null,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Save a batch manifest.
|
|
131
|
+
*/
|
|
132
|
+
export async function saveBatchManifest(batchDir, manifest) {
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(batchDir, 'manifest.json'),
|
|
135
|
+
JSON.stringify(manifest, null, 2) + '\n'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Load a batch manifest by ID.
|
|
141
|
+
*/
|
|
142
|
+
export function loadBatchManifest(projectRoot, batchId) {
|
|
143
|
+
const batchDir = join(getBatchesDir(projectRoot), batchId);
|
|
144
|
+
const manifestPath = join(batchDir, 'manifest.json');
|
|
145
|
+
|
|
146
|
+
if (!existsSync(manifestPath)) {
|
|
147
|
+
throw inputError(
|
|
148
|
+
'BATCH_NOT_FOUND',
|
|
149
|
+
`Batch "${batchId}" not found`,
|
|
150
|
+
'Check the batch ID or run: sdlab batch generate --mode <id>'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* List all batches for a project.
|
|
159
|
+
*/
|
|
160
|
+
export function listBatches(projectRoot) {
|
|
161
|
+
const batchesDir = getBatchesDir(projectRoot);
|
|
162
|
+
if (!existsSync(batchesDir)) return [];
|
|
163
|
+
|
|
164
|
+
return readdirSync(batchesDir, { withFileTypes: true })
|
|
165
|
+
.filter(e => e.isDirectory() && e.name.startsWith('batch_'))
|
|
166
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
167
|
+
.map(e => e.name);
|
|
168
|
+
}
|