@plasius/asset-contracts 0.6.0 → 0.8.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.
@@ -0,0 +1,215 @@
1
+ import { isGpuModelResourceGraph } from "@plasius/gpu-model-core";
2
+ import type {
3
+ GpuModelMaterial, GpuModelTexture, GpuModelProvenance, GpuModelResourceGraph,
4
+ GpuModelResourceLocation, GpuModelTextureEmbeddingPolicy,
5
+ } from "@plasius/gpu-model-core";
6
+ import { qualifyModelResourceManifest, ModelResourceManifestError, type ModelResourceManifestQualificationInput } from "./model-resource-manifest.js";
7
+ import type { AssetManifest, AssetFileDescriptor } from "./index.js";
8
+
9
+ /** Hard ceilings for the additive package role boundary; callers cannot raise them. */
10
+ export const MODEL_TEXTURE_RESOURCE_LIMITS = Object.freeze({ resources: 16_384, derived: 4096, nodes: 65_536, values: 262_144, characters: 2_097_152, depth: 16, timeoutMs: 60_000 });
11
+ /** An embedded resource names its owning entrypoint; external resources name their exact graph path. */
12
+ export interface ModelTextureResourceBinding { readonly resourceId: string; readonly filePath: string }
13
+ /** Declarative derived output; source identities remain independent of output hashes and paths. */
14
+ export interface ModelDerivedTextureResource {
15
+ readonly role: "gpu-upload" | "texture-atlas";
16
+ readonly filePath: string;
17
+ readonly sourceResourceIds: readonly string[];
18
+ }
19
+ /** Existing exact-byte inventory plus a privately verified canonical graph and explicit export bindings. */
20
+ export interface ModelTextureResourceQualificationInput extends Omit<ModelResourceManifestQualificationInput, "signal" | "timeoutMs"> {
21
+ readonly graph: GpuModelResourceGraph;
22
+ readonly resources: readonly ModelTextureResourceBinding[];
23
+ readonly derived: readonly ModelDerivedTextureResource[];
24
+ }
25
+ /** Deadline and cancellation cover hashing and bounded synchronous qualification. */
26
+ export interface ModelTextureResourceQualificationOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number }
27
+ /** JSON resource identity without a Blob, renderer allocation or credential-bearing URL. */
28
+ export interface QualifiedModelTextureResource extends ModelTextureResourceBinding {
29
+ readonly kind: "image" | "buffer";
30
+ readonly contentHash: string;
31
+ readonly byteLength: number;
32
+ readonly mimeType: string;
33
+ readonly location: GpuModelResourceLocation;
34
+ readonly exportEmbeddingPolicy: GpuModelTextureEmbeddingPolicy;
35
+ }
36
+ /** Trusted package metadata, including authored sourceMetadata; not a public-safe diagnostic projection. */
37
+ export interface QualifiedModelTextureResources {
38
+ readonly contractVersion: 1;
39
+ readonly manifest: AssetManifest;
40
+ readonly source: GpuModelProvenance;
41
+ readonly resources: readonly QualifiedModelTextureResource[];
42
+ readonly materials: readonly GpuModelMaterial[];
43
+ readonly textures: readonly GpuModelTexture[];
44
+ readonly derived: readonly ModelDerivedTextureResource[];
45
+ }
46
+ /** Fixed, value-free errors. No caller metadata or upstream exception text is exposed. */
47
+ export class ModelTextureResourceError extends TypeError {
48
+ constructor(public readonly code: "invalid-input" | "limit-exceeded" | "unverified-graph" | "package-mismatch" | "resource-mismatch" | "role-mismatch" | "aborted" | "deadline-exceeded") {
49
+ super(`Invalid model texture resources: ${code}`); this.name = "ModelTextureResourceError";
50
+ }
51
+ }
52
+ function fail(code: ModelTextureResourceError["code"] = "invalid-input"): never { throw new ModelTextureResourceError(code); }
53
+ function fields(value: unknown, allowed?: readonly string[]): Record<string, unknown> {
54
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
55
+ const keys = Reflect.ownKeys(value);
56
+ if (keys.length > 1024) fail("limit-exceeded");
57
+ const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
58
+ for (const key of keys) {
59
+ if (typeof key !== "string" || key.length > 128 || ["__proto__", "prototype", "constructor"].includes(key) || (allowed && !allowed.includes(key))) fail();
60
+ const property = Object.getOwnPropertyDescriptor(value, key)!;
61
+ if (!("value" in property) || !property.enumerable) fail();
62
+ result[key] = property.value;
63
+ }
64
+ return result;
65
+ }
66
+ function array(value: unknown, maximum: number): unknown[] {
67
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail();
68
+ if (value.length > maximum) fail("limit-exceeded");
69
+ if (Reflect.ownKeys(value).length !== value.length + 1) fail();
70
+ const result: unknown[] = [];
71
+ for (let i = 0; i < value.length; i++) {
72
+ const property = Object.getOwnPropertyDescriptor(value, String(i));
73
+ if (!property || !("value" in property) || !property.enumerable) fail();
74
+ result.push(property.value);
75
+ }
76
+ return result;
77
+ }
78
+ function snapshot(value: unknown, budget: { values: number; characters: number }, depth = 0): unknown {
79
+ if (--budget.values < 0 || depth > MODEL_TEXTURE_RESOURCE_LIMITS.depth) fail("limit-exceeded");
80
+ if (value === null || value === undefined || typeof value === "boolean") return value;
81
+ if (typeof value === "number" && Number.isFinite(value)) return value;
82
+ if (typeof value === "string") {
83
+ budget.characters -= value.length;
84
+ if (value.length > 2048 || budget.characters < 0) fail("limit-exceeded");
85
+ return value;
86
+ }
87
+ if (Array.isArray(value)) return Object.freeze(array(value, MODEL_TEXTURE_RESOURCE_LIMITS.resources).map((v) => snapshot(v, budget, depth + 1)));
88
+ const result = fields(value);
89
+ for (const key of Object.keys(result)) {
90
+ budget.characters -= key.length;
91
+ if (budget.characters < 0) fail("limit-exceeded");
92
+ result[key] = snapshot(result[key], budget, depth + 1);
93
+ }
94
+ return Object.freeze(result);
95
+ }
96
+ function token(value: unknown): string {
97
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value)) fail();
98
+ return value;
99
+ }
100
+ const compare = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
101
+ const imageMime = (value: string): boolean => ["image/png", "image/jpeg", "image/webp", "image/ktx2"].includes(value);
102
+ const bufferMime = (value: string): boolean => ["application/octet-stream", "application/vnd.plasius.gpu-buffer"].includes(value);
103
+ function storageRole(role: unknown, file: AssetFileDescriptor): void {
104
+ const valid = role === "texture" ? file.role === "texture" && imageMime(file.contentType)
105
+ : role === "material" || role === "animation" ? file.role === "metadata" && file.contentType === "application/json"
106
+ : role === "mesh" ? (file.role === "binary" && bufferMime(file.contentType)) || (file.role === "model" && (file.contentType.startsWith("model/") || ["application/json", "application/octet-stream", "text/plain"].includes(file.contentType)))
107
+ : role === "auxiliary" && ((file.role === "binary" && bufferMime(file.contentType)) || (file.role === "metadata" && file.contentType === "application/json"));
108
+ if (!valid) fail("role-mismatch");
109
+ }
110
+
111
+ /**
112
+ * Qualify reload metadata without parsing formats, allocating GPU objects or
113
+ * replacing authored resource identities. Metadata is trusted package content;
114
+ * adapters must sanitize it before public use. This is not promotion evidence.
115
+ */
116
+ export async function qualifyModelTextureResources(input: ModelTextureResourceQualificationInput, options: ModelTextureResourceQualificationOptions = {}): Promise<QualifiedModelTextureResources> {
117
+ let timer: ReturnType<typeof setTimeout> | undefined;
118
+ let signal: AbortSignal | undefined;
119
+ let onAbort: (() => void) | undefined;
120
+ try {
121
+ const started = performance.now();
122
+ const controls = fields(options, ["signal", "timeoutMs"]);
123
+ const timeoutMs = controls.timeoutMs === undefined ? 10_000 : controls.timeoutMs;
124
+ if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MODEL_TEXTURE_RESOURCE_LIMITS.timeoutMs) fail();
125
+ signal = controls.signal as AbortSignal | undefined;
126
+ const aborted = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted")!.get!;
127
+ const check = () => {
128
+ if (signal !== undefined && aborted.call(signal)) fail("aborted");
129
+ if (performance.now() - started >= timeoutMs) fail("deadline-exceeded");
130
+ };
131
+ check();
132
+ const root = fields(input, ["manifest", "packageManifest", "files", "graph", "resources", "derived"]);
133
+ const graph = root.graph;
134
+ if (!isGpuModelResourceGraph(graph)) fail("unverified-graph");
135
+ if (graph.nodes.length > MODEL_TEXTURE_RESOURCE_LIMITS.nodes) fail("limit-exceeded");
136
+ const budget = { values: MODEL_TEXTURE_RESOURCE_LIMITS.values, characters: MODEL_TEXTURE_RESOURCE_LIMITS.characters };
137
+ const inventory = snapshot(root.packageManifest, budget) as ModelResourceManifestQualificationInput["packageManifest"];
138
+ const bindings = array(snapshot(root.resources, budget), MODEL_TEXTURE_RESOURCE_LIMITS.resources).map((value) => {
139
+ const binding = fields(value, ["resourceId", "filePath"]);
140
+ return Object.freeze({ resourceId: token(binding.resourceId), filePath: tokenPath(binding.filePath) });
141
+ });
142
+ const derived = array(snapshot(root.derived, budget), MODEL_TEXTURE_RESOURCE_LIMITS.derived).map((value) => {
143
+ const record = fields(value, ["role", "filePath", "sourceResourceIds"]);
144
+ if (record.role !== "gpu-upload" && record.role !== "texture-atlas") fail("role-mismatch");
145
+ const ids = array(record.sourceResourceIds, MODEL_TEXTURE_RESOURCE_LIMITS.resources).map(token);
146
+ if (!ids.length || new Set(ids).size !== ids.length) fail("resource-mismatch");
147
+ return Object.freeze({ role: record.role, filePath: tokenPath(record.filePath), sourceResourceIds: Object.freeze(ids.sort(compare)) });
148
+ });
149
+ check();
150
+ const interrupted = new Promise<never>((_resolve, reject) => {
151
+ timer = setTimeout(() => reject(new ModelTextureResourceError("deadline-exceeded")), Math.max(1, timeoutMs - (performance.now() - started)));
152
+ onAbort = () => reject(new ModelTextureResourceError("aborted"));
153
+ if (signal) AbortSignal.prototype.addEventListener.call(signal, "abort", onAbort, { once: true });
154
+ });
155
+ // The existing qualifier snapshots metadata AND byte payloads synchronously
156
+ // before its first digest. Promise.race owns both rejection paths immediately.
157
+ const manifest = await Promise.race([
158
+ qualifyModelResourceManifest({ manifest: root.manifest as AssetManifest, files: root.files as ModelResourceManifestQualificationInput["files"], packageManifest: inventory, signal, timeoutMs }),
159
+ interrupted,
160
+ ]);
161
+ check();
162
+ const files = new Map(manifest.files.map((file) => [file.path, file]));
163
+ const roles = new Map(inventory.entries.map((entry) => [entry.path, entry.role]));
164
+ for (const entry of inventory.entries) storageRole(entry.role, files.get(entry.path)!);
165
+ const resources = new Map(graph.nodes.filter((node) => node.kind === "resource").map((node) => [node.id, node.value]));
166
+ if (bindings.length !== resources.size || new Set(bindings.map((b) => b.resourceId)).size !== bindings.length) fail("resource-mismatch");
167
+ const resourceFiles = new Set<string>();
168
+ const qualified = bindings.map((binding): QualifiedModelTextureResource => {
169
+ const value = resources.get(binding.resourceId); const file = files.get(binding.filePath);
170
+ if (!value || !file) fail("resource-mismatch");
171
+ const { resource, location, exportEmbeddingPolicy } = value;
172
+ if (location.kind === "external") {
173
+ if (binding.filePath !== location.relativePath || file.sha256 !== resource.contentHash || file.byteLength !== resource.byteLength || file.contentType !== resource.mimeType) fail("resource-mismatch");
174
+ if (resource.kind === "image" ? roles.get(file.path) !== "texture" : file.role !== "binary") fail("role-mismatch");
175
+ } else if (binding.filePath !== manifest.entrypoint || file.role !== "model") fail("resource-mismatch");
176
+ resourceFiles.add(binding.filePath);
177
+ return Object.freeze({ ...binding, kind: resource.kind, contentHash: resource.contentHash, byteLength: resource.byteLength, mimeType: resource.mimeType, location, exportEmbeddingPolicy });
178
+ });
179
+ const outputFiles = new Set<string>();
180
+ for (const output of derived) {
181
+ const file = files.get(output.filePath);
182
+ if (!file || outputFiles.has(output.filePath) || resourceFiles.has(output.filePath)) fail("resource-mismatch");
183
+ outputFiles.add(output.filePath);
184
+ for (const id of output.sourceResourceIds) {
185
+ const source = resources.get(id);
186
+ if (!source || (output.role === "texture-atlas" && source.resource.kind !== "image")) fail("resource-mismatch");
187
+ }
188
+ if (output.role === "gpu-upload" ? file.role !== "binary" || !bufferMime(file.contentType) || roles.get(file.path) !== "auxiliary"
189
+ : file.role !== "texture" || !imageMime(file.contentType) || roles.get(file.path) !== "texture") fail("role-mismatch");
190
+ }
191
+ const materials = graph.nodes.filter((node) => node.kind === "material").map((node) => node.value);
192
+ const textures = graph.nodes.filter((node) => node.kind === "texture").map((node) => node.value);
193
+ const source = graph.nodes.find((node) => node.kind === "provenance")!;
194
+ if (source.kind !== "provenance") fail();
195
+ // Bound authored metadata without substituting a new material/texture schema.
196
+ snapshot({ materials, textures, source: source.value }, budget);
197
+ check();
198
+ return Object.freeze({ contractVersion: 1, manifest, source: source.value,
199
+ resources: Object.freeze(qualified.sort((a, b) => compare(a.resourceId, b.resourceId))),
200
+ materials: Object.freeze(materials), textures: Object.freeze(textures),
201
+ derived: Object.freeze(derived.sort((a, b) => compare(a.filePath, b.filePath))) });
202
+ } catch (error) {
203
+ if (error instanceof ModelTextureResourceError) throw error;
204
+ if (error instanceof ModelResourceManifestError) return fail(error.code === "aborted" || error.code === "deadline-exceeded" || error.code === "limit-exceeded" ? error.code : "package-mismatch");
205
+ return fail();
206
+ } finally {
207
+ if (timer !== undefined) clearTimeout(timer);
208
+ if (signal && onAbort) AbortSignal.prototype.removeEventListener.call(signal, "abort", onAbort);
209
+ }
210
+ }
211
+ function tokenPath(value: unknown): string {
212
+ // Full portable-path and collision checks are reused from the byte qualifier.
213
+ if (typeof value !== "string" || !value.length || value.length > 1024) fail();
214
+ return value;
215
+ }