@deepblend/dsh-blender-host 0.5.0 → 0.6.1
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 +46 -0
- package/lib/recipe-state.js +62 -0
- package/lib/revision-transaction.js +38 -0
- package/package.json +2 -2
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
23
|
+
"@deepblend/dsh-blender-contracts": "0.6.1",
|
|
24
24
|
"sharp": "0.35.5"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|