@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,1176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset-source contract — load and validate an `asset-source.json` manifest,
|
|
3
|
+
* the ingest contract of the asset lane (docs/asset-lane-design.md).
|
|
4
|
+
*
|
|
5
|
+
* One manifest per exported asset, living in the export directory. The
|
|
6
|
+
* manifest carries ALL semantics (channels, palettes, classes, tolerances,
|
|
7
|
+
* gates, acceptance); this module carries none — it proves declarations
|
|
8
|
+
* against bytes:
|
|
9
|
+
*
|
|
10
|
+
* 1. schema — shape, ids, the acceptance gate (verdict must be
|
|
11
|
+
* literally "accepted"; the Director's eye is the most
|
|
12
|
+
* canon-bound gate the studio has)
|
|
13
|
+
* 2. containment — every referenced path resolves INSIDE the manifest's
|
|
14
|
+
* directory (the export dir is untrusted input) and exists
|
|
15
|
+
* 3. encoding — a PNG's IHDR must match its declared encoding; an NPY
|
|
16
|
+
* header must match its declared dtype/shape. A manifest
|
|
17
|
+
* that "looks right" but disagrees with its bytes is the
|
|
18
|
+
* failure class this lane exists to stop.
|
|
19
|
+
* 4. categorical — indexed: PLTE ⊆ declared palette (structural chunk
|
|
20
|
+
* proof, the brand/E09 contract — no pixel decode);
|
|
21
|
+
* non-indexed + filter "nearest": EXHAUSTIVE decode proof
|
|
22
|
+
* (every non-transparent pixel ∈ declared palette — a
|
|
23
|
+
* complete proof, not a sample); non-indexed + "linear":
|
|
24
|
+
* no proof here — classification happens at ingest as a
|
|
25
|
+
* measurement with an honest unclassified share; npy:
|
|
26
|
+
* EXHAUSTIVE value proof against declared integer classes.
|
|
27
|
+
*
|
|
28
|
+
* All violations are collected and refused in ONE loud error (ANDON:
|
|
29
|
+
* nothing registers on a bad manifest), with the code chosen by the most
|
|
30
|
+
* meaningful violation class present.
|
|
31
|
+
*
|
|
32
|
+
* ── Schema 1.1.0 ───────────────────────────────────────────────────────
|
|
33
|
+
* Additive, per facet E11 Ruling 6: the sidecars that rode UNDECLARED
|
|
34
|
+
* beside the declared channels become declarable, so they are contained,
|
|
35
|
+
* proven, hashed and carried into the record instead of being invisible.
|
|
36
|
+
*
|
|
37
|
+
* - render-space `npy` — per-view owner/class maps (`owner_id_*.npy`).
|
|
38
|
+
* 1.0.0 refused these outright; the proof is a
|
|
39
|
+
* header match PLUS shape parallel to the
|
|
40
|
+
* render, numpy-axis-aware ([H, W], the
|
|
41
|
+
* transpose a render-space array gets wrong).
|
|
42
|
+
* - categorical `npy` — declares `classes: [{name, value}]` instead of
|
|
43
|
+
* an rgb `palette`, proven exhaustively.
|
|
44
|
+
* - `json` encoding — the measurement/camera sidecars
|
|
45
|
+
* (`admission_*.json`, `cam.json`). The proof is
|
|
46
|
+
* deliberately weak and SAID SO: parses as an
|
|
47
|
+
* object, declared `required_keys` present. sdlab
|
|
48
|
+
* does not read the values — a number in there
|
|
49
|
+
* means what the asset says it means.
|
|
50
|
+
* - `identity.subject_name` — the split engine's authored subject-family
|
|
51
|
+
* key. Without it lib/split.js falls back to
|
|
52
|
+
* id-stem guessing, which reads two ingests of
|
|
53
|
+
* one subject as two families and lets the same
|
|
54
|
+
* subject sit in train and test.
|
|
55
|
+
*
|
|
56
|
+
* Every addition is OPTIONAL, so 1.0.0 manifests validate unchanged — which
|
|
57
|
+
* is why this is 1.1.0 and not 2.0.0: a major bump would refuse the very
|
|
58
|
+
* manifests E11 ruled to be the training input. The version gate is
|
|
59
|
+
* minor-aware in the other direction: a manifest declaring a HIGHER minor
|
|
60
|
+
* than this sdlab speaks is refused loudly rather than silently stripped of
|
|
61
|
+
* the channels this build cannot see.
|
|
62
|
+
*
|
|
63
|
+
* ── Schema 1.2.0 ───────────────────────────────────────────────────────
|
|
64
|
+
* Additive, per facet E12 Ruling 10b (the style directive), Rulings 23c/23f
|
|
65
|
+
* (the harmonization adoption) and Rulings 20a/24b (two measured generation
|
|
66
|
+
* phenomena). Same discipline as 1.1.0 — every addition OPTIONAL, so 1.0.0
|
|
67
|
+
* and 1.1.0 manifests validate unchanged. Hence 1.2.0, not 2.0.0.
|
|
68
|
+
*
|
|
69
|
+
* - `asset.style` — THE REGISTER IS SUBJECT DATA. Ruling 10b was
|
|
70
|
+
* bought by a rejection: the painterly register
|
|
71
|
+
* two subjects earned was inherited by a third
|
|
72
|
+
* that should read ultra-realistic — "a style
|
|
73
|
+
* decision nobody made." The register is an
|
|
74
|
+
* authored property of the SUBJECT, declared
|
|
75
|
+
* here beside the palette bands, never derived
|
|
76
|
+
* from the dataset and never inherited.
|
|
77
|
+
*
|
|
78
|
+
* `style.lora` carries a REQUIRED `declared`
|
|
79
|
+
* discriminator — "none" or "card". That is the
|
|
80
|
+
* ruling expressed mechanically: NO-LoRA is a
|
|
81
|
+
* positive declaration, not the absence of a
|
|
82
|
+
* field. A serializer that drops nulls cannot
|
|
83
|
+
* turn a ruled NONE into silence, and "no
|
|
84
|
+
* fixture may leave the section implicit again"
|
|
85
|
+
* becomes something bytes can prove.
|
|
86
|
+
*
|
|
87
|
+
* - `asset.tone_transform` — a deterministic, generation-free tone map
|
|
88
|
+
* applied to the generated views BEFORE they
|
|
89
|
+
* became the projection sources these renders
|
|
90
|
+
* derive from (facet's Lab colour-statistics
|
|
91
|
+
* harmonization toward a reference view,
|
|
92
|
+
* adopted at Ruling 23f). Declared because this
|
|
93
|
+
* lane MEASURES colour — the palette gate, the
|
|
94
|
+
* class shares, the pre-registered ΔE
|
|
95
|
+
* experiment — and an undeclared upstream tone
|
|
96
|
+
* map is a systematic covariate sitting under
|
|
97
|
+
* every one of those numbers. Pooling
|
|
98
|
+
* harmonized-source and raw-source assets is a
|
|
99
|
+
* legitimate choice; doing it unknowingly is
|
|
100
|
+
* not.
|
|
101
|
+
*
|
|
102
|
+
* The proof is structural and the boundary is
|
|
103
|
+
* stated out loud, as with `json`: sdlab checks
|
|
104
|
+
* that `reference` names a render THIS manifest
|
|
105
|
+
* declares, and contains + hashes + carries the
|
|
106
|
+
* operands sidecar. It does NOT verify that the
|
|
107
|
+
* transform was applied, that the operands are
|
|
108
|
+
* correct, or that the arithmetic is
|
|
109
|
+
* reversible. It records what the asset
|
|
110
|
+
* declared, so a consumer can group by it and
|
|
111
|
+
* an audit can replay it.
|
|
112
|
+
*
|
|
113
|
+
* - `renders[].generation` — per-image generation frame + seed, because
|
|
114
|
+
* both are MEASURED curation axes, not trivia:
|
|
115
|
+
* (1) bust/crop-framed generation drifts
|
|
116
|
+
* register — three independent instances
|
|
117
|
+
* (Ruling 24b); (2) term binding is
|
|
118
|
+
* seed-dependent — one seed resisted a prompt
|
|
119
|
+
* term across three independent stems where the
|
|
120
|
+
* next seed bound it (Ruling 20a), and one
|
|
121
|
+
* view's flat-black limb reproduced three times
|
|
122
|
+
* at that seed and cured all three times at the
|
|
123
|
+
* next (Ruling 23d). A curator who cannot ask
|
|
124
|
+
* "which records came from a crop frame" or
|
|
125
|
+
* "which came from that seed" cannot act on
|
|
126
|
+
* either finding.
|
|
127
|
+
*
|
|
128
|
+
* `generation.frame` is a CLOSED two-value enum
|
|
129
|
+
* ("full" | "crop") for the same reason
|
|
130
|
+
* `facing` is a derived vocabulary: it is the
|
|
131
|
+
* axis the phenomenon was measured on, and a
|
|
132
|
+
* free-text field where one asset writes "bust"
|
|
133
|
+
* and another "head-crop" cannot be queried.
|
|
134
|
+
* Everything finer is the asset's own business
|
|
135
|
+
* and rides in `frame_detail`.
|
|
136
|
+
*
|
|
137
|
+
* ── Schema 1.3.0 ───────────────────────────────────────────────────────
|
|
138
|
+
* One addition, closing a defect 1.2.0 shipped with. Per facet E11 Ruling 2
|
|
139
|
+
* (endorsed as E11 addenda 3), a dense turnaround's renders are
|
|
140
|
+
* DETERMINISTIC DERIVATIONS of the accepted asset by the anchored emit path
|
|
141
|
+
* — not generated images. They therefore carry no `generation` block, and
|
|
142
|
+
* *correctly* so: "an optional block that does not apply is left out, not
|
|
143
|
+
* filled in."
|
|
144
|
+
*
|
|
145
|
+
* 1.2.0 could not tell that apart from an export that simply failed to
|
|
146
|
+
* declare its seeds, so it reported the gap on both — which would have told
|
|
147
|
+
* an operator to go fix an export that was right by ruling. Crying wolf on
|
|
148
|
+
* correct data is how a notice channel stops being read.
|
|
149
|
+
*
|
|
150
|
+
* - `asset.render_derivation` — {kind, generated, record}. `generated` is
|
|
151
|
+
* the load-bearing bit, and it is the same
|
|
152
|
+
* silence-vs-declaration principle as
|
|
153
|
+
* `style.lora.declared`: the manifest SAYS its
|
|
154
|
+
* renders are derivations rather than leaving
|
|
155
|
+
* the lane to infer it from an absence.
|
|
156
|
+
*
|
|
157
|
+
* When `generated: false`, a per-render
|
|
158
|
+
* `generation` block is REFUSED — a
|
|
159
|
+
* deterministic derivation has no seed of its
|
|
160
|
+
* own, so declaring one is a category error
|
|
161
|
+
* worth catching rather than recording. The
|
|
162
|
+
* generating provenance of the twins such a
|
|
163
|
+
* render was projected FROM lives on the asset
|
|
164
|
+
* side; it is not this render's.
|
|
165
|
+
*
|
|
166
|
+
* Optional, so 1.0.0–1.2.0 manifests validate unchanged; hence 1.3.0, not
|
|
167
|
+
* 2.0.0. Undeclared keeps 1.2.0's behaviour exactly — the gap notice still
|
|
168
|
+
* fires — because an export that declares nothing genuinely has not said
|
|
169
|
+
* whether its renders were generated.
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
import { readFile } from 'node:fs/promises';
|
|
173
|
+
import { existsSync } from 'node:fs';
|
|
174
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
175
|
+
import { inputError } from './errors.js';
|
|
176
|
+
import { SAFE_ID_RE } from './export.js';
|
|
177
|
+
import { parsePngMeta, decodePng } from './png-meta.js';
|
|
178
|
+
import { parseNpyHeader, readNpyValues, NPY_PROVABLE_DTYPES } from './npy-meta.js';
|
|
179
|
+
import { rgbAt, alphaAt } from './asset-masks.js';
|
|
180
|
+
|
|
181
|
+
export const ASSET_SCHEMA_VERSION = '1.3.0';
|
|
182
|
+
export const ASSET_MANIFEST_FILENAME = 'asset-source.json';
|
|
183
|
+
|
|
184
|
+
const ENCODINGS = new Set(['indexed', 'rgb', 'rgba', 'grayscale', 'npy', 'json']);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Schema 1.2.0. `none` is the ruled NO-LoRA register (facet E12 Ruling 10b:
|
|
188
|
+
* "none at all is better than making the same texture for everything"); a
|
|
189
|
+
* manifest must SAY it rather than omit the field, so silence can never be
|
|
190
|
+
* read as a decision.
|
|
191
|
+
*/
|
|
192
|
+
export const LORA_DECLARATIONS = new Set(['none', 'card']);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Schema 1.2.0. The measured curation axis for the register-drift phenomenon
|
|
196
|
+
* (E12 Ruling 24b): the subject whole in frame, versus a sub-region of it
|
|
197
|
+
* generated at higher pixel density (bust, head-crop, detail crop).
|
|
198
|
+
*/
|
|
199
|
+
export const GENERATION_FRAMES = new Set(['full', 'crop']);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Encodings that decode to pixels. Consumers that measure images (class
|
|
203
|
+
* shares at ingest) must gate on this — since 1.1.0 a channel instance can
|
|
204
|
+
* be an npy array or a json sidecar, and neither answers to decodePng.
|
|
205
|
+
*/
|
|
206
|
+
export const PIXEL_ENCODINGS = new Set(['indexed', 'rgb', 'rgba', 'grayscale']);
|
|
207
|
+
const COLOR_TYPE_BY_ENCODING = { grayscale: 0, rgb: 2, indexed: 3, rgba: 6 };
|
|
208
|
+
const RENDER_COLOR_TYPES = new Set([2, 6]); // trainable renders: color, 8-bit
|
|
209
|
+
|
|
210
|
+
// Violation classes, ordered by which code the single thrown error carries.
|
|
211
|
+
// The message always lists EVERY violation; the code names the loudest class.
|
|
212
|
+
const CLASS_ORDER = [
|
|
213
|
+
'ASSET_NOT_ACCEPTED',
|
|
214
|
+
'ASSET_MANIFEST_INVALID',
|
|
215
|
+
'ASSET_PATH_ESCAPE',
|
|
216
|
+
'ASSET_FILE_MISSING',
|
|
217
|
+
'ASSET_ENCODING_MISMATCH',
|
|
218
|
+
'ASSET_PALETTE_PROOF_FAILED',
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
function v(cls, path, message) {
|
|
222
|
+
return { cls, path, message };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isNonEmptyString(x) {
|
|
226
|
+
return typeof x === 'string' && x.trim().length > 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function safeId(x) {
|
|
230
|
+
return typeof x === 'string' && SAFE_ID_RE.test(x);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validate `asset.style` (schema 1.2.0) — the STYLE-SUPPLIED section that
|
|
235
|
+
* facet E12 Ruling 10b requires every fixture to carry.
|
|
236
|
+
*
|
|
237
|
+
* The block is optional (1.0.0/1.1.0 manifests validate unchanged), but it is
|
|
238
|
+
* ALL-OR-NOTHING once present: a style block that names a register without
|
|
239
|
+
* answering the LoRA question is exactly the implicit section the ruling
|
|
240
|
+
* forbids, so it is refused rather than half-accepted.
|
|
241
|
+
*
|
|
242
|
+
* @param {Object} style — the manifest's asset.style
|
|
243
|
+
* @param {Function} bad — (jsonPath, message) => void
|
|
244
|
+
*/
|
|
245
|
+
function validateStyle(style, bad) {
|
|
246
|
+
if (typeof style !== 'object' || Array.isArray(style)) {
|
|
247
|
+
bad('asset.style', 'must be an object {register, lora}');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// register — the terms the subject was ruled to read as
|
|
252
|
+
if (!style.register || typeof style.register !== 'object' || Array.isArray(style.register)) {
|
|
253
|
+
bad('asset.style.register', 'required object {terms: [string], ruling?, record?} — the register is subject data, not a studio default');
|
|
254
|
+
} else {
|
|
255
|
+
const terms = style.register.terms;
|
|
256
|
+
if (!Array.isArray(terms) || terms.length === 0 || !terms.every(isNonEmptyString)) {
|
|
257
|
+
bad('asset.style.register.terms', 'required non-empty array of non-empty strings (e.g. ["ultra-realistic", "menacing"])');
|
|
258
|
+
}
|
|
259
|
+
for (const key of ['ruling', 'record']) {
|
|
260
|
+
if (style.register[key] != null && !isNonEmptyString(style.register[key])) {
|
|
261
|
+
bad(`asset.style.register.${key}`, 'must be a non-empty string when present (the ruling that decided this register, and where it is recorded)');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// lora — the discriminator is the whole point: NONE is declared, not omitted
|
|
267
|
+
if (!style.lora || typeof style.lora !== 'object' || Array.isArray(style.lora)) {
|
|
268
|
+
bad('asset.style.lora',
|
|
269
|
+
'required object — a style block must answer the LoRA question explicitly. Use {"declared":"none"} for the ruled no-LoRA register, or {"declared":"card","card":"<live card name>"}. Omitting it is the implicit-section failure E12 Ruling 10b forbids.');
|
|
270
|
+
} else if (!LORA_DECLARATIONS.has(style.lora.declared)) {
|
|
271
|
+
bad('asset.style.lora.declared',
|
|
272
|
+
`must be one of ${[...LORA_DECLARATIONS].join(', ')} — "none" is a positive declaration that this subject runs with NO LoRA, not the absence of a value`);
|
|
273
|
+
} else if (style.lora.declared === 'card') {
|
|
274
|
+
if (!isNonEmptyString(style.lora.card)) {
|
|
275
|
+
bad('asset.style.lora.card', 'required non-empty string when declared is "card" — the LIVE card name as it exists in the model library, not a nickname');
|
|
276
|
+
}
|
|
277
|
+
if (style.lora.weight != null && typeof style.lora.weight !== 'number') {
|
|
278
|
+
bad('asset.style.lora.weight', 'must be a number when present');
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
// declared: "none" — carrying a card alongside it is a contradiction, and
|
|
282
|
+
// silently preferring one over the other is how a register drifts back in.
|
|
283
|
+
if (style.lora.card != null) {
|
|
284
|
+
bad('asset.style.lora.card', 'must not be present when declared is "none" — a no-LoRA register that names a card contradicts itself');
|
|
285
|
+
}
|
|
286
|
+
if (style.lora.weight != null && style.lora.weight !== 0) {
|
|
287
|
+
bad('asset.style.lora.weight', 'must be absent or 0 when declared is "none"');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Validate `asset.tone_transform` (schema 1.2.0) — a deterministic tone map
|
|
294
|
+
* applied upstream of these renders. Optional; complete once present.
|
|
295
|
+
*
|
|
296
|
+
* `reference` is checked against the manifest's own render ids: a reference
|
|
297
|
+
* view that this manifest does not declare is dangling provenance, and that
|
|
298
|
+
* IS checkable structurally. Everything semantic is the asset's.
|
|
299
|
+
*
|
|
300
|
+
* @param {Object} tt — the manifest's asset.tone_transform
|
|
301
|
+
* @param {Set<string>} renderIds — ids declared in renders[]
|
|
302
|
+
* @param {Function} bad — (jsonPath, message) => void
|
|
303
|
+
*/
|
|
304
|
+
function validateToneTransform(tt, renderIds, bad) {
|
|
305
|
+
if (typeof tt !== 'object' || Array.isArray(tt)) {
|
|
306
|
+
bad('asset.tone_transform', 'must be an object');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!isNonEmptyString(tt.kind)) {
|
|
310
|
+
bad('asset.tone_transform.kind', 'required non-empty string naming the transform (e.g. "lab-stats-transfer") — sdlab does not interpret it, it records and groups by it');
|
|
311
|
+
}
|
|
312
|
+
if (!isNonEmptyString(tt.reference)) {
|
|
313
|
+
bad('asset.tone_transform.reference', 'required — the render id every other view was transferred toward');
|
|
314
|
+
} else if (renderIds.size > 0 && !renderIds.has(tt.reference)) {
|
|
315
|
+
bad('asset.tone_transform.reference',
|
|
316
|
+
`"${tt.reference}" is not a render id declared in this manifest — a tone transform whose reference view is not in the export leaves the provenance dangling`);
|
|
317
|
+
}
|
|
318
|
+
if (!isNonEmptyString(tt.operands)) {
|
|
319
|
+
bad('asset.tone_transform.operands', 'required path to the recorded per-view operands — it is contained, hashed and carried so the transform can be audited or replayed');
|
|
320
|
+
}
|
|
321
|
+
for (const key of ['space', 'scope', 'record']) {
|
|
322
|
+
if (tt[key] != null && !isNonEmptyString(tt[key])) {
|
|
323
|
+
bad(`asset.tone_transform.${key}`, 'must be a non-empty string when present');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (tt.reversible != null && typeof tt.reversible !== 'boolean') {
|
|
327
|
+
bad('asset.tone_transform.reversible', 'must be a boolean when present — this is the asset\'s claim, which sdlab records without verifying');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Validate `asset.render_derivation` (schema 1.3.0) — how these renders came
|
|
333
|
+
* to exist. Optional; complete once present.
|
|
334
|
+
*
|
|
335
|
+
* The point of the block is `generated`. A dense turnaround's renders are
|
|
336
|
+
* deterministic derivations of an already-accepted asset (facet E11 Ruling
|
|
337
|
+
* 2), so they have no seed and no generation frame — and an export that
|
|
338
|
+
* leaves `generation` out for that reason is CORRECT, where an export that
|
|
339
|
+
* leaves it out because nobody recorded the seeds is not. Only the manifest
|
|
340
|
+
* can tell those apart, so it says which.
|
|
341
|
+
*
|
|
342
|
+
* @param {Object} rd — the manifest's asset.render_derivation
|
|
343
|
+
* @param {Function} bad — (jsonPath, message) => void
|
|
344
|
+
*/
|
|
345
|
+
function validateRenderDerivation(rd, bad) {
|
|
346
|
+
if (typeof rd !== 'object' || Array.isArray(rd)) {
|
|
347
|
+
bad('asset.render_derivation', 'must be an object {kind, generated, record?}');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!isNonEmptyString(rd.kind)) {
|
|
351
|
+
bad('asset.render_derivation.kind',
|
|
352
|
+
'required non-empty string naming the path these renders came by (e.g. "emit") — sdlab does not interpret it, it records and groups by it');
|
|
353
|
+
}
|
|
354
|
+
if (typeof rd.generated !== 'boolean') {
|
|
355
|
+
bad('asset.render_derivation.generated',
|
|
356
|
+
'required boolean — true when these renders are generated images (seeds and frames apply), false when they are deterministic derivations of an already-accepted asset (no seed of their own). This is a declaration, not an inference from whether a generation block happens to be present.');
|
|
357
|
+
}
|
|
358
|
+
if (rd.record != null && !isNonEmptyString(rd.record)) {
|
|
359
|
+
bad('asset.render_derivation.record', 'must be a non-empty string when present (where this derivation path is recorded)');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Validate `renders[].generation` (schema 1.2.0) — per-image generation
|
|
365
|
+
* provenance for curation. Optional; complete once present.
|
|
366
|
+
*
|
|
367
|
+
* @param {Object} gen — the render's generation block
|
|
368
|
+
* @param {string} p — the render's json path prefix
|
|
369
|
+
* @param {Function} bad — (jsonPath, message) => void
|
|
370
|
+
*/
|
|
371
|
+
function validateGeneration(gen, p, bad) {
|
|
372
|
+
if (typeof gen !== 'object' || Array.isArray(gen)) {
|
|
373
|
+
bad(`${p}.generation`, 'must be an object');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!GENERATION_FRAMES.has(gen.frame)) {
|
|
377
|
+
bad(`${p}.generation.frame`,
|
|
378
|
+
`must be one of ${[...GENERATION_FRAMES].join(', ')} — "full" is the whole subject in frame, "crop" is a sub-region generated at higher pixel density (bust, head-crop, detail). This is the axis the register-drift phenomenon was measured on; asset-specific naming goes in frame_detail.`);
|
|
379
|
+
}
|
|
380
|
+
// Seed: integer or digit-string. A digit-string is legal because seeds can
|
|
381
|
+
// exceed what a JSON number holds exactly, and a seed that silently loses
|
|
382
|
+
// its low bits cannot be grouped on — which is the entire reason it is here.
|
|
383
|
+
if (gen.seed != null) {
|
|
384
|
+
const okInt = Number.isInteger(gen.seed) && Number.isSafeInteger(gen.seed);
|
|
385
|
+
const okStr = typeof gen.seed === 'string' && /^\d+$/.test(gen.seed);
|
|
386
|
+
if (!okInt && !okStr) {
|
|
387
|
+
bad(`${p}.generation.seed`, 'must be a safe integer or a string of digits (a string keeps seeds too large for exact JSON numbers groupable)');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const key of ['frame_detail', 'stem', 'model', 'reroll_of']) {
|
|
391
|
+
if (gen[key] != null && !isNonEmptyString(gen[key])) {
|
|
392
|
+
bad(`${p}.generation.${key}`, 'must be a non-empty string when present');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ─── 1. Schema ───────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Validate the manifest's shape. Pure — no filesystem access.
|
|
401
|
+
* @returns {Array<{cls:string, path:string, message:string}>}
|
|
402
|
+
*/
|
|
403
|
+
export function validateManifestShape(m) {
|
|
404
|
+
const out = [];
|
|
405
|
+
const bad = (path, message) => out.push(v('ASSET_MANIFEST_INVALID', path, message));
|
|
406
|
+
|
|
407
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) {
|
|
408
|
+
bad('$', 'manifest must be a JSON object');
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// schema_version — major must match; a HIGHER minor is refused rather
|
|
413
|
+
// than accepted-and-silently-stripped. Additive minors mean a newer
|
|
414
|
+
// manifest's extra channels would parse as "absent" to this build, and an
|
|
415
|
+
// asset lane that quietly drops declared provenance is the failure class
|
|
416
|
+
// this module exists to stop.
|
|
417
|
+
if (!isNonEmptyString(m.schema_version) || !/^\d+\.\d+\.\d+$/.test(m.schema_version)) {
|
|
418
|
+
bad('schema_version', 'must be a semver string, e.g. "1.1.0"');
|
|
419
|
+
} else {
|
|
420
|
+
const [major, minor] = m.schema_version.split('.').map(Number);
|
|
421
|
+
const [ourMajor, ourMinor] = ASSET_SCHEMA_VERSION.split('.').map(Number);
|
|
422
|
+
if (major !== ourMajor) {
|
|
423
|
+
bad('schema_version', `major version ${m.schema_version} is not supported (this sdlab speaks ${ASSET_SCHEMA_VERSION})`);
|
|
424
|
+
} else if (minor > ourMinor) {
|
|
425
|
+
bad('schema_version', `${m.schema_version} declares a newer minor than this sdlab speaks (${ASSET_SCHEMA_VERSION}) — it may declare channels this build cannot see, and ingesting it would silently drop them. Upgrade sdlab.`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// identity — optional, but the split engine's only authored subject key
|
|
430
|
+
if (m.identity != null) {
|
|
431
|
+
if (typeof m.identity !== 'object' || Array.isArray(m.identity)) {
|
|
432
|
+
bad('identity', 'must be an object');
|
|
433
|
+
} else if (m.identity.subject_name != null && !safeId(m.identity.subject_name)) {
|
|
434
|
+
bad('identity.subject_name', 'must match [A-Za-z0-9._-]+ — it is the split engine\'s subject-family key, and two ingests of one subject must declare the SAME value or splits will read them as two families');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// asset
|
|
439
|
+
if (!m.asset || typeof m.asset !== 'object') {
|
|
440
|
+
bad('asset', 'required object');
|
|
441
|
+
} else {
|
|
442
|
+
if (!safeId(m.asset.id)) bad('asset.id', 'required; must match [A-Za-z0-9._-]+');
|
|
443
|
+
for (const key of ['mesh', 'atlas']) {
|
|
444
|
+
const ref = m.asset[key];
|
|
445
|
+
if (ref != null && (typeof ref !== 'object' || !isNonEmptyString(ref.path))) {
|
|
446
|
+
bad(`asset.${key}`, 'when present, must be an object with a non-empty "path"');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// schema 1.2.0 — the STYLE-SUPPLIED section (E12 Ruling 10b)
|
|
450
|
+
if (m.asset.style != null) validateStyle(m.asset.style, bad);
|
|
451
|
+
// schema 1.3.0 — how these renders came to exist (E11 Ruling 2)
|
|
452
|
+
if (m.asset.render_derivation != null) validateRenderDerivation(m.asset.render_derivation, bad);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// acceptance — the door
|
|
456
|
+
if (!m.acceptance || typeof m.acceptance !== 'object') {
|
|
457
|
+
out.push(v('ASSET_NOT_ACCEPTED', 'acceptance', 'required — only accepted assets are admissible; the acceptance block records the Gate verdict'));
|
|
458
|
+
} else {
|
|
459
|
+
if (m.acceptance.verdict !== 'accepted') {
|
|
460
|
+
out.push(v('ASSET_NOT_ACCEPTED', 'acceptance.verdict',
|
|
461
|
+
`is "${m.acceptance.verdict}" — only verdict "accepted" passes the door`));
|
|
462
|
+
}
|
|
463
|
+
if (!isNonEmptyString(m.acceptance.gate)) bad('acceptance.gate', 'required non-empty string (which gate accepted this asset)');
|
|
464
|
+
if (!isNonEmptyString(m.acceptance.date) || Number.isNaN(Date.parse(m.acceptance.date))) {
|
|
465
|
+
bad('acceptance.date', 'required parseable date (ISO recommended)');
|
|
466
|
+
}
|
|
467
|
+
if (!isNonEmptyString(m.acceptance.record)) bad('acceptance.record', 'required non-empty link/path to the acceptance ruling');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// palette
|
|
471
|
+
if (!m.palette || typeof m.palette !== 'object') {
|
|
472
|
+
bad('palette', 'required object — bands come with the asset, never derived from the dataset');
|
|
473
|
+
} else {
|
|
474
|
+
if (typeof m.palette.min_chroma !== 'number' || m.palette.min_chroma < 0) {
|
|
475
|
+
bad('palette.min_chroma', 'required number >= 0 (the chroma floor is load-bearing)');
|
|
476
|
+
}
|
|
477
|
+
if (!Array.isArray(m.palette.allowed_bands) || m.palette.allowed_bands.length === 0) {
|
|
478
|
+
bad('palette.allowed_bands', 'required non-empty array of {name, hue_deg: [lo, hi]}');
|
|
479
|
+
} else {
|
|
480
|
+
m.palette.allowed_bands.forEach((b, i) => {
|
|
481
|
+
if (!b || typeof b !== 'object' || !isNonEmptyString(b.name)) {
|
|
482
|
+
bad(`palette.allowed_bands[${i}].name`, 'required non-empty string');
|
|
483
|
+
}
|
|
484
|
+
const ok = Array.isArray(b?.hue_deg) && b.hue_deg.length === 2 &&
|
|
485
|
+
b.hue_deg.every((h) => typeof h === 'number' && h >= 0 && h <= 360);
|
|
486
|
+
if (!ok) bad(`palette.allowed_bands[${i}].hue_deg`, 'required [lo, hi] with both in 0–360 (lo > hi wraps around 0°)');
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const gate = m.palette.gate;
|
|
490
|
+
if (!gate || typeof gate !== 'object') {
|
|
491
|
+
bad('palette.gate', 'required object {max_offpalette_pct: number|null, max_offpalette_blob_px: int}');
|
|
492
|
+
} else {
|
|
493
|
+
if (!Number.isInteger(gate.max_offpalette_blob_px) || gate.max_offpalette_blob_px < 0) {
|
|
494
|
+
bad('palette.gate.max_offpalette_blob_px', 'required integer >= 0 (the load-bearing blob gate)');
|
|
495
|
+
}
|
|
496
|
+
if (gate.max_offpalette_pct != null && typeof gate.max_offpalette_pct !== 'number') {
|
|
497
|
+
bad('palette.gate.max_offpalette_pct', 'must be a number or null (null = withdrawn, diagnostic only)');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// channels
|
|
503
|
+
const channelById = new Map();
|
|
504
|
+
if (m.channels != null && !Array.isArray(m.channels)) {
|
|
505
|
+
bad('channels', 'must be an array of channel declarations');
|
|
506
|
+
} else {
|
|
507
|
+
(m.channels || []).forEach((c, i) => {
|
|
508
|
+
const p = `channels[${i}]`;
|
|
509
|
+
if (!c || typeof c !== 'object') { bad(p, 'must be an object'); return; }
|
|
510
|
+
if (!safeId(c.id)) bad(`${p}.id`, 'required; must match [A-Za-z0-9._-]+');
|
|
511
|
+
else if (channelById.has(c.id)) bad(`${p}.id`, `duplicate channel id "${c.id}"`);
|
|
512
|
+
else channelById.set(c.id, c);
|
|
513
|
+
if (c.space !== 'texture' && c.space !== 'render') bad(`${p}.space`, 'must be "texture" or "render"');
|
|
514
|
+
if (!ENCODINGS.has(c.encoding)) bad(`${p}.encoding`, `must be one of ${[...ENCODINGS].join(', ')}`);
|
|
515
|
+
if (c.space === 'texture' && !isNonEmptyString(c.path)) {
|
|
516
|
+
bad(`${p}.path`, 'texture-space channels declare their single file here');
|
|
517
|
+
}
|
|
518
|
+
if (c.space === 'render' && c.path != null) {
|
|
519
|
+
bad(`${p}.path`, 'render-space channels have per-render instances (renders[].channels), not a path here');
|
|
520
|
+
}
|
|
521
|
+
if (c.categorical) {
|
|
522
|
+
if (c.encoding === 'json') {
|
|
523
|
+
bad(`${p}`, 'json channels cannot be categorical — sdlab does not read their values, so it can prove nothing about classes');
|
|
524
|
+
} else if (c.encoding === 'npy') {
|
|
525
|
+
// Integer classes, not rgb — an owner/class array has no colors.
|
|
526
|
+
if (!Array.isArray(c.classes) || c.classes.length === 0) {
|
|
527
|
+
bad(`${p}.classes`, 'categorical npy channels must declare integer classes [{name, value: int}] (rgb palettes are for pixel encodings)');
|
|
528
|
+
} else {
|
|
529
|
+
const names = new Set();
|
|
530
|
+
const values = new Set();
|
|
531
|
+
c.classes.forEach((cls, j) => {
|
|
532
|
+
if (!cls || !isNonEmptyString(cls.name)) bad(`${p}.classes[${j}].name`, 'required non-empty string');
|
|
533
|
+
else if (names.has(cls.name)) bad(`${p}.classes[${j}].name`, `duplicate class name "${cls.name}"`);
|
|
534
|
+
else names.add(cls.name);
|
|
535
|
+
if (!Number.isInteger(cls?.value)) bad(`${p}.classes[${j}].value`, 'required integer (the value carried in the array, e.g. -1 for "unowned")');
|
|
536
|
+
else if (values.has(cls.value)) bad(`${p}.classes[${j}].value`, `duplicate class value ${cls.value}`);
|
|
537
|
+
else values.add(cls.value);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (c.dtype != null && !NPY_PROVABLE_DTYPES.has(c.dtype)) {
|
|
541
|
+
bad(`${p}.dtype`, `categorical npy channels must use a dtype whose values can be read (${[...NPY_PROVABLE_DTYPES].join(', ')}); "${c.dtype}" would leave the class declaration unproven`);
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
if (!Array.isArray(c.palette) || c.palette.length === 0) {
|
|
545
|
+
bad(`${p}.palette`, 'categorical channels must declare their class palette [{name, rgb: [r,g,b]}]');
|
|
546
|
+
} else {
|
|
547
|
+
const names = new Set();
|
|
548
|
+
c.palette.forEach((cls, j) => {
|
|
549
|
+
if (!cls || !isNonEmptyString(cls.name)) bad(`${p}.palette[${j}].name`, 'required non-empty string');
|
|
550
|
+
else if (names.has(cls.name)) bad(`${p}.palette[${j}].name`, `duplicate class name "${cls.name}"`);
|
|
551
|
+
else names.add(cls.name);
|
|
552
|
+
const ok = Array.isArray(cls?.rgb) && cls.rgb.length === 3 &&
|
|
553
|
+
cls.rgb.every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
|
|
554
|
+
if (!ok) bad(`${p}.palette[${j}].rgb`, 'required [r, g, b] ints 0–255');
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (c.filter !== 'nearest' && c.filter !== 'linear') {
|
|
558
|
+
bad(`${p}.filter`, 'categorical channels must declare filter "nearest" (exact proof) or "linear" (tolerance classification)');
|
|
559
|
+
}
|
|
560
|
+
if (c.filter === 'linear' && (typeof c.classification_tolerance !== 'number' || c.classification_tolerance < 0)) {
|
|
561
|
+
bad(`${p}.classification_tolerance`, 'linear categorical channels must declare a tolerance >= 0 (max RGB distance)');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (c.encoding === 'npy') {
|
|
566
|
+
if (!isNonEmptyString(c.dtype)) bad(`${p}.dtype`, 'npy channels must declare dtype (e.g. "|b1") — proven against the header');
|
|
567
|
+
if (c.space === 'texture') {
|
|
568
|
+
// One file, one declared shape.
|
|
569
|
+
if (!Array.isArray(c.shape) || c.shape.length === 0 || !c.shape.every((n) => Number.isInteger(n) && n >= 0)) {
|
|
570
|
+
bad(`${p}.shape`, 'texture-space npy channels must declare shape (array of ints) — proven against the header');
|
|
571
|
+
}
|
|
572
|
+
} else if (c.shape != null) {
|
|
573
|
+
// Render-space instances are proven parallel to their own render,
|
|
574
|
+
// which a channel-level shape cannot express across differently
|
|
575
|
+
// sized renders. Declaring one invites the two to disagree.
|
|
576
|
+
bad(`${p}.shape`, 'render-space npy channels do not declare shape — each instance is proven [height, width] against its own render (numpy row-major)');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (c.encoding === 'json' && c.required_keys != null) {
|
|
580
|
+
const ok = Array.isArray(c.required_keys) && c.required_keys.every((k) => isNonEmptyString(k));
|
|
581
|
+
if (!ok) bad(`${p}.required_keys`, 'when present, must be an array of non-empty top-level key names');
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// renders
|
|
587
|
+
const renderIds = new Set();
|
|
588
|
+
if (!Array.isArray(m.renders) || m.renders.length === 0) {
|
|
589
|
+
bad('renders', 'required non-empty array — an asset with no renders has nothing to ingest');
|
|
590
|
+
} else {
|
|
591
|
+
m.renders.forEach((r, i) => {
|
|
592
|
+
const p = `renders[${i}]`;
|
|
593
|
+
if (!r || typeof r !== 'object') { bad(p, 'must be an object'); return; }
|
|
594
|
+
if (!safeId(r.id)) bad(`${p}.id`, 'required; must match [A-Za-z0-9._-]+');
|
|
595
|
+
else if (renderIds.has(r.id)) bad(`${p}.id`, `duplicate render id "${r.id}"`);
|
|
596
|
+
else renderIds.add(r.id);
|
|
597
|
+
if (!isNonEmptyString(r.path)) bad(`${p}.path`, 'required');
|
|
598
|
+
if (!r.camera || typeof r.camera !== 'object' || typeof r.camera.yaw_deg !== 'number') {
|
|
599
|
+
bad(`${p}.camera`, 'required object with numeric yaw_deg (elevation_deg optional, default 0)');
|
|
600
|
+
} else if (r.camera.elevation_deg != null && typeof r.camera.elevation_deg !== 'number') {
|
|
601
|
+
bad(`${p}.camera.elevation_deg`, 'must be a number when present');
|
|
602
|
+
}
|
|
603
|
+
if (!isNonEmptyString(r.silhouette_mask)) {
|
|
604
|
+
bad(`${p}.silhouette_mask`, 'required — the palette gate measures inside the EXACT silhouette (E08-A2)');
|
|
605
|
+
}
|
|
606
|
+
if (r.channels != null) {
|
|
607
|
+
if (typeof r.channels !== 'object' || Array.isArray(r.channels)) {
|
|
608
|
+
bad(`${p}.channels`, 'must be an object mapping channel-type id → instance path');
|
|
609
|
+
} else {
|
|
610
|
+
for (const [cid, cpath] of Object.entries(r.channels)) {
|
|
611
|
+
const decl = channelById.get(cid);
|
|
612
|
+
if (!decl) bad(`${p}.channels.${cid}`, `references undeclared channel id "${cid}"`);
|
|
613
|
+
else if (decl.space !== 'render') bad(`${p}.channels.${cid}`, `channel "${cid}" is ${decl.space}-space; only render-space channels have per-render instances`);
|
|
614
|
+
if (!isNonEmptyString(cpath)) bad(`${p}.channels.${cid}`, 'instance path must be a non-empty string');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (r.loss_mask != null && !isNonEmptyString(r.loss_mask)) bad(`${p}.loss_mask`, 'must be a non-empty path when present');
|
|
619
|
+
if (r.facing != null && !isNonEmptyString(r.facing)) bad(`${p}.facing`, 'must be a non-empty string when present');
|
|
620
|
+
// schema 1.2.0 — per-image generation provenance (Rulings 20a, 24b)
|
|
621
|
+
// schema 1.3.0 — refused outright on a declared non-generated render:
|
|
622
|
+
// a deterministic derivation of an accepted asset has no seed and no
|
|
623
|
+
// generation frame of its own. The generating provenance of the twins
|
|
624
|
+
// it was projected FROM is real, but it belongs to those twins, and
|
|
625
|
+
// recording it here would attribute a seed to an image that no seed
|
|
626
|
+
// produced.
|
|
627
|
+
if (r.generation != null && m.asset?.render_derivation?.generated === false) {
|
|
628
|
+
bad(`${p}.generation`,
|
|
629
|
+
`declares generation provenance, but asset.render_derivation.generated is false — these renders are "${m.asset.render_derivation.kind}" derivations of an already-accepted asset and have no seed of their own. If they ARE generated images, set generated: true.`);
|
|
630
|
+
} else if (r.generation != null) {
|
|
631
|
+
validateGeneration(r.generation, p, bad);
|
|
632
|
+
}
|
|
633
|
+
// schema 1.2.0 — a render may opt OUT of the asset-level tone transform.
|
|
634
|
+
// Boolean only: the asset declares the transform once, and a render says
|
|
635
|
+
// whether it carries it. Anything richer belongs in the asset block.
|
|
636
|
+
if (r.tone_transform != null && typeof r.tone_transform !== 'boolean') {
|
|
637
|
+
bad(`${p}.tone_transform`, 'must be a boolean when present — true/false says whether THIS render carries the asset-level tone transform; the transform itself is declared once at asset.tone_transform');
|
|
638
|
+
}
|
|
639
|
+
if (r.tone_transform != null && m.asset?.tone_transform == null) {
|
|
640
|
+
bad(`${p}.tone_transform`, 'declares a per-render tone_transform but the manifest has no asset.tone_transform to opt in or out of');
|
|
641
|
+
}
|
|
642
|
+
if (r.pair != null) {
|
|
643
|
+
if (typeof r.pair !== 'object' || Array.isArray(r.pair)) bad(`${p}.pair`, 'must be an object mapping pair name → path');
|
|
644
|
+
else {
|
|
645
|
+
for (const [name, ppath] of Object.entries(r.pair)) {
|
|
646
|
+
if (!safeId(name)) bad(`${p}.pair.${name}`, 'pair names must match [A-Za-z0-9._-]+');
|
|
647
|
+
if (!isNonEmptyString(ppath)) bad(`${p}.pair.${name}`, 'pair path must be a non-empty string');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// schema 1.2.0 — the tone transform, validated AFTER renders so its
|
|
655
|
+
// reference can be checked against the ids this manifest actually declares
|
|
656
|
+
if (m.asset?.tone_transform != null) {
|
|
657
|
+
validateToneTransform(m.asset.tone_transform, renderIds, bad);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// captions (material, not captions)
|
|
661
|
+
if (m.captions != null) {
|
|
662
|
+
if (typeof m.captions !== 'object' || Array.isArray(m.captions)) bad('captions', 'must be an object');
|
|
663
|
+
else {
|
|
664
|
+
for (const key of ['subject', 'style_trigger', 'domain_tag']) {
|
|
665
|
+
if (m.captions[key] != null && !isNonEmptyString(m.captions[key])) {
|
|
666
|
+
bad(`captions.${key}`, 'must be a non-empty string when present');
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ─── 2–4. Containment, existence, encoding + categorical proofs ──────
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Resolve a manifest-relative path, refusing absolute paths and escapes.
|
|
679
|
+
* @returns {{abs: string}|{violation: Object}}
|
|
680
|
+
*/
|
|
681
|
+
function containPath(baseDir, relPath, jsonPath) {
|
|
682
|
+
if (isAbsolute(relPath)) {
|
|
683
|
+
return { violation: v('ASSET_PATH_ESCAPE', jsonPath, `"${relPath}" is absolute — manifest paths must be relative to the manifest's directory`) };
|
|
684
|
+
}
|
|
685
|
+
const abs = resolve(baseDir, relPath);
|
|
686
|
+
const rel = relative(resolve(baseDir), abs);
|
|
687
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
688
|
+
return { violation: v('ASSET_PATH_ESCAPE', jsonPath, `"${relPath}" resolves outside the export directory`) };
|
|
689
|
+
}
|
|
690
|
+
return { abs };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function provePng(absPath, relPath, jsonPath, expectations, violations) {
|
|
694
|
+
let buf;
|
|
695
|
+
try {
|
|
696
|
+
buf = await readFile(absPath);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
violations.push(v('ASSET_FILE_MISSING', jsonPath, `cannot read "${relPath}": ${err.message}`));
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
let meta;
|
|
702
|
+
try {
|
|
703
|
+
meta = parsePngMeta(buf, relPath);
|
|
704
|
+
} catch (err) {
|
|
705
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}": ${err.message}`));
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
if (meta.interlaced) {
|
|
709
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}" is interlaced — the contract requires non-interlaced PNGs`));
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
if (expectations.colorTypes && !expectations.colorTypes.has(meta.colorType)) {
|
|
713
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
|
|
714
|
+
`"${relPath}" has PNG color type ${meta.colorType} (${meta.colorTypeName}); ${expectations.what} requires ${expectations.describe}`));
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
return { buf, meta };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Prove an npy file against its declaration.
|
|
722
|
+
*
|
|
723
|
+
* @param {number[]|null} expectShape — for render-space instances, the
|
|
724
|
+
* [height, width] the array must be parallel to (numpy row-major, so a
|
|
725
|
+
* [width, height] export is a transpose and is caught here). Null for
|
|
726
|
+
* texture-space channels, which declare their own shape.
|
|
727
|
+
*/
|
|
728
|
+
async function proveNpy(absPath, relPath, jsonPath, decl, expectShape, violations) {
|
|
729
|
+
let buf;
|
|
730
|
+
try {
|
|
731
|
+
buf = await readFile(absPath);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
violations.push(v('ASSET_FILE_MISSING', jsonPath, `cannot read "${relPath}": ${err.message}`));
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
let header;
|
|
737
|
+
try {
|
|
738
|
+
header = parseNpyHeader(buf, relPath);
|
|
739
|
+
} catch (err) {
|
|
740
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}": ${err.message}`));
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
let ok = true;
|
|
745
|
+
if (header.dtype !== decl.dtype) {
|
|
746
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
|
|
747
|
+
`"${relPath}" has dtype ${header.dtype}, manifest declares ${decl.dtype}`));
|
|
748
|
+
ok = false;
|
|
749
|
+
}
|
|
750
|
+
if (expectShape) {
|
|
751
|
+
const rankOk = header.shape.length === 2 || header.shape.length === 3;
|
|
752
|
+
if (!rankOk || header.shape[0] !== expectShape[0] || header.shape[1] !== expectShape[1]) {
|
|
753
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
|
|
754
|
+
`"${relPath}" has shape [${header.shape}]; a render-space array must open [height, width] = [${expectShape[0]}, ${expectShape[1]}] to be parallel to its render (numpy is row-major — a [width, height] export is transposed)`));
|
|
755
|
+
ok = false;
|
|
756
|
+
}
|
|
757
|
+
} else if (JSON.stringify(header.shape) !== JSON.stringify(decl.shape)) {
|
|
758
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
|
|
759
|
+
`"${relPath}" has shape [${header.shape}], manifest declares [${decl.shape}]`));
|
|
760
|
+
ok = false;
|
|
761
|
+
}
|
|
762
|
+
return ok ? { buf, meta: header } : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Prove a json sidecar. The proof is deliberately shallow and the contract
|
|
767
|
+
* says so out loud: the file parses as an object and carries the keys the
|
|
768
|
+
* manifest declared required. sdlab does NOT read the values — a number in
|
|
769
|
+
* an admission sidecar means whatever the asset says it means, and pretending
|
|
770
|
+
* to verify semantics the lane does not own is worse than declaring the
|
|
771
|
+
* boundary.
|
|
772
|
+
*/
|
|
773
|
+
async function proveJson(absPath, relPath, jsonPath, decl, violations) {
|
|
774
|
+
let buf;
|
|
775
|
+
try {
|
|
776
|
+
buf = await readFile(absPath);
|
|
777
|
+
} catch (err) {
|
|
778
|
+
violations.push(v('ASSET_FILE_MISSING', jsonPath, `cannot read "${relPath}": ${err.message}`));
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
let parsed;
|
|
782
|
+
try {
|
|
783
|
+
parsed = JSON.parse(buf.toString('utf-8'));
|
|
784
|
+
} catch (err) {
|
|
785
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}" is not valid JSON: ${err.message}`));
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
789
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, `"${relPath}" must be a JSON object`));
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
const missing = (decl.required_keys || []).filter((k) => parsed[k] === undefined);
|
|
793
|
+
if (missing.length > 0) {
|
|
794
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
|
|
795
|
+
`"${relPath}" is missing declared required key(s): ${missing.join(', ')}`));
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
return { buf, meta: { keys: Object.keys(parsed) } };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Prove every referenced file against its declaration. Reads bytes; decodes
|
|
803
|
+
* pixels only where a proof requires it.
|
|
804
|
+
*
|
|
805
|
+
* @param {Object} manifest — shape-valid manifest
|
|
806
|
+
* @param {string} baseDir — the manifest's directory (untrusted export dir)
|
|
807
|
+
* @returns {Promise<{violations: Array, resolved: Object}>}
|
|
808
|
+
* resolved: {
|
|
809
|
+
* baseDir, mesh, atlas,
|
|
810
|
+
* textureChannels: Map<id, {relPath, abs, meta?}>,
|
|
811
|
+
* renders: Map<id, {render, relPath, abs, meta,
|
|
812
|
+
* silhouette: {relPath, abs, meta},
|
|
813
|
+
* instances: Map<channelId, {relPath, abs, meta}>,
|
|
814
|
+
* lossMask: {relPath, abs, meta}|null,
|
|
815
|
+
* pairs: Map<name, {relPath, abs, meta}>}>
|
|
816
|
+
* }
|
|
817
|
+
*/
|
|
818
|
+
export async function proveFiles(manifest, baseDir) {
|
|
819
|
+
const violations = [];
|
|
820
|
+
const resolved = {
|
|
821
|
+
baseDir,
|
|
822
|
+
mesh: null,
|
|
823
|
+
atlas: null,
|
|
824
|
+
toneOperands: null,
|
|
825
|
+
textureChannels: new Map(),
|
|
826
|
+
renders: new Map(),
|
|
827
|
+
};
|
|
828
|
+
const channelById = new Map((manifest.channels || []).map((c) => [c.id, c]));
|
|
829
|
+
|
|
830
|
+
const contain = (relPath, jsonPath) => {
|
|
831
|
+
const r = containPath(baseDir, relPath, jsonPath);
|
|
832
|
+
if (r.violation) { violations.push(r.violation); return null; }
|
|
833
|
+
if (!existsSync(r.abs)) {
|
|
834
|
+
violations.push(v('ASSET_FILE_MISSING', jsonPath, `"${relPath}" does not exist in the export directory`));
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
return r.abs;
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// mesh / atlas: existence (+ atlas PNG parse when it is a .png, so its
|
|
841
|
+
// dimensions land in the receipt)
|
|
842
|
+
for (const key of ['mesh', 'atlas']) {
|
|
843
|
+
const ref = manifest.asset?.[key];
|
|
844
|
+
if (!ref) continue;
|
|
845
|
+
const abs = contain(ref.path, `asset.${key}.path`);
|
|
846
|
+
if (!abs) continue;
|
|
847
|
+
let meta = null;
|
|
848
|
+
if (key === 'atlas' && ref.path.toLowerCase().endsWith('.png')) {
|
|
849
|
+
try {
|
|
850
|
+
meta = parsePngMeta(await readFile(abs), ref.path);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', `asset.${key}.path`, `"${ref.path}": ${err.message}`));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
resolved[key] = { relPath: ref.path, abs, meta };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// tone-transform operands (schema 1.2.0): contained, parsed as an object,
|
|
859
|
+
// then hashed and carried by the ingest. The proof stops at "it is a JSON
|
|
860
|
+
// object that exists inside the export" — the same declared-weak boundary
|
|
861
|
+
// as a `json` channel. sdlab reads no operand values and verifies no
|
|
862
|
+
// arithmetic; it makes the declaration auditable, not true.
|
|
863
|
+
const ttDecl = manifest.asset?.tone_transform;
|
|
864
|
+
if (ttDecl?.operands) {
|
|
865
|
+
const abs = contain(ttDecl.operands, 'asset.tone_transform.operands');
|
|
866
|
+
if (abs) {
|
|
867
|
+
const proved = await proveJson(abs, ttDecl.operands, 'asset.tone_transform.operands', {}, violations);
|
|
868
|
+
if (proved) resolved.toneOperands = { relPath: ttDecl.operands, abs, meta: proved.meta };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// texture-space channels
|
|
873
|
+
for (const c of manifest.channels || []) {
|
|
874
|
+
if (c.space !== 'texture') continue;
|
|
875
|
+
const jsonPath = `channels(${c.id}).path`;
|
|
876
|
+
const abs = contain(c.path, jsonPath);
|
|
877
|
+
if (!abs) continue;
|
|
878
|
+
|
|
879
|
+
if (c.encoding === 'npy') {
|
|
880
|
+
const proved = await proveNpy(abs, c.path, jsonPath, c, null, violations);
|
|
881
|
+
if (!proved) continue;
|
|
882
|
+
resolved.textureChannels.set(c.id, { relPath: c.path, abs, meta: proved.meta });
|
|
883
|
+
if (c.categorical) proveCategorical(c, proved, `channels(${c.id})`, violations);
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (c.encoding === 'json') {
|
|
888
|
+
const proved = await proveJson(abs, c.path, jsonPath, c, violations);
|
|
889
|
+
if (!proved) continue;
|
|
890
|
+
resolved.textureChannels.set(c.id, { relPath: c.path, abs, meta: proved.meta });
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const expectType = COLOR_TYPE_BY_ENCODING[c.encoding];
|
|
895
|
+
const proved = await provePng(abs, c.path, jsonPath, {
|
|
896
|
+
colorTypes: new Set([expectType]),
|
|
897
|
+
what: `declared encoding "${c.encoding}"`,
|
|
898
|
+
describe: `color type ${expectType}`,
|
|
899
|
+
}, violations);
|
|
900
|
+
if (!proved) continue;
|
|
901
|
+
resolved.textureChannels.set(c.id, { relPath: c.path, abs, meta: proved.meta });
|
|
902
|
+
|
|
903
|
+
// Categorical proofs on texture channels
|
|
904
|
+
if (c.categorical) {
|
|
905
|
+
proveCategorical(c, proved, `channels(${c.id})`, violations);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// renders + silhouettes + instances + loss masks + pairs
|
|
910
|
+
for (const r of manifest.renders || []) {
|
|
911
|
+
const rEntry = { render: r, relPath: r.path, abs: null, meta: null, silhouette: null, instances: new Map(), lossMask: null, pairs: new Map() };
|
|
912
|
+
|
|
913
|
+
const rAbs = contain(r.path, `renders(${r.id}).path`);
|
|
914
|
+
if (rAbs) {
|
|
915
|
+
const proved = await provePng(rAbs, r.path, `renders(${r.id}).path`, {
|
|
916
|
+
colorTypes: RENDER_COLOR_TYPES,
|
|
917
|
+
what: 'a trainable render',
|
|
918
|
+
describe: 'rgb (2) or rgba (6)',
|
|
919
|
+
}, violations);
|
|
920
|
+
if (proved) {
|
|
921
|
+
if (proved.meta.bitDepth !== 8) {
|
|
922
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', `renders(${r.id}).path`, `"${r.path}" is ${proved.meta.bitDepth}-bit; renders must be 8-bit`));
|
|
923
|
+
} else {
|
|
924
|
+
rEntry.abs = rAbs;
|
|
925
|
+
rEntry.meta = proved.meta;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const sAbs = r.silhouette_mask ? contain(r.silhouette_mask, `renders(${r.id}).silhouette_mask`) : null;
|
|
931
|
+
if (sAbs) {
|
|
932
|
+
const proved = await provePng(sAbs, r.silhouette_mask, `renders(${r.id}).silhouette_mask`, {
|
|
933
|
+
colorTypes: new Set([0]),
|
|
934
|
+
what: 'a silhouette mask',
|
|
935
|
+
describe: 'grayscale (0)',
|
|
936
|
+
}, violations);
|
|
937
|
+
if (proved) {
|
|
938
|
+
rEntry.silhouette = { relPath: r.silhouette_mask, abs: sAbs, meta: proved.meta };
|
|
939
|
+
if (rEntry.meta && (proved.meta.width !== rEntry.meta.width || proved.meta.height !== rEntry.meta.height)) {
|
|
940
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', `renders(${r.id}).silhouette_mask`,
|
|
941
|
+
`silhouette is ${proved.meta.width}x${proved.meta.height} but the render is ${rEntry.meta.width}x${rEntry.meta.height} — masks are parallel to renders`));
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
for (const [cid, cpath] of Object.entries(r.channels || {})) {
|
|
947
|
+
const decl = channelById.get(cid);
|
|
948
|
+
if (!decl || decl.space !== 'render') continue; // shape stage already flagged
|
|
949
|
+
const jsonPath = `renders(${r.id}).channels.${cid}`;
|
|
950
|
+
const iAbs = contain(cpath, jsonPath);
|
|
951
|
+
if (!iAbs) continue;
|
|
952
|
+
|
|
953
|
+
// npy instances (schema 1.1.0): proven parallel to their own render.
|
|
954
|
+
// The render's dims are only known if the render itself proved, so an
|
|
955
|
+
// unproven render skips the parallelism check rather than inventing one.
|
|
956
|
+
if (decl.encoding === 'npy') {
|
|
957
|
+
const expectShape = rEntry.meta ? [rEntry.meta.height, rEntry.meta.width] : null;
|
|
958
|
+
const proved = await proveNpy(iAbs, cpath, jsonPath, decl, expectShape, violations);
|
|
959
|
+
if (!proved) continue;
|
|
960
|
+
rEntry.instances.set(cid, { relPath: cpath, abs: iAbs, meta: proved.meta });
|
|
961
|
+
if (decl.categorical) proveCategorical(decl, proved, jsonPath, violations);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// json sidecars (schema 1.1.0): parse + declared keys, nothing deeper.
|
|
966
|
+
if (decl.encoding === 'json') {
|
|
967
|
+
const proved = await proveJson(iAbs, cpath, jsonPath, decl, violations);
|
|
968
|
+
if (!proved) continue;
|
|
969
|
+
rEntry.instances.set(cid, { relPath: cpath, abs: iAbs, meta: proved.meta });
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const expectType = COLOR_TYPE_BY_ENCODING[decl.encoding];
|
|
974
|
+
const proved = await provePng(iAbs, cpath, jsonPath, {
|
|
975
|
+
colorTypes: new Set([expectType]),
|
|
976
|
+
what: `channel "${cid}" (declared "${decl.encoding}")`,
|
|
977
|
+
describe: `color type ${expectType}`,
|
|
978
|
+
}, violations);
|
|
979
|
+
if (!proved) continue;
|
|
980
|
+
if (rEntry.meta && (proved.meta.width !== rEntry.meta.width || proved.meta.height !== rEntry.meta.height)) {
|
|
981
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath,
|
|
982
|
+
`instance is ${proved.meta.width}x${proved.meta.height} but the render is ${rEntry.meta.width}x${rEntry.meta.height}`));
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
rEntry.instances.set(cid, { relPath: cpath, abs: iAbs, meta: proved.meta });
|
|
986
|
+
if (decl.categorical) {
|
|
987
|
+
proveCategorical(decl, proved, jsonPath, violations);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (r.loss_mask) {
|
|
992
|
+
const jsonPath = `renders(${r.id}).loss_mask`;
|
|
993
|
+
const lAbs = contain(r.loss_mask, jsonPath);
|
|
994
|
+
if (lAbs) {
|
|
995
|
+
const proved = await provePng(lAbs, r.loss_mask, jsonPath, {
|
|
996
|
+
colorTypes: new Set([0]),
|
|
997
|
+
what: 'a loss mask (soft weights)',
|
|
998
|
+
describe: 'grayscale (0)',
|
|
999
|
+
}, violations);
|
|
1000
|
+
if (proved) rEntry.lossMask = { relPath: r.loss_mask, abs: lAbs, meta: proved.meta };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
for (const [name, ppath] of Object.entries(r.pair || {})) {
|
|
1005
|
+
const jsonPath = `renders(${r.id}).pair.${name}`;
|
|
1006
|
+
const pAbs = contain(ppath, jsonPath);
|
|
1007
|
+
if (!pAbs) continue;
|
|
1008
|
+
const proved = await provePng(pAbs, ppath, jsonPath, {
|
|
1009
|
+
colorTypes: new Set([0, 2, 6]),
|
|
1010
|
+
what: 'a conditioning pair image',
|
|
1011
|
+
describe: 'grayscale, rgb, or rgba',
|
|
1012
|
+
}, violations);
|
|
1013
|
+
if (proved) rEntry.pairs.set(name, { relPath: ppath, abs: pAbs, meta: proved.meta });
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
resolved.renders.set(r.id, rEntry);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return { violations, resolved };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Categorical channel proofs.
|
|
1024
|
+
* - npy: exhaustive value scan — every element must be a declared class
|
|
1025
|
+
* value. The array analogue of the "nearest" pixel proof: a class map has
|
|
1026
|
+
* no antialiasing to forgive, so an undeclared value is a real violation.
|
|
1027
|
+
* - indexed: PLTE ⊆ declared palette — structural, no pixel decode
|
|
1028
|
+
* (the brand/E09 contract: the PLTE *is* the palette; padded or
|
|
1029
|
+
* undeclared entries are violations).
|
|
1030
|
+
* - non-indexed + filter "nearest": exhaustive decode — every
|
|
1031
|
+
* non-transparent pixel's color must be a declared class color.
|
|
1032
|
+
* - non-indexed + filter "linear": no proof (classification is an ingest
|
|
1033
|
+
* measurement with an unclassified share).
|
|
1034
|
+
*/
|
|
1035
|
+
function proveCategorical(decl, proved, jsonPath, violations) {
|
|
1036
|
+
if (decl.encoding === 'npy') {
|
|
1037
|
+
const declaredValues = new Set(decl.classes.map((c) => c.value));
|
|
1038
|
+
let values;
|
|
1039
|
+
try {
|
|
1040
|
+
values = readNpyValues(proved.buf, proved.meta, jsonPath);
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, err.message));
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const shown = [...declaredValues].sort((a, b) => a - b).join(', ');
|
|
1046
|
+
for (let i = 0; i < values.length; i++) {
|
|
1047
|
+
if (!declaredValues.has(values[i])) {
|
|
1048
|
+
violations.push(v('ASSET_PALETTE_PROOF_FAILED', jsonPath,
|
|
1049
|
+
`value ${values[i]} at flat index ${i} is not a declared class value (declared: ${shown}) — a categorical npy channel is proven exhaustively, not sampled`));
|
|
1050
|
+
return; // one offending value names the failure; no need to flood
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const declared = new Set(decl.palette.map((c) => c.rgb.join(',')));
|
|
1057
|
+
|
|
1058
|
+
if (decl.encoding === 'indexed') {
|
|
1059
|
+
const plte = proved.meta.palette || [];
|
|
1060
|
+
for (const entry of plte) {
|
|
1061
|
+
if (!declared.has(entry.join(','))) {
|
|
1062
|
+
violations.push(v('ASSET_PALETTE_PROOF_FAILED', jsonPath,
|
|
1063
|
+
`PLTE entry rgb(${entry.join(', ')}) is not in the declared class palette — E09 discipline: PLTE ⊆ declared palette, no padding`));
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (decl.filter === 'nearest') {
|
|
1070
|
+
let img;
|
|
1071
|
+
try {
|
|
1072
|
+
img = decodePng(proved.buf, jsonPath);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
violations.push(v('ASSET_ENCODING_MISMATCH', jsonPath, err.message));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const n = img.width * img.height;
|
|
1078
|
+
for (let i = 0; i < n; i++) {
|
|
1079
|
+
if (alphaAt(img, i) === 0) continue;
|
|
1080
|
+
const rgb = rgbAt(img, i);
|
|
1081
|
+
if (!declared.has(rgb.join(','))) {
|
|
1082
|
+
violations.push(v('ASSET_PALETTE_PROOF_FAILED', jsonPath,
|
|
1083
|
+
`pixel ${i % img.width},${Math.floor(i / img.width)} is rgb(${rgb.join(', ')}) — not a declared class color; a "nearest"-filtered categorical channel must be exact (this is an exhaustive proof, not a sample)`));
|
|
1084
|
+
return; // one offending color names the failure; no need to flood
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ─── Orchestration ───────────────────────────────────────────────────
|
|
1091
|
+
|
|
1092
|
+
function throwViolations(manifestPath, violations) {
|
|
1093
|
+
const order = new Map(CLASS_ORDER.map((c, i) => [c, i]));
|
|
1094
|
+
const sorted = [...violations].sort((a, b) => (order.get(a.cls) ?? 99) - (order.get(b.cls) ?? 99));
|
|
1095
|
+
const code = sorted[0].cls;
|
|
1096
|
+
const MAX_LINES = 20;
|
|
1097
|
+
const lines = sorted.slice(0, MAX_LINES).map((x) => ` - [${x.cls}] ${x.path}: ${x.message}`);
|
|
1098
|
+
if (sorted.length > MAX_LINES) lines.push(` … and ${sorted.length - MAX_LINES} more`);
|
|
1099
|
+
throw inputError(
|
|
1100
|
+
code,
|
|
1101
|
+
`${manifestPath}: ${sorted.length} contract violation(s) — nothing was registered.`,
|
|
1102
|
+
`Fix the manifest/export and re-run.\n${lines.join('\n')}`
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Load + fully validate an asset-source manifest. Throws one structured
|
|
1108
|
+
* error listing every violation; returns the manifest and resolved file map
|
|
1109
|
+
* on success.
|
|
1110
|
+
*
|
|
1111
|
+
* @param {string} manifestPath — absolute path to asset-source.json
|
|
1112
|
+
* @returns {Promise<{manifest: Object, resolved: Object, manifestRaw: Buffer}>}
|
|
1113
|
+
*/
|
|
1114
|
+
export async function validateAssetSource(manifestPath) {
|
|
1115
|
+
if (!existsSync(manifestPath)) {
|
|
1116
|
+
throw inputError(
|
|
1117
|
+
'ASSET_MANIFEST_NOT_FOUND',
|
|
1118
|
+
`No ${ASSET_MANIFEST_FILENAME} at ${manifestPath}.`,
|
|
1119
|
+
'The export directory must contain the asset\'s manifest. See docs/asset-lane-design.md for the contract.'
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
const manifestRaw = await readFile(manifestPath);
|
|
1123
|
+
let manifest;
|
|
1124
|
+
try {
|
|
1125
|
+
manifest = JSON.parse(manifestRaw.toString('utf-8'));
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
throw inputError('ASSET_MANIFEST_INVALID', `${manifestPath} is not valid JSON: ${err.message}`,
|
|
1128
|
+
'Fix the JSON syntax and re-run.');
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const shapeViolations = validateManifestShape(manifest);
|
|
1132
|
+
if (shapeViolations.length > 0) {
|
|
1133
|
+
// Refuse before touching any referenced file: a manifest whose SHAPE is
|
|
1134
|
+
// wrong cannot be trusted to name files safely.
|
|
1135
|
+
throwViolations(manifestPath, shapeViolations);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const { violations, resolved } = await proveFiles(manifest, dirname(manifestPath));
|
|
1139
|
+
if (violations.length > 0) throwViolations(manifestPath, violations);
|
|
1140
|
+
|
|
1141
|
+
return { manifest, resolved, manifestRaw };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ─── Derived facing vocabulary ───────────────────────────────────────
|
|
1145
|
+
|
|
1146
|
+
const FACING_TOKENS = [
|
|
1147
|
+
'front', // 0°
|
|
1148
|
+
'three-quarter front-right', // 45°
|
|
1149
|
+
'right profile', // 90°
|
|
1150
|
+
'three-quarter back-right', // 135°
|
|
1151
|
+
'back', // 180°
|
|
1152
|
+
'three-quarter back-left', // 225°
|
|
1153
|
+
'left profile', // 270°
|
|
1154
|
+
'three-quarter front-left', // 315°
|
|
1155
|
+
];
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Deterministic facing token from camera yaw/elevation. Yaw 0 = front;
|
|
1159
|
+
* buckets are 45° wide, centered on the eight canonical directions;
|
|
1160
|
+
* |elevation| >= 30° appends an elevation clause. Camera-relative naming —
|
|
1161
|
+
* "right" means the camera orbited toward the subject's right side of frame.
|
|
1162
|
+
*
|
|
1163
|
+
* The manifest may override per-render (`renders[].facing`) when the asset
|
|
1164
|
+
* disagrees; this is the mechanical default. Per facet E01 (and Cheng et
|
|
1165
|
+
* al. 2024, Continuous 3D Words): captions must carry facing — a
|
|
1166
|
+
* front-facing phrase on a rear view makes the text fight the image.
|
|
1167
|
+
*/
|
|
1168
|
+
export function facingFromCamera(camera) {
|
|
1169
|
+
const yaw = ((Number(camera.yaw_deg) % 360) + 360) % 360;
|
|
1170
|
+
const elevation = Number(camera.elevation_deg ?? 0);
|
|
1171
|
+
const bucket = Math.round(yaw / 45) % 8;
|
|
1172
|
+
let token = FACING_TOKENS[bucket];
|
|
1173
|
+
if (elevation >= 30) token += ', viewed from above';
|
|
1174
|
+
else if (elevation <= -30) token += ', viewed from below';
|
|
1175
|
+
return token;
|
|
1176
|
+
}
|