@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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 +33 -0
- package/README.es.md +35 -25
- package/README.fr.md +38 -28
- package/README.hi.md +36 -26
- package/README.it.md +37 -27
- package/README.ja.md +40 -30
- package/README.md +15 -5
- package/README.pt-BR.md +35 -25
- package/README.zh.md +33 -23
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +13 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +102 -16
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +48 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/args.js
CHANGED
|
@@ -149,8 +149,23 @@ export function getProjectName(argv) {
|
|
|
149
149
|
for (let i = 0; i < argv.length; i++) {
|
|
150
150
|
const a = argv[i];
|
|
151
151
|
if (a === '--project' || a === '--game') {
|
|
152
|
+
const flagName = a === '--game' ? 'game' : 'project';
|
|
152
153
|
if (a === '--game') warnDeprecated('game', 'project');
|
|
153
|
-
|
|
154
|
+
const next = argv[i + 1];
|
|
155
|
+
// SDL-L1: mirror takeFlagValue's guard below — without it, a missing
|
|
156
|
+
// value silently swallowed the NEXT flag as the project name (e.g.
|
|
157
|
+
// `--project --dry-run` yielded the project name "--dry-run").
|
|
158
|
+
if (next === undefined) {
|
|
159
|
+
throw inputError('INPUT_MISSING_VALUE', `Flag --${flagName} requires a value.`);
|
|
160
|
+
}
|
|
161
|
+
if (next.startsWith('--')) {
|
|
162
|
+
throw inputError(
|
|
163
|
+
'INPUT_MISSING_VALUE',
|
|
164
|
+
`Flag --${flagName} is missing its value (got another flag: ${next}).`,
|
|
165
|
+
`Pass a value after --${flagName}.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
explicit = next;
|
|
154
169
|
break;
|
|
155
170
|
}
|
|
156
171
|
if (a.startsWith('--project=')) { explicit = a.slice('--project='.length); break; }
|
package/lib/batch-modes.js
CHANGED
|
@@ -27,25 +27,35 @@ export function getBatchModesDir(projectRoot) {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* List all batch modes for a project.
|
|
30
|
+
*
|
|
31
|
+
* B06: listWorkflowProfiles() (its structurally identical sibling in
|
|
32
|
+
* workflow-profiles.js) already skips a malformed file per-entry via
|
|
33
|
+
* try/catch. This function had no such guard, so one bad batch-mode file
|
|
34
|
+
* threw an uncaught SyntaxError out of Array.prototype.map and hid EVERY
|
|
35
|
+
* batch mode, not just the broken one.
|
|
30
36
|
*/
|
|
31
37
|
export function listBatchModes(projectRoot) {
|
|
32
38
|
const dir = getBatchModesDir(projectRoot);
|
|
33
39
|
if (!existsSync(dir)) return [];
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
const modes = [];
|
|
42
|
+
for (const f of readdirSync(dir).filter(f => f.endsWith('.json'))) {
|
|
43
|
+
try {
|
|
38
44
|
const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
|
|
39
|
-
|
|
45
|
+
modes.push({
|
|
40
46
|
mode_id: data.mode_id,
|
|
41
47
|
label: data.label,
|
|
42
48
|
batch_type: data.batch_type,
|
|
43
49
|
base_workflow_id: data.base_workflow_id,
|
|
44
50
|
subject_mode: data.subject_mode || 'optional',
|
|
45
51
|
slot_count: data.variant_plan?.length || 0,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
// Skip malformed — matches listWorkflowProfiles' existing resilience.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return modes.sort((a, b) => a.mode_id.localeCompare(b.mode_id));
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/**
|
|
@@ -63,7 +73,18 @@ export function getBatchMode(projectRoot, modeId) {
|
|
|
63
73
|
);
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
// B06: JSON.parse used to run unguarded here, so a malformed mode file
|
|
77
|
+
// threw a raw, uncaught SyntaxError naming nothing. Wrapped in the same
|
|
78
|
+
// structured-error pattern lib/config.js's loadJsonFile already uses.
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw inputError(
|
|
83
|
+
'INPUT_INVALID_JSON',
|
|
84
|
+
`Failed to parse ${filePath}: ${err.message}`,
|
|
85
|
+
'Check the file for JSON syntax errors (trailing commas, unquoted keys, etc).'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
/**
|
package/lib/batch-runs.js
CHANGED
|
@@ -101,15 +101,35 @@ export function buildCompletedSlotMap(priorResults) {
|
|
|
101
101
|
return completed;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// SDL-H7: the only shape a batch id can ever legitimately have, mirroring
|
|
105
|
+
// the prefix/date/seq scheme generateBatchId() builds above. Validated
|
|
106
|
+
// BEFORE the id is joined into a path, so `--resume
|
|
107
|
+
// ../../../../some/dir/leftover` never reaches existsSync/readFile —
|
|
108
|
+
// executeBatchRuns checkpoints into batchDir/manifest.json after every
|
|
109
|
+
// slot, so an unvalidated id here meant repeatedly overwriting a file
|
|
110
|
+
// outside the sandbox. A mismatch reuses the SAME BATCH_NOT_FOUND code (not
|
|
111
|
+
// a distinct one) because no real batch can ever have an id outside this
|
|
112
|
+
// shape, and this avoids leaking validation internals.
|
|
113
|
+
const BATCH_ID_RE = /^batch_\d{4}-\d{2}-\d{2}_\d{3}$/;
|
|
114
|
+
|
|
104
115
|
/**
|
|
105
116
|
* Resume an existing batch directory. Loads its prior manifest so the caller
|
|
106
117
|
* can replay only the incomplete slots while keeping the same batch_id.
|
|
107
118
|
*
|
|
108
|
-
* Throws BATCH_NOT_FOUND if the batch dir or manifest is missing
|
|
119
|
+
* Throws BATCH_NOT_FOUND if the batch dir or manifest is missing (or the id
|
|
120
|
+
* is not shaped like a real batch id).
|
|
109
121
|
* Throws BATCH_NO_PROGRESS if the manifest predates the incremental-checkpoint
|
|
110
122
|
* format (no `slots` array) — those batches can't be partially resumed.
|
|
111
123
|
*/
|
|
112
124
|
export function openBatchDirForResume(projectRoot, batchId) {
|
|
125
|
+
if (typeof batchId !== 'string' || !BATCH_ID_RE.test(batchId)) {
|
|
126
|
+
throw inputError(
|
|
127
|
+
'BATCH_NOT_FOUND',
|
|
128
|
+
`Batch "${batchId}" not found`,
|
|
129
|
+
'Run `sdlab batch list` to see available batch IDs.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
const batchesDir = getBatchesDir(projectRoot);
|
|
114
134
|
const batchDir = join(batchesDir, batchId);
|
|
115
135
|
const manifestPath = join(batchDir, 'manifest.json');
|
|
@@ -271,6 +291,14 @@ export async function saveBatchManifest(batchDir, manifest) {
|
|
|
271
291
|
* Load a batch manifest by ID.
|
|
272
292
|
*/
|
|
273
293
|
export function loadBatchManifest(projectRoot, batchId) {
|
|
294
|
+
if (typeof batchId !== 'string' || !BATCH_ID_RE.test(batchId)) {
|
|
295
|
+
throw inputError(
|
|
296
|
+
'BATCH_NOT_FOUND',
|
|
297
|
+
`Batch "${batchId}" not found`,
|
|
298
|
+
'Check the batch ID or run: sdlab batch generate --mode <id>'
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
274
302
|
const batchDir = join(getBatchesDir(projectRoot), batchId);
|
|
275
303
|
const manifestPath = join(batchDir, 'manifest.json');
|
|
276
304
|
|
|
@@ -100,8 +100,11 @@ export function renderSheetHTML({ manifest, mode, projectRoot, batchDir }) {
|
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
102
|
* Compute optimal column count based on slot count.
|
|
103
|
+
*
|
|
104
|
+
* Exported for reuse by lib/candidate-sheet.js — the ad-hoc directory sheet
|
|
105
|
+
* renderer shares this grid-sizing heuristic rather than forking it.
|
|
103
106
|
*/
|
|
104
|
-
function computeColumns(slotCount) {
|
|
107
|
+
export function computeColumns(slotCount) {
|
|
105
108
|
if (slotCount <= 3) return slotCount;
|
|
106
109
|
if (slotCount <= 4) return 2;
|
|
107
110
|
if (slotCount <= 6) return 3;
|
|
@@ -111,8 +114,12 @@ function computeColumns(slotCount) {
|
|
|
111
114
|
|
|
112
115
|
/**
|
|
113
116
|
* Generate CSS for the layout.
|
|
117
|
+
*
|
|
118
|
+
* Exported for reuse by lib/candidate-sheet.js, which appends its own
|
|
119
|
+
* supplemental rules (status badges, prompt text) rather than duplicating
|
|
120
|
+
* the base grid/cell/dark-theme rules defined here.
|
|
114
121
|
*/
|
|
115
|
-
function generateCSS(layout, columns, slotCount) {
|
|
122
|
+
export function generateCSS(layout, columns, slotCount) {
|
|
116
123
|
return `
|
|
117
124
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
118
125
|
body {
|
|
@@ -188,8 +195,11 @@ function findSlotSeed(manifest, slot) {
|
|
|
188
195
|
|
|
189
196
|
/**
|
|
190
197
|
* Escape HTML entities.
|
|
198
|
+
*
|
|
199
|
+
* Exported for reuse by lib/candidate-sheet.js — one escaping implementation
|
|
200
|
+
* for every HTML sheet this tool produces.
|
|
191
201
|
*/
|
|
192
|
-
function escapeHtml(str) {
|
|
202
|
+
export function escapeHtml(str) {
|
|
193
203
|
if (!str) return '';
|
|
194
204
|
return String(str)
|
|
195
205
|
.replace(/&/g, '&')
|
package/lib/brief-compiler.js
CHANGED
|
@@ -487,6 +487,25 @@ export async function compileBrief({
|
|
|
487
487
|
subjectContext = extractSubjectContext(subjectRecords);
|
|
488
488
|
}
|
|
489
489
|
|
|
490
|
+
// H1: subject_mode 'required' is enforced above on the *string* being
|
|
491
|
+
// non-empty (BRIEF_SUBJECT_REQUIRED), but resolveSubject() swallows every
|
|
492
|
+
// per-file parse failure (catch {}) and returns [] when nothing matches —
|
|
493
|
+
// a typo'd subject id passed that string check and silently compiled a
|
|
494
|
+
// generic brief wearing a subject label it never anchored to (no
|
|
495
|
+
// subject_identity block, no subject_constraints, both gated on
|
|
496
|
+
// length > 0 below). resolveReferences() fails loudly (BRIEF_REF_NOT_FOUND)
|
|
497
|
+
// on the identical "nothing matched" case — this closes the asymmetry.
|
|
498
|
+
if (subjectMode === 'required' && subjectRecords.length === 0) {
|
|
499
|
+
throw inputError(
|
|
500
|
+
'BRIEF_SUBJECT_NOT_FOUND',
|
|
501
|
+
`Workflow "${workflowId}" requires a subject (subject_mode: required), but no records matched ` +
|
|
502
|
+
`subject "${subjectId}" in ${recordsDir}.`,
|
|
503
|
+
`Check "${subjectId}" for typos, or confirm records exist with identity.subject_id (or an id ` +
|
|
504
|
+
`prefix) matching it. resolveSubject() matches by identity.subject_id equality or by id-prefix — ` +
|
|
505
|
+
`see lib/brief-compiler.js's resolveSubject().`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
490
509
|
// Resolve explicit references
|
|
491
510
|
const referenceSelection = await resolveReferences(recordsDir, referenceIds);
|
|
492
511
|
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate sheet — HTML contact sheet over an ARBITRARY directory of
|
|
3
|
+
* images. No batch manifest required.
|
|
4
|
+
*
|
|
5
|
+
* lib/batch-sheet-render.js already has a complete, zero-dependency HTML
|
|
6
|
+
* contact-sheet renderer, but it can only be reached from `sdlab batch
|
|
7
|
+
* sheet`, which needs a batches/<id>/manifest.json that only `sdlab batch
|
|
8
|
+
* generate` produces. This module reuses that renderer's shared primitives
|
|
9
|
+
* (escapeHtml, computeColumns, generateCSS) rather than forking them, and
|
|
10
|
+
* adds the one thing the batch path doesn't need: turning a plain directory
|
|
11
|
+
* of images into reviewable tiles by matching each image to its record.
|
|
12
|
+
*
|
|
13
|
+
* Matching key: `asset_path` (project-root-relative, e.g.
|
|
14
|
+
* "outputs/candidates/foo.png"), not a guessed id/filename convention.
|
|
15
|
+
* Record id prefixes are NOT uniform across this codebase — hand-curated
|
|
16
|
+
* records use the bare filename stem as their id, but lib/reingest.js
|
|
17
|
+
* prefixes re-ingested records with "gen_". asset_path is the one field
|
|
18
|
+
* every schema version agrees on, so it is the only safe join key.
|
|
19
|
+
*
|
|
20
|
+
* Images with no matching record (pre-ingest — generated but not yet
|
|
21
|
+
* ingested into records/) still render: id/status/prompt degrade to
|
|
22
|
+
* `null` and the tile falls back to the filename. This must never throw.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
26
|
+
import { join, relative, extname, basename, dirname } from 'node:path';
|
|
27
|
+
import { escapeHtml, computeColumns, generateCSS } from './batch-sheet-render.js';
|
|
28
|
+
|
|
29
|
+
// Mirrors lib/reingest.js's IMAGE_EXTENSIONS / lib/ingest.js's copy of the
|
|
30
|
+
// same set. Not imported from there — this is a small, stable, load-bearing
|
|
31
|
+
// constant duplicated by convention elsewhere in this codebase rather than
|
|
32
|
+
// centralized; see lib/ingest.js's own comment to that effect.
|
|
33
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a path string to forward slashes with no leading "./", so
|
|
37
|
+
* Windows-vs-POSIX path separators and a stray relative-path prefix never
|
|
38
|
+
* cause an asset_path lookup to miss.
|
|
39
|
+
*/
|
|
40
|
+
function normalizeRelPath(p) {
|
|
41
|
+
return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recursively collect every image file under `rootDir`, sorted
|
|
46
|
+
* deterministically (lexicographic on the POSIX-normalized absolute path,
|
|
47
|
+
* which — because every result shares the same root prefix — is exactly
|
|
48
|
+
* equivalent to sorting on the relative path). Regenerating a sheet from an
|
|
49
|
+
* unchanged directory always produces the same order.
|
|
50
|
+
*
|
|
51
|
+
* Never throws on a missing directory — returns `[]` instead, so callers
|
|
52
|
+
* that already validated the directory exists (or want to treat "empty" and
|
|
53
|
+
* "missing" the same way) don't need a try/catch.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} rootDir — absolute directory to walk
|
|
56
|
+
* @returns {Promise<string[]>} absolute file paths
|
|
57
|
+
*/
|
|
58
|
+
export async function walkImages(rootDir) {
|
|
59
|
+
const found = [];
|
|
60
|
+
|
|
61
|
+
async function walk(current) {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const full = join(current, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
await walk(full);
|
|
73
|
+
} else if (entry.isFile() && IMAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
74
|
+
found.push(full);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await walk(rootDir);
|
|
80
|
+
found.sort((a, b) => {
|
|
81
|
+
const pa = normalizeRelPath(a);
|
|
82
|
+
const pb = normalizeRelPath(b);
|
|
83
|
+
return pa < pb ? -1 : pa > pb ? 1 : 0;
|
|
84
|
+
});
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build an index of every record in a project, keyed by its normalized
|
|
90
|
+
* asset_path. A malformed record file is skipped (not fatal) — same
|
|
91
|
+
* resilience pattern as lib/batch-modes.js's listBatchModes(): one bad file
|
|
92
|
+
* must not hide every good one.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} projectRoot
|
|
95
|
+
* @returns {Promise<Map<string, Object>>}
|
|
96
|
+
*/
|
|
97
|
+
export async function buildAssetPathIndex(projectRoot) {
|
|
98
|
+
const recordsDir = join(projectRoot, 'records');
|
|
99
|
+
const index = new Map();
|
|
100
|
+
|
|
101
|
+
let files;
|
|
102
|
+
try {
|
|
103
|
+
files = await readdir(recordsDir);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err && err.code === 'ENOENT') return index;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (!file.endsWith('.json')) continue;
|
|
111
|
+
let record;
|
|
112
|
+
try {
|
|
113
|
+
record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
|
|
114
|
+
} catch {
|
|
115
|
+
continue; // malformed record file — skip, don't abort the whole scan
|
|
116
|
+
}
|
|
117
|
+
if (record && typeof record.asset_path === 'string' && record.asset_path.length > 0) {
|
|
118
|
+
index.set(normalizeRelPath(record.asset_path), record);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return index;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the per-tile item list for a candidate sheet: every image under
|
|
127
|
+
* `targetDir`, matched against the project's records by asset_path.
|
|
128
|
+
*
|
|
129
|
+
* Each item degrades gracefully when no record matches (pre-ingest state):
|
|
130
|
+
* id/status/prompt are `null` and `hasRecord` is `false` — the caller
|
|
131
|
+
* renders the filename instead. This never throws for that case.
|
|
132
|
+
*
|
|
133
|
+
* `status` distinguishes three states:
|
|
134
|
+
* - no record at all → status: null, hasRecord: false
|
|
135
|
+
* - record exists, unjudged → status: 'uncurated', hasRecord: true
|
|
136
|
+
* - record exists, judged → status: <judgment.status>, hasRecord: true
|
|
137
|
+
*
|
|
138
|
+
* Ordering is deterministic (inherited from walkImages' sort) — regenerating
|
|
139
|
+
* a sheet over an unchanged directory produces byte-identical item order.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} projectRoot
|
|
142
|
+
* @param {string} targetDir — absolute directory to sheet
|
|
143
|
+
* @returns {Promise<Array<Object>>}
|
|
144
|
+
*/
|
|
145
|
+
export async function buildCandidateSheetItems(projectRoot, targetDir) {
|
|
146
|
+
const [images, index] = await Promise.all([
|
|
147
|
+
walkImages(targetDir),
|
|
148
|
+
buildAssetPathIndex(projectRoot),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
return images.map((absPath) => {
|
|
152
|
+
const relFromRoot = normalizeRelPath(relative(projectRoot, absPath));
|
|
153
|
+
const record = index.get(relFromRoot) || null;
|
|
154
|
+
const hasRecord = !!record;
|
|
155
|
+
return {
|
|
156
|
+
absPath,
|
|
157
|
+
relFromRoot,
|
|
158
|
+
filename: basename(absPath),
|
|
159
|
+
id: hasRecord ? (record.id ?? null) : null,
|
|
160
|
+
status: hasRecord ? (record.judgment?.status ?? 'uncurated') : null,
|
|
161
|
+
prompt: hasRecord ? (record.provenance?.prompt ?? null) : null,
|
|
162
|
+
hasRecord,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Truncate long prompt text for inline display; full text stays in the title attribute. */
|
|
168
|
+
function truncate(str, max) {
|
|
169
|
+
if (str.length <= max) return str;
|
|
170
|
+
return `${str.slice(0, max - 1)}…`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Supplemental CSS for candidate-specific tile chrome (id/status/prompt).
|
|
174
|
+
// Appended to lib/batch-sheet-render.js's generateCSS() output rather than
|
|
175
|
+
// duplicating its grid/.cell/.label-bar/dark-theme rules.
|
|
176
|
+
const CANDIDATE_EXTRA_CSS = `
|
|
177
|
+
.cell .cand-id-row {
|
|
178
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
179
|
+
}
|
|
180
|
+
.cell .cand-id {
|
|
181
|
+
font-weight: 600; color: #ccc;
|
|
182
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
183
|
+
}
|
|
184
|
+
.cell .cand-id--missing { color: #888; font-style: italic; font-weight: 400; }
|
|
185
|
+
.cell .cand-status {
|
|
186
|
+
flex: none;
|
|
187
|
+
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em;
|
|
188
|
+
padding: 2px 6px; border-radius: 3px;
|
|
189
|
+
background: #333; color: #aaa;
|
|
190
|
+
}
|
|
191
|
+
.cell .cand-status--approved { background: #1f4d2e; color: #8fdfa8; }
|
|
192
|
+
.cell .cand-status--rejected { background: #4d1f1f; color: #df8f8f; }
|
|
193
|
+
.cell .cand-status--borderline { background: #4d451f; color: #dfd08f; }
|
|
194
|
+
.cell .cand-status--uncurated { background: #333333; color: #999; }
|
|
195
|
+
.cell .cand-status--none { background: #262626; color: #666; }
|
|
196
|
+
.cell .cand-prompt {
|
|
197
|
+
margin-top: 4px; font-size: 0.72rem; color: #888; line-height: 1.3;
|
|
198
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
199
|
+
overflow: hidden;
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render a single candidate tile. Image src is a RELATIVE path from the
|
|
205
|
+
* directory the sheet HTML will be written to, to the image file — never
|
|
206
|
+
* base64. These corpora run into the thousands of images; embedding would
|
|
207
|
+
* make the sheet unopenable.
|
|
208
|
+
*/
|
|
209
|
+
function renderCandidateCell(item, sheetOutputDir) {
|
|
210
|
+
const relImg = normalizeRelPath(relative(sheetOutputDir, item.absPath));
|
|
211
|
+
const imgTag = `<img src="${escapeHtml(relImg)}" alt="${escapeHtml(item.filename)}" loading="lazy">`;
|
|
212
|
+
|
|
213
|
+
const idHtml = item.id
|
|
214
|
+
? `<span class="cand-id">${escapeHtml(item.id)}</span>`
|
|
215
|
+
: `<span class="cand-id cand-id--missing">${escapeHtml(item.filename)}</span>`;
|
|
216
|
+
|
|
217
|
+
const statusText = item.hasRecord ? (item.status || 'uncurated') : 'no record';
|
|
218
|
+
const statusClass = item.hasRecord ? `cand-status--${item.status || 'uncurated'}` : 'cand-status--none';
|
|
219
|
+
const statusHtml = `<span class="cand-status ${escapeHtml(statusClass)}">${escapeHtml(statusText)}</span>`;
|
|
220
|
+
|
|
221
|
+
const promptHtml = item.prompt
|
|
222
|
+
? `<div class="cand-prompt" title="${escapeHtml(item.prompt)}">${escapeHtml(truncate(item.prompt, 200))}</div>`
|
|
223
|
+
: '';
|
|
224
|
+
|
|
225
|
+
return ` <div class="cell">${imgTag}
|
|
226
|
+
<div class="label-bar">
|
|
227
|
+
<div class="cand-id-row">${idHtml}${statusHtml}</div>
|
|
228
|
+
${promptHtml}
|
|
229
|
+
</div>
|
|
230
|
+
</div>`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Render a full candidate sheet HTML document.
|
|
235
|
+
*
|
|
236
|
+
* @param {Object} opts
|
|
237
|
+
* @param {Object[]} opts.items — from buildCandidateSheetItems()
|
|
238
|
+
* @param {string} opts.title
|
|
239
|
+
* @param {string} opts.sourceLabel — human-readable source dir (for the meta line)
|
|
240
|
+
* @param {string} opts.sheetOutputDir — absolute dir the HTML file will be written to (image srcs are relative to this)
|
|
241
|
+
* @param {string} [opts.generatedAt] — ISO timestamp; defaults to now (pass explicitly for deterministic tests)
|
|
242
|
+
* @returns {string} HTML
|
|
243
|
+
*/
|
|
244
|
+
export function renderCandidateSheetHTML({ items, title, sourceLabel, sheetOutputDir, generatedAt }) {
|
|
245
|
+
const columns = computeColumns(items.length || 1);
|
|
246
|
+
const css = generateCSS('grid', columns, items.length) + CANDIDATE_EXTRA_CSS;
|
|
247
|
+
const cellsHtml = items.map((item) => renderCandidateCell(item, sheetOutputDir)).join('\n');
|
|
248
|
+
|
|
249
|
+
const matched = items.filter((i) => i.hasRecord).length;
|
|
250
|
+
const preIngest = items.length - matched;
|
|
251
|
+
|
|
252
|
+
const statusCounts = {};
|
|
253
|
+
for (const item of items) {
|
|
254
|
+
if (!item.hasRecord) continue;
|
|
255
|
+
const key = item.status || 'uncurated';
|
|
256
|
+
statusCounts[key] = (statusCounts[key] || 0) + 1;
|
|
257
|
+
}
|
|
258
|
+
const statusSummary = Object.entries(statusCounts)
|
|
259
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
260
|
+
.join(', ');
|
|
261
|
+
|
|
262
|
+
const metaLine = [
|
|
263
|
+
`Source: ${escapeHtml(sourceLabel)}`,
|
|
264
|
+
`${items.length} image${items.length === 1 ? '' : 's'}`,
|
|
265
|
+
`${matched} with record${matched === 1 ? '' : 's'}${statusSummary ? ` (${escapeHtml(statusSummary)})` : ''}`,
|
|
266
|
+
`${preIngest} pre-ingest`,
|
|
267
|
+
].join(' | ');
|
|
268
|
+
|
|
269
|
+
const stamp = generatedAt || new Date().toISOString();
|
|
270
|
+
|
|
271
|
+
return `<!DOCTYPE html>
|
|
272
|
+
<html lang="en">
|
|
273
|
+
<head>
|
|
274
|
+
<meta charset="utf-8">
|
|
275
|
+
<title>${escapeHtml(title)}</title>
|
|
276
|
+
<style>${css}</style>
|
|
277
|
+
</head>
|
|
278
|
+
<body>
|
|
279
|
+
<header>
|
|
280
|
+
<h1>${escapeHtml(title)}</h1>
|
|
281
|
+
<p class="meta">${metaLine}</p>
|
|
282
|
+
</header>
|
|
283
|
+
<div class="grid">${cellsHtml}
|
|
284
|
+
</div>
|
|
285
|
+
<footer>
|
|
286
|
+
<p>Generated by style-dataset-lab — ${escapeHtml(stamp)}</p>
|
|
287
|
+
</footer>
|
|
288
|
+
</body>
|
|
289
|
+
</html>`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Turn a project-relative directory path into a filesystem-safe filename
|
|
294
|
+
* stem, so the same source dir always maps to the same output filename
|
|
295
|
+
* (regenerating overwrites in place instead of accumulating duplicates).
|
|
296
|
+
*/
|
|
297
|
+
export function slugifyRelPath(relPath) {
|
|
298
|
+
const norm = normalizeRelPath(relPath || '');
|
|
299
|
+
const slug = norm
|
|
300
|
+
.replace(/[\\/]+/g, '-')
|
|
301
|
+
.replace(/[^a-zA-Z0-9-_.]+/g, '-')
|
|
302
|
+
.replace(/-+/g, '-')
|
|
303
|
+
.replace(/^-|-$/g, '');
|
|
304
|
+
return slug || 'sheet';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Default output path for a candidate sheet over `relDir`.
|
|
309
|
+
* @param {string} projectRoot
|
|
310
|
+
* @param {string} relDir — project-relative source directory
|
|
311
|
+
*/
|
|
312
|
+
export function defaultSheetOutputPath(projectRoot, relDir) {
|
|
313
|
+
return join(projectRoot, 'outputs', 'sheets', `${slugifyRelPath(relDir)}.html`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Write a candidate sheet HTML document to disk, creating parent
|
|
318
|
+
* directories as needed.
|
|
319
|
+
* @param {string} outputPath — absolute path to write
|
|
320
|
+
* @param {string} html
|
|
321
|
+
* @returns {Promise<string>} outputPath
|
|
322
|
+
*/
|
|
323
|
+
export async function saveCandidateSheet(outputPath, html) {
|
|
324
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
325
|
+
await writeFile(outputPath, html);
|
|
326
|
+
return outputPath;
|
|
327
|
+
}
|