@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.es.md +76 -144
  3. package/README.fr.md +75 -143
  4. package/README.hi.md +75 -143
  5. package/README.it.md +76 -144
  6. package/README.ja.md +75 -143
  7. package/README.md +36 -8
  8. package/README.pt-BR.md +75 -143
  9. package/README.zh.md +76 -144
  10. package/SECURITY.md +3 -1
  11. package/bin/sdlab.js +146 -17
  12. package/lib/adapters/comfyui-runner.js +170 -0
  13. package/lib/adapters/comfyui-workflows.js +163 -0
  14. package/lib/batch-compiler.js +118 -0
  15. package/lib/batch-modes.js +111 -0
  16. package/lib/batch-runs.js +168 -0
  17. package/lib/batch-sheet-render.js +206 -0
  18. package/lib/brief-compiler.js +598 -0
  19. package/lib/brief-render.js +205 -0
  20. package/lib/critique-engine.js +413 -0
  21. package/lib/critique-render.js +171 -0
  22. package/lib/errors.js +82 -0
  23. package/lib/generated-provenance.js +87 -0
  24. package/lib/log.js +36 -0
  25. package/lib/paths.js +31 -0
  26. package/lib/refine-briefs.js +329 -0
  27. package/lib/reingest-selected.js +173 -0
  28. package/lib/run-summary.js +131 -0
  29. package/lib/runtime-runs.js +168 -0
  30. package/lib/selections.js +323 -0
  31. package/lib/snapshot.js +5 -2
  32. package/lib/split.js +5 -2
  33. package/lib/workflow-profiles.js +161 -0
  34. package/package.json +8 -6
  35. package/scripts/batch-generate.js +167 -0
  36. package/scripts/batch-sheet.js +46 -0
  37. package/scripts/batch-show.js +86 -0
  38. package/scripts/brief-compile.js +134 -0
  39. package/scripts/brief-show.js +52 -0
  40. package/scripts/critique-show.js +50 -0
  41. package/scripts/critique.js +70 -0
  42. package/scripts/doctor.js +169 -1
  43. package/scripts/init.js +22 -0
  44. package/scripts/refine.js +119 -0
  45. package/scripts/reingest-selected.js +78 -0
  46. package/scripts/reingest.js +7 -1
  47. package/scripts/run-generate.js +76 -0
  48. package/scripts/run-list.js +58 -0
  49. package/scripts/run-show.js +63 -0
  50. package/scripts/select.js +118 -0
  51. package/scripts/selection-show.js +88 -0
  52. package/scripts/snapshot.js +27 -23
  53. package/scripts/split.js +3 -1
  54. package/scripts/workflow-list.js +61 -0
  55. package/scripts/workflow-show.js +96 -0
  56. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  57. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  58. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  59. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  60. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  61. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  62. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  63. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  64. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  65. 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
+ }