@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,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
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection engine — approve outputs from runs or batches into
|
|
3
|
+
* durable selection artifacts.
|
|
4
|
+
*
|
|
5
|
+
* A selection is a creative decision: what was chosen, why, and
|
|
6
|
+
* where it came from. Re-ingest consumes selections; this module
|
|
7
|
+
* only builds and stores the artifact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
11
|
+
import { writeFile, mkdir, readFile } from 'node:fs/promises';
|
|
12
|
+
import { join, basename } from 'node:path';
|
|
13
|
+
import { getRunsDir } from './paths.js';
|
|
14
|
+
import { getBatchesDir } from './batch-runs.js';
|
|
15
|
+
import { inputError } from './errors.js';
|
|
16
|
+
|
|
17
|
+
// ─── Selection ID generation ─────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export function getSelectionsDir(projectRoot) {
|
|
20
|
+
return join(projectRoot, 'selections');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateSelectionId(selectionsDir) {
|
|
24
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
25
|
+
const prefix = `selection_${today}_`;
|
|
26
|
+
|
|
27
|
+
let maxSeq = 0;
|
|
28
|
+
if (existsSync(selectionsDir)) {
|
|
29
|
+
const existing = readdirSync(selectionsDir, { withFileTypes: true })
|
|
30
|
+
.filter(e => e.isDirectory() && e.name.startsWith(prefix));
|
|
31
|
+
for (const e of existing) {
|
|
32
|
+
const seqStr = e.name.slice(prefix.length);
|
|
33
|
+
const seq = parseInt(seqStr, 10);
|
|
34
|
+
if (!isNaN(seq) && seq > maxSeq) maxSeq = seq;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Run selection ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a selection from a single run.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} opts
|
|
47
|
+
* @param {string} opts.projectRoot
|
|
48
|
+
* @param {string} opts.projectId
|
|
49
|
+
* @param {string} opts.runId
|
|
50
|
+
* @param {string[]} opts.approvedFiles — filenames to approve (e.g. ['001.png', '003.png'])
|
|
51
|
+
* @param {string} [opts.reason]
|
|
52
|
+
* @param {string[]} [opts.tags]
|
|
53
|
+
* @returns {Promise<Object>} selection manifest
|
|
54
|
+
*/
|
|
55
|
+
export async function createSelectionFromRun({
|
|
56
|
+
projectRoot, projectId, runId, approvedFiles, reason, tags,
|
|
57
|
+
}) {
|
|
58
|
+
const runsDir = getRunsDir(projectRoot);
|
|
59
|
+
const runDir = join(runsDir, runId);
|
|
60
|
+
const manifestPath = join(runDir, 'manifest.json');
|
|
61
|
+
|
|
62
|
+
if (!existsSync(manifestPath)) {
|
|
63
|
+
throw inputError('RUN_NOT_FOUND', `Run "${runId}" not found`, 'Check the run ID.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const runManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
67
|
+
const outputsDir = join(runDir, 'outputs');
|
|
68
|
+
|
|
69
|
+
// Validate all approved files exist
|
|
70
|
+
for (const file of approvedFiles) {
|
|
71
|
+
if (!existsSync(join(outputsDir, file))) {
|
|
72
|
+
throw inputError(
|
|
73
|
+
'OUTPUT_NOT_FOUND',
|
|
74
|
+
`Output "${file}" not found in ${runId}/outputs/`,
|
|
75
|
+
`Available: ${readdirSync(outputsDir).filter(f => f.endsWith('.png')).join(', ') || '(none)'}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create selection directory
|
|
81
|
+
const selectionsDir = getSelectionsDir(projectRoot);
|
|
82
|
+
const selectionId = generateSelectionId(selectionsDir);
|
|
83
|
+
const selectionDir = join(selectionsDir, selectionId);
|
|
84
|
+
await mkdir(join(selectionDir, 'chosen'), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// Copy chosen files
|
|
87
|
+
const items = [];
|
|
88
|
+
for (let i = 0; i < approvedFiles.length; i++) {
|
|
89
|
+
const file = approvedFiles[i];
|
|
90
|
+
const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
|
|
91
|
+
copyFileSync(join(outputsDir, file), join(selectionDir, 'chosen', destName));
|
|
92
|
+
|
|
93
|
+
// Find seed from run manifest
|
|
94
|
+
const outputEntry = runManifest.outputs?.find(o => o.filename === file);
|
|
95
|
+
items.push({
|
|
96
|
+
slot_or_output: file,
|
|
97
|
+
filename: destName,
|
|
98
|
+
seed: outputEntry?.seed,
|
|
99
|
+
reason: reason || 'selected',
|
|
100
|
+
tags: tags || [],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build selection manifest
|
|
105
|
+
const manifest = {
|
|
106
|
+
selection_id: selectionId,
|
|
107
|
+
project_id: projectId,
|
|
108
|
+
source_type: 'run',
|
|
109
|
+
source_id: runId,
|
|
110
|
+
workflow_id: runManifest.workflow_template_id,
|
|
111
|
+
created_at: new Date().toISOString(),
|
|
112
|
+
items,
|
|
113
|
+
reingest_ready: true,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Attach subject from brief if available
|
|
117
|
+
const briefPath = join(runDir, 'brief.json');
|
|
118
|
+
if (existsSync(briefPath)) {
|
|
119
|
+
try {
|
|
120
|
+
const brief = JSON.parse(readFileSync(briefPath, 'utf-8'));
|
|
121
|
+
if (brief.subject_id) manifest.subject_id = brief.subject_id;
|
|
122
|
+
} catch { /* skip */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { selectionId, selectionDir, manifest, runManifest };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Batch selection ─────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a selection from a batch.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} opts
|
|
134
|
+
* @param {string} opts.projectRoot
|
|
135
|
+
* @param {string} opts.projectId
|
|
136
|
+
* @param {string} opts.batchId
|
|
137
|
+
* @param {Array<{slotId: string, filename: string}>} opts.approvedSlots
|
|
138
|
+
* @param {string} [opts.reason]
|
|
139
|
+
* @param {string[]} [opts.tags]
|
|
140
|
+
* @returns {Promise<Object>} selection manifest
|
|
141
|
+
*/
|
|
142
|
+
export async function createSelectionFromBatch({
|
|
143
|
+
projectRoot, projectId, batchId, approvedSlots, reason, tags,
|
|
144
|
+
}) {
|
|
145
|
+
const batchesDir = getBatchesDir(projectRoot);
|
|
146
|
+
const batchDir = join(batchesDir, batchId);
|
|
147
|
+
const batchManifestPath = join(batchDir, 'manifest.json');
|
|
148
|
+
|
|
149
|
+
if (!existsSync(batchManifestPath)) {
|
|
150
|
+
throw inputError('BATCH_NOT_FOUND', `Batch "${batchId}" not found`, 'Check the batch ID.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const batchManifest = JSON.parse(readFileSync(batchManifestPath, 'utf-8'));
|
|
154
|
+
const runsDir = getRunsDir(projectRoot);
|
|
155
|
+
|
|
156
|
+
// Build slot → run lookup
|
|
157
|
+
const slotRunMap = new Map();
|
|
158
|
+
for (const slot of batchManifest.slots || []) {
|
|
159
|
+
slotRunMap.set(slot.slot_id, slot);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Validate each approved slot
|
|
163
|
+
const runManifests = new Map();
|
|
164
|
+
for (const { slotId, filename } of approvedSlots) {
|
|
165
|
+
const slot = slotRunMap.get(slotId);
|
|
166
|
+
if (!slot) {
|
|
167
|
+
throw inputError(
|
|
168
|
+
'SLOT_NOT_FOUND',
|
|
169
|
+
`Slot "${slotId}" not found in batch ${batchId}`,
|
|
170
|
+
`Available: ${[...slotRunMap.keys()].join(', ')}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const runDir = join(runsDir, slot.run_id);
|
|
175
|
+
const outputPath = join(runDir, 'outputs', filename);
|
|
176
|
+
if (!existsSync(outputPath)) {
|
|
177
|
+
throw inputError(
|
|
178
|
+
'OUTPUT_NOT_FOUND',
|
|
179
|
+
`Output "${filename}" not found in ${slot.run_id}/outputs/`,
|
|
180
|
+
`Check the filename.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Cache run manifests
|
|
185
|
+
if (!runManifests.has(slot.run_id)) {
|
|
186
|
+
const rmPath = join(runDir, 'manifest.json');
|
|
187
|
+
if (existsSync(rmPath)) {
|
|
188
|
+
runManifests.set(slot.run_id, JSON.parse(readFileSync(rmPath, 'utf-8')));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create selection directory
|
|
194
|
+
const selectionsDir = getSelectionsDir(projectRoot);
|
|
195
|
+
const selectionId = generateSelectionId(selectionsDir);
|
|
196
|
+
const selectionDir = join(selectionsDir, selectionId);
|
|
197
|
+
await mkdir(join(selectionDir, 'chosen'), { recursive: true });
|
|
198
|
+
|
|
199
|
+
// Copy chosen files and build items
|
|
200
|
+
const items = [];
|
|
201
|
+
for (let i = 0; i < approvedSlots.length; i++) {
|
|
202
|
+
const { slotId, filename } = approvedSlots[i];
|
|
203
|
+
const slot = slotRunMap.get(slotId);
|
|
204
|
+
const runDir = join(runsDir, slot.run_id);
|
|
205
|
+
const destName = `sel_${String(i + 1).padStart(3, '0')}.png`;
|
|
206
|
+
|
|
207
|
+
copyFileSync(join(runDir, 'outputs', filename), join(selectionDir, 'chosen', destName));
|
|
208
|
+
|
|
209
|
+
const rm = runManifests.get(slot.run_id);
|
|
210
|
+
const outputEntry = rm?.outputs?.find(o => o.filename === filename);
|
|
211
|
+
|
|
212
|
+
items.push({
|
|
213
|
+
slot_or_output: `${slotId}:${filename}`,
|
|
214
|
+
filename: destName,
|
|
215
|
+
seed: outputEntry?.seed,
|
|
216
|
+
reason: reason || `selected from slot ${slot.label || slotId}`,
|
|
217
|
+
tags: tags || [],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const manifest = {
|
|
222
|
+
selection_id: selectionId,
|
|
223
|
+
project_id: projectId,
|
|
224
|
+
source_type: 'batch',
|
|
225
|
+
source_id: batchId,
|
|
226
|
+
workflow_id: batchManifest.mode_id,
|
|
227
|
+
created_at: new Date().toISOString(),
|
|
228
|
+
items,
|
|
229
|
+
reingest_ready: true,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (batchManifest.subject_id) manifest.subject_id = batchManifest.subject_id;
|
|
233
|
+
|
|
234
|
+
return { selectionId, selectionDir, manifest, batchManifest, runManifests };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Save / Load / List ──────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Save a selection manifest + summary to disk.
|
|
241
|
+
*/
|
|
242
|
+
export async function saveSelection(selectionDir, manifest) {
|
|
243
|
+
await writeFile(
|
|
244
|
+
join(selectionDir, 'manifest.json'),
|
|
245
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Summary JSON
|
|
249
|
+
await writeFile(
|
|
250
|
+
join(selectionDir, 'summary.json'),
|
|
251
|
+
JSON.stringify({
|
|
252
|
+
selection_id: manifest.selection_id,
|
|
253
|
+
source_type: manifest.source_type,
|
|
254
|
+
source_id: manifest.source_id,
|
|
255
|
+
workflow_id: manifest.workflow_id,
|
|
256
|
+
subject_id: manifest.subject_id,
|
|
257
|
+
items: manifest.items.length,
|
|
258
|
+
reingest_ready: manifest.reingest_ready,
|
|
259
|
+
created_at: manifest.created_at,
|
|
260
|
+
}, null, 2) + '\n',
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Summary markdown
|
|
264
|
+
const lines = [
|
|
265
|
+
`# Selection: ${manifest.selection_id}`,
|
|
266
|
+
'',
|
|
267
|
+
`**Source:** ${manifest.source_type} \`${manifest.source_id}\``,
|
|
268
|
+
`**Workflow:** ${manifest.workflow_id || '—'}`,
|
|
269
|
+
];
|
|
270
|
+
if (manifest.subject_id) lines.push(`**Subject:** ${manifest.subject_id}`);
|
|
271
|
+
lines.push(`**Created:** ${manifest.created_at}`);
|
|
272
|
+
lines.push(`**Re-ingest ready:** ${manifest.reingest_ready ? 'yes' : 'no'}`);
|
|
273
|
+
lines.push('');
|
|
274
|
+
lines.push('## Chosen outputs');
|
|
275
|
+
lines.push('');
|
|
276
|
+
lines.push('| # | Source | File | Seed | Reason |');
|
|
277
|
+
lines.push('|---|--------|------|------|--------|');
|
|
278
|
+
for (let i = 0; i < manifest.items.length; i++) {
|
|
279
|
+
const item = manifest.items[i];
|
|
280
|
+
lines.push(
|
|
281
|
+
`| ${i + 1} | ${item.slot_or_output} | ${item.filename} | ${item.seed ?? '—'} | ${item.reason} |`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
lines.push('');
|
|
285
|
+
if (manifest.items.some(it => it.tags?.length)) {
|
|
286
|
+
lines.push('## Tags');
|
|
287
|
+
for (const item of manifest.items) {
|
|
288
|
+
if (item.tags?.length) {
|
|
289
|
+
lines.push(`- ${item.filename}: ${item.tags.join(', ')}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
lines.push('');
|
|
293
|
+
}
|
|
294
|
+
await writeFile(join(selectionDir, 'summary.md'), lines.join('\n'));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Load a selection manifest by ID.
|
|
299
|
+
*/
|
|
300
|
+
export function loadSelection(projectRoot, selectionId) {
|
|
301
|
+
const selectionDir = join(getSelectionsDir(projectRoot), selectionId);
|
|
302
|
+
const manifestPath = join(selectionDir, 'manifest.json');
|
|
303
|
+
if (!existsSync(manifestPath)) {
|
|
304
|
+
throw inputError(
|
|
305
|
+
'SELECTION_NOT_FOUND',
|
|
306
|
+
`Selection "${selectionId}" not found`,
|
|
307
|
+
'Check the selection ID or run: sdlab select --run <id> --approve <files>',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* List all selections in a project.
|
|
315
|
+
*/
|
|
316
|
+
export function listSelections(projectRoot) {
|
|
317
|
+
const dir = getSelectionsDir(projectRoot);
|
|
318
|
+
if (!existsSync(dir)) return [];
|
|
319
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
320
|
+
.filter(e => e.isDirectory() && e.name.startsWith('selection_'))
|
|
321
|
+
.map(e => e.name)
|
|
322
|
+
.sort();
|
|
323
|
+
}
|
package/lib/snapshot.js
CHANGED
|
@@ -53,7 +53,7 @@ export function computeConfigFingerprint(projectRoot) {
|
|
|
53
53
|
* @param {Object} profile — selection profile
|
|
54
54
|
* @returns {Promise<{snapshotId: string, included: number, excluded: number}>}
|
|
55
55
|
*/
|
|
56
|
-
export async function createSnapshot(projectRoot, profile) {
|
|
56
|
+
export async function createSnapshot(projectRoot, profile, options = {}) {
|
|
57
57
|
const config = loadProjectConfig(projectRoot);
|
|
58
58
|
const recordsDir = join(projectRoot, 'records');
|
|
59
59
|
const snapshotId = generateSnapshotId();
|
|
@@ -104,7 +104,10 @@ export async function createSnapshot(projectRoot, profile) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Write snapshot files
|
|
107
|
+
// Write snapshot files (skip if dry run)
|
|
108
|
+
if (options.dryRun) {
|
|
109
|
+
return { snapshotId, included: included.length, excluded: excluded.length };
|
|
110
|
+
}
|
|
108
111
|
await mkdir(snapshotDir, { recursive: true });
|
|
109
112
|
|
|
110
113
|
const manifest = {
|
package/lib/split.js
CHANGED
|
@@ -127,7 +127,7 @@ function buildAncestorMap(records) {
|
|
|
127
127
|
* @param {Object} profile — split profile (from loadSplitProfile)
|
|
128
128
|
* @returns {Promise<{splitId: string, train: number, val: number, test: number}>}
|
|
129
129
|
*/
|
|
130
|
-
export async function createSplit(projectRoot, snapshotId, profile) {
|
|
130
|
+
export async function createSplit(projectRoot, snapshotId, profile, options = {}) {
|
|
131
131
|
const config = loadProjectConfig(projectRoot);
|
|
132
132
|
const snapshot = await loadSnapshot(projectRoot, snapshotId);
|
|
133
133
|
const includedEntries = await loadSnapshotIncluded(projectRoot, snapshotId);
|
|
@@ -242,8 +242,11 @@ export async function createSplit(projectRoot, snapshotId, profile) {
|
|
|
242
242
|
records, trainRecords, valRecords, testRecords, config.lanes
|
|
243
243
|
);
|
|
244
244
|
|
|
245
|
-
// Write output
|
|
245
|
+
// Write output (skip if dry run)
|
|
246
246
|
const splitId = generateSplitId();
|
|
247
|
+
if (options.dryRun) {
|
|
248
|
+
return { splitId, train: trainRecords.length, val: valRecords.length, test: testRecords.length };
|
|
249
|
+
}
|
|
247
250
|
const splitDir = join(projectRoot, 'splits', splitId);
|
|
248
251
|
await mkdir(splitDir, { recursive: true });
|
|
249
252
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow profile management.
|
|
3
|
+
*
|
|
4
|
+
* A workflow profile is a production recipe — it defines how to generate
|
|
5
|
+
* a specific kind of output set (portrait pack, expression sheet, moodboard, etc.)
|
|
6
|
+
* using project canon and trained assets.
|
|
7
|
+
*
|
|
8
|
+
* Workflow profiles are NOT training profiles. They drive generation, not training.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { getWorkflowProfilesDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
const REQUIRED_FIELDS = [
|
|
17
|
+
'workflow_id', 'label', 'lane_id', 'output_mode', 'output_count',
|
|
18
|
+
'prompt_strategy', 'negative_strategy', 'canon_focus', 'drift_guards',
|
|
19
|
+
'runtime_defaults',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const VALID_SUBJECT_MODES = ['required', 'optional', 'forbidden'];
|
|
23
|
+
|
|
24
|
+
const VALID_OUTPUT_MODES = [
|
|
25
|
+
'portrait_set', 'expression_sheet', 'variant_pack',
|
|
26
|
+
'moodboard', 'silhouette_sheet', 'turnaround',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const VALID_CANON_FOCUS = [
|
|
30
|
+
'silhouette', 'material_language', 'palette', 'anatomy',
|
|
31
|
+
'costume_logic', 'era_logic', 'scale_logic', 'lighting',
|
|
32
|
+
'surface_wear', 'gesture', 'composition', 'faction_read',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a workflow profile object.
|
|
37
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
38
|
+
*/
|
|
39
|
+
export function validateWorkflowProfile(profile) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
|
|
42
|
+
for (const field of REQUIRED_FIELDS) {
|
|
43
|
+
if (profile[field] === undefined || profile[field] === null) {
|
|
44
|
+
errors.push(`missing required field: ${field}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (profile.subject_mode && !VALID_SUBJECT_MODES.includes(profile.subject_mode)) {
|
|
49
|
+
errors.push(`invalid subject_mode "${profile.subject_mode}" — must be one of: ${VALID_SUBJECT_MODES.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (profile.output_mode && !VALID_OUTPUT_MODES.includes(profile.output_mode)) {
|
|
53
|
+
errors.push(`invalid output_mode "${profile.output_mode}" — must be one of: ${VALID_OUTPUT_MODES.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!Number.isInteger(profile.output_count) || profile.output_count < 1) {
|
|
57
|
+
errors.push('output_count must be a positive integer');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Prompt strategy structure
|
|
61
|
+
const ps = profile.prompt_strategy;
|
|
62
|
+
if (ps) {
|
|
63
|
+
if (!Array.isArray(ps.style_prefix)) errors.push('prompt_strategy.style_prefix must be an array');
|
|
64
|
+
if (typeof ps.structure !== 'string') errors.push('prompt_strategy.structure must be a string');
|
|
65
|
+
if (!Array.isArray(ps.must_include)) errors.push('prompt_strategy.must_include must be an array');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Negative strategy structure
|
|
69
|
+
const ns = profile.negative_strategy;
|
|
70
|
+
if (ns) {
|
|
71
|
+
if (!Array.isArray(ns.must_avoid)) errors.push('negative_strategy.must_avoid must be an array');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Canon focus items
|
|
75
|
+
if (Array.isArray(profile.canon_focus)) {
|
|
76
|
+
for (const item of profile.canon_focus) {
|
|
77
|
+
if (!VALID_CANON_FOCUS.includes(item)) {
|
|
78
|
+
errors.push(`invalid canon_focus item "${item}" — must be one of: ${VALID_CANON_FOCUS.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} else if (profile.canon_focus !== undefined) {
|
|
82
|
+
errors.push('canon_focus must be an array');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (profile.drift_guards && !Array.isArray(profile.drift_guards)) {
|
|
86
|
+
errors.push('drift_guards must be an array');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Runtime defaults
|
|
90
|
+
const rd = profile.runtime_defaults;
|
|
91
|
+
if (rd) {
|
|
92
|
+
if (!rd.adapter_target) errors.push('runtime_defaults.adapter_target is required');
|
|
93
|
+
if (rd.seed_mode && !['fixed', 'increment', 'random'].includes(rd.seed_mode)) {
|
|
94
|
+
errors.push(`invalid runtime_defaults.seed_mode "${rd.seed_mode}" — must be fixed, increment, or random`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { valid: errors.length === 0, errors };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Load a single workflow profile by ID.
|
|
103
|
+
*/
|
|
104
|
+
export async function getWorkflowProfile(projectRoot, workflowId) {
|
|
105
|
+
const dir = getWorkflowProfilesDir(projectRoot);
|
|
106
|
+
const path = join(dir, `${workflowId}.json`);
|
|
107
|
+
if (!existsSync(path)) {
|
|
108
|
+
throw new Error(`Workflow profile "${workflowId}" not found at ${path}`);
|
|
109
|
+
}
|
|
110
|
+
const data = JSON.parse(await readFile(path, 'utf-8'));
|
|
111
|
+
const { valid, errors } = validateWorkflowProfile(data);
|
|
112
|
+
if (!valid) {
|
|
113
|
+
throw new Error(`Workflow profile "${workflowId}" is invalid:\n ${errors.join('\n ')}`);
|
|
114
|
+
}
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all workflow profiles in a project.
|
|
120
|
+
*/
|
|
121
|
+
export async function listWorkflowProfiles(projectRoot) {
|
|
122
|
+
const dir = getWorkflowProfilesDir(projectRoot);
|
|
123
|
+
if (!existsSync(dir)) return [];
|
|
124
|
+
|
|
125
|
+
const files = (await readdir(dir)).filter(f => f.endsWith('.json')).sort();
|
|
126
|
+
const profiles = [];
|
|
127
|
+
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
try {
|
|
130
|
+
const data = JSON.parse(await readFile(join(dir, file), 'utf-8'));
|
|
131
|
+
profiles.push({
|
|
132
|
+
workflow_id: data.workflow_id,
|
|
133
|
+
label: data.label,
|
|
134
|
+
lane_id: data.lane_id,
|
|
135
|
+
subject_mode: data.subject_mode || 'optional',
|
|
136
|
+
output_mode: data.output_mode,
|
|
137
|
+
output_count: data.output_count,
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip malformed
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return profiles;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Save a workflow profile to a project.
|
|
149
|
+
*/
|
|
150
|
+
export async function saveWorkflowProfile(projectRoot, profile) {
|
|
151
|
+
const { valid, errors } = validateWorkflowProfile(profile);
|
|
152
|
+
if (!valid) {
|
|
153
|
+
throw new Error(`Invalid workflow profile:\n ${errors.join('\n ')}`);
|
|
154
|
+
}
|
|
155
|
+
const dir = getWorkflowProfilesDir(projectRoot);
|
|
156
|
+
await mkdir(dir, { recursive: true });
|
|
157
|
+
await writeFile(
|
|
158
|
+
join(dir, `${profile.workflow_id}.json`),
|
|
159
|
+
JSON.stringify(profile, null, 2) + '\n'
|
|
160
|
+
);
|
|
161
|
+
}
|