@deepblend/dsh-blender-host 0.5.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.
package/lib/index.js CHANGED
@@ -26,6 +26,8 @@
26
26
  */
27
27
 
28
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'
29
31
  import { Service } from '@deepseek-ai/cordis'
30
32
  import z from '@deepseek-ai/schemastery'
31
33
 
@@ -629,6 +631,7 @@ export default class BlenderStudio extends Service {
629
631
 
630
632
  get creationRequestProtocol() { return CREATION_REQUEST_VERSION }
631
633
  get cameraTargetResetSupported() { return CAMERA_UPDATE_FIELDS.includes('clearTarget') }
634
+ get recipeParameterEditingSupported() { return true }
632
635
 
633
636
  async _createProject(request, creationRequest) {
634
637
  const source = request.recipe ? this.recipes.instantiate(request.recipe) : null
@@ -776,6 +779,49 @@ export default class BlenderStudio extends Service {
776
779
  }
777
780
  }
778
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
+
779
825
  /**
780
826
  * Validate a revision (SPEC §7.2 `validateScene`).
781
827
  *
@@ -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
+ }
@@ -60,6 +60,8 @@ import {
60
60
  sceneSpecDigest,
61
61
  shortDigest,
62
62
  specHash,
63
+ planRecipeGeometryEdit,
64
+ sha256Canonical,
63
65
  summarizeSceneSpec,
64
66
  toCanonicalFailure,
65
67
  validateScenePatch,
@@ -68,6 +70,7 @@ import {
68
70
  } from '@deepblend/dsh-blender-contracts'
69
71
  import { verifyAssetBundle, verifyUnbundledGltfAsset, verifyUnbundledObjAsset } from './asset-bundle.js'
70
72
  import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
73
+ import { readRecipeContext } from './recipe-state.js'
71
74
  import {
72
75
  fileSha256,
73
76
  fileSize,
@@ -296,6 +299,37 @@ export class RevisionTransaction {
296
299
  return this.store.withProjectWrite(patch.projectId, () => this._applyScenePatch(patch, options))
297
300
  }
298
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
+
299
333
  async _applyScenePatch(patch, options) {
300
334
  const projectId = patch.projectId
301
335
  const record = this.store.readRecord(projectId)
@@ -443,6 +477,7 @@ export class RevisionTransaction {
443
477
  operations: patch.operations,
444
478
  spec: nextSpec,
445
479
  kind: 'scene_patch',
480
+ recipeParameters: options.recipeParameters ?? null,
446
481
  summary,
447
482
  actor: patch.actor ?? null,
448
483
  stage: patch.stage ?? null,
@@ -753,6 +788,8 @@ export class RevisionTransaction {
753
788
  stage: plan.stage ?? null,
754
789
  note: plan.note ?? null,
755
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) } } : {}),
756
793
  sceneSpecVersion: plan.spec.schemaVersion,
757
794
  counts: sceneSummary.counts,
758
795
  subjectBounds: sceneSummary.subjectBounds,
@@ -829,6 +866,7 @@ export class RevisionTransaction {
829
866
  }
830
867
 
831
868
  if (plan.recipeLock) writeJsonAtomic(join(staging, 'recipe-lock.json'), plan.recipeLock)
869
+ if (plan.recipeParameters) writeJsonAtomic(join(staging, 'recipe-parameters.json'), plan.recipeParameters)
832
870
 
833
871
  writeJsonAtomic(join(staging, 'validation.json'), {
834
872
  schemaVersion: 'deepblend.validation/v1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-host",
3
- "version": "0.5.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.5.0",
23
+ "@deepblend/dsh-blender-contracts": "0.6.0",
24
24
  "sharp": "0.35.5"
25
25
  },
26
26
  "license": "MIT",