@mcptoolshop/style-dataset-lab 2.2.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.es.md +76 -144
- package/README.fr.md +75 -143
- package/README.hi.md +75 -143
- package/README.it.md +76 -144
- package/README.ja.md +75 -143
- package/README.md +36 -8
- package/README.pt-BR.md +75 -143
- package/README.zh.md +76 -144
- package/SECURITY.md +3 -1
- package/bin/sdlab.js +146 -17
- 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/errors.js +82 -0
- package/lib/generated-provenance.js +87 -0
- package/lib/log.js +36 -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/snapshot.js +5 -2
- package/lib/split.js +5 -2
- package/lib/workflow-profiles.js +161 -0
- package/package.json +8 -6
- 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/snapshot.js +27 -23
- package/scripts/split.js +3 -1
- 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,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refine briefs — derive a next-pass brief from critique of a previous run.
|
|
3
|
+
*
|
|
4
|
+
* A refined brief is NOT a brand-new brief. It is a delta layer:
|
|
5
|
+
* - Preserves: original workflow, subject, lane, canon constraints, runtime plan
|
|
6
|
+
* - Adds: preserve/push/suppress instructions derived from critique
|
|
7
|
+
* - Updates: prompt and negative prompt with targeted refinements
|
|
8
|
+
*
|
|
9
|
+
* This keeps the loop stable — each pass improves, never scrambles.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
15
|
+
import { getRunsDir } from './paths.js';
|
|
16
|
+
import { inputError } from './errors.js';
|
|
17
|
+
|
|
18
|
+
// ─── Refined brief ID generation ─────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate refined brief ID: <parent_brief_id>_refine_NN
|
|
22
|
+
*/
|
|
23
|
+
function generateRefinedBriefId(refineDir, parentBriefId) {
|
|
24
|
+
const prefix = `${parentBriefId}_refine_`;
|
|
25
|
+
|
|
26
|
+
let maxSeq = 0;
|
|
27
|
+
if (existsSync(refineDir)) {
|
|
28
|
+
const existing = readdirSync(refineDir)
|
|
29
|
+
.filter(f => f.startsWith(prefix) && f.endsWith('.json'));
|
|
30
|
+
for (const f of existing) {
|
|
31
|
+
const seqStr = f.slice(prefix.length, -5);
|
|
32
|
+
const seq = parseInt(seqStr, 10);
|
|
33
|
+
if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const seq = String(maxSeq + 1).padStart(2, '0');
|
|
38
|
+
return `${prefix}${seq}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Prompt refinement ───────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render a refined prompt from parent prompt + delta instructions.
|
|
45
|
+
*
|
|
46
|
+
* Strategy:
|
|
47
|
+
* - Start with the original prompt
|
|
48
|
+
* - Append preserve notes as reinforcement
|
|
49
|
+
* - Append push notes as emphasis
|
|
50
|
+
* - Do NOT remove anything from the original — only add emphasis
|
|
51
|
+
*/
|
|
52
|
+
export function renderRefinedPrompt(parentPrompt, delta) {
|
|
53
|
+
const parts = [parentPrompt];
|
|
54
|
+
|
|
55
|
+
// Preserve instructions — reinforce what worked
|
|
56
|
+
if (delta.preserve?.length > 0) {
|
|
57
|
+
parts.push(`[preserve: ${delta.preserve.join(', ')}]`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Push instructions — emphasize what needs to be stronger
|
|
61
|
+
if (delta.push?.length > 0) {
|
|
62
|
+
parts.push(`[emphasize: ${delta.push.join(', ')}]`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return parts.join(', ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a refined negative prompt from parent negative + suppress instructions.
|
|
70
|
+
*
|
|
71
|
+
* Strategy:
|
|
72
|
+
* - Keep the entire original negative prompt
|
|
73
|
+
* - Append suppress items that aren't already present
|
|
74
|
+
*/
|
|
75
|
+
export function renderRefinedNegative(parentNegative, delta) {
|
|
76
|
+
const existing = parentNegative.split(',').map(s => s.trim().toLowerCase());
|
|
77
|
+
const additions = [];
|
|
78
|
+
|
|
79
|
+
for (const item of delta.suppress || []) {
|
|
80
|
+
const lower = item.toLowerCase();
|
|
81
|
+
// Don't duplicate if already in negative
|
|
82
|
+
if (!existing.some(e => e.includes(lower) || lower.includes(e))) {
|
|
83
|
+
additions.push(item);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (additions.length === 0) return parentNegative;
|
|
88
|
+
return `${parentNegative}, ${additions.join(', ')}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Delta extraction from critique ──────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract a refine delta from a critique report + picked candidate.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} critique — critique report
|
|
97
|
+
* @param {string} pickedFilename — filename of the picked candidate
|
|
98
|
+
* @param {Object} cliOverrides — { preserve: string[], push: string[], suppress: string[] }
|
|
99
|
+
* @returns {{ preserve: string[], push: string[], suppress: string[] }}
|
|
100
|
+
*/
|
|
101
|
+
export function extractDelta(critique, pickedFilename, cliOverrides = {}) {
|
|
102
|
+
// Find the picked candidate in the critique
|
|
103
|
+
const picked = critique.candidates.find(c => c.filename === pickedFilename);
|
|
104
|
+
if (!picked) {
|
|
105
|
+
throw inputError(
|
|
106
|
+
'REFINE_CANDIDATE_NOT_FOUND',
|
|
107
|
+
`Candidate "${pickedFilename}" not found in critique for run "${critique.run_id}"`,
|
|
108
|
+
`Available candidates: ${critique.candidates.map(c => c.filename).join(', ')}`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Start with critique-derived instructions
|
|
113
|
+
const preserve = [...(picked.preserve_next_pass || [])];
|
|
114
|
+
const push = [];
|
|
115
|
+
const suppress = [];
|
|
116
|
+
|
|
117
|
+
// Derive push from drift issues that need correction
|
|
118
|
+
for (const issue of picked.correct_next_pass || []) {
|
|
119
|
+
push.push(issue);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Derive suppress from drift issues
|
|
123
|
+
for (const drift of picked.drift_issues || []) {
|
|
124
|
+
// Strip "check:" and "negative check:" prefixes for cleaner suppress
|
|
125
|
+
const cleaned = drift
|
|
126
|
+
.replace(/^\[.*?\]\s*check:\s*/i, '')
|
|
127
|
+
.replace(/^negative check:\s*/i, '')
|
|
128
|
+
.trim();
|
|
129
|
+
if (cleaned) suppress.push(cleaned);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// CLI overrides — append without duplicating
|
|
133
|
+
if (cliOverrides.preserve) {
|
|
134
|
+
for (const p of cliOverrides.preserve) {
|
|
135
|
+
if (!preserve.includes(p)) preserve.push(p);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (cliOverrides.push) {
|
|
139
|
+
for (const p of cliOverrides.push) {
|
|
140
|
+
if (!push.includes(p)) push.push(p);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (cliOverrides.suppress) {
|
|
144
|
+
for (const s of cliOverrides.suppress) {
|
|
145
|
+
if (!suppress.includes(s)) suppress.push(s);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { preserve, push, suppress };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Build refined brief ─────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build a refined brief from parent brief, critique, and delta.
|
|
156
|
+
*
|
|
157
|
+
* @param {Object} opts
|
|
158
|
+
* @param {Object} opts.parentBrief — original compiled brief
|
|
159
|
+
* @param {Object} opts.critique — critique report
|
|
160
|
+
* @param {string} opts.pickedCandidate — filename of selected candidate
|
|
161
|
+
* @param {Object} opts.delta — { preserve, push, suppress }
|
|
162
|
+
* @param {string} opts.runId — source run ID
|
|
163
|
+
* @param {string} opts.refineDir — path to write refined brief
|
|
164
|
+
* @returns {Object} refined brief
|
|
165
|
+
*/
|
|
166
|
+
export function buildRefinedBrief({ parentBrief, critique, pickedCandidate, delta, runId, refineDir }) {
|
|
167
|
+
const briefId = generateRefinedBriefId(refineDir, parentBrief.brief_id);
|
|
168
|
+
|
|
169
|
+
// Render updated prompts
|
|
170
|
+
const prompt = renderRefinedPrompt(parentBrief.prompt, delta);
|
|
171
|
+
const negativePrompt = renderRefinedNegative(parentBrief.negative_prompt, delta);
|
|
172
|
+
|
|
173
|
+
const refined = {
|
|
174
|
+
brief_id: briefId,
|
|
175
|
+
parent_brief_id: parentBrief.brief_id,
|
|
176
|
+
source_run_id: runId,
|
|
177
|
+
source_candidate: pickedCandidate,
|
|
178
|
+
|
|
179
|
+
project_id: parentBrief.project_id,
|
|
180
|
+
workflow_id: parentBrief.workflow_id,
|
|
181
|
+
lane_id: parentBrief.lane_id,
|
|
182
|
+
compiled_at: new Date().toISOString(),
|
|
183
|
+
config_fingerprint: parentBrief.config_fingerprint,
|
|
184
|
+
|
|
185
|
+
prompt,
|
|
186
|
+
negative_prompt: negativePrompt,
|
|
187
|
+
|
|
188
|
+
refine_delta: delta,
|
|
189
|
+
|
|
190
|
+
// Carry forward from parent
|
|
191
|
+
canon_constraints: parentBrief.canon_constraints || [],
|
|
192
|
+
drift_warnings: parentBrief.drift_warnings || [],
|
|
193
|
+
runtime_plan: { ...parentBrief.runtime_plan },
|
|
194
|
+
expected_outputs: { ...parentBrief.expected_outputs },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Optional fields
|
|
198
|
+
if (parentBrief.subject_id) refined.subject_id = parentBrief.subject_id;
|
|
199
|
+
if (parentBrief.subject_constraints) refined.subject_constraints = parentBrief.subject_constraints;
|
|
200
|
+
if (parentBrief.reference_selection) refined.reference_selection = parentBrief.reference_selection;
|
|
201
|
+
|
|
202
|
+
return refined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Markdown renderer ──────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Render a refined brief as Markdown.
|
|
209
|
+
*/
|
|
210
|
+
export function renderRefinedBriefMarkdown(brief) {
|
|
211
|
+
const lines = [];
|
|
212
|
+
|
|
213
|
+
lines.push(`# Refined Brief: ${brief.brief_id}`);
|
|
214
|
+
lines.push('');
|
|
215
|
+
lines.push(`**Parent Brief:** ${brief.parent_brief_id}`);
|
|
216
|
+
lines.push(`**Source Run:** ${brief.source_run_id}`);
|
|
217
|
+
lines.push(`**Picked Candidate:** ${brief.source_candidate}`);
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(`**Workflow:** ${brief.workflow_id}`);
|
|
220
|
+
lines.push(`**Project:** ${brief.project_id}`);
|
|
221
|
+
lines.push(`**Lane:** ${brief.lane_id}`);
|
|
222
|
+
if (brief.subject_id) lines.push(`**Subject:** ${brief.subject_id}`);
|
|
223
|
+
lines.push('');
|
|
224
|
+
|
|
225
|
+
// Refine delta — the key section
|
|
226
|
+
lines.push('## Refine Delta');
|
|
227
|
+
lines.push('');
|
|
228
|
+
|
|
229
|
+
if (brief.refine_delta?.preserve?.length > 0) {
|
|
230
|
+
lines.push('### Preserve');
|
|
231
|
+
for (const p of brief.refine_delta.preserve) lines.push(`- ✓ ${p}`);
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (brief.refine_delta?.push?.length > 0) {
|
|
236
|
+
lines.push('### Push');
|
|
237
|
+
for (const p of brief.refine_delta.push) lines.push(`- → ${p}`);
|
|
238
|
+
lines.push('');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (brief.refine_delta?.suppress?.length > 0) {
|
|
242
|
+
lines.push('### Suppress');
|
|
243
|
+
for (const s of brief.refine_delta.suppress) lines.push(`- ✗ ${s}`);
|
|
244
|
+
lines.push('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Prompt
|
|
248
|
+
lines.push('## Prompt');
|
|
249
|
+
lines.push('');
|
|
250
|
+
lines.push('```');
|
|
251
|
+
lines.push(brief.prompt);
|
|
252
|
+
lines.push('```');
|
|
253
|
+
lines.push('');
|
|
254
|
+
|
|
255
|
+
// Negative prompt
|
|
256
|
+
lines.push('## Negative Prompt');
|
|
257
|
+
lines.push('');
|
|
258
|
+
lines.push('```');
|
|
259
|
+
lines.push(brief.negative_prompt);
|
|
260
|
+
lines.push('```');
|
|
261
|
+
lines.push('');
|
|
262
|
+
|
|
263
|
+
// Canon constraints
|
|
264
|
+
if (brief.canon_constraints?.length > 0) {
|
|
265
|
+
lines.push('## Canon Constraints');
|
|
266
|
+
lines.push('');
|
|
267
|
+
for (const c of brief.canon_constraints) lines.push(`- ${c}`);
|
|
268
|
+
lines.push('');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Drift warnings
|
|
272
|
+
if (brief.drift_warnings?.length > 0) {
|
|
273
|
+
lines.push('## Drift Warnings');
|
|
274
|
+
lines.push('');
|
|
275
|
+
for (const w of brief.drift_warnings) lines.push(`- ⚠ ${w}`);
|
|
276
|
+
lines.push('');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Runtime plan
|
|
280
|
+
lines.push('## Runtime Plan');
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push('| Parameter | Value |');
|
|
283
|
+
lines.push('|-----------|-------|');
|
|
284
|
+
const rp = brief.runtime_plan || {};
|
|
285
|
+
lines.push(`| Adapter | ${rp.adapter_target || '?'} |`);
|
|
286
|
+
lines.push(`| Width | ${rp.width || '?'} |`);
|
|
287
|
+
lines.push(`| Height | ${rp.height || '?'} |`);
|
|
288
|
+
lines.push(`| Steps | ${rp.steps || '?'} |`);
|
|
289
|
+
lines.push(`| CFG | ${rp.cfg || '?'} |`);
|
|
290
|
+
lines.push(`| Sampler | ${rp.sampler || '?'} |`);
|
|
291
|
+
lines.push(`| Seed Mode | ${rp.seed_mode || '?'} |`);
|
|
292
|
+
lines.push('');
|
|
293
|
+
|
|
294
|
+
// Expected outputs
|
|
295
|
+
lines.push('## Expected Outputs');
|
|
296
|
+
lines.push('');
|
|
297
|
+
const eo = brief.expected_outputs || {};
|
|
298
|
+
lines.push(`- **Mode:** ${eo.output_mode || '?'}`);
|
|
299
|
+
lines.push(`- **Count:** ${eo.output_count || '?'}`);
|
|
300
|
+
lines.push('');
|
|
301
|
+
|
|
302
|
+
// Metadata
|
|
303
|
+
lines.push('---');
|
|
304
|
+
lines.push('');
|
|
305
|
+
lines.push(`Compiled: ${brief.compiled_at}`);
|
|
306
|
+
lines.push(`Config fingerprint: \`${brief.config_fingerprint || '—'}\``);
|
|
307
|
+
lines.push('');
|
|
308
|
+
|
|
309
|
+
return lines.join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Save ────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Save a refined brief to the run's refine directory.
|
|
316
|
+
*/
|
|
317
|
+
export async function saveRefinedBrief(projectRoot, runId, refinedBrief) {
|
|
318
|
+
const refineDir = join(getRunsDir(projectRoot), runId, 'refine');
|
|
319
|
+
await mkdir(refineDir, { recursive: true });
|
|
320
|
+
|
|
321
|
+
const jsonPath = join(refineDir, `${refinedBrief.brief_id}.json`);
|
|
322
|
+
await writeFile(jsonPath, JSON.stringify(refinedBrief, null, 2) + '\n');
|
|
323
|
+
|
|
324
|
+
const md = renderRefinedBriefMarkdown(refinedBrief);
|
|
325
|
+
const mdPath = join(refineDir, `${refinedBrief.brief_id}.md`);
|
|
326
|
+
await writeFile(mdPath, md);
|
|
327
|
+
|
|
328
|
+
return { jsonPath, mdPath, refineDir };
|
|
329
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-ingest selected outputs as new candidate records.
|
|
3
|
+
*
|
|
4
|
+
* Selected outputs re-enter the project as generated candidates
|
|
5
|
+
* with full provenance. They go through the same review and
|
|
6
|
+
* canon-binding flow as everything else. No bypass.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFile, mkdir, copyFile } from 'node:fs/promises';
|
|
10
|
+
import { join, extname } from 'node:path';
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { getRunsDir } from './paths.js';
|
|
13
|
+
import { getBatchesDir } from './batch-runs.js';
|
|
14
|
+
import { getSelectionsDir, loadSelection } from './selections.js';
|
|
15
|
+
import { buildGeneratedProvenance } from './generated-provenance.js';
|
|
16
|
+
import { buildBaseRecord } from './records.js';
|
|
17
|
+
import { inputError } from './errors.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Re-ingest a selection — creates candidate records from chosen outputs.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} opts
|
|
23
|
+
* @param {string} opts.projectRoot
|
|
24
|
+
* @param {string} opts.projectId
|
|
25
|
+
* @param {string} opts.selectionId
|
|
26
|
+
* @param {boolean} [opts.dryRun]
|
|
27
|
+
* @returns {Promise<{created: string[], skipped: string[]}>}
|
|
28
|
+
*/
|
|
29
|
+
export async function reingestSelection({ projectRoot, projectId, selectionId, dryRun }) {
|
|
30
|
+
const manifest = loadSelection(projectRoot, selectionId);
|
|
31
|
+
|
|
32
|
+
if (!manifest.reingest_ready) {
|
|
33
|
+
throw inputError(
|
|
34
|
+
'SELECTION_NOT_READY',
|
|
35
|
+
`Selection "${selectionId}" is not marked reingest_ready`,
|
|
36
|
+
'Set reingest_ready: true in the selection manifest.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
|
|
41
|
+
const recordsDir = join(projectRoot, 'records');
|
|
42
|
+
const inboxDir = join(projectRoot, 'inbox', 'generated');
|
|
43
|
+
|
|
44
|
+
if (!dryRun) {
|
|
45
|
+
await mkdir(recordsDir, { recursive: true });
|
|
46
|
+
await mkdir(inboxDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Load source manifests for provenance
|
|
50
|
+
const runsDir = getRunsDir(projectRoot);
|
|
51
|
+
const runManifestCache = new Map();
|
|
52
|
+
|
|
53
|
+
function getRunManifest(runId) {
|
|
54
|
+
if (runManifestCache.has(runId)) return runManifestCache.get(runId);
|
|
55
|
+
const mPath = join(runsDir, runId, 'manifest.json');
|
|
56
|
+
if (!existsSync(mPath)) return null;
|
|
57
|
+
const m = JSON.parse(readFileSync(mPath, 'utf-8'));
|
|
58
|
+
runManifestCache.set(runId, m);
|
|
59
|
+
return m;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let batchManifest = null;
|
|
63
|
+
if (manifest.source_type === 'batch') {
|
|
64
|
+
const bmPath = join(getBatchesDir(projectRoot), manifest.source_id, 'manifest.json');
|
|
65
|
+
if (existsSync(bmPath)) {
|
|
66
|
+
batchManifest = JSON.parse(readFileSync(bmPath, 'utf-8'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const created = [];
|
|
71
|
+
const skipped = [];
|
|
72
|
+
const provenanceLines = [];
|
|
73
|
+
|
|
74
|
+
for (const item of manifest.items) {
|
|
75
|
+
// Determine record ID from selection + item index
|
|
76
|
+
const idx = String(manifest.items.indexOf(item) + 1).padStart(3, '0');
|
|
77
|
+
const recordId = `gen_${selectionId}_${idx}`;
|
|
78
|
+
const recordPath = join(recordsDir, `${recordId}.json`);
|
|
79
|
+
|
|
80
|
+
if (existsSync(recordPath)) {
|
|
81
|
+
skipped.push(recordId);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolve the run manifest for this item
|
|
86
|
+
let runManifest;
|
|
87
|
+
if (manifest.source_type === 'run') {
|
|
88
|
+
runManifest = getRunManifest(manifest.source_id);
|
|
89
|
+
} else if (manifest.source_type === 'batch' && batchManifest) {
|
|
90
|
+
// Find which run this slot belongs to
|
|
91
|
+
const slotId = item.slot_or_output.split(':')[0];
|
|
92
|
+
const slot = batchManifest.slots?.find(s => s.slot_id === slotId);
|
|
93
|
+
if (slot) {
|
|
94
|
+
runManifest = getRunManifest(slot.run_id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!runManifest) {
|
|
99
|
+
// Fallback: minimal provenance
|
|
100
|
+
runManifest = {
|
|
101
|
+
run_id: manifest.source_id,
|
|
102
|
+
brief_id: 'unknown',
|
|
103
|
+
workflow_template_id: manifest.workflow_id || 'unknown',
|
|
104
|
+
created_at: manifest.created_at,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build provenance
|
|
109
|
+
const prov = buildGeneratedProvenance({
|
|
110
|
+
sourceType: manifest.source_type,
|
|
111
|
+
sourceId: manifest.source_id,
|
|
112
|
+
runManifest,
|
|
113
|
+
batchManifest,
|
|
114
|
+
item,
|
|
115
|
+
projectRoot,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Image source in selection/chosen/
|
|
119
|
+
const srcImage = join(selectionDir, 'chosen', item.filename);
|
|
120
|
+
const ext = extname(item.filename) || '.png';
|
|
121
|
+
const destImage = `${recordId}${ext}`;
|
|
122
|
+
const inboxPath = join(inboxDir, destImage);
|
|
123
|
+
const assetPath = `inbox/generated/${destImage}`;
|
|
124
|
+
|
|
125
|
+
// Get file size
|
|
126
|
+
let fileBytes = 0;
|
|
127
|
+
if (existsSync(srcImage)) {
|
|
128
|
+
fileBytes = statSync(srcImage).size;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build record
|
|
132
|
+
const record = buildBaseRecord(recordId, assetPath, {
|
|
133
|
+
width: null,
|
|
134
|
+
height: null,
|
|
135
|
+
bytes: fileBytes,
|
|
136
|
+
}, {
|
|
137
|
+
source: 'generated',
|
|
138
|
+
selection_id: selectionId,
|
|
139
|
+
generation_provenance: prov,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
record.schema_version = '2.2.0';
|
|
143
|
+
|
|
144
|
+
// Tags
|
|
145
|
+
const recordTags = ['generated', 'selected'];
|
|
146
|
+
if (manifest.workflow_id) recordTags.push(manifest.workflow_id);
|
|
147
|
+
if (manifest.subject_id) recordTags.push(manifest.subject_id);
|
|
148
|
+
if (item.tags?.length) recordTags.push(...item.tags);
|
|
149
|
+
record.tags = [...new Set(recordTags)];
|
|
150
|
+
|
|
151
|
+
if (!dryRun) {
|
|
152
|
+
// Copy image into inbox
|
|
153
|
+
if (existsSync(srcImage)) {
|
|
154
|
+
await copyFile(srcImage, inboxPath);
|
|
155
|
+
}
|
|
156
|
+
// Write record
|
|
157
|
+
await writeFile(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
created.push(recordId);
|
|
161
|
+
provenanceLines.push(JSON.stringify({ record_id: recordId, ...prov }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Write provenance log
|
|
165
|
+
if (!dryRun && provenanceLines.length > 0) {
|
|
166
|
+
await writeFile(
|
|
167
|
+
join(selectionDir, 'provenance.jsonl'),
|
|
168
|
+
provenanceLines.join('\n') + '\n',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { created, skipped };
|
|
173
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run summary — render manifest to human-readable summary files.
|
|
3
|
+
*
|
|
4
|
+
* Writes summary.md + summary.json to the run directory.
|
|
5
|
+
* Contact sheet generation is separate (requires image processing).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a summary object from a manifest.
|
|
13
|
+
*/
|
|
14
|
+
export function buildRunSummary(manifest) {
|
|
15
|
+
const ok = manifest.outputs.filter(o => o.status === 'ok');
|
|
16
|
+
const failed = manifest.outputs.filter(o => o.status === 'error');
|
|
17
|
+
const totalElapsed = ok.reduce((sum, o) => sum + (o.elapsed_ms || 0), 0);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
run_id: manifest.run_id,
|
|
21
|
+
brief_id: manifest.brief_id,
|
|
22
|
+
project_id: manifest.project_id,
|
|
23
|
+
adapter_target: manifest.adapter_target,
|
|
24
|
+
output_mode: manifest.output_mode,
|
|
25
|
+
created_at: manifest.created_at,
|
|
26
|
+
dry_run: manifest.dry_run,
|
|
27
|
+
success_count: manifest.success_count,
|
|
28
|
+
error_count: manifest.error_count,
|
|
29
|
+
total_outputs: manifest.output_count,
|
|
30
|
+
total_elapsed_ms: totalElapsed,
|
|
31
|
+
avg_elapsed_ms: ok.length > 0 ? Math.round(totalElapsed / ok.length) : 0,
|
|
32
|
+
seed_plan: manifest.seed_plan,
|
|
33
|
+
checkpoint: manifest.checkpoint,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a manifest to Markdown summary.
|
|
39
|
+
*/
|
|
40
|
+
export function renderRunMarkdown(manifest) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
lines.push(`# Run: ${manifest.run_id}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(`**Brief:** ${manifest.brief_id}`);
|
|
45
|
+
lines.push(`**Project:** ${manifest.project_id}`);
|
|
46
|
+
lines.push(`**Created:** ${manifest.created_at}`);
|
|
47
|
+
lines.push(`**Adapter:** ${manifest.adapter_target}`);
|
|
48
|
+
lines.push(`**Mode:** ${manifest.output_mode}`);
|
|
49
|
+
if (manifest.dry_run) lines.push('**⚠ DRY RUN — no images generated**');
|
|
50
|
+
lines.push('');
|
|
51
|
+
|
|
52
|
+
// Results
|
|
53
|
+
lines.push('## Results');
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push(`| # | Seed | Status | File | Time |`);
|
|
56
|
+
lines.push(`|---|------|--------|------|------|`);
|
|
57
|
+
for (const out of manifest.outputs) {
|
|
58
|
+
const file = out.filename || '—';
|
|
59
|
+
const time = out.elapsed_ms ? `${(out.elapsed_ms / 1000).toFixed(1)}s` : '—';
|
|
60
|
+
const status = out.status === 'ok' ? '✓' : out.status === 'dry_run' ? '○' : '✗';
|
|
61
|
+
lines.push(`| ${out.index + 1} | ${out.seed} | ${status} | ${file} | ${time} |`);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
|
|
65
|
+
// Errors
|
|
66
|
+
if (manifest.errors.length > 0) {
|
|
67
|
+
lines.push('## Errors');
|
|
68
|
+
lines.push('');
|
|
69
|
+
for (const err of manifest.errors) {
|
|
70
|
+
lines.push(`- **Image ${err.index + 1}** (seed ${err.seed}): ${err.error}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Runtime
|
|
76
|
+
lines.push('## Runtime');
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(`| Parameter | Value |`);
|
|
79
|
+
lines.push(`|-----------|-------|`);
|
|
80
|
+
lines.push(`| Checkpoint | ${manifest.checkpoint || '—'} |`);
|
|
81
|
+
lines.push(`| LoRAs | ${(manifest.loras || []).map(l => l.name).join(', ') || '—'} |`);
|
|
82
|
+
lines.push(`| ComfyUI | ${manifest.comfy_url || '—'} |`);
|
|
83
|
+
|
|
84
|
+
const rp = manifest.runtime_plan || {};
|
|
85
|
+
lines.push(`| Size | ${rp.width || '?'}×${rp.height || '?'} |`);
|
|
86
|
+
lines.push(`| Steps | ${rp.steps || '?'} |`);
|
|
87
|
+
lines.push(`| CFG | ${rp.cfg || '?'} |`);
|
|
88
|
+
lines.push(`| Sampler | ${rp.sampler || '?'} |`);
|
|
89
|
+
lines.push(`| Seed mode | ${rp.seed_mode || '?'} |`);
|
|
90
|
+
lines.push(`| Base seed | ${manifest.seed_plan?.base_seed || '?'} |`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render a manifest to terminal text.
|
|
98
|
+
*/
|
|
99
|
+
export function renderRunText(manifest) {
|
|
100
|
+
const ok = manifest.outputs.filter(o => o.status === 'ok' || o.status === 'dry_run');
|
|
101
|
+
const failed = manifest.outputs.filter(o => o.status === 'error');
|
|
102
|
+
const lines = [];
|
|
103
|
+
|
|
104
|
+
lines.push(`Run: ${manifest.run_id}`);
|
|
105
|
+
lines.push(`Brief: ${manifest.brief_id}`);
|
|
106
|
+
lines.push(`Project: ${manifest.project_id}`);
|
|
107
|
+
lines.push(`Mode: ${manifest.output_mode} (${manifest.adapter_target})`);
|
|
108
|
+
if (manifest.dry_run) lines.push('⚠ DRY RUN');
|
|
109
|
+
lines.push(`Results: ${ok.length}/${manifest.output_count} succeeded`);
|
|
110
|
+
if (failed.length > 0) lines.push(`Errors: ${failed.length}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
|
|
113
|
+
for (const out of manifest.outputs) {
|
|
114
|
+
const status = out.status === 'ok' ? '✓' : out.status === 'dry_run' ? '○' : '✗';
|
|
115
|
+
const time = out.elapsed_ms ? ` (${(out.elapsed_ms / 1000).toFixed(1)}s)` : '';
|
|
116
|
+
const file = out.filename ? ` → ${out.filename}` : '';
|
|
117
|
+
const err = out.error ? ` — ${out.error}` : '';
|
|
118
|
+
lines.push(` ${status} [${out.index + 1}] seed ${out.seed}${file}${time}${err}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Save summary files into the run directory.
|
|
126
|
+
*/
|
|
127
|
+
export async function saveRunSummary(runDir, manifest) {
|
|
128
|
+
const summary = buildRunSummary(manifest);
|
|
129
|
+
await writeFile(join(runDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
|
|
130
|
+
await writeFile(join(runDir, 'summary.md'), renderRunMarkdown(manifest) + '\n');
|
|
131
|
+
}
|