@mcptoolshop/style-dataset-lab 3.3.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.
- package/CHANGELOG.md +60 -0
- package/README.es.md +117 -55
- package/README.fr.md +125 -65
- package/README.hi.md +117 -68
- package/README.it.md +119 -58
- package/README.ja.md +124 -74
- package/README.md +73 -7
- package/README.pt-BR.md +128 -63
- package/README.zh.md +116 -65
- package/bin/sdlab.js +100 -4
- package/lib/adapters/ai-toolkit.js +224 -35
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +94 -11
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-author.js +350 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/canon-review-gate.js +205 -0
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +16 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +194 -6
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/lane1-render.js +668 -0
- package/lib/measure.js +422 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/paths.js +196 -7
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +113 -17
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +159 -25
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/train-runs.js +214 -0
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +4 -3
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/asset-ingest.js +160 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-draft.js +82 -0
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +31 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +182 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +86 -17
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/verify.js +67 -0
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
|
@@ -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
|
+
}
|