@deepblend/dsh-blender-ui 0.3.2 → 0.3.4

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 (2) hide show
  1. package/lib/client.js +189 -4
  2. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -458,6 +458,16 @@ window.__ModuleLoader__.load({
458
458
  'preview.keptPrevious': ';上一张已留作「上一次渲染」,可以直接并排比较',
459
459
  'preview.firstSheet': '(这是第一张;再渲染一次就能并排比较前后)',
460
460
  'preview.isArtifact': '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
461
+ 'download.png': '保存预览 PNG',
462
+ 'download.loading': '正在准备 PNG…',
463
+ 'download.help': '保存这张图的原始 PNG,保留显示的尺寸与采样。更高质量的交付请使用渲染任务。',
464
+ 'download.ready': '已交给浏览器保存:{filename}',
465
+ 'download.source': '这张图缺少完整来源信息,请重新生成预览后再保存。',
466
+ 'download.changed': '图片内容与当前显示的来源不一致,请刷新预览后重试。',
467
+ 'download.failed': '无法读取预览图片,请刷新后重试。',
468
+ 'download.unsupported': '浏览器无法保存图片,请使用支持文件下载的浏览器。',
469
+ 'download.timeout': '准备图片超时,未发起保存。请重试。',
470
+ 'download.cancelled': '已取消,未发起保存。',
461
471
  'jobs.cancelRequested': '取消已请求,但进程仍在',
462
472
  'projects.empty': '这个工作区还没有项目。',
463
473
  'projects.create': '新建项目',
@@ -985,6 +995,16 @@ window.__ModuleLoader__.load({
985
995
  'preview.keptPrevious': '; the previous one is kept as “last render”, so they can be compared side by side',
986
996
  'preview.firstSheet': '(this is the first; render once more to compare before and after)',
987
997
  'preview.isArtifact': '. A preview is an artifact: it replaces the old image at the same path and creates no revision.',
998
+ 'download.png': 'Save preview PNG',
999
+ 'download.loading': 'Preparing PNG…',
1000
+ 'download.help': 'Save this image’s original PNG at its displayed resolution and samples. Use render jobs for higher-quality delivery.',
1001
+ 'download.ready': 'Sent to your browser to save: {filename}',
1002
+ 'download.source': 'This image has incomplete source information. Generate a new preview before saving.',
1003
+ 'download.changed': 'The image bytes do not match the displayed source. Refresh the preview and retry.',
1004
+ 'download.failed': 'The preview image could not be read. Refresh and retry.',
1005
+ 'download.unsupported': 'This browser cannot save the image. Use a browser with file-download support.',
1006
+ 'download.timeout': 'Preparing the image timed out. No save was started. Retry.',
1007
+ 'download.cancelled': 'Cancelled. No save was started.',
988
1008
  'jobs.cancelRequested': 'cancellation requested, but the process is still there',
989
1009
  'projects.empty': 'This workspace has no projects yet.',
990
1010
  'projects.create': 'New project',
@@ -1309,6 +1329,130 @@ window.__ModuleLoader__.load({
1309
1329
  return `${base}${path}?v=${version}`
1310
1330
  }
1311
1331
 
1332
+ const imageDownloadKey = (projectId, artifact) => JSON.stringify([projectId, artifact?.path, artifact?.sha256])
1333
+ function imageDownloadValid(artifact) {
1334
+ return Boolean(artifact && typeof artifact.path === 'string' && /\.png$/i.test(artifact.path)
1335
+ && /^[a-f0-9]{64}$/i.test(artifact.sha256 || '') && Number.isSafeInteger(artifact.bytes) && artifact.bytes > 0)
1336
+ }
1337
+ function imageDownloadMessage(work) {
1338
+ switch (work.messageKey) {
1339
+ case 'download.ready': return t('download.ready', { filename: work.filename || '' })
1340
+ case 'download.changed': return t('download.changed')
1341
+ case 'download.timeout': return t('download.timeout')
1342
+ case 'download.cancelled': return t('download.cancelled')
1343
+ case 'download.unsupported': return t('download.unsupported')
1344
+ default: return t('download.failed')
1345
+ }
1346
+ }
1347
+ function imageDownloadName(projectId, artifact) {
1348
+ const label = value => String(value).replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').replace(/^[. ]+|[. ]+$/g, '').slice(0, 60) || 'image'
1349
+ return `deepblend-${label(projectId)}-${label(artifact.sourceRevision || 'preview')}-${label(artifact.mode || artifact.cameraId || artifact.kind || 'image')}${Number.isSafeInteger(artifact.frame) ? `-f${artifact.frame}` : ''}-${artifact.sha256.slice(0, 12).toLowerCase()}.png`
1350
+ }
1351
+ function savePngFile(bytes, filename) {
1352
+ const doc = typeof document === 'object' ? document : null
1353
+ if (!doc || typeof Blob !== 'function' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') throw Object.assign(new Error(t('download.unsupported')), { messageKey: 'download.unsupported' })
1354
+ const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }))
1355
+ const link = doc.createElement('a')
1356
+ link.href = url; link.download = filename; link.hidden = true
1357
+ try { doc.body.appendChild(link); link.click() } finally {
1358
+ link.remove()
1359
+ // The browser needs the Blob until its download has started.
1360
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
1361
+ }
1362
+ }
1363
+ // SHA-256 per FIPS 180-4 §6.2.2. LAN HTTP lacks SubtleCrypto; keep byte
1364
+ // verification available there without adding a framework or network call.
1365
+ // https://csrc.nist.gov/pubs/fips/180-4/upd1/final
1366
+ const SHA256_ROUNDS = [
1367
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
1368
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
1369
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
1370
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
1371
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
1372
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
1373
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
1374
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
1375
+ ]
1376
+ async function imageByteHash(bytes, signal, forceFallback = false) {
1377
+ const active = () => { if (signal?.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) }
1378
+ active()
1379
+ if (!forceFallback && globalThis.crypto?.subtle) {
1380
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); active()
1381
+ return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('')
1382
+ }
1383
+ const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
1384
+ padded.set(bytes); padded[bytes.length] = 128
1385
+ const input = new DataView(padded.buffer)
1386
+ input.setUint32(padded.length - 8, Math.floor(bytes.length * 8 / 4294967296))
1387
+ input.setUint32(padded.length - 4, (bytes.length * 8) >>> 0)
1388
+ const state = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]
1389
+ const words = new Uint32Array(64), rotate = (value, amount) => (value >>> amount) | (value << (32 - amount))
1390
+ for (let offset = 0; offset < padded.length; offset += 64) {
1391
+ active()
1392
+ for (let i = 0; i < 16; i++) words[i] = input.getUint32(offset + i * 4)
1393
+ for (let i = 16; i < 64; i++) {
1394
+ const a = words[i - 15], b = words[i - 2]
1395
+ words[i] = (words[i - 16] + (rotate(a,7)^rotate(a,18)^(a>>>3)) + words[i - 7] + (rotate(b,17)^rotate(b,19)^(b>>>10))) >>> 0
1396
+ }
1397
+ let [a,b,c,d,e,f,g,h] = state
1398
+ for (let i = 0; i < 64; i++) {
1399
+ const first = (h + (rotate(e,6)^rotate(e,11)^rotate(e,25)) + ((e&f)^((~e)&g)) + SHA256_ROUNDS[i] + words[i]) >>> 0
1400
+ const second = ((rotate(a,2)^rotate(a,13)^rotate(a,22)) + ((a&b)^(a&c)^(b&c))) >>> 0
1401
+ h=g;g=f;f=e;e=(d+first)>>>0;d=c;c=b;b=a;a=(first+second)>>>0
1402
+ }
1403
+ const round = [a,b,c,d,e,f,g,h]
1404
+ for (let i = 0; i < 8; i++) state[i] = (state[i] + round[i]) >>> 0
1405
+ if (offset && offset % 65536 === 0) await new Promise(resolve => setTimeout(resolve, 0))
1406
+ }
1407
+ active()
1408
+ return state.map(value => value.toString(16).padStart(8, '0')).join('')
1409
+ }
1410
+ async function verifiedPngBytes(response, artifact, signal) {
1411
+ const fail = key => { throw Object.assign(new Error(t(key)), { messageKey: key }) }
1412
+ const active = () => { if (signal.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) }
1413
+ active()
1414
+ if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'image/png') {
1415
+ await response.body?.cancel?.().catch(() => {})
1416
+ fail('download.failed')
1417
+ }
1418
+ const length = response.headers.get('content-length')
1419
+ if (length !== null && Number(length) !== artifact.bytes) { await response.body?.cancel?.(); fail('download.changed') }
1420
+ let bytes
1421
+ if (response.body?.getReader) {
1422
+ const reader = response.body.getReader(), chunks = []
1423
+ let received = 0
1424
+ const abort = () => { void reader.cancel().catch(() => {}) }
1425
+ signal.addEventListener('abort', abort, { once: true })
1426
+ try {
1427
+ for (;;) {
1428
+ const item = await reader.read(); active()
1429
+ if (item.done) break
1430
+ received += item.value.byteLength
1431
+ if (received > artifact.bytes) { await reader.cancel(); fail('download.changed') }
1432
+ chunks.push(item.value)
1433
+ }
1434
+ } catch (error) { await reader.cancel().catch(() => {}); throw error }
1435
+ finally { signal.removeEventListener('abort', abort); reader.releaseLock() }
1436
+ if (received !== artifact.bytes) fail('download.changed')
1437
+ bytes = new Uint8Array(received)
1438
+ let offset = 0
1439
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
1440
+ } else {
1441
+ bytes = new Uint8Array(await response.arrayBuffer()); active()
1442
+ if (bytes.byteLength !== artifact.bytes) fail('download.changed')
1443
+ }
1444
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10]
1445
+ if (bytes.length < 24 || !signature.every((byte, index) => bytes[index] === byte)) fail('download.changed')
1446
+ if (Number.isSafeInteger(artifact.width) && Number.isSafeInteger(artifact.height)) {
1447
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
1448
+ if (view.getUint32(16) !== artifact.width || view.getUint32(20) !== artifact.height) fail('download.changed')
1449
+ }
1450
+ const hash = await imageByteHash(bytes, signal)
1451
+ active()
1452
+ if (hash !== artifact.sha256.toLowerCase()) fail('download.changed')
1453
+ return bytes
1454
+ }
1455
+
1312
1456
  /** Short "when was this produced" for an artifact, or null. */
