@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/png.js ADDED
@@ -0,0 +1,452 @@
1
+ /**
2
+ * A minimal PNG codec: enough to read Blender's renders and write a contact sheet.
3
+ *
4
+ * WHY HAND-ROLLED
5
+ * ---------------
6
+ * The contact sheet is a real product artifact, and every image library is either
7
+ * a native dependency (a build step and a platform matrix for a package that must
8
+ * stay pure ESM — D4) or a large pure-JS dependency that would still need to be
9
+ * vendored into the profile's module tree. The actual requirement is narrow: read
10
+ * 8-bit PNG in the five colour types Blender emits, composite rectangles, write
11
+ * 8-bit RGBA PNG. `node:zlib` supplies the only hard part (DEFLATE) and the rest is
12
+ * arithmetic over a `Buffer`.
13
+ *
14
+ * WHAT IT DELIBERATELY DOES NOT DO
15
+ * --------------------------------
16
+ * Blender 5.2's `bpy.data.images` reads and writes PNGs (`save_render`, `save_as`),
17
+ * so a second encoder is only justified by where it has to run: the sheet is
18
+ * composed on the Node side, where the view measurements and the artifact index
19
+ * already are, and where a Python round trip would mean another Blender launch per
20
+ * review round. Two encoders would be worse than one — so this one owns the sheet
21
+ * entirely and Blender never touches it.
22
+ *
23
+ * Interlacing (Adam7) IS supported on read because it is cheap once the pass
24
+ * geometry is written down, and because "unsupported input" that only appears on
25
+ * someone else's machine is the worst kind of gap.
26
+ *
27
+ * Owner: DeepBlend Studio — M2
28
+ */
29
+
30
+ import { deflateSync, inflateSync } from 'node:zlib'
31
+
32
+ /** PNG signature. */
33
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
34
+
35
+ /** Bytes per pixel for each supported colour type (at bit depth 8/16). */
36
+ const CHANNELS_BY_COLOR_TYPE = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }
37
+
38
+ /** Adam7 interlace pass geometry: x start/step and y start/step per pass. */
39
+ const ADAM7 = [
40
+ { xStart: 0, yStart: 0, xStep: 8, yStep: 8 },
41
+ { xStart: 4, yStart: 0, xStep: 8, yStep: 8 },
42
+ { xStart: 0, yStart: 4, xStep: 4, yStep: 8 },
43
+ { xStart: 2, yStart: 0, xStep: 4, yStep: 4 },
44
+ { xStart: 0, yStart: 2, xStep: 2, yStep: 4 },
45
+ { xStart: 1, yStart: 0, xStep: 2, yStep: 2 },
46
+ { xStart: 0, yStart: 1, xStep: 1, yStep: 2 },
47
+ ]
48
+
49
+ /**
50
+ * @typedef {object} RgbaImage
51
+ * @property {number} width
52
+ * @property {number} height
53
+ * @property {Uint8Array} data - RGBA, 4 bytes per pixel, row-major, top-down.
54
+ */
55
+
56
+ /**
57
+ * Decode a PNG buffer into an RGBA image.
58
+ *
59
+ * @param {Buffer} buffer
60
+ * @returns {RgbaImage}
61
+ */
62
+ export function decodePng(buffer) {
63
+ if (!Buffer.isBuffer(buffer) || buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) {
64
+ throw new Error('not a PNG: the 8-byte signature is missing')
65
+ }
66
+
67
+ let offset = 8
68
+ let header = null
69
+ let palette = null
70
+ let transparency = null
71
+ const idat = []
72
+
73
+ while (offset < buffer.length) {
74
+ if (offset + 8 > buffer.length) throw new Error('truncated PNG: incomplete chunk header')
75
+ const length = buffer.readUInt32BE(offset)
76
+ const type = buffer.toString('latin1', offset + 4, offset + 8)
77
+ const start = offset + 8
78
+ const end = start + length
79
+ if (end + 4 > buffer.length) throw new Error(`truncated PNG: chunk ${type} claims ${length} bytes`)
80
+
81
+ if (type === 'IHDR') header = parseHeader(buffer.subarray(start, end))
82
+ else if (type === 'PLTE') palette = Buffer.from(buffer.subarray(start, end))
83
+ else if (type === 'tRNS') transparency = Buffer.from(buffer.subarray(start, end))
84
+ else if (type === 'IDAT') idat.push(Buffer.from(buffer.subarray(start, end)))
85
+ else if (type === 'IEND') break
86
+
87
+ offset = end + 4 // skip the CRC; the decode below is the real check
88
+ }
89
+
90
+ if (header === null) throw new Error('PNG has no IHDR chunk')
91
+ if (idat.length === 0) throw new Error('PNG has no IDAT data')
92
+
93
+ const { width, height, bitDepth, colorType, interlace } = header
94
+ if (!(colorType in CHANNELS_BY_COLOR_TYPE)) {
95
+ throw new Error(`unsupported PNG colour type ${colorType}`)
96
+ }
97
+ if (bitDepth !== 8 && bitDepth !== 16) {
98
+ throw new Error(`unsupported PNG bit depth ${bitDepth}`)
99
+ }
100
+ const channels = CHANNELS_BY_COLOR_TYPE[colorType]
101
+ const bytesPerSample = bitDepth / 8
102
+ const raw = inflateSync(Buffer.concat(idat))
103
+
104
+ const image = { width, height, data: new Uint8Array(width * height * 4) }
105
+
106
+ if (interlace === 0) {
107
+ const stride = width * channels * bytesPerSample
108
+ const unfiltered = unfilter(raw, height, stride, channels * bytesPerSample)
109
+ for (let y = 0; y < height; y += 1) {
110
+ for (let x = 0; x < width; x += 1) {
111
+ writePixel(image, x, y, readPixel(unfiltered, y * stride + x * channels * bytesPerSample, colorType, bitDepth, palette, transparency))
112
+ }
113
+ }
114
+ return image
115
+ }
116
+
117
+ // Adam7: each pass is an independent image with its own scanline filters.
118
+ let cursor = 0
119
+ for (const pass of ADAM7) {
120
+ const passWidth = passColumns(width, pass.xStart, pass.xStep)
121
+ const passHeight = passColumns(height, pass.yStart, pass.yStep)
122
+ if (passWidth === 0 || passHeight === 0) continue
123
+ const passStride = passWidth * channels * bytesPerSample
124
+ const passBytes = (passStride + 1) * passHeight
125
+ const chunk = raw.subarray(cursor, cursor + passBytes)
126
+ cursor += passBytes
127
+ const unfiltered = unfilter(chunk, passHeight, passStride, channels * bytesPerSample)
128
+ for (let py = 0; py < passHeight; py += 1) {
129
+ for (let px = 0; px < passWidth; px += 1) {
130
+ const x = pass.xStart + px * pass.xStep
131
+ const y = pass.yStart + py * pass.yStep
132
+ if (x >= width || y >= height) continue
133
+ writePixel(image, x, y, readPixel(unfiltered, py * passStride + px * channels * bytesPerSample, colorType, bitDepth, palette, transparency))
134
+ }
135
+ }
136
+ }
137
+ return image
138
+ }
139
+
140
+ /**
141
+ * Encode an RGBA image as an 8-bit truecolour PNG.
142
+ *
143
+ * @param {RgbaImage} image
144
+ * @returns {Buffer}
145
+ */
146
+ export function encodePng(image) {
147
+ const { width, height, data } = image
148
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
149
+ throw new Error(`cannot encode a ${width}x${height} image`)
150
+ }
151
+ if (data.length < width * height * 4) {
152
+ throw new Error(`pixel buffer is ${data.length} bytes, need ${width * height * 4}`)
153
+ }
154
+
155
+ const stride = width * 4
156
+ const raw = Buffer.alloc((stride + 1) * height)
157
+ for (let y = 0; y < height; y += 1) {
158
+ const rowStart = y * (stride + 1)
159
+ raw[rowStart] = 0 // filter type 0: none. The sheet is written once and read by a decoder here.
160
+ Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, rowStart + 1)
161
+ }
162
+
163
+ const ihdr = Buffer.alloc(13)
164
+ ihdr.writeUInt32BE(width, 0)
165
+ ihdr.writeUInt32BE(height, 4)
166
+ ihdr[8] = 8 // bit depth
167
+ ihdr[9] = 6 // colour type: truecolour with alpha
168
+ ihdr[10] = 0 // compression
169
+ ihdr[11] = 0 // filter method
170
+ ihdr[12] = 0 // no interlace
171
+
172
+ return Buffer.concat([
173
+ SIGNATURE,
174
+ chunk('IHDR', ihdr),
175
+ chunk('IDAT', deflateSync(raw, { level: 9 })),
176
+ chunk('IEND', Buffer.alloc(0)),
177
+ ])
178
+ }
179
+
180
+ /**
181
+ * Create a blank RGBA image filled with one colour.
182
+ *
183
+ * @param {number} width
184
+ * @param {number} height
185
+ * @param {[number, number, number, number]} color
186
+ * @returns {RgbaImage}
187
+ */
188
+ export function createImage(width, height, color) {
189
+ const data = new Uint8Array(width * height * 4)
190
+ for (let index = 0; index < data.length; index += 4) {
191
+ data[index] = color[0]
192
+ data[index + 1] = color[1]
193
+ data[index + 2] = color[2]
194
+ data[index + 3] = color[3]
195
+ }
196
+ return { width, height, data }
197
+ }
198
+
199
+ /**
200
+ * Copy a rectangle of pixels from one image into another, scaling as needed.
201
+ *
202
+ * Scaling is a box filter (area average) when shrinking and nearest-neighbour when
203
+ * growing. The box filter matters: a contact sheet's whole purpose is to be
204
+ * downscaled by the model's own request pipeline, and nearest-neighbour downscale
205
+ * first would turn thin features into aliased speckle that reads as noise.
206
+ *
207
+ * @param {RgbaImage} source
208
+ * @param {RgbaImage} target
209
+ * @param {{ x: number, y: number, width: number, height: number }} box
210
+ */
211
+ export function blendInto(source, target, box) {
212
+ const { x: originX, y: originY, width: boxWidth, height: boxHeight } = box
213
+ if (boxWidth <= 0 || boxHeight <= 0) return
214
+ const scaleX = source.width / boxWidth
215
+ const scaleY = source.height / boxHeight
216
+
217
+ for (let y = 0; y < boxHeight; y += 1) {
218
+ const targetY = originY + y
219
+ if (targetY < 0 || targetY >= target.height) continue
220
+ const sourceTop = y * scaleY
221
+ const sourceBottom = Math.min(source.height, Math.max(sourceTop + 1, (y + 1) * scaleY))
222
+ for (let x = 0; x < boxWidth; x += 1) {
223
+ const targetX = originX + x
224
+ if (targetX < 0 || targetX >= target.width) continue
225
+ const sourceLeft = x * scaleX
226
+ const sourceRight = Math.min(source.width, Math.max(sourceLeft + 1, (x + 1) * scaleX))
227
+
228
+ let red = 0
229
+ let green = 0
230
+ let blue = 0
231
+ let alpha = 0
232
+ let count = 0
233
+ const firstY = Math.floor(sourceTop)
234
+ const lastY = Math.max(firstY + 1, Math.ceil(sourceBottom))
235
+ const firstX = Math.floor(sourceLeft)
236
+ const lastX = Math.max(firstX + 1, Math.ceil(sourceRight))
237
+ for (let sy = firstY; sy < lastY && sy < source.height; sy += 1) {
238
+ for (let sx = firstX; sx < lastX && sx < source.width; sx += 1) {
239
+ const offset = (sy * source.width + sx) * 4
240
+ red += source.data[offset]
241
+ green += source.data[offset + 1]
242
+ blue += source.data[offset + 2]
243
+ alpha += source.data[offset + 3]
244
+ count += 1
245
+ }
246
+ }
247
+ if (count === 0) continue
248
+ const offset = (targetY * target.width + targetX) * 4
249
+ target.data[offset] = Math.round(red / count)
250
+ target.data[offset + 1] = Math.round(green / count)
251
+ target.data[offset + 2] = Math.round(blue / count)
252
+ target.data[offset + 3] = Math.round(alpha / count)
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Fill a rectangle with one colour (source-over, opaque).
259
+ *
260
+ * @param {RgbaImage} image
261
+ * @param {{ x: number, y: number, width: number, height: number }} box
262
+ * @param {[number, number, number, number]} color
263
+ */
264
+ export function fillRect(image, box, color) {
265
+ const left = Math.max(0, Math.floor(box.x))
266
+ const top = Math.max(0, Math.floor(box.y))
267
+ const right = Math.min(image.width, Math.ceil(box.x + box.width))
268
+ const bottom = Math.min(image.height, Math.ceil(box.y + box.height))
269
+ for (let y = top; y < bottom; y += 1) {
270
+ for (let x = left; x < right; x += 1) {
271
+ const offset = (y * image.width + x) * 4
272
+ image.data[offset] = color[0]
273
+ image.data[offset + 1] = color[1]
274
+ image.data[offset + 2] = color[2]
275
+ image.data[offset + 3] = color[3]
276
+ }
277
+ }
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Internals
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Parse the IHDR payload.
286
+ * @param {Buffer} payload
287
+ */
288
+ function parseHeader(payload) {
289
+ if (payload.length < 13) throw new Error('IHDR chunk is short')
290
+ return {
291
+ width: payload.readUInt32BE(0),
292
+ height: payload.readUInt32BE(4),
293
+ bitDepth: payload[8],
294
+ colorType: payload[9],
295
+ compression: payload[10],
296
+ filter: payload[11],
297
+ interlace: payload[12],
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Wrap a payload in a PNG chunk with its CRC.
303
+ * @param {string} type
304
+ * @param {Buffer} payload
305
+ * @returns {Buffer}
306
+ */
307
+ function chunk(type, payload) {
308
+ const length = Buffer.alloc(4)
309
+ length.writeUInt32BE(payload.length, 0)
310
+ const typeBuffer = Buffer.from(type, 'latin1')
311
+ const crc = Buffer.alloc(4)
312
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, payload])), 0)
313
+ return Buffer.concat([length, typeBuffer, payload, crc])
314
+ }
315
+
316
+ /** CRC-32 table, built once. */
317
+ const CRC_TABLE = (() => {
318
+ const table = new Uint32Array(256)
319
+ for (let index = 0; index < 256; index += 1) {
320
+ let value = index
321
+ for (let bit = 0; bit < 8; bit += 1) {
322
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
323
+ }
324
+ table[index] = value >>> 0
325
+ }
326
+ return table
327
+ })()
328
+
329
+ /**
330
+ * @param {Buffer} buffer
331
+ * @returns {number}
332
+ */
333
+ function crc32(buffer) {
334
+ let crc = 0xffffffff
335
+ for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
336
+ return (crc ^ 0xffffffff) >>> 0
337
+ }
338
+
339
+ /** Number of columns one Adam7 pass covers. */
340
+ function passColumns(size, start, step) {
341
+ if (size <= start) return 0
342
+ return Math.ceil((size - start) / step)
343
+ }
344
+
345
+ /**
346
+ * Reverse PNG scanline filtering.
347
+ *
348
+ * @param {Buffer} raw - the inflated IDAT stream, filter byte per scanline.
349
+ * @param {number} height
350
+ * @param {number} stride - bytes per scanline, excluding the filter byte.
351
+ * @param {number} bytesPerPixel
352
+ * @returns {Buffer} the unfiltered scanlines, still without filter bytes.
353
+ */
354
+ function unfilter(raw, height, stride, bytesPerPixel) {
355
+ const output = Buffer.alloc(stride * height)
356
+ let previous = Buffer.alloc(stride)
357
+ let cursor = 0
358
+ for (let y = 0; y < height; y += 1) {
359
+ if (cursor + 1 + stride > raw.length) {
360
+ throw new Error('PNG image data ended before the last scanline')
361
+ }
362
+ const filterType = raw[cursor]
363
+ const line = raw.subarray(cursor + 1, cursor + 1 + stride)
364
+ const target = output.subarray(y * stride, (y + 1) * stride)
365
+ cursor += 1 + stride
366
+
367
+ for (let index = 0; index < stride; index += 1) {
368
+ const left = index >= bytesPerPixel ? target[index - bytesPerPixel] : 0
369
+ const up = previous[index]
370
+ const upLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] : 0
371
+ const value = line[index]
372
+ let restored
373
+ switch (filterType) {
374
+ case 0: restored = value; break
375
+ case 1: restored = value + left; break
376
+ case 2: restored = value + up; break
377
+ case 3: restored = value + ((left + up) >> 1); break
378
+ case 4: restored = value + paeth(left, up, upLeft); break
379
+ default: throw new Error(`unknown PNG filter type ${filterType} on scanline ${y}`)
380
+ }
381
+ target[index] = restored & 0xff
382
+ }
383
+ previous = target
384
+ }
385
+ return output
386
+ }
387
+
388
+ /** The PNG Paeth predictor. */
389
+ function paeth(a, b, c) {
390
+ const p = a + b - c
391
+ const pa = Math.abs(p - a)
392
+ const pb = Math.abs(p - b)
393
+ const pc = Math.abs(p - c)
394
+ if (pa <= pb && pa <= pc) return a
395
+ return pb <= pc ? b : c
396
+ }
397
+
398
+ /**
399
+ * Read one pixel out of an unfiltered scanline as RGBA.
400
+ *
401
+ * @param {Buffer} line
402
+ * @param {number} offset
403
+ * @param {number} colorType
404
+ * @param {number} bitDepth
405
+ * @param {Buffer|null} palette
406
+ * @param {Buffer|null} transparency
407
+ * @returns {[number, number, number, number]}
408
+ */
409
+ function readPixel(line, offset, colorType, bitDepth, palette, transparency) {
410
+ const sample = index => (bitDepth === 16 ? line[offset + index * 2] : line[offset + index])
411
+ switch (colorType) {
412
+ case 0: {
413
+ const grey = sample(0)
414
+ return [grey, grey, grey, 255]
415
+ }
416
+ case 2:
417
+ return [sample(0), sample(1), sample(2), 255]
418
+ case 3: {
419
+ const index = sample(0)
420
+ if (palette === null || index * 3 + 2 >= palette.length) return [0, 0, 0, 255]
421
+ const alpha = transparency !== null && index < transparency.length ? transparency[index] : 255
422
+ return [palette[index * 3], palette[index * 3 + 1], palette[index * 3 + 2], alpha]
423
+ }
424
+ case 4: {
425
+ const grey = sample(0)
426
+ return [grey, grey, grey, sample(1)]
427
+ }
428
+ case 6:
429
+ return [sample(0), sample(1), sample(2), sample(3)]
430
+ default:
431
+ // NOT the same sentence the decoder throws for an unsupported type (`unsupported PNG colour type N`,
432
+ // raised before any pixel is read): this one is unreachable unless that check is bypassed, so saying
433
+ // the same thing here would be a second copy of a message nobody can reach — and would read as if this
434
+ // were the place that decides which types are supported.
435
+ throw new Error(`readPixel was called with colour type ${colorType}, which the decoder refuses before reading any pixel`)
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Store one RGBA pixel.
441
+ * @param {RgbaImage} image
442
+ * @param {number} x
443
+ * @param {number} y
444
+ * @param {[number, number, number, number]} pixel
445
+ */
446
+ function writePixel(image, x, y, pixel) {
447
+ const offset = (y * image.width + x) * 4
448
+ image.data[offset] = pixel[0]
449
+ image.data[offset + 1] = pixel[1]
450
+ image.data[offset + 2] = pixel[2]
451
+ image.data[offset + 3] = pixel[3]
452
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Canonical JSON projections for the M1 tool and UI surface.
3
+ *
4
+ * Every model-facing and browser-facing payload is built HERE, once, from the
5
+ * same functions. That is the SPEC §14.3 rule made structural: "浏览器状态不是
6
+ * 权威状态" is easy to honour when there is exactly one projection to disagree
7
+ * with, and impossible to honour when each surface formats its own answers.
8
+ *
9
+ * Each projection is a pure function of stored state — no clocks, no paths that
10
+ * the caller cannot see, no live Cordis objects. Only leaf values are copied, so
11
+ * a projection can never leak a Host reference into a tool result.
12
+ *
13
+ * Owner: DeepBlend Studio — M1
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} JobReference
18
+ * @property {string} jobId
19
+ * @property {string} action
20
+ * @property {'running'|'succeeded'|'failed'} status
21
+ * @property {string} projectId
22
+ * @property {string|null} revision
23
+ */
24
+
25
+ /**
26
+ * The compact project summary handed to the model and the UI.
27
+ *
28
+ * @param {object} input
29
+ * @param {object} input.record - the stored project record.
30
+ * @param {number} input.revisionCount
31
+ * @param {Record<string, unknown>|null} [input.sceneSummary] - a SceneDigest, when known.
32
+ * @returns {Record<string, unknown>}
33
+ */
34
+ export function toCanonicalProjectSummary({ record, revisionCount, sceneSummary = null }) {
35
+ return {
36
+ projectId: record.projectId,
37
+ title: record.title,
38
+ goal: record.goal ?? null,
39
+ createdAt: record.createdAt,
40
+ updatedAt: record.updatedAt,
41
+ currentRevision: record.currentRevision,
42
+ revisionCount,
43
+ projectsRoot: record.projectsRoot ?? null,
44
+ scene: sceneSummary,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The revision summary returned by a successful write or restore (SPEC §7.2
50
+ * `RevisionSummary`).
51
+ *
52
+ * @param {object} input
53
+ * @param {object} input.manifest - `revision-manifest.json`.
54
+ * @param {string} [input.checkpointPath] - project-relative, present only when a
55
+ * checkpoint exists.
56
+ * @returns {Record<string, unknown>}
57
+ */
58
+ export function toCanonicalRevisionSummary({ manifest, checkpointPath = null }) {
59
+ return {
60
+ revision: manifest.revision,
61
+ revisionNumber: manifest.revisionNumber,
62
+ baseRevision: manifest.baseRevision ?? null,
63
+ createdAt: manifest.createdAt,
64
+ kind: manifest.kind,
65
+ digest: manifest.digest,
66
+ specHash: manifest.specHashAfter ?? null,
67
+ summary: manifest.summary ?? null,
68
+ actor: manifest.actor ?? null,
69
+ stage: manifest.stage ?? null,
70
+ idempotencyKey: manifest.idempotencyKey ?? null,
71
+ sceneChanged: manifest.digestBefore !== manifest.digestAfter,
72
+ specChanged: manifest.specChanged === true,
73
+ checkpoint: checkpointPath,
74
+ previews: manifest.previews ?? [],
75
+ validation: manifest.validation ?? null,
76
+ isCurrent: manifest.isCurrent === true,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * A single rendered image reference. Paths are PROJECT-relative so a manifest
82
+ * survives the workspace being moved (SPEC §13.1: the session stores paths and
83
+ * hashes, never the bytes).
84
+ *
85
+ * @param {object} artifact
86
+ * @returns {Record<string, unknown>}
87
+ */
88
+ export function toCanonicalPreviewArtifact(artifact) {
89
+ return {
90
+ kind: artifact.kind,
91
+ path: artifact.path,
92
+ cameraId: artifact.cameraId ?? null,
93
+ frame: artifact.frame ?? null,
94
+ width: artifact.width ?? null,
95
+ height: artifact.height ?? null,
96
+ engine: artifact.engine ?? null,
97
+ samples: artifact.samples ?? null,
98
+ bytes: artifact.bytes ?? null,
99
+ sha256: artifact.sha256 ?? null,
100
+ mime: artifact.mime ?? null,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Normalize one validation finding.
106
+ *
107
+ * @param {object} issue
108
+ * @returns {Record<string, unknown>}
109
+ */
110
+ function toCanonicalIssue(issue) {
111
+ return {
112
+ severity: issue.severity ?? 'error',
113
+ code: issue.code,
114
+ path: issue.path ?? null,
115
+ message: issue.message,
116
+ detail: issue.detail ?? null,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The QA report returned by `validateScene` (SPEC §7.2 `QAReport`).
122
+ *
123
+ * `ok` means "no error-severity finding"; notices are expected and are reported
124
+ * rather than suppressed, because a notice is the compiler telling the caller
125
+ * which decision it made on their behalf.
126
+ *
127
+ * @param {object} input
128
+ * @param {string} input.projectId
129
+ * @param {string} input.revision
130
+ * @param {string} input.digest
131
+ * @param {object[]} input.errors
132
+ * @param {object[]} input.notices
133
+ * @param {object|null} [input.technical] - Blender-side technical report.
134
+ * @param {object|null} [input.job]
135
+ * @returns {Record<string, unknown>}
136
+ */
137
+ export function toCanonicalQAReport({ projectId, revision, digest, errors, notices, technical = null, job = null }) {
138
+ return {
139
+ projectId,
140
+ revision,
141
+ digest,
142
+ ok: errors.length === 0,
143
+ errorCount: errors.length,
144
+ noticeCount: notices.length,
145
+ errors: errors.map(toCanonicalIssue),
146
+ notices: notices.map(toCanonicalIssue),
147
+ technical,
148
+ job,
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The stored job record (SPEC §9.3 / §10.2).
154
+ *
155
+ * @param {object} record
156
+ * @returns {Record<string, unknown>}
157
+ */
158
+ export function toCanonicalJobRecord(record) {
159
+ return {
160
+ jobId: record.jobId,
161
+ projectId: record.projectId,
162
+ action: record.action,
163
+ status: record.status,
164
+ revision: record.revision ?? null,
165
+ errorCode: record.errorCode ?? null,
166
+ message: record.message ?? null,
167
+ startedAt: record.startedAt,
168
+ finishedAt: record.finishedAt ?? null,
169
+ durationMs: record.durationMs ?? null,
170
+ idempotencyKey: record.idempotencyKey ?? null,
171
+ baseRevision: record.baseRevision ?? null,
172
+ artifacts: (record.artifacts ?? []).map(toCanonicalPreviewArtifact),
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Project a thrown value into the canonical error envelope every tool returns.
178
+ *
179
+ * @param {unknown} cause
180
+ * @param {{ isBlenderError: (value: unknown) => boolean, fallbackCode: string }} options
181
+ * @returns {{ code: string, message: string, detail: unknown, stack: string|null }}
182
+ */
183
+ export function toCanonicalFailure(cause, options) {
184
+ const error = /** @type {any} */ (cause)
185
+ if (options.isBlenderError(cause)) {
186
+ return {
187
+ code: error.code,
188
+ message: error.message,
189
+ detail: error.detail ?? null,
190
+ stack: null,
191
+ }
192
+ }
193
+ if (error?.patchIssue !== undefined) {
194
+ return {
195
+ code: error.patchIssue.code,
196
+ message: error.patchIssue.message,
197
+ detail: { path: error.patchIssue.path },
198
+ stack: null,
199
+ }
200
+ }
201
+ return {
202
+ code: options.fallbackCode,
203
+ message: error instanceof Error ? error.message : String(cause),
204
+ detail: null,
205
+ // A stack is included ONLY for an unrecognized failure: it is the one case
206
+ // where the caller cannot branch on a code and the trace is the whole value.
207
+ stack: error instanceof Error ? (error.stack ?? null) : null,
208
+ }
209
+ }