@deepblend/dsh-blender-host 0.2.20 → 0.3.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/artifact-writer.js +49 -0
- package/lib/asset-bundle.js +381 -0
- package/lib/asset-io.js +24 -0
- package/lib/asset-library.js +113 -0
- package/lib/asset-preview.js +139 -0
- package/lib/asset-uploads.js +328 -0
- package/lib/delivery-manifest.js +6 -1
- package/lib/diagnostic-preview.js +264 -0
- package/lib/frame-provenance.js +230 -0
- package/lib/index.js +1283 -544
- package/lib/obj-resources.js +89 -0
- package/lib/paths.js +12 -1
- package/lib/project-store.js +40 -24
- package/lib/project-writer.js +96 -0
- package/lib/recipe-catalog.js +128 -0
- package/lib/reference-image.js +186 -0
- package/lib/render-job-store.js +2 -2
- package/lib/revision-transaction.js +106 -43
- package/lib/video-encoder.js +71 -13
- package/package.json +8 -3
- package/recipes/glass-ceramic/LICENSE +21 -0
- package/recipes/glass-ceramic/preview.png +0 -0
- package/recipes/glass-ceramic/recipe.json +94 -0
- package/recipes/glass-ceramic/scene-spec.json +1407 -0
- package/recipes/glazed-cup/LICENSE +21 -0
- package/recipes/glazed-cup/preview.png +0 -0
- package/recipes/glazed-cup/recipe.json +93 -0
- package/recipes/glazed-cup/scene-spec.json +248 -0
- package/recipes/metal-lamp/LICENSE +21 -0
- package/recipes/metal-lamp/preview.png +0 -0
- package/recipes/metal-lamp/recipe.json +118 -0
- package/recipes/metal-lamp/scene-spec.json +2530 -0
- package/recipes/modular-speaker/LICENSE +21 -0
- package/recipes/modular-speaker/preview.png +0 -0
- package/recipes/modular-speaker/recipe.json +95 -0
- package/recipes/modular-speaker/scene-spec.json +6268 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Inspect OBJ/MTL file references without loading a mesh or rewriting source bytes. */
|
|
2
|
+
import { openSync, closeSync, readSync, statSync } from 'node:fs'
|
|
3
|
+
import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
|
|
4
|
+
|
|
5
|
+
export const OBJ_LIMITS = Object.freeze({ modelBytes: 1024 * 1024 * 1024, materialBytes: 16 * 1024 * 1024, lineBytes: 1024 * 1024 })
|
|
6
|
+
const invalid = (message, code = BlenderErrorCode.ASSET_REQUEST_INVALID) => { throw new BlenderError(code, message) }
|
|
7
|
+
const numeric = value => /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value) && Number.isFinite(Number(value))
|
|
8
|
+
const mapKeys = new Set(['map_Kd', 'map_Ks', 'map_Ns', 'map_d', 'refl', 'map_refl', 'map_Ke', 'bump', 'map_Bump', 'map_bump', 'map_Pr', 'map_Pm', 'map_Ps'])
|
|
9
|
+
const counts = { '-bm': 1, '-type': 1, '-blendu': 1, '-blendv': 1, '-boost': 1, '-cc': 1, '-clamp': 1, '-imfchan': 1, '-mm': 2, '-t': 3, '-texres': 1 }
|
|
10
|
+
|
|
11
|
+
/** Physical lines are bounded; only OBJ has backslash/newline continuation. */
|
|
12
|
+
function lines(file, limit, callback, { continuation = false, signal } = {}) {
|
|
13
|
+
signal?.throwIfAborted()
|
|
14
|
+
if (statSync(file).size > limit) invalid('OBJ/MTL text exceeds its byte limit.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
15
|
+
const fd = openSync(file, 'r'), decoder = new TextDecoder('utf-8', { fatal: true }), chunk = Buffer.alloc(64 * 1024)
|
|
16
|
+
let pending = '', logical = '', bytes = 0
|
|
17
|
+
const emit = physical => {
|
|
18
|
+
if (Buffer.byteLength(physical) > OBJ_LIMITS.lineBytes) invalid('OBJ/MTL lines exceed 1 MiB.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
19
|
+
const continued = continuation && /\\[\t\r ]*$/.test(physical)
|
|
20
|
+
logical += continued ? physical.replace(/\\[\t\r ]*$/, ' ') : physical
|
|
21
|
+
if (Buffer.byteLength(logical) > OBJ_LIMITS.lineBytes) invalid('OBJ/MTL logical lines exceed 1 MiB.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
22
|
+
if (!continued) { callback(logical); logical = '' }
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
for (;;) {
|
|
26
|
+
signal?.throwIfAborted()
|
|
27
|
+
const n = readSync(fd, chunk, 0, chunk.length, null)
|
|
28
|
+
bytes += n
|
|
29
|
+
if (bytes > limit) invalid('OBJ/MTL text exceeds its byte limit.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
30
|
+
pending += decoder.decode(chunk.subarray(0, n), { stream: n !== 0 })
|
|
31
|
+
let start = 0, end
|
|
32
|
+
while ((end = pending.indexOf('\n', start)) >= 0) { emit(pending.slice(start, end)); start = end + 1 }
|
|
33
|
+
pending = pending.slice(start)
|
|
34
|
+
if (Buffer.byteLength(pending) > OBJ_LIMITS.lineBytes) invalid('OBJ/MTL lines exceed 1 MiB.', BlenderErrorCode.ASSET_TOO_LARGE)
|
|
35
|
+
if (!n) { if (pending || logical) emit(pending); if (logical) callback(logical); break }
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
signal?.throwIfAborted()
|
|
39
|
+
if (error instanceof BlenderError) throw error
|
|
40
|
+
invalid('OBJ/MTL text must be valid UTF-8.', BlenderErrorCode.ASSET_CONTENT_MISMATCH)
|
|
41
|
+
} finally { closeSync(fd) }
|
|
42
|
+
}
|
|
43
|
+
const field = line => /^\s*([^\s]+)(?:\s+(.*))?$/.exec(line.trim())
|
|
44
|
+
export function objLibraries(file, options = {}) {
|
|
45
|
+
const libraries = new Set()
|
|
46
|
+
lines(file, OBJ_LIMITS.modelBytes, line => {
|
|
47
|
+
const match = field(line)
|
|
48
|
+
if (match?.[1] !== 'mtllib') return
|
|
49
|
+
let name = (match[2] ?? '').trim()
|
|
50
|
+
if (name.length > 2 && name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1)
|
|
51
|
+
if (!name) invalid('An OBJ material library reference is empty.')
|
|
52
|
+
libraries.add(name)
|
|
53
|
+
}, { ...options, continuation: true })
|
|
54
|
+
return [...libraries]
|
|
55
|
+
}
|
|
56
|
+
export function mtlImages(file, options = {}) {
|
|
57
|
+
const images = new Set()
|
|
58
|
+
let material = false
|
|
59
|
+
lines(file, OBJ_LIMITS.materialBytes, line => {
|
|
60
|
+
const match = field(line)
|
|
61
|
+
if (!match) return
|
|
62
|
+
if (match[1] === 'newmtl') { material = true; return }
|
|
63
|
+
if (!material || !mapKeys.has(match[1])) return
|
|
64
|
+
let rest = (match[2] ?? '').trim()
|
|
65
|
+
for (;;) {
|
|
66
|
+
const token = /^([^\s]+)(?:\s+|$)/.exec(rest)?.[1]
|
|
67
|
+
if (!token) break
|
|
68
|
+
if (['-o', '-s'].includes(token)) {
|
|
69
|
+
rest = rest.slice(token.length).trimStart()
|
|
70
|
+
for (let n = 0; n < 3; n++) {
|
|
71
|
+
const value = /^([^\s]+)(?:\s+|$)/.exec(rest)?.[1]
|
|
72
|
+
if (!numeric(value ?? '')) break
|
|
73
|
+
rest = rest.slice(value.length).trimStart()
|
|
74
|
+
}
|
|
75
|
+
} else if (Object.hasOwn(counts, token)) {
|
|
76
|
+
rest = rest.slice(token.length).trimStart()
|
|
77
|
+
for (let n = 0; n < counts[token]; n++) {
|
|
78
|
+
const value = /^([^\s]+)(?:\s+|$)/.exec(rest)?.[1]
|
|
79
|
+
if (!value || (token === '-bm' && !numeric(value))) invalid('An MTL texture option is incomplete or invalid.')
|
|
80
|
+
rest = rest.slice(value.length).trimStart()
|
|
81
|
+
}
|
|
82
|
+
} else break
|
|
83
|
+
}
|
|
84
|
+
const name = rest.trim().replaceAll('"', '')
|
|
85
|
+
if (!name) invalid('An MTL texture reference is empty.')
|
|
86
|
+
images.add(name)
|
|
87
|
+
}, options)
|
|
88
|
+
return [...images]
|
|
89
|
+
}
|
package/lib/paths.js
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
import { createHash } from 'node:crypto'
|
|
22
22
|
import {
|
|
23
23
|
existsSync,
|
|
24
|
+
openSync,
|
|
25
|
+
closeSync,
|
|
26
|
+
readSync,
|
|
24
27
|
mkdirSync,
|
|
25
28
|
readFileSync,
|
|
26
29
|
readdirSync,
|
|
@@ -272,10 +275,18 @@ export function fileSize(path) {
|
|
|
272
275
|
|
|
273
276
|
/** sha256 of a file's bytes, or `null` when the file is absent. */
|
|
274
277
|
export function fileSha256(path) {
|
|
278
|
+
let descriptor
|
|
275
279
|
try {
|
|
276
|
-
|
|
280
|
+
descriptor = openSync(path, 'r')
|
|
281
|
+
const hash = createHash('sha256')
|
|
282
|
+
const buffer = Buffer.allocUnsafe(64 * 1024)
|
|
283
|
+
let bytes
|
|
284
|
+
while ((bytes = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes))
|
|
285
|
+
return hash.digest('hex')
|
|
277
286
|
} catch {
|
|
278
287
|
return null
|
|
288
|
+
} finally {
|
|
289
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
279
290
|
}
|
|
280
291
|
}
|
|
281
292
|
|
package/lib/project-store.js
CHANGED
|
@@ -36,14 +36,17 @@
|
|
|
36
36
|
* revision manifest describes that revision and nothing else, so publishing a
|
|
37
37
|
* revision and moving the pointer stay two separate steps and a half-finished
|
|
38
38
|
* publish cannot claim to be current.
|
|
39
|
-
* -
|
|
40
|
-
*
|
|
39
|
+
* - **Revision decisions are immutable.** SceneSpecs and checkpoints publish by
|
|
40
|
+
* atomic rename; later emitted artifacts amend only their protected indices.
|
|
41
41
|
*
|
|
42
42
|
* Owner: DeepBlend Studio — M1
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { existsSync, readdirSync } from 'node:fs'
|
|
46
|
+
import { randomUUID } from 'node:crypto'
|
|
46
47
|
import { join } from 'node:path'
|
|
48
|
+
import { assertProjectWriter, withProjectWriter } from './project-writer.js'
|
|
49
|
+
import { withArtifactWriter } from './artifact-writer.js'
|
|
47
50
|
|
|
48
51
|
import {
|
|
49
52
|
BlenderError,
|
|
@@ -186,6 +189,16 @@ export class ProjectStore {
|
|
|
186
189
|
return record
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/** Hold the revision writer lease until a synchronous or asynchronous action finishes. */
|
|
193
|
+
withProjectWrite(projectId, action) {
|
|
194
|
+
return withProjectWriter(this.projectDirectory(projectId), this.workspaceRoot, action)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Check ownership again immediately before publishing a revision. */
|
|
198
|
+
assertProjectWrite(projectId) {
|
|
199
|
+
assertProjectWriter(this.projectDirectory(projectId))
|
|
200
|
+
}
|
|
201
|
+
|
|
189
202
|
/**
|
|
190
203
|
* Allocate a project id from a title, avoiding collisions.
|
|
191
204
|
*
|
|
@@ -303,7 +316,7 @@ export class ProjectStore {
|
|
|
303
316
|
* @param {string} projectId
|
|
304
317
|
* @param {string} revision
|
|
305
318
|
* @param {object} artifact
|
|
306
|
-
* @returns {object[]} the revision's previews, in render order.
|
|
319
|
+
* @returns {Promise<object[]>} the revision's previews, in render order.
|
|
307
320
|
*/
|
|
308
321
|
recordRevisionPreview(projectId, revision, artifact) {
|
|
309
322
|
return this.recordRevisionArtifact(projectId, revision, 'previews', artifact)
|
|
@@ -333,22 +346,29 @@ export class ProjectStore {
|
|
|
333
346
|
* @param {string} revision
|
|
334
347
|
* @param {'previews'|'contactSheets'|'reviews'} indexKey
|
|
335
348
|
* @param {object} artifact
|
|
336
|
-
* @returns {object[]} the revision's entries under that key, in emission order.
|
|
349
|
+
* @returns {Promise<object[]>} the revision's entries under that key, in emission order.
|
|
337
350
|
*/
|
|
338
351
|
recordRevisionArtifact(projectId, revision, indexKey, artifact) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
+
return this.withRevisionArtifacts(projectId, revision, () => {
|
|
353
|
+
const path = join(this.revisionDirectory(projectId, revision), 'revision-manifest.json')
|
|
354
|
+
const manifest = readJson(path)
|
|
355
|
+
if (manifest === null) {
|
|
356
|
+
// A revision directory with no manifest did not finish publishing, so there
|
|
357
|
+
// is nothing to amend and nothing safe to write into it.
|
|
358
|
+
return [artifact]
|
|
359
|
+
}
|
|
360
|
+
const existing = Array.isArray(manifest[indexKey]) ? manifest[indexKey] : []
|
|
361
|
+
// Re-emitting the same path replaces its entry rather than duplicating it:
|
|
362
|
+
// re-rendering a view after a fix must not make the sheet look like two.
|
|
363
|
+
const next = [...existing.filter(entry => entry.path !== artifact.path), artifact]
|
|
364
|
+
writeJsonAtomic(path, { ...manifest, [indexKey]: next })
|
|
365
|
+
return next
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Coordinate a short group of file publications and manifest amendments. */
|
|
370
|
+
withRevisionArtifacts(projectId, revision, action, options = {}) {
|
|
371
|
+
return withArtifactWriter(this.revisionDirectory(projectId, revision), this.workspaceRoot, action, options)
|
|
352
372
|
}
|
|
353
373
|
|
|
354
374
|
/**
|
|
@@ -416,19 +436,15 @@ export class ProjectStore {
|
|
|
416
436
|
}
|
|
417
437
|
|
|
418
438
|
/**
|
|
419
|
-
* Allocate
|
|
420
|
-
*
|
|
439
|
+
* Allocate an opaque attempt id across independent Host processes. The time
|
|
440
|
+
* helps locate an attempt; uniqueness must not depend on directory counts.
|
|
421
441
|
* @param {string} projectId
|
|
422
442
|
* @param {string} action
|
|
423
443
|
* @returns {string}
|
|
424
444
|
*/
|
|
425
445
|
allocateJobId(projectId, action) {
|
|
426
|
-
const directory = join(this.projectDirectory(projectId), 'jobs')
|
|
427
|
-
const existing = existsSync(directory)
|
|
428
|
-
? readdirSync(directory).filter(name => name.endsWith('.json')).length
|
|
429
|
-
: 0
|
|
430
446
|
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
|
|
431
|
-
return `${action}-${stamp}-${
|
|
447
|
+
return `${action}-${stamp}-${randomUUID()}`
|
|
432
448
|
}
|
|
433
449
|
|
|
434
450
|
// -------------------------------------------------------------------------
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project has one revision writer, including across local Node processes.
|
|
3
|
+
*
|
|
4
|
+
* mkdir is the acquisition primitive. A lease has no time-based expiry: a slow
|
|
5
|
+
* compile must never lose ownership to a timeout. Only a recorded owner on this
|
|
6
|
+
* host whose process is known to be gone can be recovered automatically. Missing
|
|
7
|
+
* or malformed ownership, another hostname, EPERM and a reused live PID all fail
|
|
8
|
+
* closed. The recovery guard is deliberately not recovered automatically; a crash
|
|
9
|
+
* inside that short critical section needs an operator to inspect the lock paths.
|
|
10
|
+
*
|
|
11
|
+
* This coordinates cooperating processes on one local filesystem. It is not a
|
|
12
|
+
* distributed lock for network filesystems or protection against external edits.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { hostname } from 'node:os'
|
|
17
|
+
import { basename, join } from 'node:path'
|
|
18
|
+
import { BlenderError, BlenderErrorCode, sha256 } from '@deepblend/dsh-blender-contracts'
|
|
19
|
+
import { ensureDirectory, readJsonSafe, resolveInside } from './paths.js'
|
|
20
|
+
|
|
21
|
+
const held = new Map()
|
|
22
|
+
|
|
23
|
+
function ownerAt(path) {
|
|
24
|
+
try { return JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8')) } catch { return null }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function gone(owner) {
|
|
28
|
+
if (owner?.hostname !== hostname() || !Number.isSafeInteger(owner.pid) || owner.pid <= 0
|
|
29
|
+
|| typeof owner.token !== 'string' || owner.token.length === 0) return false
|
|
30
|
+
try { process.kill(owner.pid, 0); return false } catch (cause) { return cause.code === 'ESRCH' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function conflict(directory, lockPath, owner, reason = 'another revision write is in progress') {
|
|
34
|
+
const currentRevision = readJsonSafe(join(directory, 'project.json'))?.currentRevision ?? null
|
|
35
|
+
return new BlenderError(BlenderErrorCode.REVISION_CONFLICT,
|
|
36
|
+
`Project "${basename(directory)}" cannot be changed: ${reason}. Current revision is ${currentRevision ?? 'not yet created'}. ` +
|
|
37
|
+
'Wait for the writer to finish, then read the scene again before retrying. ' +
|
|
38
|
+
'If a lock owner cannot be verified, inspect the lock only after all writers have stopped.',
|
|
39
|
+
{ detail: { projectId: basename(directory), currentRevision, lockPath, owner } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function acquire(directory, workspaceRoot) {
|
|
43
|
+
const root = ensureDirectory(workspaceRoot, join(workspaceRoot, '.revision-writers'), 'revision writer locks')
|
|
44
|
+
const lockPath = resolveInside(root, join(root, sha256(directory)), 'project writer lock')
|
|
45
|
+
const recoveryPath = `${lockPath}.recovery`
|
|
46
|
+
const owner = { pid: process.pid, hostname: hostname(), token: randomUUID(), createdAt: new Date().toISOString() }
|
|
47
|
+
// Creating the recovery guard is itself an atomic test for another recovery.
|
|
48
|
+
// Normal acquisition also passes through it, so no new writer can enter between
|
|
49
|
+
// checking a dead owner and removing its stale lease.
|
|
50
|
+
try { mkdirSync(recoveryPath) } catch (cause) {
|
|
51
|
+
if (cause.code !== 'EEXIST') throw cause
|
|
52
|
+
throw conflict(directory, recoveryPath, ownerAt(lockPath), 'writer ownership is being checked or requires recovery')
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
try { mkdirSync(lockPath) } catch (cause) {
|
|
56
|
+
if (cause.code !== 'EEXIST') throw cause
|
|
57
|
+
const previous = ownerAt(lockPath)
|
|
58
|
+
if (!gone(previous)) throw conflict(directory, lockPath, previous)
|
|
59
|
+
rmSync(lockPath, { recursive: true })
|
|
60
|
+
mkdirSync(lockPath)
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(join(lockPath, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 })
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
rmSync(lockPath, { recursive: true, force: true })
|
|
66
|
+
throw cause
|
|
67
|
+
}
|
|
68
|
+
return { lockPath, owner }
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(recoveryPath, { recursive: true, force: true })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertProjectWriter(directory) {
|
|
75
|
+
const lease = held.get(directory)
|
|
76
|
+
if (!lease || ownerAt(lease.lockPath)?.token !== lease.owner.token) {
|
|
77
|
+
throw conflict(directory, lease?.lockPath ?? null, null, 'this transaction no longer owns the project writer lease')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function withProjectWriter(directory, workspaceRoot, action) {
|
|
82
|
+
const existing = held.get(directory)
|
|
83
|
+
if (existing) throw conflict(directory, existing.lockPath, existing.owner)
|
|
84
|
+
const lease = acquire(directory, workspaceRoot)
|
|
85
|
+
held.set(directory, lease)
|
|
86
|
+
const release = () => {
|
|
87
|
+
held.delete(directory)
|
|
88
|
+
// Never remove another writer's lease after an external lock replacement.
|
|
89
|
+
if (ownerAt(lease.lockPath)?.token === lease.owner.token) rmSync(lease.lockPath, { recursive: true, force: true })
|
|
90
|
+
}
|
|
91
|
+
let result
|
|
92
|
+
try { result = action() } catch (cause) { release(); throw cause }
|
|
93
|
+
if (result && typeof result.then === 'function') return Promise.resolve(result).finally(release)
|
|
94
|
+
release()
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Local, data-only recipe packages. Only operator-configured directories are read. */
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { BlenderError, canonicalStringify, sha256, validateRecipePackage, instantiateRecipe, RECIPE_LIMITS, RECIPE_SCHEMA_VERSION } from '@deepblend/dsh-blender-contracts'
|
|
6
|
+
|
|
7
|
+
export const BUILTIN_RECIPES = fileURLToPath(new URL('../recipes/', import.meta.url))
|
|
8
|
+
const fail = (code, message) => { throw new BlenderError(code, message) }
|
|
9
|
+
const identity = manifest => `${manifest.id}@${manifest.version}`
|
|
10
|
+
const limits = { 'recipe.json': 65536, 'scene-spec.json': RECIPE_LIMITS.sceneBytes, 'preview.png': RECIPE_LIMITS.previewBytes }
|
|
11
|
+
|
|
12
|
+
function readBounded(path, limit) {
|
|
13
|
+
if (lstatSync(path).isSymbolicLink()) fail('RECIPE_PATH_INVALID', 'Recipe files cannot be symbolic links')
|
|
14
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
15
|
+
try {
|
|
16
|
+
const stat = fstatSync(fd)
|
|
17
|
+
if (!stat.isFile() || stat.size > limit) fail('RECIPE_SIZE_LIMIT', 'Recipe file exceeds its byte limit or is not a regular file')
|
|
18
|
+
const bytes = Buffer.alloc(limit + 1)
|
|
19
|
+
let length = 0, read = 0
|
|
20
|
+
do { read = readSync(fd, bytes, length, bytes.length - length, null); length += read } while (read && length < bytes.length)
|
|
21
|
+
if (length > limit) fail('RECIPE_SIZE_LIMIT', 'Recipe file grew beyond its byte limit')
|
|
22
|
+
return Buffer.from(bytes.subarray(0, length))
|
|
23
|
+
} finally { closeSync(fd) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class RecipeCatalog {
|
|
27
|
+
constructor({ directories = [], builtinRoot = BUILTIN_RECIPES } = {}) {
|
|
28
|
+
if (directories.length > 32) fail('RECIPE_CATALOG_LIMIT', 'At most 32 recipe directories may be configured')
|
|
29
|
+
this.roots = [...new Set([builtinRoot, ...directories].map(path => resolve(path)))]
|
|
30
|
+
this.cache = new Map()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_read(directory, fresh = false) {
|
|
34
|
+
if (lstatSync(directory).isSymbolicLink() || !lstatSync(directory).isDirectory()) fail('RECIPE_PATH_INVALID', 'Recipe packages must be real directories')
|
|
35
|
+
const real = realpathSync(directory)
|
|
36
|
+
if (dirname(real) !== realpathSync(dirname(directory))) fail('RECIPE_PATH_INVALID', 'Recipe directory escapes its registered root')
|
|
37
|
+
const signature = Object.keys(limits).map(name => {
|
|
38
|
+
const stat = lstatSync(join(real, name), { bigint: true })
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('RECIPE_PATH_INVALID', 'Recipe files must be regular files')
|
|
40
|
+
return `${name}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`
|
|
41
|
+
}).join('|')
|
|
42
|
+
const cached = this.cache.get(real)
|
|
43
|
+
if (!fresh && cached?.signature === signature) return cached
|
|
44
|
+
const manifestBytes = readBounded(join(real, 'recipe.json'), limits['recipe.json'])
|
|
45
|
+
const bundle = { manifest: JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes)),
|
|
46
|
+
sceneBytes: readBounded(join(real, 'scene-spec.json'), limits['scene-spec.json']),
|
|
47
|
+
previewBytes: readBounded(join(real, 'preview.png'), limits['preview.png']) }
|
|
48
|
+
const check = validateRecipePackage(bundle)
|
|
49
|
+
if (!check.ok) fail(check.errors[0].code, check.summary)
|
|
50
|
+
const digest = sha256(canonicalStringify(bundle.manifest))
|
|
51
|
+
const { id, version, title, description, author, license, source, parameters } = bundle.manifest
|
|
52
|
+
const value = { signature, directory: real, bundle, digest,
|
|
53
|
+
summary: { id, version, title, description, author, license, source, parameters, digest,
|
|
54
|
+
previewUrl: `/deepblend/recipes/${encodeURIComponent(id)}/${encodeURIComponent(version)}/preview?digest=${digest}` } }
|
|
55
|
+
value.byteLength = manifestBytes.length + bundle.sceneBytes.length + bundle.previewBytes.length
|
|
56
|
+
this.cache.delete(real)
|
|
57
|
+
this.cache.set(real, value)
|
|
58
|
+
let cachedBytes = [...this.cache.values()].reduce((sum, item) => sum + item.byteLength, 0)
|
|
59
|
+
for (const [key, item] of this.cache) {
|
|
60
|
+
if (cachedBytes <= 64 * 1024 * 1024) break
|
|
61
|
+
this.cache.delete(key); cachedBytes -= item.byteLength
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_scan() {
|
|
67
|
+
const entries = new Map(), conflicts = new Set(), errors = []
|
|
68
|
+
let count = 0
|
|
69
|
+
const seenRoots = new Set()
|
|
70
|
+
for (const root of this.roots) {
|
|
71
|
+
try {
|
|
72
|
+
const realRoot = realpathSync(root)
|
|
73
|
+
if (seenRoots.has(realRoot)) continue
|
|
74
|
+
seenRoots.add(realRoot)
|
|
75
|
+
for (const folder of readdirSync(realRoot, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
76
|
+
if (!folder.isDirectory() && !folder.isSymbolicLink()) continue
|
|
77
|
+
if (++count > 200) fail('RECIPE_CATALOG_LIMIT', 'At most 200 recipe packages may be registered')
|
|
78
|
+
try {
|
|
79
|
+
const entry = this._read(join(realRoot, folder.name)), key = identity(entry.bundle.manifest)
|
|
80
|
+
if (entries.has(key) || conflicts.has(key)) {
|
|
81
|
+
entries.delete(key); conflicts.add(key)
|
|
82
|
+
fail('RECIPE_ID_CONFLICT', `More than one package declares ${key}; neither can be selected`)
|
|
83
|
+
}
|
|
84
|
+
entries.set(key, { directory: entry.directory, summary: entry.summary })
|
|
85
|
+
} catch (error) { errors.push({ package: folder.name, code: error.code ?? 'RECIPE_PACKAGE_INVALID', message: error.message }) }
|
|
86
|
+
}
|
|
87
|
+
} catch (error) { errors.push({ code: error.code ?? 'RECIPE_DIRECTORY_INVALID', message: error.message }) }
|
|
88
|
+
}
|
|
89
|
+
const active = new Set([...entries.values()].map(entry => entry.directory))
|
|
90
|
+
for (const key of this.cache.keys()) if (!active.has(key)) this.cache.delete(key)
|
|
91
|
+
return { entries, errors }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
list() {
|
|
95
|
+
const { entries, errors } = this._scan()
|
|
96
|
+
return { recipeSchemaVersion: RECIPE_SCHEMA_VERSION, recipes: [...entries.values()].map(entry => structuredClone(entry.summary)), errors }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get(request) {
|
|
100
|
+
if (!request || typeof request.id !== 'string' || typeof request.version !== 'string' || !/^[a-f0-9]{64}$/.test(request.digest ?? '')) {
|
|
101
|
+
fail('RECIPE_REQUEST_INVALID', 'Select a recipe with its id, version and digest')
|
|
102
|
+
}
|
|
103
|
+
const entry = this._scan().entries.get(`${request.id}@${request.version}`)
|
|
104
|
+
if (!entry) fail('RECIPE_NOT_FOUND', 'The selected recipe is unavailable; refresh the catalog')
|
|
105
|
+
// Recheck actual bytes at the write/preview boundary, even when listing used a cache.
|
|
106
|
+
const current = this._read(entry.directory, true)
|
|
107
|
+
if (current.digest !== request.digest) fail('RECIPE_CHANGED', 'The recipe changed; refresh the catalog before creating a project')
|
|
108
|
+
return current
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
instantiate(request) {
|
|
112
|
+
const entry = this.get(request)
|
|
113
|
+
let result
|
|
114
|
+
try { result = instantiateRecipe(entry.bundle, request.parameters === undefined ? {} : request.parameters) }
|
|
115
|
+
catch (error) { throw new BlenderError(error.code ?? 'RECIPE_PARAMETER_INVALID', error.message, { detail: error.details }) }
|
|
116
|
+
return { sceneSpec: result.spec, lock: {
|
|
117
|
+
schemaVersion: 'deepblend.recipe-lock/v1', ...result.recipe, packageDigest: entry.digest,
|
|
118
|
+
manifest: entry.bundle.manifest, values: result.values,
|
|
119
|
+
// Exact bytes make the declared input hash verifiable after the local recipe is removed.
|
|
120
|
+
sceneSource: entry.bundle.sceneBytes.toString('utf8'),
|
|
121
|
+
} }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
preview(request) {
|
|
125
|
+
const { bundle } = this.get(request)
|
|
126
|
+
return { bytes: bundle.previewBytes, contentType: 'image/png', size: bundle.previewBytes.length }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { extname } from 'node:path'
|
|
3
|
+
import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
|
|
4
|
+
|
|
5
|
+
export const REFERENCE_IMAGE_MAX_BYTES = 8 * 1024 * 1024
|
|
6
|
+
export const REFERENCE_IMAGE_MAX_PIXELS = 16_000_000
|
|
7
|
+
export const REFERENCE_IMAGE_MAX_EDGE = 8192
|
|
8
|
+
|
|
9
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
10
|
+
const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
11
|
+
for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0)
|
|
12
|
+
return value >>> 0
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function invalid(message) {
|
|
16
|
+
throw new BlenderError(BlenderErrorCode.ASSET_REQUEST_INVALID, message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mismatch(message, cause) {
|
|
20
|
+
throw new BlenderError(BlenderErrorCode.ASSET_CONTENT_MISMATCH, message, { cause })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function crc32(bytes) {
|
|
24
|
+
let value = 0xffffffff
|
|
25
|
+
for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8)
|
|
26
|
+
return (value ^ 0xffffffff) >>> 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// libpng can ignore unknown ancillary chunks, including APNG's animation chunks.
|
|
30
|
+
// Check the complete container as well as decoding its pixels; never silently
|
|
31
|
+
// accept just a first frame, a concatenated file, or a damaged ancillary chunk.
|
|
32
|
+
function checkPng(data) {
|
|
33
|
+
let offset = PNG_SIGNATURE.length
|
|
34
|
+
let imageData = false
|
|
35
|
+
let imageDataEnded = false
|
|
36
|
+
while (offset < data.length) {
|
|
37
|
+
if (offset + 12 > data.length) mismatch('The PNG image has a truncated chunk.')
|
|
38
|
+
const length = data.readUInt32BE(offset)
|
|
39
|
+
const end = offset + 12 + length
|
|
40
|
+
if (end > data.length) mismatch('The PNG image has a truncated chunk.')
|
|
41
|
+
const type = data.toString('ascii', offset + 4, offset + 8)
|
|
42
|
+
if (!/^[A-Za-z]{2}[A-Z][A-Za-z]$/.test(type)) mismatch('The PNG image has an invalid chunk type.')
|
|
43
|
+
if (crc32(data.subarray(offset + 4, end - 4)) !== data.readUInt32BE(end - 4)) {
|
|
44
|
+
mismatch('The PNG image has a damaged chunk checksum.')
|
|
45
|
+
}
|
|
46
|
+
if (offset === 8 ? type !== 'IHDR' || length !== 13 : type === 'IHDR') {
|
|
47
|
+
mismatch('The PNG image has an invalid header.')
|
|
48
|
+
}
|
|
49
|
+
if (['acTL', 'fcTL', 'fdAT'].includes(type)) invalid('Reference images must be still images; animated PNG is not supported.')
|
|
50
|
+
if (type === 'IDAT') {
|
|
51
|
+
if (imageDataEnded) mismatch('The PNG image has nonconsecutive pixel chunks.')
|
|
52
|
+
imageData = true
|
|
53
|
+
} else if (imageData) imageDataEnded = true
|
|
54
|
+
if (type === 'IEND') {
|
|
55
|
+
if (length !== 0 || !imageData || end !== data.length) mismatch('The PNG image has an invalid ending or trailing content.')
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
offset = end
|
|
59
|
+
}
|
|
60
|
+
mismatch('The PNG image is missing its complete ending.')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Walk marker lengths and entropy-coded scans so progressive JPEG remains valid
|
|
64
|
+
// while truncated streams and extra images after EOI cannot be repaired by the
|
|
65
|
+
// decoder. MPF is a multi-picture container even if only its first image decodes.
|
|
66
|
+
function checkJpeg(data) {
|
|
67
|
+
let offset = 2
|
|
68
|
+
let sawScan = false
|
|
69
|
+
let frames = 0
|
|
70
|
+
let inScan = false
|
|
71
|
+
while (offset < data.length) {
|
|
72
|
+
if (inScan) {
|
|
73
|
+
while (offset < data.length) {
|
|
74
|
+
if (data[offset] !== 0xff) { offset += 1; continue }
|
|
75
|
+
const start = offset
|
|
76
|
+
while (data[offset] === 0xff) offset += 1
|
|
77
|
+
const marker = data[offset]
|
|
78
|
+
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) { offset += 1; continue }
|
|
79
|
+
offset = start
|
|
80
|
+
inScan = false
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (data[offset] !== 0xff) mismatch('The JPEG image has a truncated scan or invalid marker.')
|
|
85
|
+
while (data[offset] === 0xff) offset += 1
|
|
86
|
+
const marker = data[offset++]
|
|
87
|
+
if (marker === 0xd9) {
|
|
88
|
+
if (!sawScan || offset !== data.length) mismatch('The JPEG image has an invalid ending or trailing content.')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (marker === undefined || marker === 0x00 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
92
|
+
mismatch('The JPEG image has an invalid marker.')
|
|
93
|
+
}
|
|
94
|
+
if (marker === 0x01) continue // TEM is the only other stand-alone marker.
|
|
95
|
+
if (offset + 2 > data.length) mismatch('The JPEG image has a truncated segment.')
|
|
96
|
+
const length = data.readUInt16BE(offset)
|
|
97
|
+
const end = offset + length
|
|
98
|
+
if (length < 2 || end > data.length) mismatch('The JPEG image has a truncated segment.')
|
|
99
|
+
if (marker === 0xe2 && data.toString('latin1', offset + 2, offset + 6) === 'MPF\0') {
|
|
100
|
+
invalid('Reference images must contain one image; multi-picture JPEG is not supported.')
|
|
101
|
+
}
|
|
102
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
103
|
+
frames += 1
|
|
104
|
+
if (frames > 1) invalid('Reference images must contain one image; multi-frame JPEG is not supported.')
|
|
105
|
+
}
|
|
106
|
+
offset = end
|
|
107
|
+
if (marker === 0xda) { sawScan = true; inScan = true }
|
|
108
|
+
}
|
|
109
|
+
mismatch('The JPEG image is missing its complete ending.')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Inspect immutable source bytes without resizing, orienting or re-encoding.
|
|
114
|
+
* Width/height describe the stored pixel grid, before any EXIF orientation.
|
|
115
|
+
* The native codec is loaded only when a reference image is actually inspected.
|
|
116
|
+
*/
|
|
117
|
+
export async function inspectReferenceImage(data, { name, mediaType } = {}) {
|
|
118
|
+
if (!(data instanceof Uint8Array) || data.byteLength === 0) invalid('A reference image must contain PNG or JPEG bytes.')
|
|
119
|
+
if (data.byteLength > REFERENCE_IMAGE_MAX_BYTES) {
|
|
120
|
+
throw new BlenderError(BlenderErrorCode.ASSET_TOO_LARGE, 'A reference image must be at most 8 MiB.', {
|
|
121
|
+
detail: { bytes: data.byteLength, maxBytes: REFERENCE_IMAGE_MAX_BYTES },
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
let extension
|
|
125
|
+
if (name !== undefined) {
|
|
126
|
+
if (typeof name !== 'string' || !name.trim()) invalid('A reference image name must end in .png, .jpg or .jpeg.')
|
|
127
|
+
extension = extname(name).slice(1).toLowerCase()
|
|
128
|
+
if (!['png', 'jpg', 'jpeg'].includes(extension)) invalid('A reference image name must end in .png, .jpg or .jpeg.')
|
|
129
|
+
}
|
|
130
|
+
let declaredMime
|
|
131
|
+
if (mediaType !== undefined) {
|
|
132
|
+
if (typeof mediaType !== 'string') invalid('A reference image media type must be image/png or image/jpeg.')
|
|
133
|
+
declaredMime = mediaType.trim().toLowerCase()
|
|
134
|
+
if (!['image/png', 'image/jpeg'].includes(declaredMime)) invalid('A reference image media type must be image/png or image/jpeg.')
|
|
135
|
+
}
|
|
136
|
+
// Own the bytes across asynchronous decode so a caller cannot change the hash
|
|
137
|
+
// or source content after its type was checked.
|
|
138
|
+
const source = Buffer.from(data)
|
|
139
|
+
const format = source.subarray(0, 8).equals(PNG_SIGNATURE) ? 'png'
|
|
140
|
+
: source[0] === 0xff && source[1] === 0xd8 && source[2] === 0xff ? 'jpeg' : undefined
|
|
141
|
+
if (!format) mismatch('The reference image bytes are not a PNG or JPEG image.')
|
|
142
|
+
const mime = format === 'png' ? 'image/png' : 'image/jpeg'
|
|
143
|
+
if ((extension && (extension === 'png' ? 'png' : 'jpeg') !== format) || (declaredMime && declaredMime !== mime)) {
|
|
144
|
+
mismatch('The reference image extension, media type and actual content must agree.')
|
|
145
|
+
}
|
|
146
|
+
if (format === 'png') checkPng(source)
|
|
147
|
+
else checkJpeg(source)
|
|
148
|
+
|
|
149
|
+
// Installation errors are not malformed user files. Let the original import
|
|
150
|
+
// failure surface with its actionable native-binary/package diagnostic.
|
|
151
|
+
const { default: sharp } = await import('sharp')
|
|
152
|
+
let metadata
|
|
153
|
+
try {
|
|
154
|
+
// metadata() does not decode pixels. Keep libvips' safety checks; our stricter
|
|
155
|
+
// dimensions are checked below before allocating the decoded image buffer.
|
|
156
|
+
metadata = await sharp(source, { failOn: 'warning', limitInputPixels: false }).metadata()
|
|
157
|
+
} catch (cause) {
|
|
158
|
+
mismatch('The reference image metadata is invalid.', cause)
|
|
159
|
+
}
|
|
160
|
+
const { width, height } = metadata
|
|
161
|
+
if (metadata.format !== format || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
|
|
162
|
+
mismatch('The reference image has invalid dimensions or content type.')
|
|
163
|
+
}
|
|
164
|
+
if ((metadata.pages ?? 1) !== 1 || metadata.pageHeight !== undefined || metadata.loop !== undefined || metadata.delay !== undefined) {
|
|
165
|
+
invalid('Reference images must contain one still image.')
|
|
166
|
+
}
|
|
167
|
+
if (width > REFERENCE_IMAGE_MAX_EDGE || height > REFERENCE_IMAGE_MAX_EDGE || width * height > REFERENCE_IMAGE_MAX_PIXELS) {
|
|
168
|
+
throw new BlenderError(BlenderErrorCode.ASSET_TOO_LARGE, 'A reference image must be at most 16 megapixels and 8192 pixels on each side.', {
|
|
169
|
+
detail: { width, height, maxPixels: REFERENCE_IMAGE_MAX_PIXELS, maxEdge: REFERENCE_IMAGE_MAX_EDGE },
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
// raw().toBuffer() forces every pixel to be decoded; metadata alone accepts
|
|
174
|
+
// some corrupt/truncated pixel streams. No encoded copy replaces the source.
|
|
175
|
+
const { info } = await sharp(source, { failOn: 'warning', limitInputPixels: REFERENCE_IMAGE_MAX_PIXELS })
|
|
176
|
+
.raw().toBuffer({ resolveWithObject: true })
|
|
177
|
+
if (info.width !== width || info.height !== height) mismatch('The decoded reference image dimensions do not match its header.')
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
if (cause instanceof BlenderError) throw cause
|
|
180
|
+
mismatch('The reference image pixels cannot be completely decoded.', cause)
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
type: extension ?? (format === 'png' ? 'png' : 'jpg'), mime, width, height,
|
|
184
|
+
bytes: source.length, sha256: createHash('sha256').update(source).digest('hex'),
|
|
185
|
+
}
|
|
186
|
+
}
|
package/lib/render-job-store.js
CHANGED
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
* `jobs/` holds M1's attempt log: written once at the start of an action and once
|
|
25
25
|
* at the end, because every M1/M2 action finished inside the tool call that
|
|
26
26
|
* started it. The durable render job is a different object with a different
|
|
27
|
-
* lifetime (SPEC §10.2)
|
|
28
|
-
*
|
|
27
|
+
* lifetime (SPEC §10.2). Attempt ids use UUIDs; delivery ids keep their existing
|
|
28
|
+
* ordinal format. Writing render jobs into `jobs/` would make
|
|
29
29
|
* "list this project's attempts" return two shapes.
|
|
30
30
|
*
|
|
31
31
|
* WHY THE RECORD IS WRITTEN SO OFTEN
|