@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +36 -8
  3. package/bin/sdlab.js +129 -0
  4. package/lib/adapters/comfyui-runner.js +170 -0
  5. package/lib/adapters/comfyui-workflows.js +163 -0
  6. package/lib/batch-compiler.js +118 -0
  7. package/lib/batch-modes.js +111 -0
  8. package/lib/batch-runs.js +168 -0
  9. package/lib/batch-sheet-render.js +206 -0
  10. package/lib/brief-compiler.js +598 -0
  11. package/lib/brief-render.js +205 -0
  12. package/lib/critique-engine.js +413 -0
  13. package/lib/critique-render.js +171 -0
  14. package/lib/generated-provenance.js +87 -0
  15. package/lib/paths.js +31 -0
  16. package/lib/refine-briefs.js +329 -0
  17. package/lib/reingest-selected.js +173 -0
  18. package/lib/run-summary.js +131 -0
  19. package/lib/runtime-runs.js +168 -0
  20. package/lib/selections.js +323 -0
  21. package/lib/workflow-profiles.js +161 -0
  22. package/package.json +6 -3
  23. package/scripts/batch-generate.js +167 -0
  24. package/scripts/batch-sheet.js +46 -0
  25. package/scripts/batch-show.js +86 -0
  26. package/scripts/brief-compile.js +134 -0
  27. package/scripts/brief-show.js +52 -0
  28. package/scripts/critique-show.js +50 -0
  29. package/scripts/critique.js +70 -0
  30. package/scripts/doctor.js +169 -1
  31. package/scripts/init.js +22 -0
  32. package/scripts/refine.js +119 -0
  33. package/scripts/reingest-selected.js +78 -0
  34. package/scripts/reingest.js +7 -1
  35. package/scripts/run-generate.js +76 -0
  36. package/scripts/run-list.js +58 -0
  37. package/scripts/run-show.js +63 -0
  38. package/scripts/select.js +118 -0
  39. package/scripts/selection-show.js +88 -0
  40. package/scripts/workflow-list.js +61 -0
  41. package/scripts/workflow-show.js +96 -0
  42. package/templates/domains/architecture/workflows/profiles/environment-moodboard.json +62 -0
  43. package/templates/domains/architecture/workflows/profiles/interior-variant-pack.json +60 -0
  44. package/templates/domains/character-design/workflows/profiles/character-portrait-set.json +61 -0
  45. package/templates/domains/character-design/workflows/profiles/expression-sheet.json +59 -0
  46. package/templates/domains/creature-design/workflows/profiles/creature-portrait-set.json +61 -0
  47. package/templates/domains/creature-design/workflows/profiles/silhouette-exploration.json +58 -0
  48. package/templates/domains/game-art/workflows/profiles/character-portrait-set.json +62 -0
  49. package/templates/domains/game-art/workflows/profiles/environment-moodboard.json +61 -0
  50. package/templates/domains/vehicle-mech/workflows/profiles/variant-pack.json +60 -0
  51. package/templates/domains/vehicle-mech/workflows/profiles/vehicle-silhouette-pack.json +59 -0
