@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
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
|
+
}
|