@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.
Files changed (36) hide show
  1. package/lib/artifact-writer.js +49 -0
  2. package/lib/asset-bundle.js +381 -0
  3. package/lib/asset-io.js +24 -0
  4. package/lib/asset-library.js +113 -0
  5. package/lib/asset-preview.js +139 -0
  6. package/lib/asset-uploads.js +328 -0
  7. package/lib/delivery-manifest.js +6 -1
  8. package/lib/diagnostic-preview.js +264 -0
  9. package/lib/frame-provenance.js +230 -0
  10. package/lib/index.js +1283 -544
  11. package/lib/obj-resources.js +89 -0
  12. package/lib/paths.js +12 -1
  13. package/lib/project-store.js +40 -24
  14. package/lib/project-writer.js +96 -0
  15. package/lib/recipe-catalog.js +128 -0
  16. package/lib/reference-image.js +186 -0
  17. package/lib/render-job-store.js +2 -2
  18. package/lib/revision-transaction.js +106 -43
  19. package/lib/video-encoder.js +71 -13
  20. package/package.json +8 -3
  21. package/recipes/glass-ceramic/LICENSE +21 -0
  22. package/recipes/glass-ceramic/preview.png +0 -0
  23. package/recipes/glass-ceramic/recipe.json +94 -0
  24. package/recipes/glass-ceramic/scene-spec.json +1407 -0
  25. package/recipes/glazed-cup/LICENSE +21 -0
  26. package/recipes/glazed-cup/preview.png +0 -0
  27. package/recipes/glazed-cup/recipe.json +93 -0
  28. package/recipes/glazed-cup/scene-spec.json +248 -0
  29. package/recipes/metal-lamp/LICENSE +21 -0
  30. package/recipes/metal-lamp/preview.png +0 -0
  31. package/recipes/metal-lamp/recipe.json +118 -0
  32. package/recipes/metal-lamp/scene-spec.json +2530 -0
  33. package/recipes/modular-speaker/LICENSE +21 -0
  34. package/recipes/modular-speaker/preview.png +0 -0
  35. package/recipes/modular-speaker/recipe.json +95 -0
  36. 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
- return createHash('sha256').update(readFileSync(path)).digest('hex')
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
 
@@ -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
- * - **`staging/` is the only mutable revision state.** Everything under
40
- * `revisions/` was published by an atomic rename and is never written again.
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
- const path = join(this.revisionDirectory(projectId, revision), 'revision-manifest.json')
340
- const manifest = readJson(path)
341
- if (manifest === null) {
342
- // A revision directory with no manifest did not finish publishing, so there
343
- // is nothing to amend and nothing safe to write into it.
344
- return [artifact]
345
- }
346
- const existing = Array.isArray(manifest[indexKey]) ? manifest[indexKey] : []
347
- // Re-emitting the same path replaces its entry rather than duplicating it:
348
- // re-rendering a view after a fix must not make the sheet look like two.
349
- const next = [...existing.filter(entry => entry.path !== artifact.path), artifact]
350
- writeJsonAtomic(path, { ...manifest, [indexKey]: next })
351
- return next
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 a job id. Monotonic within the project and readable in a log,
420
- * because a job id ends up in a tool result the model reasons about.
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}-${String(existing + 1).padStart(3, '0')}`
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
+ }
@@ -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), and `allocateJobId` counts `*.json` in `jobs/` to mint
28
- * its ids — writing render jobs there would both inflate that counter and make
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