@mcptoolshop/style-dataset-lab 3.4.0 → 3.5.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.es.md +111 -59
  3. package/README.fr.md +117 -67
  4. package/README.hi.md +109 -70
  5. package/README.it.md +111 -60
  6. package/README.ja.md +116 -76
  7. package/README.md +58 -2
  8. package/README.pt-BR.md +119 -64
  9. package/README.zh.md +108 -67
  10. package/bin/sdlab.js +50 -1
  11. package/lib/adapters/ai-toolkit.js +187 -30
  12. package/lib/adapters/comfyui-workflows.js +84 -9
  13. package/lib/asset-ingest.js +575 -0
  14. package/lib/asset-masks.js +323 -0
  15. package/lib/asset-source.js +1176 -0
  16. package/lib/canon-author.js +350 -0
  17. package/lib/canon-review-gate.js +205 -0
  18. package/lib/colab-launcher-guards.js +285 -0
  19. package/lib/export.js +1 -1
  20. package/lib/freeze-gate.js +3 -0
  21. package/lib/generated-provenance.js +178 -5
  22. package/lib/lane1-render.js +668 -0
  23. package/lib/memory-store.js +663 -0
  24. package/lib/model-licences.js +305 -0
  25. package/lib/npy-meta.js +130 -0
  26. package/lib/png-meta.js +512 -0
  27. package/lib/records.js +54 -8
  28. package/lib/reingest-selected.js +15 -1
  29. package/lib/reingest.js +27 -3
  30. package/lib/run-manifest.js +59 -11
  31. package/lib/train-runs.js +214 -0
  32. package/lib/verifier.js +3 -0
  33. package/lib/verify-chain.js +367 -0
  34. package/package.json +2 -2
  35. package/scripts/asset-ingest.js +160 -0
  36. package/scripts/canon-draft.js +82 -0
  37. package/scripts/canon-freeze.js +4 -0
  38. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  39. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  40. package/scripts/colab/colab_window.sh +587 -0
  41. package/scripts/colab/lane1_render.py +970 -0
  42. package/scripts/colab/launch_guards.py +564 -0
  43. package/scripts/colab/launch_lane1_stub.sh +309 -0
  44. package/scripts/colab/launch_lane3_stub.sh +366 -0
  45. package/scripts/colab/remote_runner.py +59 -0
  46. package/scripts/doctor.js +1 -0
  47. package/scripts/generate.js +38 -12
  48. package/scripts/verify.js +67 -0