@@ -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
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Runtime run management — create, load, list run artifacts.
3
+ *
4
+ * A run is a frozen execution artifact: brief + seeds + outputs + manifest.
5
+ * The run folder IS the review surface — humans open it directly.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir, readdir, copyFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { getRunsDir, getBriefsDir } from './paths.js';
12
+ import { inputError } from './errors.js';
13
+
14
+ // ─── Run ID generation ───────────────────────────────────────────
15
+
16
+ /**
17
+ * Generate a run ID: run_YYYY-MM-DD_NNN
18
+ */
19
+ export function generateRunId(runsDir) {
20
+ const today = new Date().toISOString().slice(0, 10);
21
+ const prefix = `run_${today}_`;
22
+
23
+ let maxSeq = 0;
24
+ if (existsSync(runsDir)) {
25
+ const existing = readdirSync(runsDir, { withFileTypes: true })
26
+ .filter(e => e.isDirectory() && e.name.startsWith(prefix));
27
+ for (const e of existing) {
28
+ const seqStr = e.name.slice(prefix.length);
29
+ const seq = parseInt(seqStr, 10);
30
+ if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
31
+ }
32
+ }
33
+
34
+ const seq = String(maxSeq + 1).padStart(3, '0');
35
+ return `${prefix}${seq}`;
36
+ }
37
+
38
+ // ─── Seed planning ───────────────────────────────────────────────
39
+
40
+ /**
41
+ * Build a deterministic seed plan.
42
+ * @param {Object} opts
43
+ * @param {'fixed'|'increment'|'random'} opts.seedMode
44
+ * @param {number} opts.outputCount
45
+ * @param {number} [opts.baseSeed] — explicit base seed (default: random)
46
+ * @returns {{ base_seed: number, seeds: number[] }}
47
+ */
48
+ export function buildSeedPlan({ seedMode, outputCount, baseSeed }) {
49
+ const base = baseSeed ?? Math.floor(Math.random() * 2_147_483_647);
50
+ const seeds = [];
51
+
52
+ switch (seedMode) {
53
+ case 'fixed':
54
+ for (let i = 0; i < outputCount; i++) seeds.push(base);
55
+ break;
56
+ case 'increment':
57
+ for (let i = 0; i < outputCount; i++) seeds.push(base + i);
58
+ break;
59
+ case 'random':
60
+ seeds.push(base);
61
+ for (let i = 1; i < outputCount; i++) {
62
+ seeds.push(Math.floor(Math.random() * 2_147_483_647));
63
+ }
64
+ break;
65
+ default:
66
+ // Default to increment
67
+ for (let i = 0; i < outputCount; i++) seeds.push(base + i);
68
+ }
69
+
70
+ return { base_seed: base, seeds };
71
+ }
72
+
73
+ // ─── Run directory setup ─────────────────────────────────────────
74
+
75
+ /**
76
+ * Prepare a run directory and copy brief artifacts into it.
77
+ * @returns {{ runId: string, runDir: string }}
78
+ */
79
+ export async function prepareRunDir(projectRoot, brief) {
80
+ const runsDir = getRunsDir(projectRoot);
81
+ const runId = generateRunId(runsDir);
82
+ const runDir = join(runsDir, runId);
83
+
84
+ await mkdir(join(runDir, 'outputs'), { recursive: true });
85
+
86
+ // Copy brief artifacts
87
+ const briefsDir = getBriefsDir(projectRoot);
88
+ const briefJsonSrc = join(briefsDir, `${brief.brief_id}.json`);
89
+ const briefMdSrc = join(briefsDir, `${brief.brief_id}.md`);
90
+
91
+ await writeFile(join(runDir, 'brief.json'), JSON.stringify(brief, null, 2) + '\n');
92
+
93
+ if (existsSync(briefMdSrc)) {
94
+ await copyFile(briefMdSrc, join(runDir, 'brief.md'));
95
+ }
96
+
97
+ // Write standalone prompt/negative files for easy inspection
98
+ await writeFile(join(runDir, 'prompt.txt'), brief.prompt || '');
99
+ await writeFile(join(runDir, 'negative-prompt.txt'), brief.negative_prompt || '');
100
+
101
+ return { runId, runDir };
102
+ }
103
+
104
+ // ─── Manifest ────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Build and save a run manifest.
108
+ */
109
+ export async function saveRunManifest(runDir, manifest) {
110
+ await writeFile(
111
+ join(runDir, 'manifest.json'),
112
+ JSON.stringify(manifest, null, 2) + '\n'
113
+ );
114
+ }
115
+
116
+ // ─── Load / List ─────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Load a run manifest by ID.
120
+ */
121
+ export async function loadRun(projectRoot, runId) {
122
+ const runDir = join(getRunsDir(projectRoot), runId);
123
+ const manifestPath = join(runDir, 'manifest.json');
124
+
125
+ if (!existsSync(manifestPath)) {
126
+ throw inputError(
127
+ 'RUN_NOT_FOUND',
128
+ `Run "${runId}" not found`,
129
+ `Check the run ID or run: sdlab run generate --brief <id>`
130
+ );
131
+ }
132
+
133
+ return JSON.parse(await readFile(manifestPath, 'utf-8'));
134
+ }
135
+
136
+ /**
137
+ * List all runs in a project.
138
+ */
139
+ export function listRuns(projectRoot) {
140
+ const runsDir = getRunsDir(projectRoot);
141
+ if (!existsSync(runsDir)) return [];
142
+
143
+ const entries = readdirSync(runsDir, { withFileTypes: true })
144
+ .filter(e => e.isDirectory() && e.name.startsWith('run_'))
145
+ .sort((a, b) => a.name.localeCompare(b.name));
146
+
147
+ const runs = [];
148
+ for (const entry of entries) {
149
+ const manifestPath = join(runsDir, entry.name, 'manifest.json');
150
+ if (!existsSync(manifestPath)) continue;
151
+
152
+ try {
153
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
154
+ runs.push({
155
+ run_id: manifest.run_id,
156
+ brief_id: manifest.brief_id,
157
+ workflow_template_id: manifest.workflow_template_id,
158
+ output_count: manifest.output_count,
159
+ created_at: manifest.created_at,
160
+ adapter_target: manifest.adapter_target,
161
+ });
162
+ } catch {
163
+ // Skip malformed
164
+ }
165
+ }
166
+
167
+ return runs;
168
+ }