@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.
- package/lib/asset-preview.js +3 -0
- package/lib/diagnostic-preview.js +3 -0
- package/lib/index.js +49 -0
- package/lib/recipe-state.js +62 -0
- package/lib/revision-transaction.js +41 -0
- package/lib/runtime-scene-features.js +21 -0
- package/package.json +2 -2
- package/recipes/modular-speaker/recipe.json +2428 -5
- package/recipes/modular-speaker/scene-spec.json +48 -16
package/lib/asset-preview.js
CHANGED
|
@@ -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.
|
|
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.
|
|
23
|
+
"@deepblend/dsh-blender-contracts": "0.6.0",
|
|
24
24
|
"sharp": "0.35.5"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|