@@ -0,0 +1,575 @@
1
+ /**
2
+ * Asset ingest engine — register a validated asset export into a project.
3
+ *
4
+ * Sits beside lib/ingest.js (bare external images) as the manifest-driven,
5
+ * gated sibling: `sdlab asset ingest` consumes an export directory whose
6
+ * asset-source.json has passed the full contract (lib/asset-source.js), runs
7
+ * the ported receiving-dock gates per render, and registers records under
8
+ * the existing machinery.
9
+ *
10
+ * Non-negotiables inherited from lib/ingest.js, held here too:
11
+ * - NEVER invents a judgment, a score, or a caption. Records land with
12
+ * judgment: null, canon: null via the same buildBaseRecord. Caption
13
+ * MATERIAL (subject, facing, trigger, domain tag) rides in provenance —
14
+ * assembling a caption is a package-build decision, not an ingest fact.
15
+ * - provenance.source is 'asset' — a third honest source alongside
16
+ * 'generated' and 'external', carrying the acceptance block verbatim.
17
+ * - --dry-run performs zero filesystem writes.
18
+ * - idempotent + crash-safe: records are skipped when already present
19
+ * (same asset, same manifest hash), both images and records land via
20
+ * temp-then-rename, and the receipt is written LAST — its presence
21
+ * means the ingest completed. A crash mid-ingest leaves records that a
22
+ * re-run skips, then finishes, then writes the receipt.
23
+ * - ANDON: a render failing the asset's own declared palette gate is
24
+ * REJECTED (reported, never registered). All renders failing fails the
25
+ * ingest. A record-id collision with a different asset refuses loudly.
26
+ *
27
+ * Compensator (NAMED_COMPENSATORS): the receipt lists every path this
28
+ * ingest created. Undo = delete those paths (or `git clean` / `git revert`
29
+ * once committed). Owner: user.
30
+ */
31
+
32
+ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
33
+ import { existsSync } from 'node:fs';
34
+ import { createHash } from 'node:crypto';
35
+ import { basename, extname, join, sep } from 'node:path';
36
+ import { buildBaseRecord, buildIdentityRecord } from './records.js';
37
+ import { inputError } from './errors.js';
38
+ import { decodePng } from './png-meta.js';
39
+ import { paletteGate, classShares } from './asset-masks.js';
40
+ import {
41
+ ASSET_MANIFEST_FILENAME,
42
+ PIXEL_ENCODINGS,
43
+ validateAssetSource,
44
+ facingFromCamera,
45
+ } from './asset-source.js';
46
+
47
+ export const ASSET_INGEST_TOOL_ID = 'sdlab-asset-ingest-v1';
48
+ // 1.1.0: adds `notices` (provenance gaps reported, not gated) and the
49
+ // `tone_operands` file role. Additive — a 1.0.0 receipt reads fine.
50
+ export const ASSET_RECEIPT_SCHEMA_VERSION = '1.1.0';
51
+ export const RECEIPT_FILENAME = 'ingest-receipt.json';
52
+
53
+ const DEFAULT_DOMAIN_TAG = '3d asset'; // MVDream's verbatim render-caption tag (design doc Q4)
54
+
55
+ function toPortablePath(p) {
56
+ return p.split(sep).join('/');
57
+ }
58
+
59
+ function sha256(buf) {
60
+ return createHash('sha256').update(buf).digest('hex');
61
+ }
62
+
63
+ // Same temp-then-rename shape as lib/ingest.js / lib/runtime-runs.js /
64
+ // lib/batch-runs.js — each module keeps its own copy by established
65
+ // convention in this codebase.
66
+ async function atomicWriteFile(targetPath, body) {
67
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
68
+ try {
69
+ await writeFile(tmpPath, body);
70
+ await rename(tmpPath, targetPath);
71
+ } catch (err) {
72
+ try { await unlink(tmpPath); } catch { /* best-effort cleanup */ }
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Read + hash + atomically materialize a source file into the project.
79
+ * Returns the sha256 either way; writes only when `write` is true.
80
+ */
81
+ async function hashAndMaybeCopy(absSrc, absDest, write) {
82
+ const buf = await readFile(absSrc);
83
+ const hash = sha256(buf);
84
+ if (write) await atomicWriteFile(absDest, buf);
85
+ return { hash, bytes: buf.length };
86
+ }
87
+
88
+ /**
89
+ * Ingest a validated asset export into a project.
90
+ *
91
+ * @param {string} projectRoot — absolute project root (resolved by caller)
92
+ * @param {string} sourceDir — absolute export directory (must contain asset-source.json)
93
+ * @param {Object} [options]
94
+ * @param {boolean} [options.dryRun]
95
+ * @returns {Promise<Object>} report — see the shape at the bottom
96
+ */
97
+ export async function ingestAssetSource(projectRoot, sourceDir, options = {}) {
98
+ const dryRun = Boolean(options.dryRun);
99
+
100
+ const manifestPath = join(sourceDir, ASSET_MANIFEST_FILENAME);
101
+ const { manifest, resolved, manifestRaw } = await validateAssetSource(manifestPath);
102
+
103
+ const assetId = manifest.asset.id;
104
+ const manifestSha = sha256(manifestRaw);
105
+ const assetDir = join(projectRoot, 'assets', assetId);
106
+ const receiptPath = join(assetDir, RECEIPT_FILENAME);
107
+ const recordsDir = join(projectRoot, 'records');
108
+ const candidatesDir = join(projectRoot, 'outputs', 'candidates');
109
+
110
+ // ── Idempotency at the asset level ────────────────────────────────
111
+ if (existsSync(receiptPath)) {
112
+ let receipt;
113
+ try {
114
+ receipt = JSON.parse(await readFile(receiptPath, 'utf-8'));
115
+ } catch (err) {
116
+ throw inputError(
117
+ 'ASSET_RECEIPT_CORRUPT',
118
+ `${receiptPath} exists but is unreadable: ${err.message}`,
119
+ 'Inspect the assets directory; remove it explicitly (it is listed as the compensator) to re-ingest.'
120
+ );
121
+ }
122
+ if (receipt.manifest_sha256 === manifestSha) {
123
+ // Notices replay from the stored receipt rather than being recomputed —
124
+ // a re-run must report the same provenance gaps the original ingest did,
125
+ // and a receipt written before 1.2.0 honestly has none to report.
126
+ return { assetId, alreadyIngested: true, receiptPath: toPortablePath(join('assets', assetId, RECEIPT_FILENAME)), created: [], skipped: (receipt.records_created || []).slice(), rejected: receipt.rejected_renders || [], notices: receipt.notices || [], dryRun };
127
+ }
128
+ throw inputError(
129
+ 'ASSET_ALREADY_INGESTED',
130
+ `Asset "${assetId}" was already ingested from a DIFFERENT manifest (receipt sha ${receipt.manifest_sha256?.slice(0, 12)}…, incoming ${manifestSha.slice(0, 12)}…).`,
131
+ `A changed export is a new truth, not an overwrite. Compensator: delete projects/<project>/assets/${assetId}/ and the records it lists, then re-run. Nothing was written.`
132
+ );
133
+ }
134
+
135
+ // ── Receiving-dock gates + measurements (pure reads) ──────────────
136
+ const channelDecls = new Map((manifest.channels || []).map((c) => [c.id, c]));
137
+ const admitted = [];
138
+ const rejected = [];
139
+
140
+ for (const [renderId, entry] of resolved.renders) {
141
+ const renderBuf = await readFile(entry.abs);
142
+ const renderImg = decodePng(renderBuf, entry.relPath);
143
+ const silhouetteImg = decodePng(await readFile(entry.silhouette.abs), entry.silhouette.relPath);
144
+
145
+ const gate = paletteGate(renderImg, silhouetteImg, manifest.palette, entry.relPath);
146
+
147
+ // Class shares are a PIXEL measurement. Since schema 1.1.0 an instance
148
+ // may be an npy array or a json sidecar; those carry their class facts
149
+ // asset-side (facet's admission sidecars already count them) and sdlab
150
+ // does not recompute what it would have to interpret to recompute.
151
+ const shares = {};
152
+ for (const [cid, inst] of entry.instances) {
153
+ const decl = channelDecls.get(cid);
154
+ if (!decl?.categorical || !PIXEL_ENCODINGS.has(decl.encoding)) continue;
155
+ const instImg = decodePng(await readFile(inst.abs), inst.relPath);
156
+ shares[cid] = classShares(instImg, silhouetteImg, decl, inst.relPath);
157
+ }
158
+
159
+ if (!gate.pass) {
160
+ rejected.push({ render_id: renderId, reason: 'palette-gate', gate });
161
+ continue;
162
+ }
163
+ admitted.push({
164
+ renderId,
165
+ entry,
166
+ renderBuf,
167
+ renderSha: sha256(renderBuf),
168
+ width: renderImg.width,
169
+ height: renderImg.height,
170
+ gate,
171
+ shares,
172
+ });
173
+ }
174
+
175
+ if (admitted.length === 0) {
176
+ throw inputError(
177
+ 'ASSET_ALL_RENDERS_REJECTED',
178
+ `Every render of "${assetId}" failed the asset's own declared palette gate — nothing to register.`,
179
+ `Rejected: ${rejected.map((r) => `${r.render_id} (blob ${r.gate.largest_blob_px}px)`).join(', ')}. ` +
180
+ 'The gate bands and thresholds come from the manifest itself; if they are wrong, that is an export-side fix.'
181
+ );
182
+ }
183
+
184
+ // ── Plan record ids; refuse collisions with foreign records ───────
185
+ const captionDefaults = manifest.captions || {};
186
+ const subjectName = manifest.identity?.subject_name ?? null;
187
+
188
+ // ── Asset-level style + tone provenance (schema 1.2.0) ─────────────
189
+ // Resolved ONCE here and copied onto every record, so a consumer never has
190
+ // to re-open the manifest (or re-derive a default) to know what register a
191
+ // record was painted in or whether a tone map sits under its colours.
192
+ const style = manifest.asset.style ?? null;
193
+ const styleRecord = style
194
+ ? {
195
+ register_terms: [...style.register.terms],
196
+ register_ruling: style.register.ruling ?? null,
197
+ register_record: style.register.record ?? null,
198
+ lora: style.lora.declared === 'none'
199
+ ? { declared: 'none', card: null, weight: 0 }
200
+ : { declared: 'card', card: style.lora.card, weight: style.lora.weight ?? null },
201
+ }
202
+ : null;
203
+
204
+ const toneDecl = manifest.asset.tone_transform ?? null;
205
+
206
+ // schema 1.3.0 — how these renders came to exist. `generated === false`
207
+ // means an absent generation block is CORRECT by declaration rather than a
208
+ // gap, so the notice below must not report one.
209
+ const derivationDecl = manifest.asset.render_derivation ?? null;
210
+ const derivationRecord = derivationDecl
211
+ ? { kind: derivationDecl.kind, generated: derivationDecl.generated, record: derivationDecl.record ?? null }
212
+ : null;
213
+ const plans = [];
214
+ const skipped = [];
215
+ for (const a of admitted) {
216
+ const recordId = `${assetId}__${a.renderId}`;
217
+ const recordPath = join(recordsDir, `${recordId}.json`);
218
+ if (existsSync(recordPath)) {
219
+ let existing = null;
220
+ try {
221
+ existing = JSON.parse(await readFile(recordPath, 'utf-8'));
222
+ } catch { /* unreadable => treated as foreign below */ }
223
+ const sameIngest = existing?.provenance?.source === 'asset'
224
+ && existing?.provenance?.asset_id === assetId
225
+ && existing?.provenance?.manifest_sha256 === manifestSha;
226
+ if (sameIngest) {
227
+ skipped.push(recordId); // crash-resume: record done, receipt wasn't
228
+ continue;
229
+ }
230
+ throw inputError(
231
+ 'ASSET_RECORD_COLLISION',
232
+ `Record id "${recordId}" already exists and does not belong to this ingest (source: ${existing?.provenance?.source ?? 'unreadable'}).`,
233
+ 'Rename the asset id or resolve the existing record explicitly. Nothing was written.'
234
+ );
235
+ }
236
+ plans.push({ ...a, recordId, recordPath });
237
+ }
238
+
239
+ // ── Provenance notices (schema 1.2.0) ──────────────────────────────
240
+ // NOT gates. Every one of these describes a manifest that is contract-legal
241
+ // and ingests fine — a 1.0.0 export declares none of it, and E11 Ruling 2
242
+ // named those exports as the training input, so refusing them is not on the
243
+ // table. What IS on the table is refusing to be quiet: an absent register or
244
+ // an unknown seed is a fact about the training data, and a fact nobody is
245
+ // told is one nobody can act on. These ride in the receipt and print at the
246
+ // CLI.
247
+ //
248
+ // Two kinds, and the difference matters to anything that reads them:
249
+ // 'gap' — the manifest did NOT declare something a curator needs. Closing
250
+ // it is export-side work.
251
+ // 'info' — the manifest DID declare something that changes how these
252
+ // records should be read. Nothing to fix; something to know.
253
+ const notices = [];
254
+ if (!styleRecord) {
255
+ notices.push({
256
+ code: 'ASSET_STYLE_UNDECLARED',
257
+ kind: 'gap',
258
+ message: 'No asset.style — the register this subject was painted in is UNKNOWN to the lane. ' +
259
+ 'Per facet E12 Ruling 10b the style register is subject data; a training pool mixing declared and undeclared registers cannot be filtered by it. ' +
260
+ 'Records carry style: null, which is distinguishable from a ruled no-LoRA register.',
261
+ });
262
+ }
263
+ if (toneDecl) {
264
+ const optedOut = (manifest.renders || []).filter((r) => r.tone_transform === false).length;
265
+ notices.push({
266
+ code: 'ASSET_TONE_TRANSFORM_DECLARED',
267
+ kind: 'info',
268
+ message: `Renders derive from sources carrying a declared "${toneDecl.kind}" tone transform toward ${toneDecl.reference}` +
269
+ (optedOut > 0 ? ` (${optedOut} render(s) opted out)` : '') +
270
+ '. sdlab records the declaration and hashes the operands; it does NOT verify the transform was applied or is reversible. ' +
271
+ 'Colour measurements on these records — palette gate, class shares, ΔE — sit downstream of it.',
272
+ });
273
+ }
274
+ // The generation notice is suppressed — not weakened — when the manifest
275
+ // DECLARES its renders are deterministic derivations. An emit render of an
276
+ // already-accepted asset has no seed; reporting its absence as a gap would
277
+ // tell an operator to go fix an export that is correct by ruling (facet E11
278
+ // Ruling 2), and a notice channel that cries wolf on correct data stops
279
+ // being read. An UNDECLARED manifest still gets the gap: saying nothing is
280
+ // not the same as saying "these are derivations."
281
+ if (derivationDecl?.generated === false) {
282
+ notices.push({
283
+ code: 'ASSET_RENDERS_ARE_DERIVATIONS',
284
+ kind: 'info',
285
+ message: `Renders are declared "${derivationDecl.kind}" derivations of the accepted asset, not generated images — so they carry no seed or generation frame, correctly. ` +
286
+ 'Records carry generation: null by declaration rather than by omission. ' +
287
+ 'The generating provenance of any twins these were projected from belongs to those twins, not to these records.',
288
+ });
289
+ } else {
290
+ const noGeneration = admitted.filter((a) => !a.entry.render.generation).length;
291
+ if (noGeneration > 0) {
292
+ notices.push({
293
+ code: 'ASSET_GENERATION_PROVENANCE_ABSENT',
294
+ kind: 'gap',
295
+ message: `${noGeneration} of ${admitted.length} admitted render(s) declare no generation block — frame and seed are unknown for them. ` +
296
+ 'Both are measured curation axes (E12 Ruling 24b: crop-framed generation drifts register, 3 instances; Ruling 20a: term binding is seed-dependent). ' +
297
+ 'Records carry generation: null. ' +
298
+ 'If these renders are deterministic derivations rather than generated images, declare asset.render_derivation with generated: false and this stops being a gap.',
299
+ });
300
+ }
301
+ }
302
+ if (!subjectName) {
303
+ notices.push({
304
+ code: 'ASSET_SUBJECT_NAME_ABSENT',
305
+ kind: 'gap',
306
+ message: 'No identity.subject_name — lib/split.js will fall back to id-stem guessing for these records. ' +
307
+ 'Two ingests of ONE subject then read as two families, and the same subject can land in both train and test. ' +
308
+ 'Declare it before any split is cut.',
309
+ });
310
+ }
311
+
312
+ const wouldWrite = [];
313
+ const written = [];
314
+ const filesLedger = [];
315
+ const note = (role, sourceRel, destRel, materialized, hash, bytes) => {
316
+ filesLedger.push({
317
+ role,
318
+ source: toPortablePath(sourceRel),
319
+ dest: destRel ? toPortablePath(destRel) : null,
320
+ materialized,
321
+ sha256: hash ?? null,
322
+ bytes: bytes ?? null,
323
+ });
324
+ };
325
+
326
+ // ── Dry run: full plan, zero writes ────────────────────────────────
327
+ if (dryRun) {
328
+ for (const p of plans) {
329
+ wouldWrite.push(toPortablePath(join('records', `${p.recordId}.json`)));
330
+ wouldWrite.push(toPortablePath(join('outputs', 'candidates', `${p.recordId}.png`)));
331
+ }
332
+ wouldWrite.push(toPortablePath(join('assets', assetId, ASSET_MANIFEST_FILENAME)));
333
+ wouldWrite.push(toPortablePath(join('assets', assetId, RECEIPT_FILENAME)));
334
+ return {
335
+ assetId,
336
+ alreadyIngested: false,
337
+ dryRun: true,
338
+ created: plans.map((p) => p.recordId),
339
+ skipped,
340
+ rejected,
341
+ notices,
342
+ gates: Object.fromEntries(plans.map((p) => [p.recordId, p.gate])),
343
+ wouldWrite,
344
+ };
345
+ }
346
+
347
+ // ── Writes ──────────────────────────────────────────────────────────
348
+ await mkdir(recordsDir, { recursive: true });
349
+ await mkdir(candidatesDir, { recursive: true });
350
+ await mkdir(assetDir, { recursive: true });
351
+
352
+ // Texture-space artifacts: hashed in place, not materialized — they are
353
+ // provenance-of-record (mesh, atlas, texture channels), never consumed
354
+ // per-render by sdlab mechanism. The receipt pins them by source + hash.
355
+ for (const key of ['mesh', 'atlas']) {
356
+ const ref = resolved[key];
357
+ if (!ref) continue;
358
+ const { hash, bytes } = await hashAndMaybeCopy(ref.abs, null, false);
359
+ note(key, ref.relPath, null, false, hash, bytes);
360
+ }
361
+ for (const [cid, ref] of resolved.textureChannels) {
362
+ const { hash, bytes } = await hashAndMaybeCopy(ref.abs, null, false);
363
+ note(`channel:${cid}`, ref.relPath, null, false, hash, bytes);
364
+ }
365
+
366
+ // Tone-transform operands (schema 1.2.0) are MATERIALIZED, unlike the mesh,
367
+ // atlas and texture channels beside them. They are small, and they are the
368
+ // only record of what the upstream transform actually did to these colours
369
+ // — leaving them as a pointer would make the lane's audit trail depend on
370
+ // the export tree surviving. Copying them costs kilobytes and removes that
371
+ // dependency.
372
+ let toneOperandsRef = null;
373
+ if (resolved.toneOperands) {
374
+ const opName = basename(resolved.toneOperands.relPath);
375
+ const opDest = join(assetDir, opName);
376
+ const opRel = toPortablePath(join('assets', assetId, opName));
377
+ const { hash, bytes } = await hashAndMaybeCopy(resolved.toneOperands.abs, opDest, true);
378
+ note('tone_operands', resolved.toneOperands.relPath, opRel, true, hash, bytes);
379
+ written.push(opRel);
380
+ toneOperandsRef = { path: opRel, sha256: hash };
381
+ }
382
+
383
+ // Per-render artifacts: materialized into the project.
384
+ const subdirs = { masks: join(assetDir, 'masks'), channels: join(assetDir, 'channels'), pairs: join(assetDir, 'pairs'), loss: join(assetDir, 'loss-masks') };
385
+
386
+ const created = [];
387
+ for (const p of plans) {
388
+ const { entry } = p;
389
+ const candidateName = `${p.recordId}.png`;
390
+ const candidatePath = join(candidatesDir, candidateName);
391
+ const candidateRel = toPortablePath(join('outputs', 'candidates', candidateName));
392
+
393
+ if (!existsSync(candidatePath)) {
394
+ await atomicWriteFile(candidatePath, p.renderBuf);
395
+ }
396
+ note('render', entry.relPath, candidateRel, true, p.renderSha, p.renderBuf.length);
397
+ written.push(candidateRel);
398
+
399
+ const silName = `${p.renderId}${extname(entry.silhouette.relPath) || '.png'}`;
400
+ await mkdir(subdirs.masks, { recursive: true });
401
+ const silDest = join(subdirs.masks, silName);
402
+ const silRel = toPortablePath(join('assets', assetId, 'masks', silName));
403
+ const sil = await hashAndMaybeCopy(entry.silhouette.abs, silDest, true);
404
+ note('silhouette', entry.silhouette.relPath, silRel, true, sil.hash, sil.bytes);
405
+ written.push(silRel);
406
+
407
+ const channelRefs = {};
408
+ for (const [cid, inst] of entry.instances) {
409
+ await mkdir(subdirs.channels, { recursive: true });
410
+ const instName = `${cid}__${p.renderId}${extname(inst.relPath) || '.png'}`;
411
+ const instDest = join(subdirs.channels, instName);
412
+ const instRel = toPortablePath(join('assets', assetId, 'channels', instName));
413
+ const h = await hashAndMaybeCopy(inst.abs, instDest, true);
414
+ note(`channel:${cid}:${p.renderId}`, inst.relPath, instRel, true, h.hash, h.bytes);
415
+ written.push(instRel);
416
+ channelRefs[cid] = { path: instRel, sha256: h.hash };
417
+ }
418
+
419
+ let lossRef;
420
+ if (entry.lossMask) {
421
+ await mkdir(subdirs.loss, { recursive: true });
422
+ const lossName = `${p.renderId}${extname(entry.lossMask.relPath) || '.png'}`;
423
+ const lossDest = join(subdirs.loss, lossName);
424
+ const lossRel = toPortablePath(join('assets', assetId, 'loss-masks', lossName));
425
+ const h = await hashAndMaybeCopy(entry.lossMask.abs, lossDest, true);
426
+ note(`loss-mask:${p.renderId}`, entry.lossMask.relPath, lossRel, true, h.hash, h.bytes);
427
+ written.push(lossRel);
428
+ lossRef = { path: lossRel, sha256: h.hash };
429
+ }
430
+
431
+ const pairRefs = {};
432
+ for (const [name, pref] of entry.pairs) {
433
+ await mkdir(subdirs.pairs, { recursive: true });
434
+ const pairName = `${name}__${p.renderId}${extname(pref.relPath) || '.png'}`;
435
+ const pairDest = join(subdirs.pairs, pairName);
436
+ const pairRel = toPortablePath(join('assets', assetId, 'pairs', pairName));
437
+ const h = await hashAndMaybeCopy(pref.abs, pairDest, true);
438
+ note(`pair:${name}:${p.renderId}`, pref.relPath, pairRel, true, h.hash, h.bytes);
439
+ written.push(pairRel);
440
+ pairRefs[name] = { path: pairRel, sha256: h.hash };
441
+ }
442
+
443
+ const render = entry.render;
444
+ const facing = render.facing || facingFromCamera(render.camera);
445
+ const provenance = {
446
+ source: 'asset',
447
+ ingested_at: new Date().toISOString(),
448
+ asset_id: assetId,
449
+ asset_source: manifest.asset.source ?? null,
450
+ manifest_path: toPortablePath(join('assets', assetId, ASSET_MANIFEST_FILENAME)),
451
+ manifest_sha256: manifestSha,
452
+ acceptance: { ...manifest.acceptance },
453
+ render_id: p.renderId,
454
+ camera: { yaw_deg: render.camera.yaw_deg, elevation_deg: render.camera.elevation_deg ?? 0 },
455
+ light: render.light ?? null,
456
+ facing,
457
+ silhouette_mask: { path: silRel, sha256: sil.hash },
458
+ channels: channelRefs,
459
+ caption_fields: {
460
+ subject: captionDefaults.subject ?? null,
461
+ style_trigger: captionDefaults.style_trigger ?? null,
462
+ domain_tag: captionDefaults.domain_tag ?? DEFAULT_DOMAIN_TAG,
463
+ facing,
464
+ },
465
+ generation_provenance_known: true,
466
+ // ── schema 1.2.0 ────────────────────────────────────────────────
467
+ // `style` is null when the manifest declared none. That null is
468
+ // MEANINGFUL and is why the field is always written: a record whose
469
+ // register is unknown is distinguishable from one ruled no-LoRA, and a
470
+ // curator can filter on it. Silence would collapse the two — the exact
471
+ // accident E12 Ruling 10b was bought to stop.
472
+ style: styleRecord,
473
+ // The RESOLVED per-render answer, never a default the consumer re-derives:
474
+ // a render carries the asset's tone transform unless it opts out.
475
+ tone_transform: toneDecl
476
+ ? {
477
+ applied: render.tone_transform !== false,
478
+ kind: toneDecl.kind,
479
+ space: toneDecl.space ?? null,
480
+ scope: toneDecl.scope ?? null,
481
+ reference: toneDecl.reference,
482
+ reference_is_self: toneDecl.reference === p.renderId,
483
+ reversible: toneDecl.reversible ?? null,
484
+ record: toneDecl.record ?? null,
485
+ operands: toneOperandsRef,
486
+ }
487
+ : null,
488
+ // How this render came to exist (schema 1.3.0). Load-bearing beside
489
+ // `generation` below: with `generated: false`, a null generation is the
490
+ // asset's DECLARATION that no seed exists, not a missing field. A
491
+ // curator filtering for unknown-seed records must not sweep up emit
492
+ // derivations that never had one.
493
+ render_derivation: derivationRecord,
494
+ // Per-image curation axes. Both measured phenomena (register drift at
495
+ // crop frames; seed-dependent term binding) are per-image, so a record
496
+ // that cannot answer "what frame, what seed" cannot be curated on them.
497
+ generation: render.generation
498
+ ? {
499
+ frame: render.generation.frame,
500
+ frame_detail: render.generation.frame_detail ?? null,
501
+ seed: render.generation.seed ?? null,
502
+ stem: render.generation.stem ?? null,
503
+ model: render.generation.model ?? null,
504
+ reroll_of: render.generation.reroll_of ?? null,
505
+ }
506
+ : null,
507
+ };
508
+ if (lossRef) provenance.loss_mask = lossRef;
509
+ if (Object.keys(pairRefs).length > 0) provenance.pair = pairRefs;
510
+
511
+ const base = buildBaseRecord(
512
+ p.recordId,
513
+ candidateRel,
514
+ { width: p.width, height: p.height, bytes: p.renderBuf.length },
515
+ provenance
516
+ );
517
+ // identity.subject_name (schema 1.1.0) is the split engine's only
518
+ // AUTHORED subject-family key. Without it lib/split.js falls back to
519
+ // id-stem guessing, which reads two ingests of one subject as two
520
+ // families and will happily put the same subject in train and test.
521
+ // Lineage is null: an asset render is not derived from another record.
522
+ const record = subjectName
523
+ ? buildIdentityRecord(base, { subject_name: subjectName }, null)
524
+ : base;
525
+ // Distinct key from `sdlab measure`'s record.measurements, which that
526
+ // command overwrites wholesale — asset receiving-dock numbers must
527
+ // survive a later measure run.
528
+ record.asset_measurements = {
529
+ tool: ASSET_INGEST_TOOL_ID,
530
+ palette_gate: p.gate,
531
+ class_shares: p.shares,
532
+ };
533
+
534
+ const recordRel = toPortablePath(join('records', `${p.recordId}.json`));
535
+ await atomicWriteFile(p.recordPath, JSON.stringify(record, null, 2) + '\n');
536
+ written.push(recordRel);
537
+ created.push(p.recordId);
538
+ }
539
+
540
+ // Manifest copy — byte-identical.
541
+ const manifestDestRel = toPortablePath(join('assets', assetId, ASSET_MANIFEST_FILENAME));
542
+ await atomicWriteFile(join(assetDir, ASSET_MANIFEST_FILENAME), manifestRaw);
543
+ note('manifest', ASSET_MANIFEST_FILENAME, manifestDestRel, true, manifestSha, manifestRaw.length);
544
+ written.push(manifestDestRel);
545
+
546
+ // Receipt LAST — its presence means this ingest completed.
547
+ const receipt = {
548
+ schema_version: ASSET_RECEIPT_SCHEMA_VERSION,
549
+ tool: ASSET_INGEST_TOOL_ID,
550
+ ingested_at: new Date().toISOString(),
551
+ asset_id: assetId,
552
+ source_dir: toPortablePath(sourceDir),
553
+ manifest_sha256: manifestSha,
554
+ files: filesLedger,
555
+ records_created: created,
556
+ records_skipped: skipped,
557
+ rejected_renders: rejected,
558
+ notices,
559
+ written,
560
+ };
561
+ const receiptRel = toPortablePath(join('assets', assetId, RECEIPT_FILENAME));
562
+ await atomicWriteFile(receiptPath, JSON.stringify(receipt, null, 2) + '\n');
563
+
564
+ return {
565
+ assetId,
566
+ alreadyIngested: false,
567
+ dryRun: false,
568
+ created,
569
+ skipped,
570
+ rejected,
571
+ notices,
572
+ gates: Object.fromEntries(plans.map((p) => [p.recordId, p.gate])),
573
+ receiptPath: receiptRel,
574
+ };
575
+ }