@mcptoolshop/style-dataset-lab 3.1.0 → 3.3.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 +61 -0
- package/README.es.md +96 -63
- package/README.fr.md +97 -64
- package/README.hi.md +108 -77
- package/README.it.md +97 -64
- package/README.ja.md +105 -72
- package/README.md +33 -0
- package/README.pt-BR.md +94 -61
- package/README.zh.md +98 -65
- package/bin/sdlab.js +71 -0
- package/lib/adapters/ai-toolkit.js +209 -0
- package/lib/adapters/comfyui-runner.js +36 -2
- package/lib/adapters/comfyui-workflows.js +133 -2
- package/lib/adapters/diffusers-lora.js +46 -66
- package/lib/adapters/generic-image-caption.js +22 -50
- package/lib/canon-build/build.js +450 -0
- package/lib/canon-build/emitters/base.js +122 -0
- package/lib/canon-build/emitters/character.js +167 -0
- package/lib/canon-build/emitters/deity.js +165 -0
- package/lib/canon-build/emitters/faction.js +161 -0
- package/lib/canon-build/emitters/index.js +38 -0
- package/lib/canon-build/emitters/location.js +153 -0
- package/lib/canon-build/emitters/monster.js +131 -0
- package/lib/canon-build/emitters/relic.js +142 -0
- package/lib/canon-build/emitters/ship.js +154 -0
- package/lib/canon-build/emitters/species.js +165 -0
- package/lib/canon-build/frontmatter.js +76 -0
- package/lib/canon-build/lane-resolve.js +64 -0
- package/lib/canon-build/load-config.js +117 -0
- package/lib/canon-build/load-entry.js +62 -0
- package/lib/canon-build/load-schema.js +46 -0
- package/lib/captions.js +197 -0
- package/lib/config.js +19 -0
- package/lib/critique-engine.js +46 -1
- package/lib/critique-render.js +45 -0
- package/lib/freeze-events.js +109 -0
- package/lib/freeze-gate.js +208 -0
- package/lib/freeze-stamp.js +161 -0
- package/lib/rows.js +228 -0
- package/lib/run-manifest.js +308 -0
- package/lib/snapshot.js +7 -1
- package/lib/training-adapters.js +5 -0
- package/lib/training-packages.js +24 -18
- package/lib/training-profiles.js +33 -0
- package/lib/verifier.js +67 -0
- package/package.json +12 -3
- package/scripts/_batch_cut.py +71 -0
- package/scripts/_seam_cutout.py +74 -0
- package/scripts/canon-build.js +88 -0
- package/scripts/canon-drift.js +193 -0
- package/scripts/canon-freeze-status.js +85 -0
- package/scripts/canon-freeze.js +211 -0
- package/scripts/canon-unfreeze.js +137 -0
- package/scripts/critique.js +32 -7
- package/scripts/curate.js +33 -0
- package/scripts/generate-controlnet.js +12 -0
- package/scripts/generate-identity.js +20 -4
- package/scripts/generate-ipadapter.js +11 -0
- package/scripts/generate.js +262 -102
- package/scripts/qwen_generate.py +327 -0
- package/scripts/run-generate.js +3 -0
package/lib/rows.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical dataset row shape.
|
|
3
|
+
*
|
|
4
|
+
* The single source-of-truth shape that adapters (generic-image-caption,
|
|
5
|
+
* diffusers-lora, ai-toolkit) consume. Two producers feed this shape:
|
|
6
|
+
*
|
|
7
|
+
* 1. `canon-build` — emits rows to disk at
|
|
8
|
+
* `<project>/canon-build/<canon_sha>/dataset/<lane>-<partition>.jsonl`
|
|
9
|
+
* from the canon entity store (research D1, D2).
|
|
10
|
+
* 2. `training-packages` — converts legacy records-path `{record, lane, group}`
|
|
11
|
+
* entries into rows in memory so the records flow keeps working while
|
|
12
|
+
* canon authoring catches up.
|
|
13
|
+
*
|
|
14
|
+
* Both paths produce rows the adapter cannot distinguish. This is D6:
|
|
15
|
+
* adapters have one input contract, not two.
|
|
16
|
+
*
|
|
17
|
+
* The research deliverable pins the row schema. See
|
|
18
|
+
* `memory/three-projection-build-research-2026-04-24.md` §Projection contracts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { buildCaption, deriveStyleTrigger } from './captions.js';
|
|
24
|
+
|
|
25
|
+
export const ROW_SCHEMA_VERSION = 'canon-build-dataset-1.0';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compute SHA-256 of a deterministic serialization of the inputs.
|
|
29
|
+
* Used for `entry_hash` (cache key) when building rows from canon entries.
|
|
30
|
+
*/
|
|
31
|
+
export function hashRowInputs({ entryBody, schemaVersion, buildConfigJson, projectFingerprint }) {
|
|
32
|
+
const h = createHash('sha256');
|
|
33
|
+
h.update(entryBody || '');
|
|
34
|
+
h.update('\0');
|
|
35
|
+
h.update(schemaVersion || '');
|
|
36
|
+
h.update('\0');
|
|
37
|
+
h.update(buildConfigJson || '');
|
|
38
|
+
h.update('\0');
|
|
39
|
+
h.update(projectFingerprint || '');
|
|
40
|
+
return h.digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a row from a legacy record object.
|
|
45
|
+
*
|
|
46
|
+
* The records flow (star-freight today) produces `{ record, lane, group }`
|
|
47
|
+
* tuples via split + snapshot + canon-binding. Each tuple becomes one row.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} record — the records/<id>.json object
|
|
50
|
+
* @param {string|null} lane — resolved lane (costume, environment, ...)
|
|
51
|
+
* @param {string|null} group — resolved group (faction, region, ...)
|
|
52
|
+
* @param {Object} profile — training profile (for caption + trigger)
|
|
53
|
+
* @param {string} partition — train | val | test
|
|
54
|
+
* @returns {Object|null} row, or null if the record has no asset_path
|
|
55
|
+
*/
|
|
56
|
+
export function recordToRow(record, lane, group, profile, partition) {
|
|
57
|
+
if (!record?.id || !record.asset_path) return null;
|
|
58
|
+
|
|
59
|
+
const trigger = deriveStyleTrigger(profile) || null;
|
|
60
|
+
const caption = buildCaption(record, lane, group, profile);
|
|
61
|
+
|
|
62
|
+
const row = {
|
|
63
|
+
schema_version: ROW_SCHEMA_VERSION,
|
|
64
|
+
generated_from: null, // records path: no canon commit
|
|
65
|
+
entity_id: record.id,
|
|
66
|
+
schema_kind: 'record', // legacy marker: not a canon entity
|
|
67
|
+
entity_type: record.identity?.kind || null,
|
|
68
|
+
lane,
|
|
69
|
+
partition,
|
|
70
|
+
asset_path: record.asset_path,
|
|
71
|
+
caption,
|
|
72
|
+
trigger,
|
|
73
|
+
subject_filter_key: record.identity?.subject_name || record.id,
|
|
74
|
+
forbidden_tokens: record.canon?.forbidden_inputs || [],
|
|
75
|
+
pass_ratio: record.canon?.assertion_count > 0
|
|
76
|
+
? +(record.canon.pass_count / record.canon.assertion_count).toFixed(3)
|
|
77
|
+
: null,
|
|
78
|
+
group,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return row;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a row from a canon entity + approved reference plate.
|
|
86
|
+
*
|
|
87
|
+
* The canon-build flow produces rows from canon entries. Each entry may
|
|
88
|
+
* contribute one or more rows (one per approved reference plate × lane).
|
|
89
|
+
*
|
|
90
|
+
* @param {Object} args
|
|
91
|
+
* @param {Object} args.entry — parsed canon entry (frontmatter object)
|
|
92
|
+
* @param {string} args.schemaKind — monster | character | deity | location | relic
|
|
93
|
+
* @param {string} args.assetPath — project-relative path to the approved plate
|
|
94
|
+
* @param {string} args.lane — resolved lane
|
|
95
|
+
* @param {string} args.caption — pre-rendered via buildCaption
|
|
96
|
+
* @param {string|null} args.trigger — from the training profile that drove the plate
|
|
97
|
+
* @param {string} args.partition — train | val | test (canon-build defaults to train)
|
|
98
|
+
* @param {string} args.entryHash — SHA-256 of entry + schema_version + build_config + project_fingerprint
|
|
99
|
+
* @param {string|null} args.generatedFrom — canon commit SHA or "content-sha:..." fallback
|
|
100
|
+
*/
|
|
101
|
+
export function canonEntryToRow({
|
|
102
|
+
entry,
|
|
103
|
+
schemaKind,
|
|
104
|
+
assetPath,
|
|
105
|
+
lane,
|
|
106
|
+
caption,
|
|
107
|
+
trigger,
|
|
108
|
+
partition,
|
|
109
|
+
entryHash,
|
|
110
|
+
generatedFrom,
|
|
111
|
+
}) {
|
|
112
|
+
const entityType = entry.kind
|
|
113
|
+
|| entry.species_tag
|
|
114
|
+
|| entry.location_type
|
|
115
|
+
|| entry.relic_type
|
|
116
|
+
|| entry.generation
|
|
117
|
+
|| null;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
schema_version: ROW_SCHEMA_VERSION,
|
|
121
|
+
generated_from: generatedFrom,
|
|
122
|
+
entity_id: entry.id,
|
|
123
|
+
schema_kind: schemaKind,
|
|
124
|
+
entity_type: entityType,
|
|
125
|
+
lane,
|
|
126
|
+
partition,
|
|
127
|
+
asset_path: assetPath,
|
|
128
|
+
caption,
|
|
129
|
+
trigger,
|
|
130
|
+
subject_filter_key: entry.id,
|
|
131
|
+
forbidden_tokens: entry.forbidden_inputs || [],
|
|
132
|
+
entry_hash: entryHash,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read rows from a dataset.jsonl file.
|
|
138
|
+
*
|
|
139
|
+
* Parses each non-empty line as JSON; rejects the file if any row's
|
|
140
|
+
* `schema_version` does not match ROW_SCHEMA_VERSION.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} path — absolute path to dataset.jsonl
|
|
143
|
+
* @returns {Promise<Array<Object>>} rows
|
|
144
|
+
*/
|
|
145
|
+
export async function readDatasetJsonl(path) {
|
|
146
|
+
const text = await readFile(path, 'utf-8');
|
|
147
|
+
const rows = [];
|
|
148
|
+
const lines = text.split('\n');
|
|
149
|
+
for (let i = 0; i < lines.length; i++) {
|
|
150
|
+
const line = lines[i].trim();
|
|
151
|
+
if (!line) continue;
|
|
152
|
+
let row;
|
|
153
|
+
try {
|
|
154
|
+
row = JSON.parse(line);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw new Error(`readDatasetJsonl: line ${i + 1} of ${path} is not valid JSON (${err.message})`);
|
|
157
|
+
}
|
|
158
|
+
if (row.schema_version !== ROW_SCHEMA_VERSION) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`readDatasetJsonl: line ${i + 1} of ${path} has schema_version "${row.schema_version}" (expected "${ROW_SCHEMA_VERSION}")`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
rows.push(row);
|
|
164
|
+
}
|
|
165
|
+
return rows;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Group rows by partition (train | val | test).
|
|
170
|
+
* Rows with missing/unknown partition are grouped under an "other" key.
|
|
171
|
+
*/
|
|
172
|
+
export function groupRowsByPartition(rows) {
|
|
173
|
+
const out = { train: [], val: [], test: [] };
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const p = row.partition || 'other';
|
|
176
|
+
if (!out[p]) out[p] = [];
|
|
177
|
+
out[p].push(row);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Filter rows for a training profile.
|
|
184
|
+
*
|
|
185
|
+
* Two filter axes:
|
|
186
|
+
* - lane-based: include row if row.lane ∈ profile.eligible_lanes
|
|
187
|
+
* - entity-based: include row if row.entity_id === profile.entity_id_scope
|
|
188
|
+
* (when entity_id_scope is set — per-character LoRA case, D8)
|
|
189
|
+
*
|
|
190
|
+
* Both filters compose: a per-character profile with both eligible_lanes and
|
|
191
|
+
* entity_id_scope set applies both as AND. Zero filters = pass everything.
|
|
192
|
+
*/
|
|
193
|
+
export function filterRowsForProfile(rows, profile) {
|
|
194
|
+
const eligibleLanes = profile.eligible_lanes;
|
|
195
|
+
const entityScope = profile.entity_id_scope;
|
|
196
|
+
|
|
197
|
+
return rows.filter((row) => {
|
|
198
|
+
if (Array.isArray(eligibleLanes) && eligibleLanes.length > 0) {
|
|
199
|
+
if (!eligibleLanes.includes(row.lane)) return false;
|
|
200
|
+
}
|
|
201
|
+
if (entityScope) {
|
|
202
|
+
if (row.entity_id !== entityScope && row.subject_filter_key !== entityScope) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Serialize a row to a stable JSONL line.
|
|
212
|
+
* Key order is enforced by emitting known fields first, then alphabetized extras.
|
|
213
|
+
*/
|
|
214
|
+
export function serializeRow(row) {
|
|
215
|
+
const knownOrder = [
|
|
216
|
+
'schema_version', 'generated_from', 'entity_id', 'schema_kind', 'entity_type',
|
|
217
|
+
'lane', 'partition', 'asset_path', 'caption', 'trigger',
|
|
218
|
+
'subject_filter_key', 'forbidden_tokens', 'entry_hash', 'pass_ratio', 'group',
|
|
219
|
+
];
|
|
220
|
+
const ordered = {};
|
|
221
|
+
for (const key of knownOrder) {
|
|
222
|
+
if (row[key] !== undefined) ordered[key] = row[key];
|
|
223
|
+
}
|
|
224
|
+
for (const key of Object.keys(row).sort()) {
|
|
225
|
+
if (!knownOrder.includes(key)) ordered[key] = row[key];
|
|
226
|
+
}
|
|
227
|
+
return JSON.stringify(ordered);
|
|
228
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-manifest pinning contract (PIN_PER_STEP).
|
|
3
|
+
*
|
|
4
|
+
* ONE source of truth for the fields that make a generation wave byte-for-byte
|
|
5
|
+
* replayable. Three writers emit provenance today — `scripts/generate.js`
|
|
6
|
+
* (record `provenance`), `lib/adapters/comfyui-runner.js` (run `manifest.json`),
|
|
7
|
+
* and `scripts/qwen_generate.py` (the wave `generation.json` receipt) — and they
|
|
8
|
+
* MUST all pin the same way. This module is the field-name authority; the
|
|
9
|
+
* Python receipt mirrors the identical field contract (see qwen_generate.py).
|
|
10
|
+
*
|
|
11
|
+
* Design (DECOMPOSE_BY_SECRETS, Parnas 1972): the pinning field names + the
|
|
12
|
+
* best-effort model hasher live here so the writers can never drift apart
|
|
13
|
+
* silently. When the model changes, this secret-hiding boundary survives.
|
|
14
|
+
*
|
|
15
|
+
* The pinning block closes three PIN_PER_STEP gaps that the pre-3.3.0 manifests
|
|
16
|
+
* left open (base/sampler/scheduler/shift/seed/steps/cfg were already recorded):
|
|
17
|
+
* 1. comfy_workflow_sha — the exact ComfyUI graph that ran, as a hash.
|
|
18
|
+
* 2. model/LoRA content identity — filenames upgraded to {name,size_bytes,sha256}.
|
|
19
|
+
* 3. seed_policy — HOW seeds were chosen (intent), not just the values.
|
|
20
|
+
*
|
|
21
|
+
* Every field is optional and additive. Legacy manifests with no `pinning`
|
|
22
|
+
* block load fine; readers nullish-coalesce (`m.pinning?.comfy_workflow_sha ?? null`).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { openSync, readSync, closeSync, statSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Contract version for the `pinning` block itself. Bump when the block's shape
|
|
31
|
+
* changes (a new field, a renamed field). Independent of the manifest
|
|
32
|
+
* SCHEMA_VERSION (lib/snapshot.js) — that versions the snapshot/split/export
|
|
33
|
+
* manifest family; this versions the generation-provenance pin. Mirrored as a
|
|
34
|
+
* literal in scripts/qwen_generate.py — keep the two in lockstep.
|
|
35
|
+
*/
|
|
36
|
+
export const PINNING_VERSION = '1.0.0';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Seed policies — the INTENT behind seed selection, recorded alongside the
|
|
40
|
+
* concrete per-item seed values so a reader knows how to regenerate.
|
|
41
|
+
*/
|
|
42
|
+
export const SEED_POLICIES = Object.freeze({
|
|
43
|
+
EXPLICIT_PER_ITEM: 'explicit-per-item', // each item's seed is recorded verbatim (qwen_generate.py wave receipt)
|
|
44
|
+
BASE_INCREMENT: 'base+increment', // seed = base_seed + job_index (generate.js / batch path)
|
|
45
|
+
FIXED: 'fixed', // every item shares one seed
|
|
46
|
+
RANDOM: 'random', // first seed fixed, rest sampled
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Map a runtime seed_mode ('increment' | 'fixed' | 'random') to a seed_policy.
|
|
51
|
+
* `increment` is the base+index scheme; unknown modes fall back to base+increment.
|
|
52
|
+
*/
|
|
53
|
+
export function seedPolicyFromMode(seedMode) {
|
|
54
|
+
switch (seedMode) {
|
|
55
|
+
case 'fixed': return SEED_POLICIES.FIXED;
|
|
56
|
+
case 'random': return SEED_POLICIES.RANDOM;
|
|
57
|
+
case 'increment':
|
|
58
|
+
default: return SEED_POLICIES.BASE_INCREMENT;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Stable serialization ────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Deterministic JSON: object keys sorted, arrays kept in order (arrays are
|
|
66
|
+
* meaningful sequences — a LoRA chain, a node's input wiring). Guarantees the
|
|
67
|
+
* same semantic graph hashes identically regardless of key insertion order.
|
|
68
|
+
*/
|
|
69
|
+
export function stableStringify(value) {
|
|
70
|
+
if (value === null || value === undefined) return 'null';
|
|
71
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
|
|
72
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
73
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
74
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
75
|
+
if (typeof value === 'object') {
|
|
76
|
+
const keys = Object.keys(value).sort();
|
|
77
|
+
const parts = [];
|
|
78
|
+
for (const k of keys) {
|
|
79
|
+
if (value[k] === undefined) continue;
|
|
80
|
+
parts.push(JSON.stringify(k) + ':' + stableStringify(value[k]));
|
|
81
|
+
}
|
|
82
|
+
return '{' + parts.join(',') + '}';
|
|
83
|
+
}
|
|
84
|
+
return 'null';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Return a copy of a ComfyUI graph with per-ITEM nondeterminism removed, so the
|
|
89
|
+
* whole wave shares ONE comfy_workflow_sha. Normalized out (recorded elsewhere,
|
|
90
|
+
* per item): KSampler seed / noise_seed, CLIPTextEncode prompt text, SaveImage
|
|
91
|
+
* filename_prefix. What SURVIVES the hash is the pipeline skeleton — node
|
|
92
|
+
* topology, model loaders, sampler/scheduler/shift, LoRA chain + weights,
|
|
93
|
+
* dims/steps/cfg — i.e. exactly the part that must match to replay the wave.
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeGraphForHash(graph) {
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const [id, node] of Object.entries(graph || {})) {
|
|
98
|
+
const inputs = { ...(node?.inputs || {}) };
|
|
99
|
+
const cls = node?.class_type;
|
|
100
|
+
if (cls === 'KSampler' || cls === 'KSamplerAdvanced') {
|
|
101
|
+
if ('seed' in inputs) inputs.seed = 0;
|
|
102
|
+
if ('noise_seed' in inputs) inputs.noise_seed = 0;
|
|
103
|
+
} else if (cls === 'CLIPTextEncode') {
|
|
104
|
+
if ('text' in inputs) inputs.text = '';
|
|
105
|
+
} else if (cls === 'SaveImage') {
|
|
106
|
+
if ('filename_prefix' in inputs) inputs.filename_prefix = '';
|
|
107
|
+
}
|
|
108
|
+
out[id] = { class_type: cls, inputs };
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* SHA-256 of the exact ComfyUI graph submitted for the wave (per-item seed +
|
|
115
|
+
* prompt normalized out — see normalizeGraphForHash). Returns a `sha256:`-prefixed
|
|
116
|
+
* digest, or null for an empty/absent graph.
|
|
117
|
+
*/
|
|
118
|
+
export function comfyWorkflowSha(graph) {
|
|
119
|
+
if (!graph || Object.keys(graph).length === 0) return null;
|
|
120
|
+
const normalized = normalizeGraphForHash(graph);
|
|
121
|
+
return 'sha256:' + createHash('sha256').update(stableStringify(normalized)).digest('hex');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Model file resolution + hashing (best-effort) ───────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Default ComfyUI models directory on the rig. Override with SDLAB_MODELS_DIR.
|
|
128
|
+
* Hashing is opt-in and best-effort, so a wrong/absent path degrades cleanly to
|
|
129
|
+
* `sha256: null` — it never fails a generation.
|
|
130
|
+
*/
|
|
131
|
+
export function defaultModelsDir() {
|
|
132
|
+
return process.env.SDLAB_MODELS_DIR
|
|
133
|
+
|| 'E:/AI-Models/ComfyUI_windows_portable/ComfyUI/models';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Kind → candidate subdirectories under the models dir, in search order.
|
|
137
|
+
const KIND_SUBDIRS = {
|
|
138
|
+
unet: ['diffusion_models', 'unet'],
|
|
139
|
+
clip: ['text_encoders', 'clip'],
|
|
140
|
+
vae: ['vae'],
|
|
141
|
+
checkpoint: ['checkpoints'],
|
|
142
|
+
lora: ['loras'],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a model filename to an absolute path under the models dir. Searches
|
|
147
|
+
* the kind-appropriate subdirs first, then every known subdir, then the root.
|
|
148
|
+
* Returns null when nothing matches (unresolvable → sha256 null with a note).
|
|
149
|
+
*/
|
|
150
|
+
export function resolveModelPath(name, kind, modelsDir = defaultModelsDir()) {
|
|
151
|
+
if (!name || !modelsDir) return null;
|
|
152
|
+
const tried = new Set();
|
|
153
|
+
const subdirs = [
|
|
154
|
+
...(KIND_SUBDIRS[kind] || []),
|
|
155
|
+
...Object.values(KIND_SUBDIRS).flat(),
|
|
156
|
+
'.',
|
|
157
|
+
];
|
|
158
|
+
for (const sub of subdirs) {
|
|
159
|
+
const candidate = join(modelsDir, sub, name);
|
|
160
|
+
if (tried.has(candidate)) continue;
|
|
161
|
+
tried.add(candidate);
|
|
162
|
+
if (existsSync(candidate)) return candidate;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Streaming SHA-256 of a file — 1 MiB chunks, synchronous, never loads the whole
|
|
169
|
+
* (multi-GB) file into memory. Returns a `sha256:`-prefixed digest.
|
|
170
|
+
*/
|
|
171
|
+
export function sha256File(path) {
|
|
172
|
+
const hash = createHash('sha256');
|
|
173
|
+
const fd = openSync(path, 'r');
|
|
174
|
+
try {
|
|
175
|
+
const buf = Buffer.allocUnsafe(1 << 20);
|
|
176
|
+
let bytes;
|
|
177
|
+
while ((bytes = readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
178
|
+
hash.update(bytes === buf.length ? buf : buf.subarray(0, bytes));
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
closeSync(fd);
|
|
182
|
+
}
|
|
183
|
+
return 'sha256:' + hash.digest('hex');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Best-effort content identity for one model/LoRA file: {name, size_bytes, sha256}
|
|
188
|
+
* (+ a short `hash_note` when sha256 can't be produced). size_bytes is always
|
|
189
|
+
* filled when the file resolves (a cheap stat); sha256 is gated behind
|
|
190
|
+
* `doHash` (the --hash-models opt-in) and cached by (name, size, mtime) so
|
|
191
|
+
* multi-GB checkpoints aren't re-hashed every wave.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} name — model filename as it appears in the graph
|
|
194
|
+
* @param {Object} [opts]
|
|
195
|
+
* @param {'unet'|'clip'|'vae'|'checkpoint'|'lora'} [opts.kind]
|
|
196
|
+
* @param {string} [opts.modelsDir]
|
|
197
|
+
* @param {boolean} [opts.doHash=false] — compute sha256 (else record name+size only)
|
|
198
|
+
* @param {{map: Map, cacheFile?: string, dirty: boolean}} [opts.cache]
|
|
199
|
+
*/
|
|
200
|
+
export function hashModelFile(name, { kind, modelsDir = defaultModelsDir(), doHash = false, cache } = {}) {
|
|
201
|
+
if (!name) return { name: name ?? null, size_bytes: null, sha256: null, hash_note: 'no filename' };
|
|
202
|
+
const resolved = resolveModelPath(name, kind, modelsDir);
|
|
203
|
+
if (!resolved) return { name, size_bytes: null, sha256: null, hash_note: `unresolved under ${modelsDir}` };
|
|
204
|
+
let size_bytes = null;
|
|
205
|
+
let mtimeMs = 0;
|
|
206
|
+
try {
|
|
207
|
+
const st = statSync(resolved);
|
|
208
|
+
size_bytes = st.size;
|
|
209
|
+
mtimeMs = st.mtimeMs;
|
|
210
|
+
} catch {
|
|
211
|
+
return { name, size_bytes: null, sha256: null, hash_note: 'stat failed' };
|
|
212
|
+
}
|
|
213
|
+
if (!doHash) return { name, size_bytes, sha256: null, hash_note: 'hash skipped (pass --hash-models)' };
|
|
214
|
+
const key = `${name}:${size_bytes}:${Math.round(mtimeMs)}`;
|
|
215
|
+
if (cache?.map?.has(key)) return { name, size_bytes, sha256: cache.map.get(key) };
|
|
216
|
+
let sha256;
|
|
217
|
+
try {
|
|
218
|
+
sha256 = sha256File(resolved);
|
|
219
|
+
} catch {
|
|
220
|
+
return { name, size_bytes, sha256: null, hash_note: 'hash failed (read error)' };
|
|
221
|
+
}
|
|
222
|
+
if (cache?.map) {
|
|
223
|
+
cache.map.set(key, sha256);
|
|
224
|
+
cache.dirty = true;
|
|
225
|
+
}
|
|
226
|
+
return { name, size_bytes, sha256 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Content-identity block for the model loaders of a wave. `modelSpec` maps a
|
|
231
|
+
* role to a filename — {unet, clip, vae} for the Qwen DiT path, {checkpoint}
|
|
232
|
+
* for the SDXL path. Only provided roles appear in the result.
|
|
233
|
+
*/
|
|
234
|
+
export function buildModelHashes(modelSpec = {}, opts = {}) {
|
|
235
|
+
const roleKind = { unet: 'unet', clip: 'clip', vae: 'vae', checkpoint: 'checkpoint' };
|
|
236
|
+
const out = {};
|
|
237
|
+
for (const [role, kind] of Object.entries(roleKind)) {
|
|
238
|
+
const name = modelSpec[role];
|
|
239
|
+
if (!name) continue;
|
|
240
|
+
out[role] = hashModelFile(name, { ...opts, kind });
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Persistent hash cache (best-effort) ─────────────────────────
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Create a hash cache. Loads the on-disk JSON map (name:size:mtime → sha256) if
|
|
249
|
+
* present, so hashes survive across waves. `cacheFile` defaults to a dotfile in
|
|
250
|
+
* the models dir; override with SDLAB_HASH_CACHE. All I/O is best-effort — a
|
|
251
|
+
* missing/unwritable cache never fails a run.
|
|
252
|
+
*/
|
|
253
|
+
export function createHashCache(cacheFile) {
|
|
254
|
+
const file = cacheFile
|
|
255
|
+
|| process.env.SDLAB_HASH_CACHE
|
|
256
|
+
|| join(defaultModelsDir(), '.sdlab-hash-cache.json');
|
|
257
|
+
const map = new Map();
|
|
258
|
+
try {
|
|
259
|
+
if (existsSync(file)) {
|
|
260
|
+
const obj = JSON.parse(readFileSync(file, 'utf-8'));
|
|
261
|
+
for (const [k, v] of Object.entries(obj)) map.set(k, v);
|
|
262
|
+
}
|
|
263
|
+
} catch { /* best-effort: ignore a corrupt/unreadable cache */ }
|
|
264
|
+
return { map, cacheFile: file, dirty: false };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Persist a hash cache to disk if it grew. Best-effort — swallows write errors.
|
|
269
|
+
*/
|
|
270
|
+
export function flushHashCache(cache) {
|
|
271
|
+
if (!cache || !cache.dirty || !cache.cacheFile) return;
|
|
272
|
+
try {
|
|
273
|
+
const obj = Object.fromEntries(cache.map);
|
|
274
|
+
writeFileSync(cache.cacheFile, JSON.stringify(obj, null, 2));
|
|
275
|
+
cache.dirty = false;
|
|
276
|
+
} catch { /* best-effort: ignore */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── The pinning block ───────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build the pinning block for a wave/item. Identical shape across all three
|
|
283
|
+
* writers (see module header). Every writer calls this so the contract is
|
|
284
|
+
* enforced in ONE place.
|
|
285
|
+
*
|
|
286
|
+
* @param {Object} args
|
|
287
|
+
* @param {Object} [args.graph] — the ComfyUI node graph submitted (for comfy_workflow_sha)
|
|
288
|
+
* @param {Object} [args.models] — {unet?, clip?, vae?, checkpoint?} filenames
|
|
289
|
+
* @param {Array<{name:string, weight?:number}>} [args.loras]
|
|
290
|
+
* @param {string} [args.seedPolicy] — one of SEED_POLICIES
|
|
291
|
+
* @param {boolean} [args.hashModels=false] — opt-in sha256 of model/LoRA files
|
|
292
|
+
* @param {string} [args.modelsDir]
|
|
293
|
+
* @param {{map: Map, cacheFile?: string, dirty: boolean}} [args.cache]
|
|
294
|
+
* @returns {Object} pinning block
|
|
295
|
+
*/
|
|
296
|
+
export function buildPinning({ graph, models = {}, loras = [], seedPolicy = null, hashModels = false, modelsDir = defaultModelsDir(), cache } = {}) {
|
|
297
|
+
const opts = { modelsDir, doHash: hashModels, cache };
|
|
298
|
+
return {
|
|
299
|
+
pinning_version: PINNING_VERSION,
|
|
300
|
+
comfy_workflow_sha: comfyWorkflowSha(graph),
|
|
301
|
+
seed_policy: seedPolicy,
|
|
302
|
+
models: buildModelHashes(models, opts),
|
|
303
|
+
loras: (loras || []).map((l) => ({
|
|
304
|
+
...hashModelFile(l.name, { ...opts, kind: 'lora' }),
|
|
305
|
+
weight: l.weight ?? 1.0,
|
|
306
|
+
})),
|
|
307
|
+
};
|
|
308
|
+
}
|
package/lib/snapshot.js
CHANGED
|
@@ -19,8 +19,14 @@ import { warn } from './log.js';
|
|
|
19
19
|
* snapshot/split/export/eval-pack/eval-run/training-manifest/training-package/
|
|
20
20
|
* impl-pack stamps this version; readers log a WARNING on version mismatch
|
|
21
21
|
* (best-effort compatibility).
|
|
22
|
+
*
|
|
23
|
+
* 2.3.0 (v3.3.0): PIN_PER_STEP — the ComfyUI run manifest now also stamps this
|
|
24
|
+
* version and carries a `pinning` block (comfy_workflow_sha + model/LoRA content
|
|
25
|
+
* hashes + seed_policy; see lib/run-manifest.js). The block is additive and
|
|
26
|
+
* optional, so a legacy manifest with no schema_version / no pinning still loads
|
|
27
|
+
* (checkManifestVersion only warns; it never throws).
|
|
22
28
|
*/
|
|
23
|
-
export const SCHEMA_VERSION = '2.
|
|
29
|
+
export const SCHEMA_VERSION = '2.3.0';
|
|
24
30
|
|
|
25
31
|
/**
|
|
26
32
|
* Warn if a loaded manifest's schema_version does not match the reader.
|
package/lib/training-adapters.js
CHANGED
|
@@ -27,6 +27,11 @@ export const ADAPTER_REGISTRY = Object.freeze({
|
|
|
27
27
|
description: 'Diffusers fine-tuning layout: image + caption .txt sidecars + per-partition JSONL',
|
|
28
28
|
module: './adapters/diffusers-lora.js',
|
|
29
29
|
},
|
|
30
|
+
'ai-toolkit': {
|
|
31
|
+
id: 'ai-toolkit',
|
|
32
|
+
description: 'Ostris ai-toolkit Flux training package: image + .txt sidecars + ai-toolkit-config.yaml at root',
|
|
33
|
+
module: './adapters/ai-toolkit.js',
|
|
34
|
+
},
|
|
30
35
|
});
|
|
31
36
|
|
|
32
37
|
/**
|
package/lib/training-packages.js
CHANGED
|
@@ -18,6 +18,7 @@ import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
|
18
18
|
import { inputError } from './errors.js';
|
|
19
19
|
import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
20
20
|
import { loadAdapter } from './training-adapters.js';
|
|
21
|
+
import { recordToRow, filterRowsForProfile } from './rows.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -61,38 +62,43 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
61
62
|
);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Convert split partitions → canonical rows (D6 adapter input shape).
|
|
66
|
+
// Adapters only understand rows — records-flow callers go through this
|
|
67
|
+
// converter, canon-build-flow callers produce rows directly. One input
|
|
68
|
+
// contract, two producers.
|
|
69
|
+
const rows = [];
|
|
70
|
+
const partitionCounts = { train: 0, val: 0, test: 0 };
|
|
68
71
|
|
|
69
72
|
for (const partition of ['train', 'val', 'test']) {
|
|
70
73
|
const entries = await loadSplitPartition(projectRoot, manifest.source_split_id, partition);
|
|
71
|
-
const records = [];
|
|
72
74
|
|
|
73
75
|
for (const entry of entries) {
|
|
74
76
|
const record = await loadRecord(recordsDir, entry.record_id);
|
|
75
77
|
if (!record) continue;
|
|
76
78
|
|
|
77
|
-
// Filter by profile's eligible lanes
|
|
78
79
|
const prompt = record.provenance?.prompt || '';
|
|
79
80
|
const lane = detectLane(record.id, prompt, config.lanes);
|
|
80
|
-
if (profile.eligible_lanes.length > 0 && !profile.eligible_lanes.includes(lane)) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
81
|
const group = detectGroup(record.id, prompt, config.terminology);
|
|
85
|
-
|
|
82
|
+
|
|
83
|
+
const row = recordToRow(record, lane, group, profile, partition);
|
|
84
|
+
if (row) rows.push(row);
|
|
86
85
|
}
|
|
86
|
+
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// Apply profile filters (eligible_lanes + entity_id_scope — D8).
|
|
89
|
+
// filterRowsForProfile returns rows where lane ∈ eligible_lanes AND
|
|
90
|
+
// entity_id === entity_id_scope (when set). Zero filters = identity.
|
|
91
|
+
const filteredRows = filterRowsForProfile(rows, profile);
|
|
92
|
+
for (const row of filteredRows) {
|
|
93
|
+
if (partitionCounts[row.partition] !== undefined) partitionCounts[row.partition]++;
|
|
90
94
|
}
|
|
95
|
+
const totalRecords = filteredRows.length;
|
|
96
|
+
let totalImages = 0;
|
|
91
97
|
|
|
92
|
-
// Let the adapter build the package layout
|
|
98
|
+
// Let the adapter build the package layout from the canonical rows.
|
|
93
99
|
const adapterResult = await adapter.buildPackage({
|
|
94
100
|
packageDir,
|
|
95
|
-
|
|
101
|
+
rows: filteredRows,
|
|
96
102
|
profile,
|
|
97
103
|
manifest,
|
|
98
104
|
config,
|
|
@@ -118,9 +124,9 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
118
124
|
config_fingerprint: manifest.config_fingerprint,
|
|
119
125
|
counts: {
|
|
120
126
|
total_records: totalRecords,
|
|
121
|
-
train:
|
|
122
|
-
val:
|
|
123
|
-
test:
|
|
127
|
+
train: partitionCounts.train,
|
|
128
|
+
val: partitionCounts.val,
|
|
129
|
+
test: partitionCounts.test,
|
|
124
130
|
images: totalImages,
|
|
125
131
|
checksums: 0, // updated after checksum pass below
|
|
126
132
|
},
|