@mcptoolshop/style-dataset-lab 2.3.0 → 3.0.1
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 +178 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +71 -0
- package/README.md +82 -9
- package/SECURITY.md +6 -5
- package/bin/sdlab.js +359 -11
- package/lib/adapters/comfyui-runner.js +170 -0
- package/lib/adapters/comfyui-workflows.js +163 -0
- package/lib/adapters/diffusers-lora.js +7 -2
- package/lib/adapters/generic-image-caption.js +6 -1
- package/lib/args.js +200 -13
- package/lib/batch-compiler.js +122 -0
- package/lib/batch-modes.js +111 -0
- package/lib/batch-runs.js +237 -0
- package/lib/batch-sheet-render.js +209 -0
- package/lib/brief-compiler.js +625 -0
- package/lib/brief-render.js +205 -0
- package/lib/card.js +11 -4
- package/lib/comfyui.js +100 -6
- package/lib/config.js +68 -3
- package/lib/critique-engine.js +413 -0
- package/lib/critique-render.js +171 -0
- package/lib/deprecation.js +1 -1
- package/lib/eval-pack.js +94 -4
- package/lib/eval-runs.js +42 -3
- package/lib/export.js +144 -34
- package/lib/generated-provenance.js +87 -0
- package/lib/implementation-packs.js +15 -1
- package/lib/log.js +41 -4
- package/lib/paths.js +72 -3
- package/lib/refine-briefs.js +336 -0
- package/lib/reingest-selected.js +184 -0
- package/lib/reingest.js +15 -1
- package/lib/run-summary.js +132 -0
- package/lib/runtime-runs.js +218 -0
- package/lib/selections.js +375 -0
- package/lib/snapshot.js +100 -7
- package/lib/split.js +175 -17
- package/lib/training-manifests.js +17 -3
- package/lib/training-packages.js +28 -14
- package/lib/workflow-profiles.js +161 -0
- package/package.json +13 -4
- package/scripts/batch-generate.js +176 -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 +53 -0
- package/scripts/canon-bind.js +7 -9
- package/scripts/card.js +22 -25
- package/scripts/compare.js +38 -33
- package/scripts/critique-show.js +50 -0
- package/scripts/critique.js +70 -0
- package/scripts/curate.js +91 -46
- package/scripts/doctor.js +172 -6
- package/scripts/eligibility.js +17 -14
- package/scripts/eval-pack.js +13 -11
- package/scripts/eval-run.js +30 -29
- package/scripts/export.js +27 -31
- package/scripts/generate-controlnet.js +31 -17
- package/scripts/generate-identity.js +37 -20
- package/scripts/generate-ipadapter.js +22 -16
- package/scripts/generate.js +68 -13
- package/scripts/implementation-pack.js +19 -14
- package/scripts/init.js +137 -17
- package/scripts/painterly.js +8 -10
- package/scripts/refine.js +119 -0
- package/scripts/reingest-selected.js +78 -0
- package/scripts/reingest.js +37 -18
- 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 +119 -0
- package/scripts/selection-show.js +88 -0
- package/scripts/snapshot.js +22 -24
- package/scripts/split.js +26 -31
- package/scripts/training-manifest.js +29 -24
- package/scripts/training-package.js +24 -20
- package/scripts/training-profile.js +13 -8
- package/scripts/workflow-list.js +61 -0
- package/scripts/workflow-show.js +97 -0
- package/templates/domains/architecture/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/architecture/terminology.json +10 -8
- 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/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/character-design/project.json +4 -2
- 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/inputs/prompts/example-wave.json +32 -0
- package/templates/domains/creature-design/project.json +4 -2
- package/templates/domains/creature-design/terminology.json +1 -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/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/game-art/project.json +4 -2
- 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/inputs/prompts/example-wave.json +37 -0
- package/templates/domains/vehicle-mech/project.json +4 -2
- 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
- package/scripts/bulk-curate-wave2-5.js +0 -138
- package/scripts/bulk-curate-waves11-18.js +0 -413
- package/scripts/curate-wave25.js +0 -209
- package/scripts/migrate-records.js +0 -141
- package/templates/inputs/prompts/example-wave.json +0 -27
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch runs — create batch directories, execute coordinated runs, save manifests.
|
|
3
|
+
*
|
|
4
|
+
* A batch groups multiple runs under one artifact:
|
|
5
|
+
* batch_YYYY-MM-DD_NNN/
|
|
6
|
+
* manifest.json
|
|
7
|
+
* summary.json / summary.md
|
|
8
|
+
* sheets/
|
|
9
|
+
* runs/ (symlinks or copies to project-level runs)
|
|
10
|
+
* briefs/ (slot briefs)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { writeFile, mkdir, copyFile, rename, unlink } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { getProjectRoot, getRunsDir } from './paths.js';
|
|
17
|
+
import { executeRun } from './adapters/comfyui-runner.js';
|
|
18
|
+
import { saveRunSummary } from './run-summary.js';
|
|
19
|
+
import { log, info, warn, verbose } from './log.js';
|
|
20
|
+
import { inputError } from './errors.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* PB-002 / PB-005: atomic write helper — write temp + rename.
|
|
24
|
+
* Rename is atomic on a single filesystem, so a crash mid-write cannot
|
|
25
|
+
* corrupt an existing manifest.
|
|
26
|
+
*/
|
|
27
|
+
async function atomicWriteJson(targetPath, obj) {
|
|
28
|
+
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
29
|
+
const body = JSON.stringify(obj, null, 2) + '\n';
|
|
30
|
+
try {
|
|
31
|
+
await writeFile(tmpPath, body);
|
|
32
|
+
await rename(tmpPath, targetPath);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
try { await unlink(tmpPath); } catch { /* ignore */ }
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Return the batches directory for a project.
|
|
41
|
+
*/
|
|
42
|
+
export function getBatchesDir(projectRoot) {
|
|
43
|
+
return join(projectRoot, 'batches');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate batch ID: batch_YYYY-MM-DD_NNN
|
|
48
|
+
*/
|
|
49
|
+
// PB-008: tighten regex so stray directories like 'batch_<date>_001_wip'
|
|
50
|
+
// don't inflate the sequence via lenient parseInt.
|
|
51
|
+
const BATCH_SEQ_RE = /^(\d{3})$/;
|
|
52
|
+
|
|
53
|
+
export function generateBatchId(batchesDir) {
|
|
54
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
55
|
+
const prefix = `batch_${today}_`;
|
|
56
|
+
|
|
57
|
+
let maxSeq = 0;
|
|
58
|
+
if (existsSync(batchesDir)) {
|
|
59
|
+
const existing = readdirSync(batchesDir, { withFileTypes: true })
|
|
60
|
+
.filter(e => e.isDirectory() && e.name.startsWith(prefix));
|
|
61
|
+
for (const e of existing) {
|
|
62
|
+
const seqStr = e.name.slice(prefix.length);
|
|
63
|
+
const match = BATCH_SEQ_RE.exec(seqStr);
|
|
64
|
+
if (!match) continue;
|
|
65
|
+
const seq = parseInt(match[1], 10);
|
|
66
|
+
if (seq > maxSeq) maxSeq = seq;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const seq = String(maxSeq + 1).padStart(3, '0');
|
|
71
|
+
return `${prefix}${seq}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a batch directory and prepare its structure.
|
|
76
|
+
*/
|
|
77
|
+
export async function createBatchDir(projectRoot) {
|
|
78
|
+
const batchesDir = getBatchesDir(projectRoot);
|
|
79
|
+
const batchId = generateBatchId(batchesDir);
|
|
80
|
+
const batchDir = join(batchesDir, batchId);
|
|
81
|
+
|
|
82
|
+
await mkdir(join(batchDir, 'sheets'), { recursive: true });
|
|
83
|
+
await mkdir(join(batchDir, 'briefs'), { recursive: true });
|
|
84
|
+
|
|
85
|
+
return { batchId, batchDir };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Execute all slot briefs as coordinated runs.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} opts
|
|
92
|
+
* @param {string} opts.projectRoot
|
|
93
|
+
* @param {string} opts.projectName
|
|
94
|
+
* @param {string} opts.batchDir
|
|
95
|
+
* @param {Object} opts.mode — batch mode definition
|
|
96
|
+
* @param {Object[]} opts.slotBriefs — one brief per slot
|
|
97
|
+
* @param {boolean} [opts.dryRun]
|
|
98
|
+
* @returns {Promise<Object[]>} — array of { slot, brief, manifest }
|
|
99
|
+
*/
|
|
100
|
+
export async function executeBatchRuns({
|
|
101
|
+
projectRoot,
|
|
102
|
+
projectName,
|
|
103
|
+
batchDir,
|
|
104
|
+
mode,
|
|
105
|
+
slotBriefs,
|
|
106
|
+
dryRun = false,
|
|
107
|
+
}) {
|
|
108
|
+
// PB-005: fail fast on length mismatch instead of crashing deep in the loop.
|
|
109
|
+
if (!Array.isArray(mode?.variant_plan) || mode.variant_plan.length !== slotBriefs.length) {
|
|
110
|
+
throw inputError(
|
|
111
|
+
'BATCH_SLOT_PLAN_MISMATCH',
|
|
112
|
+
`Batch variant_plan has ${mode?.variant_plan?.length ?? 0} slots but ` +
|
|
113
|
+
`${slotBriefs.length} brief(s) were compiled`,
|
|
114
|
+
'This is an internal invariant — rebuild the batch from the mode definition.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const results = [];
|
|
119
|
+
// PB-005: incremental batch-progress manifest — written after every slot
|
|
120
|
+
// so a crash never orphans the batch dir without a record of what ran.
|
|
121
|
+
const progressPath = join(batchDir, 'manifest.json');
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < slotBriefs.length; i++) {
|
|
124
|
+
const slot = mode.variant_plan[i];
|
|
125
|
+
const brief = slotBriefs[i];
|
|
126
|
+
const slotLabel = slot.label || slot.slot_id;
|
|
127
|
+
|
|
128
|
+
info(`[${i + 1}/${slotBriefs.length}] Slot: ${slotLabel}`);
|
|
129
|
+
|
|
130
|
+
// Save brief into batch/briefs/
|
|
131
|
+
const briefFilename = `${slot.slot_id}.json`;
|
|
132
|
+
await writeFile(
|
|
133
|
+
join(batchDir, 'briefs', briefFilename),
|
|
134
|
+
JSON.stringify(brief, null, 2) + '\n'
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// PB-005: per-slot try/catch — one slot failure no longer throws out of
|
|
138
|
+
// the whole batch. Record the error and keep going so the user doesn't
|
|
139
|
+
// lose already-completed slots.
|
|
140
|
+
try {
|
|
141
|
+
// Execute the run through the existing 4B pipeline
|
|
142
|
+
const manifest = await executeRun({
|
|
143
|
+
projectRoot,
|
|
144
|
+
projectName,
|
|
145
|
+
brief,
|
|
146
|
+
dryRun,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Save run summary
|
|
150
|
+
const runDir = join(getRunsDir(projectRoot), manifest.run_id);
|
|
151
|
+
await saveRunSummary(runDir, manifest);
|
|
152
|
+
|
|
153
|
+
results.push({
|
|
154
|
+
slot_id: slot.slot_id,
|
|
155
|
+
label: slotLabel,
|
|
156
|
+
brief_id: brief.brief_id,
|
|
157
|
+
run_id: manifest.run_id,
|
|
158
|
+
manifest,
|
|
159
|
+
status: 'ok',
|
|
160
|
+
// Pick the first successful output as the selected one
|
|
161
|
+
selected_output: manifest.outputs.find(o =>
|
|
162
|
+
o.status === 'ok' || o.status === 'dry_run'
|
|
163
|
+
)?.filename || null,
|
|
164
|
+
});
|
|
165
|
+
} catch (err) {
|
|
166
|
+
warn(` ✗ slot ${slotLabel} failed: ${err.message}`);
|
|
167
|
+
results.push({
|
|
168
|
+
slot_id: slot.slot_id,
|
|
169
|
+
label: slotLabel,
|
|
170
|
+
brief_id: brief.brief_id,
|
|
171
|
+
run_id: null,
|
|
172
|
+
manifest: null,
|
|
173
|
+
status: 'error',
|
|
174
|
+
error: err.message,
|
|
175
|
+
selected_output: null,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// PB-005: checkpoint after every slot (atomic write).
|
|
180
|
+
try {
|
|
181
|
+
await atomicWriteJson(progressPath, {
|
|
182
|
+
batch_dir: batchDir,
|
|
183
|
+
mode_id: mode?.mode_id ?? mode?.id ?? null,
|
|
184
|
+
incremental: true,
|
|
185
|
+
last_checkpoint_at: new Date().toISOString(),
|
|
186
|
+
completed: i + 1,
|
|
187
|
+
total: slotBriefs.length,
|
|
188
|
+
slots: results,
|
|
189
|
+
});
|
|
190
|
+
verbose(`batch checkpoint: ${i + 1}/${slotBriefs.length} slots`);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
warn(` batch checkpoint write failed: ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Save a batch manifest.
|
|
201
|
+
*/
|
|
202
|
+
export async function saveBatchManifest(batchDir, manifest) {
|
|
203
|
+
// PB-002: atomic write — safe to overwrite an in-progress checkpoint
|
|
204
|
+
// because rename is atomic on a single filesystem.
|
|
205
|
+
await atomicWriteJson(join(batchDir, 'manifest.json'), manifest);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Load a batch manifest by ID.
|
|
210
|
+
*/
|
|
211
|
+
export function loadBatchManifest(projectRoot, batchId) {
|
|
212
|
+
const batchDir = join(getBatchesDir(projectRoot), batchId);
|
|
213
|
+
const manifestPath = join(batchDir, 'manifest.json');
|
|
214
|
+
|
|
215
|
+
if (!existsSync(manifestPath)) {
|
|
216
|
+
throw inputError(
|
|
217
|
+
'BATCH_NOT_FOUND',
|
|
218
|
+
`Batch "${batchId}" not found`,
|
|
219
|
+
'Check the batch ID or run: sdlab batch generate --mode <id>'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* List all batches for a project.
|
|
228
|
+
*/
|
|
229
|
+
export function listBatches(projectRoot) {
|
|
230
|
+
const batchesDir = getBatchesDir(projectRoot);
|
|
231
|
+
if (!existsSync(batchesDir)) return [];
|
|
232
|
+
|
|
233
|
+
return readdirSync(batchesDir, { withFileTypes: true })
|
|
234
|
+
.filter(e => e.isDirectory() && e.name.startsWith('batch_'))
|
|
235
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
236
|
+
.map(e => e.name);
|
|
237
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch sheet renderer — assembles run outputs into reviewable HTML surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Produces an HTML file with a CSS grid layout that references output images.
|
|
5
|
+
* Zero dependencies — works in any browser. Slot labels, seeds, and metadata
|
|
6
|
+
* are embedded for human review.
|
|
7
|
+
*
|
|
8
|
+
* Future: add PNG rendering via sharp if needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { writeFile } from 'node:fs/promises';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { getRunsDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the image path for a slot result.
|
|
18
|
+
* Returns a relative path from the sheet directory to the output image.
|
|
19
|
+
*/
|
|
20
|
+
function resolveImagePath(projectRoot, result, batchDir) {
|
|
21
|
+
if (!result.selected_output || !result.run_id) return null;
|
|
22
|
+
|
|
23
|
+
const imagePath = join(getRunsDir(projectRoot), result.run_id, 'outputs', result.selected_output);
|
|
24
|
+
if (!existsSync(imagePath)) return null;
|
|
25
|
+
|
|
26
|
+
// Relative from sheets/ dir to the image
|
|
27
|
+
const sheetsDir = join(batchDir, 'sheets');
|
|
28
|
+
return relative(sheetsDir, imagePath).replace(/\\/g, '/');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a batch sheet as HTML.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} opts
|
|
35
|
+
* @param {Object} opts.manifest — batch manifest
|
|
36
|
+
* @param {Object} opts.mode — batch mode definition
|
|
37
|
+
* @param {string} opts.projectRoot
|
|
38
|
+
* @param {string} opts.batchDir
|
|
39
|
+
* @returns {string} HTML content
|
|
40
|
+
*/
|
|
41
|
+
export function renderSheetHTML({ manifest, mode, projectRoot, batchDir }) {
|
|
42
|
+
const assembly = mode.assembly || {};
|
|
43
|
+
const layout = assembly.layout || 'grid';
|
|
44
|
+
const slots = manifest.slots || [];
|
|
45
|
+
const rawColumns = assembly.columns;
|
|
46
|
+
const columns = (typeof rawColumns === 'number' && rawColumns > 0)
|
|
47
|
+
? rawColumns
|
|
48
|
+
: computeColumns(slots.length);
|
|
49
|
+
const showSlotLabels = assembly.annotate_slots !== false;
|
|
50
|
+
const showSeed = assembly.show_seed || false;
|
|
51
|
+
|
|
52
|
+
const title = `${manifest.mode_id} — ${manifest.subject_id || manifest.theme || manifest.project_id}`;
|
|
53
|
+
|
|
54
|
+
// Build slot cells
|
|
55
|
+
const cells = slots.map(slot => {
|
|
56
|
+
const imgPath = resolveImagePath(projectRoot, slot, batchDir);
|
|
57
|
+
const hasImage = !!imgPath;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
slot_id: slot.slot_id,
|
|
61
|
+
label: slot.label,
|
|
62
|
+
run_id: slot.run_id,
|
|
63
|
+
selected_output: slot.selected_output,
|
|
64
|
+
imgPath,
|
|
65
|
+
hasImage,
|
|
66
|
+
seed: findSlotSeed(manifest, slot),
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Generate HTML
|
|
71
|
+
const css = generateCSS(layout, columns, cells.length);
|
|
72
|
+
const grid = cells.map((cell, i) => renderCell(cell, i, showSlotLabels, showSeed)).join('\n');
|
|
73
|
+
|
|
74
|
+
return `<!DOCTYPE html>
|
|
75
|
+
<html lang="en">
|
|
76
|
+
<head>
|
|
77
|
+
<meta charset="utf-8">
|
|
78
|
+
<title>${escapeHtml(title)}</title>
|
|
79
|
+
<style>${css}</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<header>
|
|
83
|
+
<h1>${escapeHtml(title)}</h1>
|
|
84
|
+
<p class="meta">
|
|
85
|
+
Batch: ${escapeHtml(manifest.batch_id)} |
|
|
86
|
+
Mode: ${escapeHtml(manifest.mode_id)} |
|
|
87
|
+
Type: ${escapeHtml(mode.batch_type)} |
|
|
88
|
+
${slots.length} slots |
|
|
89
|
+
${manifest.created_at?.slice(0, 16) || '?'}
|
|
90
|
+
</p>
|
|
91
|
+
</header>
|
|
92
|
+
<div class="grid">${grid}
|
|
93
|
+
</div>
|
|
94
|
+
<footer>
|
|
95
|
+
<p>Generated by style-dataset-lab — ${manifest.created_at || ''}</p>
|
|
96
|
+
</footer>
|
|
97
|
+
</body>
|
|
98
|
+
</html>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Compute optimal column count based on slot count.
|
|
103
|
+
*/
|
|
104
|
+
function computeColumns(slotCount) {
|
|
105
|
+
if (slotCount <= 3) return slotCount;
|
|
106
|
+
if (slotCount <= 4) return 2;
|
|
107
|
+
if (slotCount <= 6) return 3;
|
|
108
|
+
if (slotCount <= 9) return 3;
|
|
109
|
+
return 4;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate CSS for the layout.
|
|
114
|
+
*/
|
|
115
|
+
function generateCSS(layout, columns, slotCount) {
|
|
116
|
+
return `
|
|
117
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
118
|
+
body {
|
|
119
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
120
|
+
background: #1a1a1a; color: #e0e0e0;
|
|
121
|
+
padding: 24px;
|
|
122
|
+
}
|
|
123
|
+
header { margin-bottom: 24px; }
|
|
124
|
+
h1 { font-size: 1.4rem; font-weight: 600; }
|
|
125
|
+
.meta { font-size: 0.85rem; color: #888; margin-top: 4px; }
|
|
126
|
+
.grid {
|
|
127
|
+
display: grid;
|
|
128
|
+
grid-template-columns: repeat(${columns}, 1fr);
|
|
129
|
+
gap: 12px;
|
|
130
|
+
}
|
|
131
|
+
.cell {
|
|
132
|
+
background: #252525;
|
|
133
|
+
border: 1px solid #333;
|
|
134
|
+
border-radius: 6px;
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
position: relative;
|
|
137
|
+
}
|
|
138
|
+
.cell img {
|
|
139
|
+
width: 100%; height: auto; display: block;
|
|
140
|
+
}
|
|
141
|
+
.cell .placeholder {
|
|
142
|
+
width: 100%; aspect-ratio: 1;
|
|
143
|
+
background: #2a2a2a;
|
|
144
|
+
display: flex; align-items: center; justify-content: center;
|
|
145
|
+
color: #555; font-size: 0.9rem;
|
|
146
|
+
}
|
|
147
|
+
.cell .label-bar {
|
|
148
|
+
padding: 6px 10px;
|
|
149
|
+
font-size: 0.8rem;
|
|
150
|
+
background: #2a2a2a;
|
|
151
|
+
border-top: 1px solid #333;
|
|
152
|
+
}
|
|
153
|
+
.cell .label-bar .slot-label { font-weight: 600; color: #ccc; }
|
|
154
|
+
.cell .label-bar .seed { color: #666; margin-left: 8px; }
|
|
155
|
+
footer {
|
|
156
|
+
margin-top: 24px;
|
|
157
|
+
font-size: 0.75rem; color: #555;
|
|
158
|
+
text-align: center;
|
|
159
|
+
}
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Render a single cell.
|
|
165
|
+
*/
|
|
166
|
+
function renderCell(cell, index, showLabel, showSeed) {
|
|
167
|
+
const imgTag = cell.hasImage
|
|
168
|
+
? `<img src="${escapeHtml(cell.imgPath)}" alt="${escapeHtml(cell.label)}" loading="lazy">`
|
|
169
|
+
: `<div class="placeholder">${escapeHtml(cell.label)}<br><small>${cell.selected_output || 'no output'}</small></div>`;
|
|
170
|
+
|
|
171
|
+
const labelHtml = showLabel
|
|
172
|
+
? `<div class="label-bar">
|
|
173
|
+
<span class="slot-label">${escapeHtml(cell.label)}</span>
|
|
174
|
+
${showSeed && cell.seed != null ? `<span class="seed">seed: ${cell.seed}</span>` : ''}
|
|
175
|
+
</div>`
|
|
176
|
+
: '';
|
|
177
|
+
|
|
178
|
+
return ` <div class="cell">${imgTag}${labelHtml}</div>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Find the seed used for a slot's selected output.
|
|
183
|
+
*/
|
|
184
|
+
function findSlotSeed(manifest, slot) {
|
|
185
|
+
// Not always available — return null if not found
|
|
186
|
+
return null; // Could be enriched from run manifests later
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Escape HTML entities.
|
|
191
|
+
*/
|
|
192
|
+
function escapeHtml(str) {
|
|
193
|
+
if (!str) return '';
|
|
194
|
+
return String(str)
|
|
195
|
+
.replace(/&/g, '&')
|
|
196
|
+
.replace(/</g, '<')
|
|
197
|
+
.replace(/>/g, '>')
|
|
198
|
+
.replace(/"/g, '"');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Save the sheet HTML to the batch sheets directory.
|
|
203
|
+
*/
|
|
204
|
+
export async function saveSheet(batchDir, filename, html) {
|
|
205
|
+
const sheetsDir = join(batchDir, 'sheets');
|
|
206
|
+
const sheetPath = join(sheetsDir, filename);
|
|
207
|
+
await writeFile(sheetPath, html);
|
|
208
|
+
return sheetPath;
|
|
209
|
+
}
|