@deepblend/dsh-blender-contracts 0.1.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/lib/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, dependency-free JSON Schema validator for the DeepBlend schemas.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT A LIBRARY
|
|
5
|
+
* -----------------
|
|
6
|
+
* The DeepBlend schemas use a deliberately narrow, closed subset of JSON Schema
|
|
7
|
+
* draft 2020-12: type, additionalProperties, required, properties, items,
|
|
8
|
+
* minItems/maxItems, minimum/maximum/exclusiveMinimum/exclusiveMaximum,
|
|
9
|
+
* minLength/maxLength, pattern, enum, const, oneOf and local `#/$defs/...` refs.
|
|
10
|
+
* That subset is ~200 lines of straightforward code, and writing it here buys
|
|
11
|
+
* three things a dependency would not:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Zero install surface.** This repository has no pnpm and no build step
|
|
14
|
+
* (M0 decision D4, SPEC §6.1 deviation #2). A dependency would have to be
|
|
15
|
+
* vendored by hand into a profile's node_modules.
|
|
16
|
+
* 2. **Issue-path fidelity.** Validation output is consumed by the model, so a
|
|
17
|
+
* failure must read like `entities[2].transform.location[1]` and name the
|
|
18
|
+
* keyword that rejected it — not a library-formatted error string.
|
|
19
|
+
* 3. **Honest failure on the unsupported subset.** Any keyword this validator
|
|
20
|
+
* does not implement is reported as an error while loading the SCHEMA, not
|
|
21
|
+
* silently ignored while validating DATA. A silently ignored constraint is
|
|
22
|
+
* worse than no validator at all, because it converts a rejected scene into
|
|
23
|
+
* an accepted one.
|
|
24
|
+
*
|
|
25
|
+
* The schemas themselves live in `deepblend/schemas/` per SPEC §5.2 and are
|
|
26
|
+
* mirrored into this package's `lib/schemas/` so the published package is
|
|
27
|
+
* self-contained. A contract test asserts the mirror is byte-identical, so the
|
|
28
|
+
* two copies cannot drift.
|
|
29
|
+
*
|
|
30
|
+
* Owner: DeepBlend Studio — M1
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Keywords this validator understands. Anything else fails schema loading. */
|
|
34
|
+
const SUPPORTED_KEYWORDS = new Set([
|
|
35
|
+
'$schema', '$id', '$defs', '$ref',
|
|
36
|
+
'title', 'description', 'default', 'examples',
|
|
37
|
+
'type', 'properties', 'required', 'additionalProperties',
|
|
38
|
+
'items', 'minItems', 'maxItems',
|
|
39
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum',
|
|
40
|
+
'minLength', 'maxLength', 'pattern', 'enum', 'const',
|
|
41
|
+
'oneOf',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
/** JSON types as JSON Schema spells them. `integer` is handled separately. */
|
|
45
|
+
const JSON_TYPES = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'])
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} SchemaIssue
|
|
49
|
+
* @property {string} path - JSON Pointer-ish data path, `''` for the root.
|
|
50
|
+
* @property {string} keyword - the JSON Schema keyword that rejected the value.
|
|
51
|
+
* @property {string} message - human-readable, safe to hand to the model.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** Thrown when a schema document itself is malformed or uses unsupported keywords. */
|
|
55
|
+
export class SchemaDefinitionError extends Error {
|
|
56
|
+
/** @param {string} message */
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message)
|
|
59
|
+
this.name = 'SchemaDefinitionError'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Render a data path as `a.b[0].c`, or `<root>`. */
|
|
64
|
+
function formatPath(segments) {
|
|
65
|
+
if (segments.length === 0) return '<root>'
|
|
66
|
+
let text = ''
|
|
67
|
+
for (const segment of segments) {
|
|
68
|
+
if (typeof segment === 'number') text += `[${segment}]`
|
|
69
|
+
else if (text.length === 0) text = segment
|
|
70
|
+
else text += `.${segment}`
|
|
71
|
+
}
|
|
72
|
+
return text
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when `value` satisfies one JSON type name. */
|
|
76
|
+
function matchesType(value, type) {
|
|
77
|
+
switch (type) {
|
|
78
|
+
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
79
|
+
case 'array': return Array.isArray(value)
|
|
80
|
+
case 'string': return typeof value === 'string'
|
|
81
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value)
|
|
82
|
+
// An integer is a number without a fractional part; 3.0 is a valid integer.
|
|
83
|
+
case 'integer': return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value)
|
|
84
|
+
case 'boolean': return typeof value === 'boolean'
|
|
85
|
+
case 'null': return value === null
|
|
86
|
+
default: return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compile a schema document into a reusable validator.
|
|
92
|
+
*
|
|
93
|
+
* Compilation walks the whole schema once and throws {@link SchemaDefinitionError}
|
|
94
|
+
* for any unsupported keyword, so a typo in a schema can never become a silently
|
|
95
|
+
* unenforced rule at validation time.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} schema - a parsed JSON Schema document.
|
|
98
|
+
* @param {{ id?: string }} [options]
|
|
99
|
+
* @returns {(value: unknown) => SchemaIssue[]} validator returning `[]` when valid.
|
|
100
|
+
*/
|
|
101
|
+
export function compileSchema(schema, options = {}) {
|
|
102
|
+
const label = options.id ?? schema?.$id ?? '<anonymous schema>'
|
|
103
|
+
const root = schema
|
|
104
|
+
|
|
105
|
+
/** Assert that only supported keywords appear anywhere in the document. */
|
|
106
|
+
function assertSupported(node, trail) {
|
|
107
|
+
if (typeof node !== 'object' || node === null) return
|
|
108
|
+
if (Array.isArray(node)) {
|
|
109
|
+
throw new SchemaDefinitionError(`${label}: unexpected array at ${trail}`)
|
|
110
|
+
}
|
|
111
|
+
for (const key of Object.keys(node)) {
|
|
112
|
+
if (!SUPPORTED_KEYWORDS.has(key)) {
|
|
113
|
+
throw new SchemaDefinitionError(
|
|
114
|
+
`${label}: unsupported JSON Schema keyword "${key}" at ${trail || '<root>'}. ` +
|
|
115
|
+
`Add it to SUPPORTED_KEYWORDS with real enforcement, or remove it — an ` +
|
|
116
|
+
`unimplemented keyword would be silently unenforced.`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const [key, value] of Object.entries(node)) {
|
|
121
|
+
// `$defs` holds named sub-schemas; `oneOf` holds a list of sub-schemas.
|
|
122
|
+
if (key === '$defs' && typeof value === 'object' && value !== null) {
|
|
123
|
+
for (const [name, sub] of Object.entries(value)) assertSupported(sub, `$defs/${name}`)
|
|
124
|
+
} else if (key === 'oneOf' && Array.isArray(value)) {
|
|
125
|
+
value.forEach((sub, index) => assertSupported(sub, `${trail}#oneOf/${index}`))
|
|
126
|
+
} else if (key === 'properties' && typeof value === 'object' && value !== null) {
|
|
127
|
+
for (const [name, sub] of Object.entries(value)) assertSupported(sub, `${trail}/properties/${name}`)
|
|
128
|
+
} else if ((key === 'items' || key === 'additionalProperties') && typeof value === 'object' && value !== null) {
|
|
129
|
+
assertSupported(value, `${trail}/${key}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
assertSupported(root, '')
|
|
135
|
+
|
|
136
|
+
/** Resolve a local `#/...` JSON pointer. External refs are not supported. */
|
|
137
|
+
function resolveRef(reference) {
|
|
138
|
+
if (typeof reference !== 'string' || !reference.startsWith('#/')) {
|
|
139
|
+
throw new SchemaDefinitionError(
|
|
140
|
+
`${label}: only local "#/..." $ref values are supported, got "${reference}".`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
let node = root
|
|
144
|
+
for (const rawToken of reference.slice(2).split('/')) {
|
|
145
|
+
const token = rawToken.replace(/~1/g, '/').replace(/~0/g, '~')
|
|
146
|
+
if (typeof node !== 'object' || node === null || !(token in node)) {
|
|
147
|
+
throw new SchemaDefinitionError(`${label}: $ref "${reference}" does not resolve.`)
|
|
148
|
+
}
|
|
149
|
+
node = node[token]
|
|
150
|
+
}
|
|
151
|
+
return node
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate `value` against `node`, appending to `issues`.
|
|
156
|
+
* @param {unknown} value
|
|
157
|
+
* @param {any} node
|
|
158
|
+
* @param {(string|number)[]} path
|
|
159
|
+
* @param {SchemaIssue[]} issues
|
|
160
|
+
*/
|
|
161
|
+
function visit(value, node, path, issues) {
|
|
162
|
+
if (typeof node !== 'object' || node === null) return
|
|
163
|
+
|
|
164
|
+
if (node.$ref !== undefined) {
|
|
165
|
+
// A `$ref` in this subset is the only constraint on the node, matching the
|
|
166
|
+
// "sibling keywords are ignored" behaviour of the 2020-12 dialect.
|
|
167
|
+
visit(value, resolveRef(node.$ref), path, issues)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (Array.isArray(node.oneOf)) {
|
|
172
|
+
const survivors = []
|
|
173
|
+
for (const branch of node.oneOf) {
|
|
174
|
+
const branchIssues = []
|
|
175
|
+
visit(value, branch, path, branchIssues)
|
|
176
|
+
if (branchIssues.length === 0) survivors.push(branch)
|
|
177
|
+
}
|
|
178
|
+
if (survivors.length !== 1) {
|
|
179
|
+
issues.push({
|
|
180
|
+
path: formatPath(path),
|
|
181
|
+
keyword: 'oneOf',
|
|
182
|
+
message: survivors.length === 0
|
|
183
|
+
? `value matches none of the ${node.oneOf.length} allowed operation shapes`
|
|
184
|
+
: `value matches ${survivors.length} of the ${node.oneOf.length} allowed operation shapes`,
|
|
185
|
+
})
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (node.type !== undefined) {
|
|
192
|
+
const declared = Array.isArray(node.type) ? node.type : [node.type]
|
|
193
|
+
for (const type of declared) {
|
|
194
|
+
if (!JSON_TYPES.has(type)) {
|
|
195
|
+
throw new SchemaDefinitionError(`${label}: unknown type "${type}" at ${formatPath(path)}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!declared.some(type => matchesType(value, type))) {
|
|
199
|
+
issues.push({
|
|
200
|
+
path: formatPath(path),
|
|
201
|
+
keyword: 'type',
|
|
202
|
+
message: `expected ${declared.join(' or ')}, received ${
|
|
203
|
+
value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
|
|
204
|
+
}`,
|
|
205
|
+
})
|
|
206
|
+
// Every remaining check would be guesswork against the wrong type.
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (node.const !== undefined && value !== node.const) {
|
|
212
|
+
issues.push({
|
|
213
|
+
path: formatPath(path),
|
|
214
|
+
keyword: 'const',
|
|
215
|
+
message: `expected the literal ${JSON.stringify(node.const)}, received ${JSON.stringify(value)}`,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (Array.isArray(node.enum) && !node.enum.some(allowed => allowed === value)) {
|
|
220
|
+
issues.push({
|
|
221
|
+
path: formatPath(path),
|
|
222
|
+
keyword: 'enum',
|
|
223
|
+
message: `expected one of ${node.enum.map(entry => JSON.stringify(entry)).join(', ')}, received ${JSON.stringify(value)}`,
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (typeof value === 'string') {
|
|
228
|
+
if (typeof node.minLength === 'number' && value.length < node.minLength) {
|
|
229
|
+
issues.push({
|
|
230
|
+
path: formatPath(path),
|
|
231
|
+
keyword: 'minLength',
|
|
232
|
+
message: `string is ${value.length} characters, minimum is ${node.minLength}`,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
if (typeof node.maxLength === 'number' && value.length > node.maxLength) {
|
|
236
|
+
issues.push({
|
|
237
|
+
path: formatPath(path),
|
|
238
|
+
keyword: 'maxLength',
|
|
239
|
+
message: `string is ${value.length} characters, maximum is ${node.maxLength}`,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
if (typeof node.pattern === 'string' && !new RegExp(node.pattern).test(value)) {
|
|
243
|
+
issues.push({
|
|
244
|
+
path: formatPath(path),
|
|
245
|
+
keyword: 'pattern',
|
|
246
|
+
message: `"${value}" does not match ${node.pattern}`,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof value === 'number') {
|
|
252
|
+
if (typeof node.minimum === 'number' && value < node.minimum) {
|
|
253
|
+
issues.push({ path: formatPath(path), keyword: 'minimum', message: `${value} is below ${node.minimum}` })
|
|
254
|
+
}
|
|
255
|
+
if (typeof node.maximum === 'number' && value > node.maximum) {
|
|
256
|
+
issues.push({ path: formatPath(path), keyword: 'maximum', message: `${value} is above ${node.maximum}` })
|
|
257
|
+
}
|
|
258
|
+
if (typeof node.exclusiveMinimum === 'number' && value <= node.exclusiveMinimum) {
|
|
259
|
+
issues.push({
|
|
260
|
+
path: formatPath(path),
|
|
261
|
+
keyword: 'exclusiveMinimum',
|
|
262
|
+
message: `${value} must be greater than ${node.exclusiveMinimum}`,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
if (typeof node.exclusiveMaximum === 'number' && value >= node.exclusiveMaximum) {
|
|
266
|
+
issues.push({
|
|
267
|
+
path: formatPath(path),
|
|
268
|
+
keyword: 'exclusiveMaximum',
|
|
269
|
+
message: `${value} must be less than ${node.exclusiveMaximum}`,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (Array.isArray(value)) {
|
|
275
|
+
if (typeof node.minItems === 'number' && value.length < node.minItems) {
|
|
276
|
+
issues.push({
|
|
277
|
+
path: formatPath(path),
|
|
278
|
+
keyword: 'minItems',
|
|
279
|
+
message: `array has ${value.length} items, minimum is ${node.minItems}`,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
if (typeof node.maxItems === 'number' && value.length > node.maxItems) {
|
|
283
|
+
issues.push({
|
|
284
|
+
path: formatPath(path),
|
|
285
|
+
keyword: 'maxItems',
|
|
286
|
+
message: `array has ${value.length} items, maximum is ${node.maxItems}`,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
if (node.items !== undefined) {
|
|
290
|
+
value.forEach((item, index) => visit(item, node.items, [...path, index], issues))
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
295
|
+
const properties = node.properties ?? {}
|
|
296
|
+
for (const name of node.required ?? []) {
|
|
297
|
+
if (!Object.hasOwn(value, name) || value[name] === undefined) {
|
|
298
|
+
issues.push({
|
|
299
|
+
path: formatPath([...path, name]),
|
|
300
|
+
keyword: 'required',
|
|
301
|
+
message: `required property "${name}" is missing`,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const [name, member] of Object.entries(value)) {
|
|
306
|
+
if (Object.hasOwn(properties, name)) {
|
|
307
|
+
visit(member, properties[name], [...path, name], issues)
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
if (node.additionalProperties === false) {
|
|
311
|
+
issues.push({
|
|
312
|
+
path: formatPath([...path, name]),
|
|
313
|
+
keyword: 'additionalProperties',
|
|
314
|
+
message: `unknown property "${name}" is not permitted here`,
|
|
315
|
+
})
|
|
316
|
+
} else if (typeof node.additionalProperties === 'object' && node.additionalProperties !== null) {
|
|
317
|
+
visit(member, node.additionalProperties, [...path, name], issues)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return function validate(value) {
|
|
324
|
+
const issues = []
|
|
325
|
+
visit(value, root, [], issues)
|
|
326
|
+
return issues
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Render issues as a compact, model-readable list.
|
|
332
|
+
* @param {SchemaIssue[]} issues
|
|
333
|
+
* @returns {string}
|
|
334
|
+
*/
|
|
335
|
+
export function formatIssues(issues) {
|
|
336
|
+
return issues.map(issue => `${issue.path}: ${issue.message} [${issue.keyword}]`).join('\n')
|
|
337
|
+
}
|