@mcptoolshop/style-dataset-lab 1.2.1 → 2.1.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 +73 -0
- package/README.md +138 -113
- package/bin/sdlab.js +126 -0
- package/lib/args.js +106 -0
- package/lib/card.js +145 -0
- package/lib/comfyui.js +109 -0
- package/lib/config.js +248 -0
- package/lib/deprecation.js +14 -0
- package/lib/eligibility.js +110 -0
- package/lib/eval-pack.js +283 -0
- package/lib/export.js +259 -0
- package/lib/paths.js +38 -0
- package/lib/records.js +69 -0
- package/lib/snapshot.js +211 -0
- package/lib/split.js +409 -0
- package/package.json +20 -9
- package/scripts/bulk-curate-wave2-5.js +1 -1
- package/scripts/bulk-curate-waves11-18.js +1 -1
- package/scripts/canon-bind.js +188 -338
- package/scripts/card.js +73 -0
- package/scripts/compare.js +40 -30
- package/scripts/curate-wave25.js +1 -1
- package/scripts/curate.js +34 -28
- package/scripts/doctor.js +324 -0
- package/scripts/eligibility.js +91 -0
- package/scripts/eval-pack.js +92 -0
- package/scripts/export.js +85 -0
- package/scripts/generate-controlnet.js +36 -121
- package/scripts/generate-identity.js +38 -106
- package/scripts/generate-ipadapter.js +42 -119
- package/scripts/generate.js +22 -81
- package/scripts/init.js +229 -0
- package/scripts/migrate-records.js +15 -21
- package/scripts/painterly-test.js +20 -47
- package/scripts/painterly.js +27 -106
- package/scripts/snapshot.js +132 -0
- package/scripts/split.js +154 -0
- package/templates/domains/architecture/constitution.json +97 -0
- package/templates/domains/architecture/lanes.json +41 -0
- package/templates/domains/architecture/project.json +20 -0
- package/templates/domains/architecture/rubric.json +40 -0
- package/templates/domains/architecture/terminology.json +19 -0
- package/templates/domains/character-design/constitution.json +88 -0
- package/templates/domains/character-design/lanes.json +35 -0
- package/templates/domains/character-design/project.json +17 -0
- package/templates/domains/character-design/rubric.json +38 -0
- package/templates/domains/character-design/terminology.json +21 -0
- package/templates/domains/creature-design/constitution.json +87 -0
- package/templates/domains/creature-design/lanes.json +41 -0
- package/templates/domains/creature-design/project.json +18 -0
- package/templates/domains/creature-design/rubric.json +39 -0
- package/templates/domains/creature-design/terminology.json +20 -0
- package/templates/domains/game-art/constitution.json +113 -0
- package/templates/domains/game-art/lanes.json +47 -0
- package/templates/domains/game-art/project.json +17 -0
- package/templates/domains/game-art/rubric.json +36 -0
- package/templates/domains/game-art/terminology.json +30 -0
- package/templates/domains/vehicle-mech/constitution.json +87 -0
- package/templates/domains/vehicle-mech/lanes.json +41 -0
- package/templates/domains/vehicle-mech/project.json +17 -0
- package/templates/domains/vehicle-mech/rubric.json +40 -0
- package/templates/domains/vehicle-mech/terminology.json +21 -0
package/lib/eval-pack.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canon-aware eval pack builder.
|
|
3
|
+
*
|
|
4
|
+
* Eval packs are curated test instruments that verify a model's
|
|
5
|
+
* relationship with project canon. Four task types:
|
|
6
|
+
*
|
|
7
|
+
* 1. Lane coverage — representative approved records per lane
|
|
8
|
+
* 2. Forbidden drift — rejected/borderline records paired with violated rules
|
|
9
|
+
* 3. Anchor/gold — highest pass-ratio records per faction
|
|
10
|
+
* 4. Subject continuity — same-subject records for identity testing
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
18
|
+
import { loadRecord } from './records.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
|
|
22
|
+
*/
|
|
23
|
+
function generateEvalId() {
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
26
|
+
const rand = randomBytes(2).toString('hex');
|
|
27
|
+
return `eval-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build an eval pack from project records.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} projectRoot
|
|
34
|
+
* @param {Object} options — { maxPerLane?, maxPerFaction?, maxDrift?, maxContinuity? }
|
|
35
|
+
* @returns {Promise<{evalId: string, tasks: Object}>}
|
|
36
|
+
*/
|
|
37
|
+
export async function buildEvalPack(projectRoot, options = {}) {
|
|
38
|
+
const config = loadProjectConfig(projectRoot);
|
|
39
|
+
const recordsDir = join(projectRoot, 'records');
|
|
40
|
+
const files = (await readdir(recordsDir)).filter(f => f.endsWith('.json')).sort();
|
|
41
|
+
|
|
42
|
+
const maxPerLane = options.maxPerLane || 5;
|
|
43
|
+
const maxPerFaction = options.maxPerFaction || 3;
|
|
44
|
+
const maxDrift = options.maxDrift || 20;
|
|
45
|
+
const maxContinuity = options.maxContinuity || 10;
|
|
46
|
+
|
|
47
|
+
// Load all records
|
|
48
|
+
const records = [];
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const record = JSON.parse(await readFile(join(recordsDir, file), 'utf-8'));
|
|
51
|
+
records.push(record);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Task 1: Lane coverage
|
|
55
|
+
const laneCoverage = buildLaneCoverage(records, config, maxPerLane);
|
|
56
|
+
|
|
57
|
+
// Task 2: Forbidden drift
|
|
58
|
+
const forbiddenDrift = buildForbiddenDrift(records, config, maxDrift);
|
|
59
|
+
|
|
60
|
+
// Task 3: Anchor/gold
|
|
61
|
+
const anchorGold = buildAnchorGold(records, config, maxPerFaction);
|
|
62
|
+
|
|
63
|
+
// Task 4: Subject continuity
|
|
64
|
+
const subjectContinuity = buildSubjectContinuity(records, maxContinuity);
|
|
65
|
+
|
|
66
|
+
// Write eval pack
|
|
67
|
+
const evalId = generateEvalId();
|
|
68
|
+
const evalDir = join(projectRoot, 'eval-packs', evalId);
|
|
69
|
+
await mkdir(evalDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
const manifest = {
|
|
72
|
+
eval_id: evalId,
|
|
73
|
+
created_at: new Date().toISOString(),
|
|
74
|
+
created_by: 'sdlab-eval-v1',
|
|
75
|
+
project: config.meta.name,
|
|
76
|
+
task_counts: {
|
|
77
|
+
lane_coverage: laneCoverage.length,
|
|
78
|
+
forbidden_drift: forbiddenDrift.length,
|
|
79
|
+
anchor_gold: anchorGold.length,
|
|
80
|
+
subject_continuity: subjectContinuity.length,
|
|
81
|
+
},
|
|
82
|
+
total_records: laneCoverage.length + forbiddenDrift.length +
|
|
83
|
+
anchorGold.length + subjectContinuity.length,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
await writeFile(join(evalDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
87
|
+
await writeFile(join(evalDir, 'lane-coverage.jsonl'), laneCoverage.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
88
|
+
await writeFile(join(evalDir, 'forbidden-drift.jsonl'), forbiddenDrift.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
89
|
+
await writeFile(join(evalDir, 'anchor-gold.jsonl'), anchorGold.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
90
|
+
await writeFile(join(evalDir, 'subject-continuity.jsonl'), subjectContinuity.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
evalId,
|
|
94
|
+
tasks: manifest.task_counts,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Task 1: Lane coverage — best approved record per lane.
|
|
100
|
+
*/
|
|
101
|
+
function buildLaneCoverage(records, config, maxPerLane) {
|
|
102
|
+
const byLane = new Map();
|
|
103
|
+
|
|
104
|
+
for (const record of records) {
|
|
105
|
+
if (record.judgment?.status !== 'approved') continue;
|
|
106
|
+
if (!record.canon?.assertions?.length) continue;
|
|
107
|
+
|
|
108
|
+
const prompt = record.provenance?.prompt || '';
|
|
109
|
+
const lane = detectLane(record.id, prompt, config.lanes);
|
|
110
|
+
if (!byLane.has(lane)) byLane.set(lane, []);
|
|
111
|
+
byLane.get(lane).push(record);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = [];
|
|
115
|
+
for (const [lane, recs] of [...byLane].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
116
|
+
// Sort by pass ratio descending
|
|
117
|
+
const sorted = recs.sort((a, b) => {
|
|
118
|
+
const ratioA = a.canon.assertion_count > 0 ? a.canon.pass_count / a.canon.assertion_count : 0;
|
|
119
|
+
const ratioB = b.canon.assertion_count > 0 ? b.canon.pass_count / b.canon.assertion_count : 0;
|
|
120
|
+
return ratioB - ratioA;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
for (const record of sorted.slice(0, maxPerLane)) {
|
|
124
|
+
result.push({
|
|
125
|
+
task: 'lane_coverage',
|
|
126
|
+
record_id: record.id,
|
|
127
|
+
lane,
|
|
128
|
+
pass_ratio: record.canon.assertion_count > 0
|
|
129
|
+
? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : 0,
|
|
130
|
+
asset_path: record.asset_path,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Task 2: Forbidden drift — rejected/borderline records with violated rules.
|
|
140
|
+
*/
|
|
141
|
+
function buildForbiddenDrift(records, config, maxDrift) {
|
|
142
|
+
const candidates = [];
|
|
143
|
+
|
|
144
|
+
for (const record of records) {
|
|
145
|
+
const status = record.judgment?.status;
|
|
146
|
+
if (status !== 'rejected' && status !== 'borderline') continue;
|
|
147
|
+
|
|
148
|
+
// Collect failure modes from judgment
|
|
149
|
+
const failureModes = record.judgment?.failure_modes || [];
|
|
150
|
+
// Collect failed canon assertions
|
|
151
|
+
const failedAssertions = (record.canon?.assertions || [])
|
|
152
|
+
.filter(a => a.verdict === 'fail')
|
|
153
|
+
.map(a => a.rule_id || a.rule);
|
|
154
|
+
|
|
155
|
+
const violations = [...new Set([...failureModes, ...failedAssertions])];
|
|
156
|
+
if (violations.length === 0 && !record.judgment?.explanation) continue;
|
|
157
|
+
|
|
158
|
+
candidates.push({
|
|
159
|
+
task: 'forbidden_drift',
|
|
160
|
+
record_id: record.id,
|
|
161
|
+
status,
|
|
162
|
+
violations,
|
|
163
|
+
explanation: record.judgment?.explanation || null,
|
|
164
|
+
asset_path: record.asset_path,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Sort by number of violations descending (most instructive first)
|
|
169
|
+
candidates.sort((a, b) => b.violations.length - a.violations.length);
|
|
170
|
+
return candidates.slice(0, maxDrift);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Task 3: Anchor/gold — highest pass-ratio records per faction.
|
|
175
|
+
*/
|
|
176
|
+
function buildAnchorGold(records, config, maxPerFaction) {
|
|
177
|
+
const byFaction = new Map();
|
|
178
|
+
|
|
179
|
+
for (const record of records) {
|
|
180
|
+
if (record.judgment?.status !== 'approved') continue;
|
|
181
|
+
if (!record.canon?.assertions?.length) continue;
|
|
182
|
+
|
|
183
|
+
const prompt = record.provenance?.prompt || '';
|
|
184
|
+
const faction = detectGroup(record.id, prompt, config.terminology) || 'unknown';
|
|
185
|
+
if (!byFaction.has(faction)) byFaction.set(faction, []);
|
|
186
|
+
byFaction.get(faction).push(record);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = [];
|
|
190
|
+
for (const [faction, recs] of [...byFaction].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
191
|
+
const sorted = recs.sort((a, b) => {
|
|
192
|
+
const ratioA = a.canon.assertion_count > 0 ? a.canon.pass_count / a.canon.assertion_count : 0;
|
|
193
|
+
const ratioB = b.canon.assertion_count > 0 ? b.canon.pass_count / b.canon.assertion_count : 0;
|
|
194
|
+
return ratioB - ratioA;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
for (const record of sorted.slice(0, maxPerFaction)) {
|
|
198
|
+
result.push({
|
|
199
|
+
task: 'anchor_gold',
|
|
200
|
+
record_id: record.id,
|
|
201
|
+
faction,
|
|
202
|
+
pass_ratio: record.canon.assertion_count > 0
|
|
203
|
+
? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3) : 0,
|
|
204
|
+
assertion_count: record.canon.assertion_count,
|
|
205
|
+
asset_path: record.asset_path,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Task 4: Subject continuity — groups of same-subject records for identity testing.
|
|
215
|
+
*/
|
|
216
|
+
function buildSubjectContinuity(records, maxGroups) {
|
|
217
|
+
const bySubject = new Map();
|
|
218
|
+
|
|
219
|
+
for (const record of records) {
|
|
220
|
+
const subject = record.identity?.subject_name;
|
|
221
|
+
if (!subject) continue;
|
|
222
|
+
if (!bySubject.has(subject)) bySubject.set(subject, []);
|
|
223
|
+
bySubject.get(subject).push(record);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = [];
|
|
227
|
+
// Sort subjects by record count descending (richest clusters first)
|
|
228
|
+
const sorted = [...bySubject].sort((a, b) => b[1].length - a[1].length);
|
|
229
|
+
|
|
230
|
+
for (const [subject, recs] of sorted.slice(0, maxGroups)) {
|
|
231
|
+
result.push({
|
|
232
|
+
task: 'subject_continuity',
|
|
233
|
+
subject_name: subject,
|
|
234
|
+
record_count: recs.length,
|
|
235
|
+
record_ids: recs.map(r => r.id).sort(),
|
|
236
|
+
subject_type: recs[0].identity?.subject_type || 'unknown',
|
|
237
|
+
faction: recs[0].identity?.faction || null,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* List all eval packs in a project.
|
|
246
|
+
*/
|
|
247
|
+
export async function listEvalPacks(projectRoot) {
|
|
248
|
+
const evalDir = join(projectRoot, 'eval-packs');
|
|
249
|
+
if (!existsSync(evalDir)) return [];
|
|
250
|
+
|
|
251
|
+
const entries = await readdir(evalDir, { withFileTypes: true });
|
|
252
|
+
const packs = [];
|
|
253
|
+
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (!entry.isDirectory() || !entry.name.startsWith('eval-')) continue;
|
|
256
|
+
const manifestPath = join(evalDir, entry.name, 'manifest.json');
|
|
257
|
+
if (!existsSync(manifestPath)) continue;
|
|
258
|
+
try {
|
|
259
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
260
|
+
packs.push({
|
|
261
|
+
id: manifest.eval_id,
|
|
262
|
+
created_at: manifest.created_at,
|
|
263
|
+
total: manifest.total_records,
|
|
264
|
+
tasks: manifest.task_counts,
|
|
265
|
+
});
|
|
266
|
+
} catch {
|
|
267
|
+
// Skip malformed
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return packs.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Load an eval pack manifest.
|
|
276
|
+
*/
|
|
277
|
+
export async function loadEvalPack(projectRoot, evalId) {
|
|
278
|
+
const path = join(projectRoot, 'eval-packs', evalId, 'manifest.json');
|
|
279
|
+
if (!existsSync(path)) {
|
|
280
|
+
throw new Error(`Eval pack "${evalId}" not found at ${path}`);
|
|
281
|
+
}
|
|
282
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
283
|
+
}
|
package/lib/export.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export package builder — self-contained, reproducible dataset exports.
|
|
3
|
+
*
|
|
4
|
+
* An export package contains everything needed to use or rebuild the dataset:
|
|
5
|
+
* manifest, metadata, images (symlinked or copied), splits, card, and checksums.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile, mkdir, readdir, symlink, copyFile, stat } from 'node:fs/promises';
|
|
9
|
+
import { join, relative, resolve } from 'node:path';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
|
|
13
|
+
import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint } from './snapshot.js';
|
|
14
|
+
import { loadSplit, loadSplitPartition } from './split.js';
|
|
15
|
+
import { loadRecord } from './records.js';
|
|
16
|
+
import { generateCard } from './card.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate an export ID: export-YYYYMMDD-HHMMSS-XXXX
|
|
20
|
+
*/
|
|
21
|
+
function generateExportId() {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const date = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
24
|
+
const rand = randomBytes(2).toString('hex');
|
|
25
|
+
return `export-${date.slice(0, 8)}-${date.slice(8, 14)}-${rand}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute SHA-256 of a file.
|
|
30
|
+
*/
|
|
31
|
+
async function sha256File(filePath) {
|
|
32
|
+
const data = await readFile(filePath);
|
|
33
|
+
return createHash('sha256').update(data).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build an export package.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} projectRoot
|
|
40
|
+
* @param {string} snapshotId
|
|
41
|
+
* @param {string} splitId
|
|
42
|
+
* @param {Object} options — { profileId?, copy? }
|
|
43
|
+
* @returns {Promise<{exportId: string, recordCount: number, imageCount: number}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function buildExport(projectRoot, snapshotId, splitId, options = {}) {
|
|
46
|
+
const config = loadProjectConfig(projectRoot);
|
|
47
|
+
const profile = loadExportProfile(projectRoot, options.profileId || null);
|
|
48
|
+
const snapshot = await loadSnapshot(projectRoot, snapshotId);
|
|
49
|
+
const split = await loadSplit(projectRoot, splitId);
|
|
50
|
+
|
|
51
|
+
// Load all included records
|
|
52
|
+
const included = await loadSnapshotIncluded(projectRoot, snapshotId);
|
|
53
|
+
const recordsDir = join(projectRoot, 'records');
|
|
54
|
+
|
|
55
|
+
const exportId = generateExportId();
|
|
56
|
+
const exportDir = join(projectRoot, 'exports', exportId);
|
|
57
|
+
await mkdir(exportDir, { recursive: true });
|
|
58
|
+
|
|
59
|
+
// Build metadata + collect image paths
|
|
60
|
+
const metadata = [];
|
|
61
|
+
const imagePaths = [];
|
|
62
|
+
const checksumEntries = [];
|
|
63
|
+
|
|
64
|
+
for (const entry of included) {
|
|
65
|
+
const record = await loadRecord(recordsDir, entry.record_id);
|
|
66
|
+
if (!record) continue;
|
|
67
|
+
|
|
68
|
+
const prompt = record.provenance?.prompt || '';
|
|
69
|
+
const lane = detectLane(record.id, prompt, config.lanes);
|
|
70
|
+
const group = detectGroup(record.id, prompt, config.terminology);
|
|
71
|
+
|
|
72
|
+
// Build metadata row with requested fields
|
|
73
|
+
const row = {};
|
|
74
|
+
for (const field of profile.metadata_fields) {
|
|
75
|
+
if (field === 'id') row.id = record.id;
|
|
76
|
+
else if (field === 'asset_path') row.asset_path = record.asset_path;
|
|
77
|
+
else if (field === 'provenance') row.provenance = record.provenance;
|
|
78
|
+
else if (field === 'judgment') row.judgment = record.judgment;
|
|
79
|
+
else if (field === 'canon') row.canon = record.canon;
|
|
80
|
+
else if (field === 'identity') row.identity = record.identity || null;
|
|
81
|
+
else if (field === 'lineage') row.lineage = record.lineage || null;
|
|
82
|
+
else if (record[field] !== undefined) row[field] = record[field];
|
|
83
|
+
}
|
|
84
|
+
row.detected_lane = lane;
|
|
85
|
+
row.detected_group = group;
|
|
86
|
+
metadata.push(row);
|
|
87
|
+
|
|
88
|
+
// Track image for linking
|
|
89
|
+
if (profile.include_images && record.asset_path) {
|
|
90
|
+
imagePaths.push({
|
|
91
|
+
record_id: record.id,
|
|
92
|
+
source: join(projectRoot, record.asset_path),
|
|
93
|
+
filename: record.asset_path.split('/').pop(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Write metadata.jsonl
|
|
99
|
+
const metadataPath = join(exportDir, 'metadata.jsonl');
|
|
100
|
+
await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
|
|
101
|
+
|
|
102
|
+
// Images directory
|
|
103
|
+
if (profile.include_images && imagePaths.length > 0) {
|
|
104
|
+
const imagesDir = join(exportDir, 'images');
|
|
105
|
+
await mkdir(imagesDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
let linked = 0;
|
|
108
|
+
for (const img of imagePaths) {
|
|
109
|
+
const dest = join(imagesDir, img.filename);
|
|
110
|
+
if (existsSync(img.source)) {
|
|
111
|
+
try {
|
|
112
|
+
if (options.copy) {
|
|
113
|
+
await copyFile(img.source, dest);
|
|
114
|
+
} else {
|
|
115
|
+
const relPath = relative(imagesDir, img.source);
|
|
116
|
+
await symlink(relPath, dest);
|
|
117
|
+
}
|
|
118
|
+
linked++;
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip individual failures (e.g. duplicate filenames)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Splits
|
|
127
|
+
if (profile.include_splits) {
|
|
128
|
+
const splitsDir = join(exportDir, 'splits');
|
|
129
|
+
await mkdir(splitsDir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
for (const partition of ['train', 'val', 'test']) {
|
|
132
|
+
const entries = await loadSplitPartition(projectRoot, splitId, partition);
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(splitsDir, `${partition}.jsonl`),
|
|
135
|
+
entries.map(e => JSON.stringify(e)).join('\n') + '\n'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Dataset card
|
|
141
|
+
if (profile.include_card) {
|
|
142
|
+
const { markdown, json } = await generateCard(projectRoot, snapshotId, splitId);
|
|
143
|
+
await writeFile(join(exportDir, 'dataset-card.md'), markdown);
|
|
144
|
+
await writeFile(join(exportDir, 'dataset-card.json'), JSON.stringify(json, null, 2) + '\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Checksums
|
|
148
|
+
if (profile.include_checksums) {
|
|
149
|
+
// Checksum all files in the export dir (except checksums.txt itself)
|
|
150
|
+
const allFiles = await collectFiles(exportDir);
|
|
151
|
+
for (const filePath of allFiles.sort()) {
|
|
152
|
+
const relPath = relative(exportDir, filePath);
|
|
153
|
+
if (relPath === 'checksums.txt') continue;
|
|
154
|
+
const hash = await sha256File(filePath);
|
|
155
|
+
checksumEntries.push(`SHA256 (${relPath}) = ${hash}`);
|
|
156
|
+
}
|
|
157
|
+
await writeFile(join(exportDir, 'checksums.txt'), checksumEntries.join('\n') + '\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Manifest (written last — references everything)
|
|
161
|
+
const manifest = {
|
|
162
|
+
export_id: exportId,
|
|
163
|
+
created_at: new Date().toISOString(),
|
|
164
|
+
created_by: 'sdlab-export-v1',
|
|
165
|
+
project: config.meta.name,
|
|
166
|
+
snapshot_id: snapshotId,
|
|
167
|
+
split_id: splitId,
|
|
168
|
+
config_fingerprint: computeConfigFingerprint(projectRoot),
|
|
169
|
+
profile,
|
|
170
|
+
counts: {
|
|
171
|
+
records: metadata.length,
|
|
172
|
+
images: imagePaths.filter(i => existsSync(i.source)).length,
|
|
173
|
+
checksums: checksumEntries.length,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
await writeFile(join(exportDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
177
|
+
|
|
178
|
+
// Summary
|
|
179
|
+
const summary = {
|
|
180
|
+
export_id: exportId,
|
|
181
|
+
snapshot_id: snapshotId,
|
|
182
|
+
split_id: splitId,
|
|
183
|
+
record_count: metadata.length,
|
|
184
|
+
image_count: manifest.counts.images,
|
|
185
|
+
partitions: {
|
|
186
|
+
train: split.counts.train,
|
|
187
|
+
val: split.counts.val,
|
|
188
|
+
test: split.counts.test,
|
|
189
|
+
},
|
|
190
|
+
config_fingerprint: manifest.config_fingerprint,
|
|
191
|
+
};
|
|
192
|
+
await writeFile(join(exportDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n');
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
exportId,
|
|
196
|
+
recordCount: metadata.length,
|
|
197
|
+
imageCount: manifest.counts.images,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Recursively collect all files in a directory.
|
|
203
|
+
*/
|
|
204
|
+
async function collectFiles(dir, base = dir) {
|
|
205
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
206
|
+
const files = [];
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const full = join(dir, entry.name);
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
files.push(...await collectFiles(full, base));
|
|
211
|
+
} else {
|
|
212
|
+
files.push(full);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return files;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* List all exports in a project.
|
|
220
|
+
*/
|
|
221
|
+
export async function listExports(projectRoot) {
|
|
222
|
+
const exportsDir = join(projectRoot, 'exports');
|
|
223
|
+
if (!existsSync(exportsDir)) return [];
|
|
224
|
+
|
|
225
|
+
const entries = await readdir(exportsDir, { withFileTypes: true });
|
|
226
|
+
const exports = [];
|
|
227
|
+
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!entry.isDirectory() || !entry.name.startsWith('export-')) continue;
|
|
230
|
+
const manifestPath = join(exportsDir, entry.name, 'manifest.json');
|
|
231
|
+
if (!existsSync(manifestPath)) continue;
|
|
232
|
+
try {
|
|
233
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
234
|
+
exports.push({
|
|
235
|
+
id: manifest.export_id,
|
|
236
|
+
created_at: manifest.created_at,
|
|
237
|
+
snapshot_id: manifest.snapshot_id,
|
|
238
|
+
split_id: manifest.split_id,
|
|
239
|
+
records: manifest.counts.records,
|
|
240
|
+
images: manifest.counts.images,
|
|
241
|
+
});
|
|
242
|
+
} catch {
|
|
243
|
+
// Skip malformed
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return exports.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Load an export manifest.
|
|
252
|
+
*/
|
|
253
|
+
export async function loadExport(projectRoot, exportId) {
|
|
254
|
+
const path = join(projectRoot, 'exports', exportId, 'manifest.json');
|
|
255
|
+
if (!existsSync(path)) {
|
|
256
|
+
throw new Error(`Export "${exportId}" not found at ${path}`);
|
|
257
|
+
}
|
|
258
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
259
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution — single source of truth for repo root and project paths.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
|
|
12
|
+
/** Absolute path to the repository root. */
|
|
13
|
+
export const REPO_ROOT = join(__dirname, '..');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the root directory for a named project.
|
|
17
|
+
* @param {string} name — project name (e.g. 'star-freight')
|
|
18
|
+
* @returns {string} absolute path to projects/<name>/
|
|
19
|
+
*/
|
|
20
|
+
export function getProjectRoot(name) {
|
|
21
|
+
const root = join(REPO_ROOT, 'projects', name);
|
|
22
|
+
if (!existsSync(root)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Project "${name}" not found at ${root}.\n` +
|
|
25
|
+
`Run: sdlab init ${name} --domain <domain>`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Join segments onto a project root.
|
|
33
|
+
* @param {string} name — project name
|
|
34
|
+
* @param {...string} segments — path segments
|
|
35
|
+
*/
|
|
36
|
+
export function resolveProjectPath(name, ...segments) {
|
|
37
|
+
return join(getProjectRoot(name), ...segments);
|
|
38
|
+
}
|
package/lib/records.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record builder and I/O utilities.
|
|
3
|
+
*
|
|
4
|
+
* Standardizes the JSON record format used across all scripts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a base provenance record (generated by txt2img scripts).
|
|
12
|
+
*/
|
|
13
|
+
export function buildBaseRecord(id, assetPath, imageData, provenance) {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
schema_version: '2.0.0',
|
|
17
|
+
created_at: new Date().toISOString(),
|
|
18
|
+
asset_path: assetPath,
|
|
19
|
+
image: {
|
|
20
|
+
format: 'png',
|
|
21
|
+
width: imageData.width || 1024,
|
|
22
|
+
height: imageData.height || 1024,
|
|
23
|
+
bytes: imageData.bytes || 0,
|
|
24
|
+
},
|
|
25
|
+
provenance,
|
|
26
|
+
judgment: null,
|
|
27
|
+
canon: null,
|
|
28
|
+
iteration: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build an identity record (extends base with identity + lineage blocks).
|
|
34
|
+
*/
|
|
35
|
+
export function buildIdentityRecord(baseRecord, identity, lineage) {
|
|
36
|
+
return {
|
|
37
|
+
...baseRecord,
|
|
38
|
+
identity,
|
|
39
|
+
lineage,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load a record by asset ID from a records directory.
|
|
45
|
+
* @param {string} recordsDir — absolute path to records/
|
|
46
|
+
* @param {string} assetId — record filename (without .json)
|
|
47
|
+
* @returns {Promise<Object|null>}
|
|
48
|
+
*/
|
|
49
|
+
export async function loadRecord(recordsDir, assetId) {
|
|
50
|
+
const path = join(recordsDir, `${assetId}.json`);
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(path, 'utf-8');
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === 'ENOENT') return null;
|
|
56
|
+
throw new Error(`Failed to load record ${assetId}: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Save a record to disk.
|
|
62
|
+
* @param {string} recordsDir — absolute path to records/
|
|
63
|
+
* @param {string} assetId — record filename (without .json)
|
|
64
|
+
* @param {Object} record — the record object
|
|
65
|
+
*/
|
|
66
|
+
export async function saveRecord(recordsDir, assetId, record) {
|
|
67
|
+
const path = join(recordsDir, `${assetId}.json`);
|
|
68
|
+
await writeFile(path, JSON.stringify(record, null, 2) + '\n');
|
|
69
|
+
}
|