@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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file's first bytes say it is — the content half of SPEC §15.2's
|
|
3
|
+
* "MIME 与扩展名双重校验".
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* The extension check (`IMPORT_OPERATOR_BY_ASSET_TYPE`) decides which import operator a file
|
|
8
|
+
* gets, and it trusts the NAME. That is fine as far as it goes — Blender classifies the import
|
|
9
|
+
* behaviourally and a file that is not really a glTF fails as a branchable result (D10) — but
|
|
10
|
+
* it means the first thing that notices "this `.glb` is actually a ZIP" is a Blender launch
|
|
11
|
+
* that fails several seconds later, with a message about the importer rather than about the
|
|
12
|
+
* file. This module is the cheap gate in front of that: 512 bytes, no subprocess, decided
|
|
13
|
+
* before anything is copied into the project.
|
|
14
|
+
*
|
|
15
|
+
* THE RULE IS ASYMMETRIC ON PURPOSE
|
|
16
|
+
* ---------------------------------
|
|
17
|
+
* It refuses only when the bytes POSITIVELY CONTRADICT the extension — a known signature for
|
|
18
|
+
* a different format, a NUL byte in a format that must be text, or an empty file. Anything it
|
|
19
|
+
* cannot tell about is `inconclusive` and passes, because the alternative (a matcher clever
|
|
20
|
+
* enough to be sure) is also a matcher clever enough to reject somebody's legitimate model.
|
|
21
|
+
* Three of the six importable types have no magic number at all: `.obj` and ascii `.usd` are
|
|
22
|
+
* text, and `.gltf` is JSON.
|
|
23
|
+
*
|
|
24
|
+
* WHAT IT IS NOT
|
|
25
|
+
* --------------
|
|
26
|
+
* Not a security boundary. It cannot tell a real glTF from a crafted one, and it is not meant
|
|
27
|
+
* to: DECIDING what a file is remains Blender's job, and this only refuses the cases where the
|
|
28
|
+
* file says out loud that it is something else.
|
|
29
|
+
*
|
|
30
|
+
* Owner: DeepBlend Studio — M5
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** How many bytes of a file this needs. Enough for a text sniff, small enough to be free. */
|
|
34
|
+
export const ASSET_HEAD_BYTES = 512
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The signatures worth knowing, in one table.
|
|
38
|
+
*
|
|
39
|
+
* `format` is the asset type a signature belongs to, or `null` for a format this product
|
|
40
|
+
* cannot import — which is the interesting half: a ZIP or a PNG inside a `.glb` is exactly the
|
|
41
|
+
* case the extension check cannot see.
|
|
42
|
+
*/
|
|
43
|
+
const SIGNATURES = Object.freeze([
|
|
44
|
+
// Importable types.
|
|
45
|
+
{ format: 'glb', magic: 'glTF', at: 0 },
|
|
46
|
+
{ format: 'blend', magic: 'BLENDER', at: 0 },
|
|
47
|
+
{ format: 'fbx', magic: 'Kaydara FBX Binary', at: 0 },
|
|
48
|
+
{ format: 'usd', magic: 'PXR-USDC', at: 0 },
|
|
49
|
+
// Everything else that is common enough to name, all of it a contradiction for any of the
|
|
50
|
+
// six importable types.
|
|
51
|
+
{ format: null, magic: 'PK\u0003\u0004', at: 0, label: 'a ZIP archive' },
|
|
52
|
+
{ format: null, magic: '\u0089PNG\r\n\u001a\n', at: 0, label: 'a PNG image' },
|
|
53
|
+
{ format: null, magic: '\u00ff\u00d8\u00ff', at: 0, label: 'a JPEG image' },
|
|
54
|
+
{ format: null, magic: '%PDF', at: 0, label: 'a PDF' },
|
|
55
|
+
{ format: null, magic: '\u007fELF', at: 0, label: 'an ELF binary' },
|
|
56
|
+
{ format: null, magic: 'MZ', at: 0, label: 'a Windows executable' },
|
|
57
|
+
{ format: null, magic: '\u001f\u008b', at: 0, label: 'a gzip stream' },
|
|
58
|
+
{ format: null, magic: 'RIFF', at: 0, label: 'a RIFF container' },
|
|
59
|
+
{ format: null, magic: 'OggS', at: 0, label: 'an Ogg stream' },
|
|
60
|
+
{ format: null, magic: 'ID3', at: 0, label: 'an MP3' },
|
|
61
|
+
{ format: null, magic: 'ftyp', at: 4, label: 'an ISO base media file (MP4/MOV)' },
|
|
62
|
+
{ format: null, magic: '\u0000asm', at: 0, label: 'a WebAssembly module' },
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/** Asset types whose content is text and must therefore contain no NUL byte. */
|
|
66
|
+
const TEXT_TYPES = Object.freeze(['obj', 'gltf', 'usd', 'fbx'])
|
|
67
|
+
|
|
68
|
+
/** The label a contradiction reports, from the signature it matched. */
|
|
69
|
+
function labelFor(entry) {
|
|
70
|
+
return entry.label ?? `a ${entry.format} file`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Does `head` begin with `magic` at `at`? Bytes, never a decoded string. */
|
|
74
|
+
function startsWith(head, magic, at) {
|
|
75
|
+
const needle = Buffer.from(magic, 'latin1')
|
|
76
|
+
if (head.length < at + needle.length) return false
|
|
77
|
+
return head.subarray(at, at + needle.length).equals(needle)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bytes a text file may legitimately contain besides the printable range. */
|
|
81
|
+
const PRINTABLE_EXCEPTIONS = Object.freeze([0x09, 0x0a, 0x0d])
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What the bytes say, independently of the name.
|
|
85
|
+
*
|
|
86
|
+
* `signature` is the whole verdict about magic numbers: an entry MATCHED, and whether the
|
|
87
|
+
* format it belongs to is importable is a separate question — `format: null` in the table
|
|
88
|
+
* means "a known format this product cannot import". Collapsing those two, "no signature
|
|
89
|
+
* matched" and "a signature for something else matched", is the bug the first version of this
|
|
90
|
+
* function had: it made a ZIP named `.glb` read as *unknown* rather than as a ZIP.
|
|
91
|
+
*
|
|
92
|
+
* @param {Uint8Array|Buffer} head - the first bytes of the file (`ASSET_HEAD_BYTES` is enough)
|
|
93
|
+
* @returns {{ signature: {format: string|null, label: string}|null, text: boolean, empty: boolean }}
|
|
94
|
+
*/
|
|
95
|
+
export function describeAssetContent(head) {
|
|
96
|
+
const bytes = Buffer.isBuffer(head) ? head : Buffer.from(head ?? [])
|
|
97
|
+
const empty = bytes.length === 0
|
|
98
|
+
for (const entry of SIGNATURES) {
|
|
99
|
+
if (startsWith(bytes, entry.magic, entry.at)) {
|
|
100
|
+
return { signature: { format: entry.format, label: labelFor(entry) }, text: false, empty }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Text, for the purposes of this gate, is bytes a text file can contain: the printable
|
|
104
|
+
// range, UTF-8 continuation bytes, and tab/newline/CR. A NUL fails that, and so does binary
|
|
105
|
+
// garbage that merely happens to contain no NUL — which is what the second version of this
|
|
106
|
+
// function learned, because "contains no zero byte" is not the same claim as "is text".
|
|
107
|
+
const text = !empty && bytes.every(byte => byte >= 0x20 || PRINTABLE_EXCEPTIONS.includes(byte))
|
|
108
|
+
return { signature: null, text, empty }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Does the content agree with the extension?
|
|
113
|
+
*
|
|
114
|
+
* @param {Uint8Array|Buffer} head
|
|
115
|
+
* @param {string} type - an asset type from `IMPORT_OPERATOR_BY_ASSET_TYPE`
|
|
116
|
+
* @returns {'agrees'|'contradicts'|'inconclusive'}
|
|
117
|
+
*/
|
|
118
|
+
export function assetContentVerdict(head, type) {
|
|
119
|
+
const described = describeAssetContent(head)
|
|
120
|
+
|
|
121
|
+
if (described.empty) return 'contradicts'
|
|
122
|
+
if (described.signature !== null) {
|
|
123
|
+
// A signature for THIS type agrees. A signature for anything else contradicts — including
|
|
124
|
+
// for another importable type, which is how a `.obj` holding a `.blend` reads, and
|
|
125
|
+
// including for a format this product cannot import at all, which is how a ZIP named
|
|
126
|
+
// `.glb` reads.
|
|
127
|
+
return described.signature.format === type ? 'agrees' : 'contradicts'
|
|
128
|
+
}
|
|
129
|
+
// No signature. Text formats are satisfied by text; a binary format is not satisfied by
|
|
130
|
+
// anything (its magic was required and did not match), but it is not contradicted either,
|
|
131
|
+
// so it stays inconclusive rather than being refused on a guess.
|
|
132
|
+
if (TEXT_TYPES.includes(type)) return described.text ? 'agrees' : 'contradicts'
|
|
133
|
+
return 'inconclusive'
|
|
134
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 5x7 bitmap font, for labelling contact-sheet cells.
|
|
3
|
+
*
|
|
4
|
+
* WHY A FONT IS IN THE CONTRACTS PACKAGE AT ALL
|
|
5
|
+
* --------------------------------------------
|
|
6
|
+
* A contact sheet without labels forces the model to guess which cell is which
|
|
7
|
+
* view. It *could* guess — the cells come back in a documented order — but the one
|
|
8
|
+
* thing a vision review must not do is mislabel its own evidence: an occlusion
|
|
9
|
+
* reported against "the top view" that is actually the three-quarter view produces
|
|
10
|
+
* a confident, well-formed, wrong patch. A label removes the guess.
|
|
11
|
+
*
|
|
12
|
+
* The alternative was an image library with real text rendering, which is the
|
|
13
|
+
* dependency this design exists to avoid (see `png.js`). 5x7 uppercase glyphs are
|
|
14
|
+
* about forty lines of data and are unambiguous at 2x scale inside a 1600px sheet.
|
|
15
|
+
*
|
|
16
|
+
* Each glyph is 7 rows of 5 bits, most significant bit on the left. Every row is
|
|
17
|
+
* OR'd with `0b1000000`, which both pads to a byte and makes a truncated or
|
|
18
|
+
* mistyped row visibly wrong rather than a plausible shorter one.
|
|
19
|
+
*
|
|
20
|
+
* Owner: DeepBlend Studio — M2
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One glyph: 7 rows of 5 bits, each padded to a byte by a set high bit. */
|
|
24
|
+
const GLYPHS = {
|
|
25
|
+
' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
|
26
|
+
'-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00],
|
|
27
|
+
'_': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f],
|
|
28
|
+
'.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c],
|
|
29
|
+
',': [0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c, 0x18],
|
|
30
|
+
':': [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x0c, 0x00],
|
|
31
|
+
'/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00],
|
|
32
|
+
'#': [0x0a, 0x1f, 0x0a, 0x0a, 0x1f, 0x0a, 0x00],
|
|
33
|
+
'(': [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
|
|
34
|
+
')': [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
|
|
35
|
+
'+': [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00],
|
|
36
|
+
'=': [0x00, 0x00, 0x1f, 0x00, 0x1f, 0x00, 0x00],
|
|
37
|
+
'?': [0x0e, 0x11, 0x01, 0x06, 0x04, 0x00, 0x04],
|
|
38
|
+
'!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
|
|
39
|
+
'0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e],
|
|
40
|
+
'1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e],
|
|
41
|
+
'2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f],
|
|
42
|
+
'3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e],
|
|
43
|
+
'4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02],
|
|
44
|
+
'5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e],
|
|
45
|
+
'6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e],
|
|
46
|
+
'7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
|
|
47
|
+
'8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e],
|
|
48
|
+
'9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c],
|
|
49
|
+
'A': [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
|
|
50
|
+
'B': [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e],
|
|
51
|
+
'C': [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e],
|
|
52
|
+
'D': [0x1c, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1c],
|
|
53
|
+
'E': [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f],
|
|
54
|
+
'F': [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10],
|
|
55
|
+
'G': [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f],
|
|
56
|
+
'H': [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
|
|
57
|
+
'I': [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e],
|
|
58
|
+
'J': [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c],
|
|
59
|
+
'K': [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
|
|
60
|
+
'L': [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f],
|
|
61
|
+
'M': [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11],
|
|
62
|
+
'N': [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
|
|
63
|
+
'O': [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
|
|
64
|
+
'P': [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10],
|
|
65
|
+
'Q': [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d],
|
|
66
|
+
'R': [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11],
|
|
67
|
+
'S': [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e],
|
|
68
|
+
'T': [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
|
|
69
|
+
'U': [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
|
|
70
|
+
'V': [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04],
|
|
71
|
+
'W': [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11],
|
|
72
|
+
'X': [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11],
|
|
73
|
+
'Y': [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04],
|
|
74
|
+
'Z': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Glyph cell size in pixels, before scaling. */
|
|
78
|
+
export const GLYPH_WIDTH = 5
|
|
79
|
+
export const GLYPH_HEIGHT = 7
|
|
80
|
+
/** One blank column between glyphs. */
|
|
81
|
+
const GLYPH_ADVANCE = GLYPH_WIDTH + 1
|
|
82
|
+
|
|
83
|
+
/** The character drawn for anything the font has no glyph for. */
|
|
84
|
+
const FALLBACK = '?'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Measure the text width at a given scale.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @param {number} scale
|
|
91
|
+
* @returns {number}
|
|
92
|
+
*/
|
|
93
|
+
export function textWidth(text, scale = 1) {
|
|
94
|
+
if (text.length === 0) return 0
|
|
95
|
+
return (text.length * GLYPH_ADVANCE - 1) * scale
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Draw text with the top-left corner at (x, y). Uppercases its input, because the
|
|
100
|
+
* font has only one case and silently drawing blanks for lowercase would be worse
|
|
101
|
+
* than the visible transformation.
|
|
102
|
+
*
|
|
103
|
+
* @param {import('./png.js').RgbaImage} image
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @param {number} x
|
|
106
|
+
* @param {number} y
|
|
107
|
+
* @param {[number, number, number, number]} color
|
|
108
|
+
* @param {number} [scale]
|
|
109
|
+
*/
|
|
110
|
+
export function drawText(image, text, x, y, color, scale = 1) {
|
|
111
|
+
const upper = text.toUpperCase()
|
|
112
|
+
let cursor = x
|
|
113
|
+
for (const character of upper) {
|
|
114
|
+
const glyph = GLYPHS[character] ?? GLYPHS[FALLBACK]
|
|
115
|
+
for (let row = 0; row < GLYPH_HEIGHT; row += 1) {
|
|
116
|
+
const bits = glyph[row]
|
|
117
|
+
for (let column = 0; column < GLYPH_WIDTH; column += 1) {
|
|
118
|
+
// The set high bit is the padding marker, never a pixel: reading the
|
|
119
|
+
// glyph's own 5 data bits means a row written with the wrong length
|
|
120
|
+
// still cannot produce stray pixels to the right of the glyph.
|
|
121
|
+
const on = (bits >> (GLYPH_WIDTH - 1 - column)) & 1
|
|
122
|
+
if (on === 0) continue
|
|
123
|
+
for (let dy = 0; dy < scale; dy += 1) {
|
|
124
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
125
|
+
setPixel(image, cursor + column * scale + dx, y + row * scale + dy, color)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
cursor += GLYPH_ADVANCE * scale
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Write one pixel, ignoring anything outside the image.
|
|
136
|
+
*
|
|
137
|
+
* @param {import('./png.js').RgbaImage} image
|
|
138
|
+
* @param {number} x
|
|
139
|
+
* @param {number} y
|
|
140
|
+
* @param {[number, number, number, number]} color
|
|
141
|
+
*/
|
|
142
|
+
function setPixel(image, x, y, color) {
|
|
143
|
+
if (x < 0 || y < 0 || x >= image.width || y >= image.height) return
|
|
144
|
+
const offset = (y * image.width + x) * 4
|
|
145
|
+
image.data[offset] = color[0]
|
|
146
|
+
image.data[offset + 1] = color[1]
|
|
147
|
+
image.data[offset + 2] = color[2]
|
|
148
|
+
image.data[offset + 3] = color[3]
|
|
149
|
+
}
|
package/lib/canonical.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON serialization and content hashing.
|
|
3
|
+
*
|
|
4
|
+
* The word "canonical" here is load-bearing (SPEC §11.1 "返回 Canonical JSON"):
|
|
5
|
+
*
|
|
6
|
+
* - **Key order is significant and fixed** (sorted, recursively), because a
|
|
7
|
+
* revision digest, an idempotency record and a test fixture all hash the same
|
|
8
|
+
* document. Two structurally identical SceneSpecs that differ only in key
|
|
9
|
+
* insertion order MUST hash identically, or the same intent would look like
|
|
10
|
+
* two different revisions.
|
|
11
|
+
* - **Absent keys are omitted rather than emitted as `null`**, so "not set" has
|
|
12
|
+
* exactly one representation. `undefined` is never valid JSON and is dropped.
|
|
13
|
+
* - **No whitespace**, so the hash is over bytes and not over formatting.
|
|
14
|
+
*
|
|
15
|
+
* Owner: DeepBlend Studio — M1
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serialize a JSON value with recursively sorted object keys and no whitespace.
|
|
22
|
+
*
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function canonicalStringify(value) {
|
|
27
|
+
return JSON.stringify(sortValue(value))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pretty-print a JSON value with recursively sorted keys.
|
|
32
|
+
*
|
|
33
|
+
* Used for every document DeepBlend writes to disk. Sorted keys make a revision
|
|
34
|
+
* diff between two SceneSpecs read as an actual change rather than as noise from
|
|
35
|
+
* insertion order — which is the whole point of keeping the spec, not the
|
|
36
|
+
* `.blend`, as the source of truth (SPEC §8.1).
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @param {number} [indent]
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function canonicalPretty(value, indent = 2) {
|
|
43
|
+
return JSON.stringify(sortValue(value), null, indent)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Recursively rebuild a value with sorted keys, dropping `undefined`.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} value
|
|
50
|
+
* @returns {unknown}
|
|
51
|
+
*/
|
|
52
|
+
export function sortValue(value) {
|
|
53
|
+
if (Array.isArray(value)) return value.map(entry => sortValue(entry))
|
|
54
|
+
if (value === null || typeof value !== 'object') return value
|
|
55
|
+
/** @type {Record<string, unknown>} */
|
|
56
|
+
const output = {}
|
|
57
|
+
for (const key of Object.keys(value).sort()) {
|
|
58
|
+
const member = /** @type {Record<string, unknown>} */ (value)[key]
|
|
59
|
+
if (member === undefined) continue
|
|
60
|
+
output[key] = sortValue(member)
|
|
61
|
+
}
|
|
62
|
+
return output
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* SHA-256 of the canonical serialization, as lowercase hex.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} value
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function sha256Canonical(value) {
|
|
72
|
+
return createHash('sha256').update(canonicalStringify(value), 'utf8').digest('hex')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* SHA-256 of raw bytes or text.
|
|
77
|
+
*
|
|
78
|
+
* @param {Buffer|string} data
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function sha256(data) {
|
|
82
|
+
return createHash('sha256').update(data).digest('hex')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Hex prefix length used when hashes appear inside identifiers. */
|
|
86
|
+
const SHORT_HASH_LENGTH = 16
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A short, file-name-safe digest used for cache keys and idempotency records.
|
|
90
|
+
* Long enough that a collision would require a deliberate attack, short enough
|
|
91
|
+
* to keep an on-disk path readable.
|
|
92
|
+
*
|
|
93
|
+
* @param {unknown} value
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function shortDigest(value) {
|
|
97
|
+
return sha256Canonical(value).slice(0, SHORT_HASH_LENGTH)
|
|
98
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which config keys a row actually reads — the check that turns a silent no-op into a startup error.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* SPEC §17 illustrates the configuration as a NESTED document:
|
|
7
|
+
*
|
|
8
|
+
* { "finalRender": { "requireApprovalAboveFrames": 900 }, "security": { "assetMaxBytes": … } }
|
|
9
|
+
*
|
|
10
|
+
* and the implementation reads FLAT keys (`requireApprovalAboveFrames`, `assetMaxBytes`, …), because
|
|
11
|
+
* each key belongs to the package that enforces it rather than to a group. MEASURED before this
|
|
12
|
+
* module existed: Schemastery accepts the nested document, keeps `finalRender` as an unknown property,
|
|
13
|
+
* and reports nothing — so an operator who follows the spec gets a deployment where the approval
|
|
14
|
+
* threshold is whatever the DEFAULT was, with no error anywhere. The key is not ignored loudly; it is
|
|
15
|
+
* ignored silently, which is the only failure mode this repository treats as unacceptable in a
|
|
16
|
+
* configuration.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT DOES NOT DO
|
|
19
|
+
* -------------------
|
|
20
|
+
* It does not reject a key for having the wrong TYPE or VALUE — the schema already does that, with a
|
|
21
|
+
* better message than this could write. It answers one question only: does this row read this name at
|
|
22
|
+
* all?
|
|
23
|
+
*
|
|
24
|
+
* Owner: DeepBlend Studio — M5
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The top-level keys a schema declares.
|
|
29
|
+
*
|
|
30
|
+
* Read from `toJSON()` rather than from the builder: the same shape the bundle validator and the
|
|
31
|
+
* settings card use, so there is one description of a row's surface rather than a second one here.
|
|
32
|
+
*
|
|
33
|
+
* @param {{toJSON: () => object}} schema
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
export function declaredConfigKeys(schema) {
|
|
37
|
+
const json = schema.toJSON()
|
|
38
|
+
// `dict` sits on the ROOT node, which is `refs[uid]` as soon as the schema has any refs at all — a
|
|
39
|
+
// schema of scalars with defaults has them, so reading `json.dict` alone found nothing and this check
|
|
40
|
+
// declared every key unknown. MEASURED against `StudioConfig`: `{uid: 47, refs: {…, 47: {dict: {…}}}}`.
|
|
41
|
+
const dict = json?.refs?.[json.uid]?.dict ?? json?.dict ?? {}
|
|
42
|
+
return Object.keys(dict).sort()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A message naming every key this row does not read, or `null` when there are none.
|
|
47
|
+
*
|
|
48
|
+
* The known keys are listed in full rather than counted: this runs once, at composition, and the
|
|
49
|
+
* operator reading it is looking for the name they meant to type.
|
|
50
|
+
*
|
|
51
|
+
* @param {{toJSON: () => object}} schema
|
|
52
|
+
* @param {object} config - the row's config as the loader passed it
|
|
53
|
+
* @param {string} label - the row id, so the error says which row is wrong
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
export function describeUnknownConfigKeys(schema, config, label) {
|
|
57
|
+
const known = declaredConfigKeys(schema)
|
|
58
|
+
const knownSet = new Set(known)
|
|
59
|
+
const given = config !== null && typeof config === 'object' ? Object.keys(config) : []
|
|
60
|
+
const unknown = given.filter(key => !knownSet.has(key))
|
|
61
|
+
if (unknown.length === 0) return null
|
|
62
|
+
return (
|
|
63
|
+
`${label} does not read ${unknown.map(key => `"${key}"`).join(', ')}. ` +
|
|
64
|
+
`It reads: ${known.join(', ')}. ` +
|
|
65
|
+
'Note that a key with the wrong shape is NOT rejected by the schema — a nested object like SPEC §17\u2019s ' +
|
|
66
|
+
'`finalRender: {…}` is accepted and simply never read, so this check is what makes that loud. ' +
|
|
67
|
+
// THE MOST LIKELY CAUSE IS NOT A TYPO, and the message has to say so: an operator layer is GENERATED from
|
|
68
|
+
// the bundle (`npm run plugin:install`), so a bundle that dropped a key leaves every existing layer
|
|
69
|
+
// carrying it — and this refusal then fires at the next restart, which is a long way from the edit that
|
|
70
|
+
// caused it. MEASURED: exactly that happened in this repository when `serveCachedCapabilities` was
|
|
71
|
+
// deleted, and the only thing that surfaced it was `npm run plugin:check` being run by hand.
|
|
72
|
+
'If this config came from a generated operator layer, the layer is stale rather than wrong: ' +
|
|
73
|
+
'run `npm run plugin:install` to regenerate it from the bundle, then restart the profile. ' +
|
|
74
|
+
'See `deepblend/docs/install.md` for the SPEC §17 name \u2192 real name table.'
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Throw when a row's config carries a key the row does not read.
|
|
80
|
+
*
|
|
81
|
+
* @param {{toJSON: () => object}} schema
|
|
82
|
+
* @param {object} config
|
|
83
|
+
* @param {string} label
|
|
84
|
+
*/
|
|
85
|
+
export function assertKnownConfigKeys(schema, config, label) {
|
|
86
|
+
const problem = describeUnknownConfigKeys(schema, config, label)
|
|
87
|
+
if (problem !== null) throw new Error(problem)
|
|
88
|
+
}
|