1313
1457
  function artifactTime(artifact) {
1314
1458
  const at = artifact && artifact.at ? artifact.at : null
@@ -2291,6 +2435,7 @@ window.__ModuleLoader__.load({
2291
2435
  diff: null,
2292
2436
  diffError: null,
2293
2437
  previewBusy: false,
2438
+ imageDownloads: {},
2294
2439
  inspectionForms: {},
2295
2440
  inspectionWork: {},
2296
2441
  inspectionRevisions: {},
@@ -2339,6 +2484,9 @@ window.__ModuleLoader__.load({
2339
2484
  const fetchImpl = settings.fetch ?? ((...args) => fetch(...args))
2340
2485
  const pollLiveMs = settings.pollLiveMs ?? POLL_LIVE_MS
2341
2486
  const pollIdleMs = settings.pollIdleMs ?? POLL_IDLE_MS
2487
+ const saveImage = settings.saveImage ?? savePngFile
2488
+ const imageDownloadTimeoutMs = settings.imageDownloadTimeoutMs ?? 30000
2489
+ if (!Number.isSafeInteger(imageDownloadTimeoutMs) || imageDownloadTimeoutMs <= 0 || imageDownloadTimeoutMs > 2147483647) throw new Error('Image download timeout must be a positive timer-safe integer')
2342
2490
 
2343
2491
  const preferenceStorage = appearanceStorage(settings)
2344
2492
  let data = { ...emptySnapshot(), appearance: savedAppearance(preferenceStorage) }
@@ -2352,6 +2500,7 @@ window.__ModuleLoader__.load({
2352
2500
  const photographyControllers = new Map()
2353
2501
  const photographyWork = (projectId, changes) => set({ photographyWork: { ...data.photographyWork, [projectId]: { ...data.photographyWork[projectId], ...changes } } })
2354
2502
  const inspectionControllers = new Map()
2503
+ const imageDownloadControllers = new Map()
2355
2504
  const assetControllers = new Map()
2356
2505
  const assetListSequences = new Map()
2357
2506
  const bundleFiles = new Map(), bundleOperations = new Map()
@@ -3106,6 +3255,30 @@ window.__ModuleLoader__.load({
3106
3255
  }
3107
3256
  },
3108
3257
 
3258
+ downloadImage: async ({ projectId, artifactBase, artifact }) => {
3259
+ if (fileChoicesStopped || !projectId || typeof artifactBase !== 'string' || !artifactBase || !imageDownloadValid(artifact)) return
3260
+ const source = editorClone(artifact), key = imageDownloadKey(projectId, source)
3261
+ if (imageDownloadControllers.has(key)) return
3262
+ const controller = new AbortController(); imageDownloadControllers.set(key, controller)
3263
+ const work = changes => set({ imageDownloads: { ...data.imageDownloads, [key]: { ...data.imageDownloads[key], ...changes } } })
3264
+ work({ busy: true, status: 'preparing', messageKey: null, filename: null })
3265
+ const deadline = setTimeout(() => controller.abort('timeout'), imageDownloadTimeoutMs)
3266
+ try {
3267
+ const response = await fetchImpl(artifactUrl(artifactBase, source), { headers: { accept: 'image/png' }, cache: 'no-store', signal: controller.signal })
3268
+ const bytes = await verifiedPngBytes(response, source, controller.signal)
3269
+ if (controller.signal.aborted) throw Object.assign(new Error('cancelled'), { name: 'AbortError' })
3270
+ const filename = imageDownloadName(projectId, source)
3271
+ clearTimeout(deadline)
3272
+ await saveImage(bytes, filename)
3273
+ work({ status: 'ready', filename, messageKey: 'download.ready' })
3274
+ } catch (error) {
3275
+ work({ status: controller.signal.aborted ? 'cancelled' : 'error', messageKey: controller.signal.aborted
3276
+ ? controller.signal.reason === 'timeout' ? 'download.timeout' : 'download.cancelled'
3277
+ : error.messageKey || 'download.failed' })
3278
+ } finally { clearTimeout(deadline); imageDownloadControllers.delete(key); work({ busy: false }) }
3279
+ },
3280
+ cancelImageDownload: key => imageDownloadControllers.get(key)?.abort('cancelled'),
3281
+
3109
3282
  createProject: async () => {
3110
3283
  if (data.busy.create) return
3111
3284
  if (!recipeSelectionCurrent(data) || !recipeValuesValid(data.forms.recipe, data.forms.recipeParameters)) return
@@ -3281,6 +3454,7 @@ window.__ModuleLoader__.load({
3281
3454
  photographyControllers.clear()
3282
3455
  for (const controller of inspectionControllers.values()) controller.abort()
3283
3456
  inspectionControllers.clear()
3457
+ for (const controller of imageDownloadControllers.values()) controller.abort('stopped')
3284
3458
  for (const controller of assetControllers.values()) controller.abort()
3285
3459
  assetControllers.clear()
3286
3460
  for (const operation of bundleOperations.values()) void cancelBundle(operation).catch(() => {})
@@ -3917,7 +4091,7 @@ window.__ModuleLoader__.load({
3917
4091
  work.artifact ? el('figure', { 'data-photography-artifact': work.artifact.path },
3918
4092
  el('figcaption', null, t('photo.ready', { revision: edit.after, camera: edit.cameraId, frame: edit.frame })),
3919
4093
  el('img', { src: artifactUrl(state.artifactBase, work.artifact), alt: t('inspection.beauty'), style: { display: 'block', width: 'auto', maxWidth: '100%', height: 'auto', maxHeight: '640px', margin: '0 auto', objectFit: 'contain' } }),
3920
- PreviewSettings(work.artifact)) : null) : null,
4094
+ PreviewSettings(work.artifact), ImageDownload({ state, actions, artifact: work.artifact })) : null) : null,
3921
4095
  work.error ? el('p', { className: 'db-error', role: 'status', 'data-photography-error': true }, work.error) : null)
3922
4096
  }
3923
4097
 
@@ -4193,6 +4367,16 @@ window.__ModuleLoader__.load({
4193
4367
  references: scene.project.referenceImages?.length || 0, images: evidence?.diagnostics?.length || 0 }) : t('guide.noProject')),
4194
4368
  el('small', { className: 'db-muted' }, t('guide.help'))))
4195
4369
  }
4370
+ function ImageDownload({ state, actions, artifact }) {
4371
+ if (!artifact) return null
4372
+ const request = { projectId: state.activeProjectId, artifactBase: state.artifactBase, artifact }
4373
+ const key = imageDownloadKey(request.projectId, artifact), work = state.imageDownloads?.[key] || {}, valid = Boolean(imageDownloadValid(artifact) && request.projectId && request.artifactBase)
4374
+ return el('div', { className: 'db-inline', style: { flexWrap: 'wrap' }, 'data-image-download': artifact.path, 'data-image-download-digest': artifact.sha256 || '' },
4375
+ Button({ action: `download-image:${artifact.path}`, disabled: !valid || work.busy, title: t('download.help'), onClick: () => actions.downloadImage(request), children: work.busy ? t('download.loading') : t('download.png') }),
4376
+ work.busy ? Button({ action: `cancel-image-download:${artifact.path}`, onClick: () => actions.cancelImageDownload(key), children: t('inspection.cancel') }) : null,
4377
+ !valid ? el('small', { className: 'db-muted' }, t('download.source')) : null,
4378
+ work.messageKey ? el('small', { className: work.status === 'error' ? 'db-error' : 'db-muted', role: 'status', style: { overflowWrap: 'anywhere', maxWidth: '100%', minWidth: 0 }, 'data-image-download-status': work.status }, imageDownloadMessage(work)) : null)
4379
+ }
4196
4380
  function InspectionPanel({ state, actions }) {
4197
4381
  const projectId = state.activeProjectId, scene = state.selected?.scene, form = inspectionForm(state), work = state.inspectionWork?.[projectId] || {}
4198
4382
  if (!scene || !form) return null
@@ -4226,7 +4410,7 @@ window.__ModuleLoader__.load({
4226
4410
  selectedRevision !== scene.revision ? el('p', { className: 'db-muted' }, t('inspection.history', { revision: selectedRevision, current: scene.revision })) : null,
4227
4411
  artifacts.length === 0 ? el('p', { className: 'db-muted' }, t('inspection.none')) : el('div', { className: 'db-grid' }, artifacts.map(artifact => el('article', { className: 'db-card', key: artifact.path,
4228
4412
  'data-inspection-artifact': artifact.path, 'data-inspection-mode': artifact.mode, 'data-inspection-revision': artifact.sourceRevision || selectedRevision },
4229
- el('h5', null, t(`inspection.${artifact.mode}`)),
4413
+ el('h5', null, t(`inspection.${artifact.mode}`)), ImageDownload({ state, actions, artifact }),
4230
4414
  el('p', null, t('inspection.identity', { revision: artifact.sourceRevision || selectedRevision, camera: artifact.cameraId || '—', frame: artifact.frame ?? '—' })),
4231
4415
  el('img', { src: artifactUrl(state.artifactBase, artifact), alt: t(`inspection.${artifact.mode}`), 'data-inspection-image': artifact.path, style: { width: '100%', objectFit: 'contain' } }),
4232
4416
  el('p', { className: 'db-muted' }, t('inspection.actual', { engine: artifact.engine || '—', width: artifact.width ?? '—', height: artifact.height ?? '—', samples: artifact.samples ?? '—' })),
@@ -4255,7 +4439,7 @@ window.__ModuleLoader__.load({
4255
4439
  && Number.isSafeInteger(artifact?.height) && artifact.height > 0
4256
4440
  ? { width: artifact.width, height: artifact.height } : {}
4257
4441
  return el('div', { className: 'db-shot', 'data-compare': side, 'data-compare-kind': artifact === null ? 'empty' : 'image' },
4258
- el('h5', null, title),
4442
+ el('div', { className: 'db-inline', style: { justifyContent: 'space-between', flexWrap: 'wrap' } }, el('h5', null, title), ImageDownload({ state, actions, artifact })),
4259
4443
  artifact === null
4260
4444
  ? el('div', { className: 'db-muted' }, missing)
4261
4445
  : [
@@ -4286,7 +4470,7 @@ window.__ModuleLoader__.load({
4286
4470
 
4287
4471
  /** The revision axis: two revisions side by side, each one's newest image. */
4288
4472
  const revisionPane = (entry, side) => el('div', { className: 'db-shot', 'data-compare': side },
4289
- el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? t('revisions.currentSuffix') : ''}`),
4473
+ el('div', { className: 'db-inline', style: { justifyContent: 'space-between', flexWrap: 'wrap' } }, el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? t('revisions.currentSuffix') : ''}`), ImageDownload({ state, actions, artifact: entry ? sheetOf(entry) : null })),
4290
4474
  entry === null
4291
4475
  ? el('div', { className: 'db-muted' }, t('revisions.missing'))
4292
4476
  : [
@@ -5263,6 +5447,7 @@ window.__ModuleLoader__.load({
5263
5447
  ROUTES,
5264
5448
  projectRoute,
5265
5449
  artifactUrl,
5450
+ imageDownload: { key: imageDownloadKey, valid: imageDownloadValid, name: imageDownloadName, hash: imageByteHash },
5266
5451
  shortDigest,
5267
5452
  formatTime,
5268
5453
  statusTone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.3.2"
22
+ "@deepblend/dsh-blender-contracts": "0.3.4"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {