@deepblend/dsh-blender-host 0.4.0 → 0.6.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.
@@ -1,4 +1,5 @@
1
1
  /** Fixed asset preview scenes, compiled in batch mode outside project revisions. */
2
+ import { assertRuntimeSceneFeatures } from './runtime-scene-features.js'
2
3
  import { copyFileSync, existsSync, writeFileSync } from 'node:fs'
3
4
  import { join } from 'node:path'
4
5
  import { randomUUID } from 'node:crypto'
@@ -100,6 +101,8 @@ export async function renderAssetPreview({ runtime, asset, directory, maxMeshPol
100
101
  'The isolated preview scene is invalid.', { detail: { errors: checked.errors } })
101
102
  const compiled = compileSceneSpec(scene).spec
102
103
  writeFileSync(sceneSpecPath, JSON.stringify(compiled))
104
+ const featureCheck = assertRuntimeSceneFeatures(runtime, compiled, { signal })
105
+ if (featureCheck) await featureCheck
103
106
  const result = await runtime.compileScene({ sceneSpecPath, profile: 'preview', projectRoot: directory,
104
107
  jobId: `asset-compile-${randomUUID()}`, session: false, signal,
105
108
  onWorkingDirectory: info => { copyFileSync(join(info.directory, 'result.blend'), checkpointPath) } })
@@ -1,4 +1,5 @@
1
1
  /** Isolated inspection renders. Successful tasks are published with one directory rename. */
2
+ import { assertRuntimeSceneFeatures } from './runtime-scene-features.js'
2
3
  import { createReadStream, copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
3
4
  import { dirname, join } from 'node:path'
4
5
  import { randomUUID } from 'node:crypto'
@@ -186,6 +187,8 @@ export async function renderDiagnostic(host, request) {
186
187
  }
187
188
  const sceneSpecPath = join(compileRoot, 'scene-spec.json'), checkpointPath = join(compileRoot, 'derived.blend')
188
189
  writeJsonAtomic(sceneSpecPath, scene)
190
+ const featureCheck = assertRuntimeSceneFeatures(runtime, scene, { signal })
191
+ if (featureCheck) await featureCheck
189
192
  const compiled = await runtime.compileScene({ sceneSpecPath, projectRoot: compileRoot, profile: 'preview',
190
193
  jobId: `${jobId}-compile`, session: false, signal,
191
194
  onWorkingDirectory: info => copyFileSync(join(info.directory, 'result.blend'), checkpointPath) })
package/lib/index.js CHANGED
@@ -25,6 +25,9 @@
25
25
  * Plane: Host composition
26
26
  */
27
27
 
28
+ import { assertRuntimeSceneFeatures } from './runtime-scene-features.js'
29
+ import { readRecipeContext } from './recipe-state.js'
30
+ import { instantiateRecipeLock, planRecipeGeometryEdit, recipeGeometryParameters } from '@deepblend/dsh-blender-contracts'
28
31
  import { Service } from '@deepseek-ai/cordis'
29
32
  import z from '@deepseek-ai/schemastery'
30
33
 
@@ -628,6 +631,7 @@ export default class BlenderStudio extends Service {
628
631
 
629
632
  get creationRequestProtocol() { return CREATION_REQUEST_VERSION }
630
633
  get cameraTargetResetSupported() { return CAMERA_UPDATE_FIELDS.includes('clearTarget') }
634
+ get recipeParameterEditingSupported() { return true }
631
635
 
632
636
  async _createProject(request, creationRequest) {
633
637
  const source = request.recipe ? this.recipes.instantiate(request.recipe) : null
@@ -775,6 +779,49 @@ export default class BlenderStudio extends Service {
775
779
  }
776
780
  }
777
781
 
782
+ async getRecipeParameters(projectId, options = {}) {
783
+ const record = this.store.readRecord(projectId)
784
+ const revision = options.revision ?? record.currentRevision
785
+ const context = readRecipeContext(this.store, projectId, revision)
786
+ if (context === null) return { projectId, revision, available: false, parameters: [], reason: 'This revision has no saved recipe' }
787
+ const definitions = recipeGeometryParameters(context.lock.manifest)
788
+ if (!definitions.length) return { projectId, revision, available: false, parameters: [], reason: 'This saved recipe has no assembly dimensions' }
789
+ const locked = context.state ? { ...context.lock, values: context.state.values,
790
+ valuesSha256: context.state.valuesSha256 } : context.lock
791
+ const resolved = instantiateRecipeLock(locked)
792
+ let conflicts = []
793
+ try { planRecipeGeometryEdit(context.lock, this.store.readRevisionSpec(projectId, revision), context.state ?? undefined, {}) }
794
+ catch (error) {
795
+ if (error.code !== 'RECIPE_PARAMETER_CONFLICT') throw error
796
+ conflicts = error.details.conflicts
797
+ }
798
+ return { projectId, revision, available: true, originRevision: context.originRevision,
799
+ recipe: { id: context.lock.id, version: context.lock.version }, valuesSha256: resolved.recipe.valuesSha256,
800
+ parameters: definitions.map(parameter => ({ id: parameter.id, title: parameter.title, type: parameter.type,
801
+ minimum: parameter.minimum, maximum: parameter.maximum, default: parameter.default, value: resolved.values[parameter.id] })),
802
+ conflicts }
803
+ }
804
+
805
+ async updateRecipeParameters(request) {
806
+ if (!request || typeof request !== 'object' || Array.isArray(request)
807
+ || typeof request.projectId !== 'string' || !/^r[0-9]{4,}$/.test(request.baseRevision ?? '')
808
+ || ['saveCheckpoint', 'renderPreview'].some(key => request[key] !== undefined && typeof request[key] !== 'boolean')
809
+ || request.idempotencyKey !== undefined && typeof request.idempotencyKey !== 'string'
810
+ || Object.keys(request).some(key => !['projectId', 'baseRevision', 'parameters', 'saveCheckpoint', 'renderPreview', 'idempotencyKey', 'signal', 'projectsRoot'].includes(key))) {
811
+ throw new BlenderError('RECIPE_PARAMETER_INVALID', 'Expected a bounded recipe parameter request')
812
+ }
813
+ const { signal, ...input } = request
814
+ if (input.projectsRoot !== undefined && input.projectsRoot !== this.store.projectsRoot) {
815
+ throw new BlenderError('REVISION_CONFLICT', 'The active project store changed; read the selected work again')
816
+ }
817
+ const outcome = await this.transactions.applyRecipeParameters(input, { signal })
818
+ return { ...toCanonicalRevisionSummary({ manifest: outcome.revision, checkpointPath: outcome.revision.checkpoint }),
819
+ unchanged: outcome.unchanged === true, idempotentReplay: outcome.idempotentReplay === true,
820
+ parameterState: outcome.parameterState, preservedCameraIds: outcome.preservedCameraIds,
821
+ parameters: await this.getRecipeParameters(input.projectId, { revision: outcome.revision.revision }),
822
+ ...(outcome.job ? { job: toCanonicalJobRecord(outcome.job) } : {}) }
823
+ }
824
+
778
825
  /**
779
826
  * Validate a revision (SPEC §7.2 `validateScene`).
780
827
  *
@@ -1187,6 +1234,8 @@ export default class BlenderStudio extends Service {
1187
1234
  */
1188
1235
  async compileRevisionForRender(input) {
1189
1236
  const { projectId, revision, spec } = input
1237
+ const featureCheck = assertRuntimeSceneFeatures(this.runtime, spec, { signal: input.signal })
1238
+ if (featureCheck) await featureCheck
1190
1239
  const scratch = resolveInside(
1191
1240
  this.store.workspaceRoot,
1192
1241
  join(this.store.workspaceRoot, 'tmp', `render-${revision}-${Date.now().toString(36)}`),
@@ -0,0 +1,62 @@
1
+ /** Resolve saved recipe provenance using the selected revision's own ancestry. */
2
+ import { constants, openSync, closeSync, fstatSync, readSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { BlenderError, instantiateRecipeLock, sha256Canonical } from '@deepblend/dsh-blender-contracts'
5
+
6
+ const fail = message => { throw new BlenderError('RECIPE_LOCK_INVALID', message) }
7
+ function readBounded(path, limit) {
8
+ let fd
9
+ try {
10
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
11
+ const stat = fstatSync(fd)
12
+ if (!stat.isFile() || stat.size > limit) fail('Stored recipe metadata exceeds its limit or is not a file')
13
+ const bytes = Buffer.alloc(limit + 1)
14
+ let length = 0, read
15
+ do { read = readSync(fd, bytes, length, bytes.length - length, null); length += read } while (read && length < bytes.length)
16
+ if (length > limit) fail('Stored recipe metadata grew beyond its limit')
17
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length)))
18
+ } catch (error) {
19
+ if (error instanceof BlenderError) throw error
20
+ fail('Stored recipe metadata cannot be decoded')
21
+ } finally { if (fd !== undefined) closeSync(fd) }
22
+ }
23
+
24
+ export function readRecipeContext(store, projectId, revision) {
25
+ const visited = new Set()
26
+ let cursor = revision, state = null, stateRevision = null
27
+ while (cursor !== 'r0000') {
28
+ if (visited.has(cursor) || visited.size >= 4096) fail('Recipe revision ancestry is cyclic or exceeds its limit')
29
+ visited.add(cursor)
30
+ const manifest = store.readRevisionManifest(projectId, cursor)
31
+ if (!manifest || manifest.revision !== cursor) fail('Recipe revision ancestry contains an invalid manifest')
32
+ if (state === null && manifest.recipeParameters) {
33
+ if (manifest.recipeParameters.statePath !== 'recipe-parameters.json') fail('Unexpected recipe parameter state path')
34
+ state = readBounded(join(store.revisionDirectory(projectId, cursor), 'recipe-parameters.json'), 65536)
35
+ if (sha256Canonical(state) !== manifest.recipeParameters.stateSha256) fail('Recipe parameter state differs from its saved hash')
36
+ stateRevision = cursor
37
+ }
38
+ if (manifest.recipe) {
39
+ if (manifest.recipe.lockPath !== 'recipe-lock.json') fail('Unexpected recipe origin path')
40
+ const lock = readBounded(join(store.revisionDirectory(projectId, cursor), 'recipe-lock.json'), 8 * 1024 * 1024)
41
+ if (lock.id !== manifest.recipe.id || lock.version !== manifest.recipe.version
42
+ || lock.packageDigest !== manifest.recipe.packageDigest) fail('Recipe origin differs from its revision reference')
43
+ try { instantiateRecipeLock(lock) }
44
+ catch (error) { throw new BlenderError(error.code ?? 'RECIPE_LOCK_INVALID', error.message, { detail: error.details }) }
45
+ if (state !== null && (state.schemaVersion !== 'deepblend.recipe-parameters/v1'
46
+ || state.originRevision !== cursor || state.manifestSha256 !== lock.manifestSha256)) {
47
+ fail('Saved parameters belong to a different recipe origin')
48
+ }
49
+ if (state !== null) {
50
+ try { instantiateRecipeLock({ ...lock, values: state.values, valuesSha256: state.valuesSha256 }) }
51
+ catch (error) { throw new BlenderError(error.code ?? 'RECIPE_LOCK_INVALID', error.message, { detail: error.details }) }
52
+ }
53
+ return { originRevision: cursor, lock, state, stateRevision }
54
+ }
55
+ const base = manifest.baseRevision
56
+ if (typeof base !== 'string' || !/^r[0-9]{4,}$/.test(base)
57
+ || Number(base.slice(1)) >= Number(cursor.slice(1))) fail('Recipe ancestry must refer to an earlier revision')
58
+ cursor = base
59
+ }
60
+ if (state !== null) fail('Saved parameters have no recipe origin')
61
+ return null
62
+ }
@@ -41,6 +41,7 @@
41
41
  * Plane: Host composition
42
42
  */
43
43
 
44
+ import { assertRuntimeSceneFeatures } from './runtime-scene-features.js'
44
45
  import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs'
45
46
  import { randomUUID } from 'node:crypto'
46
47
  import { join } from 'node:path'
@@ -59,6 +60,8 @@ import {
59
60
  sceneSpecDigest,
60
61
  shortDigest,
61
62
  specHash,
63
+ planRecipeGeometryEdit,
64
+ sha256Canonical,
62
65
  summarizeSceneSpec,
63
66
  toCanonicalFailure,
64
67
  validateScenePatch,
@@ -67,6 +70,7 @@ import {
67
70
  } from '@deepblend/dsh-blender-contracts'
68
71
  import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
69
72
  import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
73
+ import { readRecipeContext } from './recipe-state.js'
70
74
  import {
71
75
  fileSha256,
72
76
  fileSize,
@@ -295,6 +299,37 @@ export class RevisionTransaction {
295
299
  return this.store.withProjectWrite(patch.projectId, () => this._applyScenePatch(patch, options))
296
300
  }
297
301
 
302
+ async applyRecipeParameters(request, options = {}) {
303
+ return this.store.withProjectWrite(request.projectId, async () => {
304
+ const context = readRecipeContext(this.store, request.projectId, request.baseRevision)
305
+ if (context === null) throw new BlenderError('RECIPE_NOT_FOUND', 'This revision has no saved recipe origin')
306
+ const current = this.store.readRevisionSpec(request.projectId, request.baseRevision)
307
+ let planned
308
+ try { planned = planRecipeGeometryEdit(context.lock, current, context.state ?? undefined, request.parameters) }
309
+ catch (error) { throw new BlenderError(error.code ?? 'RECIPE_PARAMETER_INVALID', error.message, { detail: error.details }) }
310
+ if (planned.operations.length === 0) {
311
+ const record = this.store.readRecord(request.projectId)
312
+ if (record.currentRevision !== request.baseRevision) throw new BlenderError(BlenderErrorCode.REVISION_CONFLICT, 'The current revision changed')
313
+ return { unchanged: true, projectId: request.projectId,
314
+ revision: this.store.readRevisionManifest(request.projectId, request.baseRevision),
315
+ parameterState: context.state, preservedCameraIds: planned.preservedCameraIds }
316
+ }
317
+ const patch = { projectId: request.projectId, baseRevision: request.baseRevision, operations: planned.operations,
318
+ saveCheckpoint: request.saveCheckpoint !== false, renderPreview: request.renderPreview === true,
319
+ idempotencyKey: request.idempotencyKey ?? `recipe-${sha256Canonical({ projectId: request.projectId,
320
+ baseRevision: request.baseRevision, manifestSha256: context.lock.manifestSha256,
321
+ valuesSha256: planned.parameterState.valuesSha256 })}`,
322
+ note: 'Update assembled recipe dimensions' }
323
+ const checked = validateScenePatch(patch)
324
+ if (!checked.ok) throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, checked.summary)
325
+ const state = { ...planned.parameterState, originRevision: context.originRevision }
326
+ const outcome = await this._applyScenePatch(patch, { ...options, recipeParameters: state })
327
+ const committed = readRecipeContext(this.store, request.projectId, outcome.revision.revision)
328
+ if (!committed?.state) throw new BlenderError('RECIPE_LOCK_INVALID', 'The recorded outcome has no recipe parameter state')
329
+ return { ...outcome, parameterState: committed.state, preservedCameraIds: planned.preservedCameraIds }
330
+ })
331
+ }
332
+
298
333
  async _applyScenePatch(patch, options) {
299
334
  const projectId = patch.projectId
300
335
  const record = this.store.readRecord(projectId)
@@ -442,6 +477,7 @@ export class RevisionTransaction {
442
477
  operations: patch.operations,
443
478
  spec: nextSpec,
444
479
  kind: 'scene_patch',
480
+ recipeParameters: options.recipeParameters ?? null,
445
481
  summary,
446
482
  actor: patch.actor ?? null,
447
483
  stage: patch.stage ?? null,
@@ -570,6 +606,8 @@ export class RevisionTransaction {
570
606
  const checkpointStaging = join(staging, 'scene.blend')
571
607
 
572
608
  try {
609
+ const featureCheck = assertRuntimeSceneFeatures(this.runtime, plan.spec, { signal: plan.signal })
610
+ if (featureCheck) await featureCheck
573
611
  const run = await this.runtime.compileScene({
574
612
  sceneSpecPath: specPath,
575
613
  profile: plan.spec.renderProfiles?.preview === undefined ? undefined : 'preview',
@@ -750,6 +788,8 @@ export class RevisionTransaction {
750
788
  stage: plan.stage ?? null,
751
789
  note: plan.note ?? null,
752
790
  ...(plan.recipeLock ? { recipe: { id: plan.recipeLock.id, version: plan.recipeLock.version, packageDigest: plan.recipeLock.packageDigest, lockPath: 'recipe-lock.json' } } : {}),
791
+ ...(plan.recipeParameters ? { recipeParameters: { statePath: 'recipe-parameters.json',
792
+ stateSha256: sha256Canonical(plan.recipeParameters) } } : {}),
753
793
  sceneSpecVersion: plan.spec.schemaVersion,
754
794
  counts: sceneSummary.counts,
755
795
  subjectBounds: sceneSummary.subjectBounds,
@@ -826,6 +866,7 @@ export class RevisionTransaction {
826
866
  }
827
867
 
828
868
  if (plan.recipeLock) writeJsonAtomic(join(staging, 'recipe-lock.json'), plan.recipeLock)
869
+ if (plan.recipeParameters) writeJsonAtomic(join(staging, 'recipe-parameters.json'), plan.recipeParameters)
829
870
 
830
871
  writeJsonAtomic(join(staging, 'validation.json'), {
831
872
  schemaVersion: 'deepblend.validation/v1',
@@ -0,0 +1,21 @@
1
+ import { BlenderError, BlenderErrorCode, requiredSceneFeatures } from '@deepblend/dsh-blender-contracts'
2
+
3
+ /** Old runtimes must not silently compile an axis-sized box as their scalar default. */
4
+ export function assertRuntimeSceneFeatures(runtime, spec, { signal } = {}) {
5
+ const required = requiredSceneFeatures(spec)
6
+ if (required.length === 0) return
7
+ return (async () => {
8
+ signal?.throwIfAborted()
9
+ const capabilities = typeof runtime?.getCapabilities === 'function'
10
+ ? await runtime.getCapabilities({ signal }) : null
11
+ signal?.throwIfAborted()
12
+ if (capabilities?.installed === false) {
13
+ throw new BlenderError(BlenderErrorCode.NOT_FOUND, 'Blender is unavailable for this scene.')
14
+ }
15
+ const supported = Array.isArray(capabilities?.sceneFeatures) ? capabilities.sceneFeatures : []
16
+ const missing = required.filter(feature => !supported.includes(feature))
17
+ if (missing.length) throw new BlenderError(BlenderErrorCode.SCENE_FEATURE_UNSUPPORTED,
18
+ `The runtime does not support ${missing.join(', ')}. Update the runtime before compiling this scene.`,
19
+ { detail: { required, supported, missing } })
20
+ })()
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-host",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
6
6
  "main": "lib/index.js",
@@ -20,7 +20,7 @@
20
20
  "./package.json": "./package.json"
21
21
  },
22
22
  "dependencies": {
23
- "@deepblend/dsh-blender-contracts": "0.4.0",
23
+ "@deepblend/dsh-blender-contracts": "0.6.0",
24
24
  "sharp": "0.35.5"
25
25
  },
26
26
  "license": "MIT",