@deepblend/dsh-blender-tool 0.6.4 → 0.7.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.
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { persistImage } from './shared.js'
3
+
4
+ const MAX_IMAGE_BYTES = 32 * 1024 * 1024
5
+
6
+ /** Persist one already verified PNG; validate any normalized durable reference. */
7
+ export async function persistVerifiedPng(attachments, bytes, artifact, name, signal) {
8
+ const store = typeof attachments?.saveImage === 'function' ? {
9
+ async saveImage(input) {
10
+ const ref = await attachments.saveImage(input)
11
+ const exact = ref?.mediaType === 'image/png' && ref.bytes === artifact.bytes &&
12
+ ref.width === artifact.width && ref.height === artifact.height
13
+ const dimensionsMatch = ref?.width === artifact.width && ref?.height === artifact.height ||
14
+ ref?.originalDimensions?.width === artifact.width && ref?.originalDimensions?.height === artifact.height &&
15
+ ref.width <= artifact.width && ref.height <= artifact.height
16
+ if (typeof ref?.attachmentId !== 'string' || !ref.attachmentId.trim() ||
17
+ !['image/png', 'image/jpeg', 'image/webp'].includes(ref.mediaType) ||
18
+ !Number.isSafeInteger(ref.bytes) || ref.bytes <= 0 || ref.bytes > MAX_IMAGE_BYTES ||
19
+ !Number.isSafeInteger(ref.width) || ref.width <= 0 || !Number.isSafeInteger(ref.height) || ref.height <= 0 ||
20
+ !dimensionsMatch) {
21
+ throw new Error('attachment reference does not match the verified PNG')
22
+ }
23
+ // DSH may normalize the verified PNG into a smaller encoded image. Its
24
+ // read API verifies that durable image's digest, format and dimensions.
25
+ if (!exact) {
26
+ if (typeof attachments.readImage !== 'function') throw new Error('normalized attachment cannot be read back')
27
+ const stored = await attachments.readImage(ref, signal)
28
+ if (!(stored?.data instanceof Uint8Array) || stored.data.byteLength !== ref.bytes ||
29
+ ['attachmentId', 'mediaType', 'bytes', 'width', 'height'].some(key => stored.ref?.[key] !== ref[key]) ||
30
+ ref.attachmentId.startsWith('sha256:') &&
31
+ createHash('sha256').update(stored.data).digest('hex') !== ref.attachmentId.slice(7)) {
32
+ throw new Error('normalized attachment bytes do not match its durable reference')
33
+ }
34
+ }
35
+ return ref
36
+ },
37
+ } : attachments
38
+ const attached = await persistImage(store, bytes, name)
39
+ let { image, note } = attached
40
+ if (image && (image.mediaType !== 'image/png' || image.bytes !== artifact.bytes ||
41
+ image.width !== artifact.width || image.height !== artifact.height)) {
42
+ note = 'The attached image is the attachment service normalization of the verified source PNG. ' +
43
+ `Attached format: ${image.mediaType}; dimensions: ${image.width}x${image.height}. ` +
44
+ 'The original PNG and its source digest remain at the project path in this result.'
45
+ }
46
+ return { image, note }
47
+ }
@@ -0,0 +1,57 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
3
+ import { persistVerifiedPng } from './attachment-image.js'
4
+ import { imageForCallingModel } from './image-delivery.js'
5
+
6
+ const MAX_IMAGE_BYTES = 32 * 1024 * 1024
7
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
8
+
9
+ /** Attach the receipt's existing preview, with no compile, render or output-time IO. */
10
+ export async function attachCommittedPreview(ctx, exec, resolved, value, { projectId, summary }) {
11
+ const artifact = summary?.previews?.[0]
12
+ if (!artifact) return value
13
+ const cancelled = () => {
14
+ if (exec?.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED,
15
+ 'Preview attachment cancelled; the saved revision may already exist.')
16
+ }
17
+ try {
18
+ cancelled()
19
+ const revision = summary.revision
20
+ if (typeof projectId !== 'string' || !projectId || !/^r[0-9]{4,}$/.test(revision) ||
21
+ !/^[a-f0-9]{64}$/.test(summary.digest) || artifact.kind !== 'preview' ||
22
+ artifact.sourceRevision !== revision || artifact.sourceDigest !== summary.digest ||
23
+ artifact.mime !== 'image/png' || typeof artifact.path !== 'string' ||
24
+ !artifact.path.startsWith(`revisions/${revision}/previews/`) ||
25
+ artifact.path.split('/').some(part => !part || part === '.' || part === '..') ||
26
+ !/^[a-f0-9]{64}$/.test(artifact.sha256) ||
27
+ !Number.isSafeInteger(artifact.bytes) || artifact.bytes <= 0 || artifact.bytes > MAX_IMAGE_BYTES ||
28
+ !Number.isSafeInteger(artifact.width) || artifact.width <= 0 || artifact.width > 2048 ||
29
+ !Number.isSafeInteger(artifact.height) || artifact.height <= 0 || artifact.height > 2048 ||
30
+ typeof artifact.cameraId !== 'string' || !artifact.cameraId || !Number.isSafeInteger(artifact.frame) ||
31
+ typeof resolved.studio.readArtifact !== 'function') throw new Error('Invalid saved preview receipt')
32
+ const read = await resolved.studio.readArtifact({ projectId, path: artifact.path })
33
+ cancelled()
34
+ if (read?.path !== artifact.path || read.contentType !== 'image/png' || !Buffer.isBuffer(read.bytes) ||
35
+ read.size !== artifact.bytes || read.bytes.length !== artifact.bytes || read.bytes.length < 24 ||
36
+ !read.bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
37
+ read.bytes.readUInt32BE(16) !== artifact.width || read.bytes.readUInt32BE(20) !== artifact.height ||
38
+ createHash('sha256').update(read.bytes).digest('hex') !== artifact.sha256) {
39
+ throw new Error('Saved PNG differs from its receipt')
40
+ }
41
+ const attached = await persistVerifiedPng(resolved.attachments, read.bytes, artifact,
42
+ `preview-${revision}-${artifact.cameraId}-${artifact.frame}.png`, exec?.signal)
43
+ cancelled()
44
+ return await imageForCallingModel(ctx, exec, {
45
+ ...value, image: attached.image,
46
+ imageSource: { ...artifact, projectId, revision, digest: summary.digest },
47
+ text: value.text + '\n\nThe image uses the preview already saved with this revision; no extra render was started.' +
48
+ (attached.note ? '\n' + attached.note : ''),
49
+ })
50
+ } catch (cause) {
51
+ cancelled()
52
+ if (cause?.code === BlenderErrorCode.ABORTED) throw cause
53
+ return { ...value, image: null,
54
+ text: value.text + '\n\nThe revision remains saved, but its preview could not be verified and attached here. ' +
55
+ 'The model has not seen this image. Use the receipt to inspect the saved artifact; do not recreate the project or repeat the render to repair an attachment.' }
56
+ }
57
+ }
@@ -0,0 +1,23 @@
1
+ import { BlenderError, BlenderErrorCode } from '@deepblend/dsh-blender-contracts'
2
+
3
+ /** Keep rendered artifacts available without sending images to a known text-only route. */
4
+ export async function imageForCallingModel(ctx, exec, value) {
5
+ if (!value.image) return value
6
+ const route = exec?.agent?.session?.requestHeader?.()?.config
7
+ const llm = ctx.get('llm')
8
+ if (!route?.provider || !route.model || typeof llm?.listModels !== 'function') return value
9
+ let catalog
10
+ try { catalog = await llm.listModels(route.provider) } catch {
11
+ if (exec?.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Image delivery cancelled.')
12
+ return value
13
+ }
14
+ if (exec?.signal?.aborted) throw new BlenderError(BlenderErrorCode.ABORTED, 'Image delivery cancelled.')
15
+ const model = catalog?.find(entry => entry.id === route.model)
16
+ if (!Array.isArray(model?.inputModalities) || model.inputModalities.includes('image')) return value
17
+ return { ...value, image: null,
18
+ imageDelivery: { provider: route.provider, model: route.model, deliveredToModel: false,
19
+ reason: 'The active request model declares no image input.' },
20
+ text: value.text + '\n\nThe rendered image remains saved at the path in this result. ' +
21
+ `It was not attached to the ${route.provider}/${route.model} conversation because this model does not accept images. ` +
22
+ 'The model has not seen these pixels; report measurements separately from appearance and artistic approval.' }
23
+ }
package/lib/inspection.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { BlenderError, BlenderErrorCode, warning } from '@deepblend/dsh-blender-contracts'
3
- import { canonicalCall, definedFields, persistImage, renderSuccess } from './shared.js'
3
+ import { canonicalCall, definedFields, renderSuccess } from './shared.js'
4
+
5
+ import { persistVerifiedPng } from './attachment-image.js'
4
6
 
5
7
  const REQUIRED_HOST_API = 6
6
8
  const MAX_IMAGE_BYTES = 32 * 1024 * 1024
@@ -66,17 +68,7 @@ export async function renderInspection(resolved, request) {
66
68
  let image = null
67
69
  let note = null
68
70
  try {
69
- const store = typeof attachments?.saveImage === 'function' ? {
70
- async saveImage(input) {
71
- const ref = await attachments.saveImage(input)
72
- if (typeof ref?.attachmentId !== 'string' || !ref.attachmentId.trim() || ref.mediaType !== 'image/png' ||
73
- ref.bytes !== artifact.bytes || ref.width !== artifact.width || ref.height !== artifact.height) {
74
- throw new Error('attachment reference does not match the verified PNG')
75
- }
76
- return ref
77
- },
78
- } : attachments
79
- const attached = await persistImage(store, read.bytes, `${mode}-${revision}-${cameraId}-${frame}.png`)
71
+ const attached = await persistVerifiedPng(attachments, read.bytes, artifact, `${mode}-${revision}-${cameraId}-${frame}.png`, signal)
80
72
  aborted(signal)
81
73
  image = attached.image
82
74
  note = attached.note
package/lib/shared.js CHANGED
@@ -99,6 +99,9 @@ export async function persistImage(attachments, bytes, name) {
99
99
  width: Number(ref.width),
100
100
  height: Number(ref.height),
101
101
  name: typeof ref.name === 'string' ? ref.name : name,
102
+ ...(ref.originalDimensions ? { originalDimensions: {
103
+ width: Number(ref.originalDimensions.width), height: Number(ref.originalDimensions.height),
104
+ } } : {}),
102
105
  },
103
106
  note: null,
104
107
  }
package/lib/tools.js CHANGED
@@ -28,6 +28,8 @@
28
28
 
29
29
  import { defineTool } from '@deepseek-ai/dsh-tools'
30
30
  import { renderInspection } from './inspection.js'
31
+ import { imageForCallingModel } from './image-delivery.js'
32
+ import { attachCommittedPreview } from './committed-preview.js'
31
33
 
32
34
  import {
33
35
  SCENE_OPERATION_NAMES,
@@ -116,7 +118,7 @@ function recipeParameters(ctx) {
116
118
  'Saving needs that exact baseRevision and numeric parameters. It updates the same project, preserves independent ' +
117
119
  'materials, bevels, photography and history, and refuses manually conflicting controlled geometry. ' +
118
120
  'A scene without declared assembly controls returns available:false; do not invent parameters or replace the project. ' +
119
- 'Saving compiles a checkpoint and preview by default; explicit false flags can request a data-only revision. ' +
121
+ 'Saving compiles a checkpoint and preview by default and can attach that existing preview for inspection; explicit false flags can request a data-only revision. ' +
120
122
  'Use a unique idempotencyKey per user save and reuse it only when retrying the same intent.',
121
123
  parameters: {
122
124
  action: { type: 'string', enum: ['read', 'save'], description: 'read (default) is read-only; save creates one atomic revision.' },
@@ -128,7 +130,7 @@ function recipeParameters(ctx) {
128
130
  saveCheckpoint: { type: 'boolean', description: 'Save a Blender checkpoint (default true).' },
129
131
  renderPreview: { type: 'boolean', description: 'Render an actual new preview (default true).' },
130
132
  },
131
- output: TOOL_OUTPUT,
133
+ output: TOOL_OUTPUT_WITH_IMAGE,
132
134
  async execute(args, exec) {
133
135
  const resolved = resolveStudio(ctx)
134
136
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
@@ -143,7 +145,9 @@ function recipeParameters(ctx) {
143
145
  saveCheckpoint: args.saveCheckpoint !== false, renderPreview: args.renderPreview !== false,
144
146
  signal: exec?.signal })
145
147
  const { data, canonicalWarnings } = await canonicalCall(result, warning)
146
- return { ok: true, text: renderSuccess(action === 'read' ? 'Saved assembly controls.' : 'Assembly dimensions saved.', data, { warnings: canonicalWarnings }), data }
148
+ const value = { ok: true, text: renderSuccess(action === 'read' ? 'Saved assembly controls.' : 'Assembly dimensions saved.', data, { warnings: canonicalWarnings }), data }
149
+ return action === 'save' && args.renderPreview !== false
150
+ ? await attachCommittedPreview(ctx, exec, resolved, value, { projectId: args.projectId, summary: data }) : value
147
151
  } catch (cause) { return { ok: false, ...renderFailure(cause, 'RECIPE_PARAMETER_FAILED') } }
148
152
  },
149
153
  presentCall: args => ({ card: 'generic', title: args?.action === 'save' ? 'Save assembly dimensions' : 'Read assembly dimensions',
@@ -194,6 +198,9 @@ function projectCreate(ctx) {
194
198
  'Give it the operator\'s brief as `goal`: it is stored with the project and never parsed, so write it ' +
195
199
  'in full. For an existing product design, call blender_recipe_list and pass its id/version/digest ' +
196
200
  'as recipe, with only its declared parameter overrides. recipe and sceneSpec are mutually exclusive. ' +
201
+ 'When the brief asks for an editable Blender file and a preview, set saveCheckpoint:true and ' +
202
+ 'renderPreview:true together to save both in this creation. The saved preview can be attached in this result; inspect it before rendering that view again. Inspect those returned receipts before ' +
203
+ 'reading a full scene or starting another render. ' +
197
204
  'Omit both to start from a minimal renderable scaffold and build up with ' +
198
205
  'blender_scene_patch; pass one only when you already know the whole scene. ' +
199
206
  'Set saveCheckpoint:false to skip the Blender compile and store the SceneSpec alone. The revision then ' +
@@ -246,7 +253,7 @@ function projectCreate(ctx) {
246
253
  description: 'Optional stable key for this creation intent (1–128 characters, no control characters). Reuse it with the same inputs when retrying an uncertain result. A completed request opens the same project without another compile or render; a new project needs a new key. Recovery returns its current saved revision.',
247
254
  },
248
255
  },
249
- output: TOOL_OUTPUT,
256
+ output: TOOL_OUTPUT_WITH_IMAGE,
250
257
  async execute(args, exec) {
251
258
  const resolved = resolveStudio(ctx)
252
259
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
@@ -282,7 +289,7 @@ function projectCreate(ctx) {
282
289
  notes.push('Compiler notes (these describe decisions made on your behalf):')
283
290
  for (const entry of data.warnings) notes.push(` - ${entry.message}`)
284
291
  }
285
- return {
292
+ const value = {
286
293
  ok: true,
287
294
  text: renderSuccess(`Created project "${data.projectId}" at revision ${data.revision.revision}.`, data, {
288
295
  notes,
@@ -290,6 +297,8 @@ function projectCreate(ctx) {
290
297
  }),
291
298
  data,
292
299
  }
300
+ return args.renderPreview === true
301
+ ? await attachCommittedPreview(ctx, exec, resolved, value, { projectId: data.projectId, summary: data.revision }) : value
293
302
  } catch (cause) {
294
303
  const failure = renderFailure(cause, 'PROJECT_CREATE_FAILED')
295
304
  return { ok: false, ...failure }
@@ -563,7 +572,7 @@ function scenePatch(ctx) {
563
572
  description: 'Also render the preview profile as part of this commit, so the revision arrives with an image. Default false.',
564
573
  },
565
574
  },
566
- output: TOOL_OUTPUT,
575
+ output: TOOL_OUTPUT_WITH_IMAGE,
567
576
  async execute(args, exec) {
568
577
  const resolved = resolveStudio(ctx)
569
578
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
@@ -619,7 +628,7 @@ function scenePatch(ctx) {
619
628
  }
620
629
  const scene = data.scene ?? {}
621
630
  if (scene.counts) notes.push(`Scene now: ${JSON.stringify(scene.counts)}`)
622
- return {
631
+ const value = {
623
632
  ok: true,
624
633
  text: renderSuccess(
625
634
  `${data.idempotentReplay === true ? 'Reused' : 'Committed'} revision ${data.revision}.`,
@@ -628,6 +637,8 @@ function scenePatch(ctx) {
628
637
  ),
629
638
  data,
630
639
  }
640
+ return args.renderPreview === true
641
+ ? await attachCommittedPreview(ctx, exec, resolved, value, { projectId: args.projectId, summary: data }) : value
631
642
  } catch (cause) {
632
643
  const failure = renderFailure(cause, 'SCENE_PATCH_FAILED')
633
644
  return { ok: false, ...failure }
@@ -688,11 +699,11 @@ function previewRender(ctx) {
688
699
  const resolved = resolveStudio(ctx)
689
700
  if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
690
701
  try {
691
- if (args.mode !== undefined) return await renderInspection(resolved, {
702
+ if (args.mode !== undefined) return await imageForCallingModel(ctx, exec, await renderInspection(resolved, {
692
703
  projectId: args.projectId, revision: args.revision, mode: args.mode,
693
704
  cameraId: args.cameraId, frame: args.frame, width: args.width, height: args.height,
694
705
  samples: args.samples, signal: exec.signal,
695
- })
706
+ }))
696
707
  const { data, canonicalWarnings } = await canonicalCall(resolved.studio.renderPreview({
697
708
  ...definedFields({
698
709
  projectId: args.projectId,
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { defineTool } from '@deepseek-ai/dsh-tools'
43
+ import { imageForCallingModel } from './image-delivery.js'
43
44
 
44
45
  import { BlenderWarningCode, warning } from '@deepblend/dsh-blender-contracts'
45
46
 
@@ -229,12 +230,12 @@ function visualReview(ctx) {
229
230
  )
230
231
  if (persisted.note !== null) notes.push(persisted.note)
231
232
 
232
- return {
233
+ return await imageForCallingModel(ctx, exec, {
233
234
  ok: true,
234
235
  text: renderSuccess(`Visual review of ${data.revision}: ${data.score}/100.`, data, { notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }),
235
236
  data,
236
237
  image: persisted.image,
237
- }
238
+ })
238
239
  } catch (cause) {
239
240
  const failure = renderFailure(cause, 'VISUAL_REVIEW_FAILED')
240
241
  return { ok: false, ...failure, image: null }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-tool",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Agent-preset tool consumer: registers the model-visible DeepBlend Blender tools. Publishes no service.",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.6.4"
14
+ "@deepblend/dsh-blender-contracts": "0.7.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {