@deepblend/dsh-blender-tool 0.15.0 → 0.18.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.
@@ -62,7 +62,7 @@ export function describeRenderCost(frames) {
62
62
 
63
63
  import { defineTool } from '@deepseek-ai/dsh-tools'
64
64
 
65
- import { BlenderErrorCode, HOST_API_VERSION, warning } from '@deepblend/dsh-blender-contracts'
65
+ import { BlenderError, BlenderErrorCode, HOST_API_VERSION, warning } from '@deepblend/dsh-blender-contracts'
66
66
 
67
67
  import {
68
68
  TOOL_OUTPUT,
@@ -171,6 +171,7 @@ export function describeJobLines(job) {
171
171
  `status: ${job.status}`,
172
172
  `frames: ${job.completedFrames}/${job.expectedFrames} complete (${job.percent}%) in range ${job.frameStart}..${job.frameEnd}`,
173
173
  ]
174
+ if (job.cameraSelection?.mode === 'shots') lines.push(`cameras: saved shot sequence (${job.cameraSelection.shots.length} shots)`)
174
175
  if (job.pid !== null && job.pid !== undefined) lines.push(`pid: ${job.pid}`)
175
176
  if (job.meanMsPerFrame !== null && job.meanMsPerFrame !== undefined) {
176
177
  const remaining = job.estimatedRemainingMs === null || job.estimatedRemainingMs === undefined
@@ -216,6 +217,7 @@ function finalRender(ctx) {
216
217
  description:
217
218
  'Start a DELIVERY render: every frame of the project\'s range (or a sub-range you name) at the ' +
218
219
  'SceneSpec\'s "final" render profile, then encode it to MP4 and publish the delivery package. ' +
220
+ 'The default still renders one camera; saved shots alone do not switch it. Set useShotCameras:true to follow the saved shots, with exactly one shot covering each rendered frame. This freezes the camera/range selection for restart and records actual per-frame camera evidence. Never combine it with cameraId; on resume omit it and use the existing job selection. ' +
219
221
  'This is the expensive operation — measured on this machine at 19.6-41.4 s PER FRAME for a ' +
220
222
  '1920x1080 / Cycles / 256-sample delivery, so a 450-frame animation is about 3.4 hours. ' +
221
223
  '\n\nIt RETURNS IMMEDIATELY with a jobId; the render continues in the background while you keep ' +
@@ -244,10 +246,10 @@ function finalRender(ctx) {
244
246
  type: 'array',
245
247
  items: { type: 'number' },
246
248
  description:
247
- 'An explicit list of frames instead of a contiguous range — useful for a spot check of a few ' +
248
- 'expensive frames. Frames outside the project\'s own range are dropped with a warning.',
249
+ 'An explicit list of frames forming a contiguous interval. Frames outside the project\'s own range are dropped with a warning; remaining gaps are refused before rendering because the MP4 encoder requires consecutive frames. Use blender_preview_render for separated spot checks.',
249
250
  },
250
251
  cameraId: { type: 'string', description: 'Camera to render from. Defaults to the camera with role "active-camera".' },
252
+ useShotCameras: { type: 'boolean', description: 'Explicit true: follow the saved shots and their frameRange/cameraId. Requires supported Host/runtime, at most64 nonoverlapping ranged shots and complete coverage of requested frames. Cannot combine with cameraId or resumeJobId. Omit on resume to keep the job’s frozen selection. Default single-camera behavior is unchanged.' },
251
253
  profileName: { type: 'string', description: 'SceneSpec render profile name. Defaults to "final".' },
252
254
  samples: {
253
255
  type: 'number',
@@ -263,6 +265,10 @@ function finalRender(ctx) {
263
265
  return { ok: false, text: `Delivery render unavailable.\n${resolved.unavailable.text}`, data: resolved.unavailable.data }
264
266
  }
265
267
  try {
268
+ if (args.useShotCameras !== undefined && typeof args.useShotCameras !== 'boolean') throw new BlenderError(
269
+ BlenderErrorCode.SCENE_SPEC_INVALID, 'useShotCameras must be boolean.')
270
+ if (args.useShotCameras === true && (args.cameraId !== undefined || args.resumeJobId !== undefined)) throw new BlenderError(
271
+ BlenderErrorCode.SCENE_SPEC_INVALID, 'Saved-shot rendering cannot also select cameraId or resumeJobId; resume with the recorded selection instead.')
266
272
  if (typeof args?.resumeJobId === 'string' && args.resumeJobId.length > 0) {
267
273
  const stale = hostPlaneIsCurrent(resolved.studio, ['resumeRenderJob', 'getJob'])
268
274
  if (stale !== null) return { ok: false, ...stale }
@@ -296,8 +302,13 @@ function finalRender(ctx) {
296
302
  }
297
303
  }
298
304
 
299
- const stale = hostPlaneIsCurrent(resolved.studio, ['startFinalRender', 'listJobs', 'getJob'])
305
+ const startMethod = args.useShotCameras === true ? 'startShotSequenceRender' : 'startFinalRender'
306
+ const stale = hostPlaneIsCurrent(resolved.studio, [startMethod, 'listJobs', 'getJob'])
300
307
  if (stale !== null) return { ok: false, ...stale }
308
+ if (typeof resolved.studio[startMethod] !== 'function') throw new BlenderError(
309
+ BlenderErrorCode.RUNTIME_UNAVAILABLE,
310
+ 'The running Host does not support explicit shot-camera rendering. Upgrade/restart the profile; no single-camera fallback was started.')
311
+ const start = request => resolved.studio[startMethod](request)
301
312
 
302
313
  const startRequest = {
303
314
  projectId: args.projectId,
@@ -312,7 +323,7 @@ function finalRender(ctx) {
312
323
 
313
324
  let started
314
325
  try {
315
- started = await canonicalCall(resolved.studio.startFinalRender(definedFields(startRequest)), warning)
326
+ started = await canonicalCall(start(definedFields(startRequest)), warning)
316
327
  } catch (cause) {
317
328
  // ── the approval gate (SPEC §15.1, architecture-decisions Q7) ────────
318
329
  //
@@ -367,7 +378,7 @@ function finalRender(ctx) {
367
378
  // between is enough. The refusal names the revision it measured, and that is the one this renders.
368
379
  const approvedRevision = cause.detail?.revision ?? startRequest.revision
369
380
  started = await canonicalCall(
370
- resolved.studio.startFinalRender(definedFields({
381
+ start(definedFields({
371
382
  ...startRequest,
372
383
  ...approvedRevision === undefined ? {} : { revision: approvedRevision },
373
384
  approved: true,
package/lib/tools.js CHANGED
@@ -33,6 +33,8 @@ import { attachCommittedPreview } from './committed-preview.js'
33
33
 
34
34
  import {
35
35
  SCENE_OPERATION_NAMES,
36
+ CAMERA_UPDATE_FIELDS,
37
+ CAMERA_TARGET_UPDATE_HINT,
36
38
  BlenderError,
37
39
  BlenderErrorCode,
38
40
  CREATION_REQUEST_VERSION,
@@ -423,14 +425,17 @@ function sceneGet(ctx) {
423
425
  parameters: {
424
426
  projectId: { type: 'string', required: true, description: 'The project id.' },
425
427
  revision: { type: 'string', description: 'Read a specific revision instead of the current one.' },
426
- sourceEntityId: { type: 'string', description: 'Read exact saved JSON for one entity (1–64 character ID), without the scene catalogue. Cannot combine with full:true or geometry options.' },
428
+ sourceEntityId: { type: 'string', description: 'Read exact saved JSON for one entity (1–64 character ID), without the scene catalogue. This is an entity ID, not an animation-track, camera or asset-descriptor ID; use the scene catalogue/full:true to read those instead, without sourceEntityId. Cannot combine with full:true or geometry options.' },
427
429
  sourceOffset: { type: 'integer', description: 'UTF-16 character offset in compact entity JSON, default 0. Requires sourceEntityId; offsets above zero require the revision returned by the first chunk.' },
428
430
  sourceLimit: { type: 'integer', description: 'Maximum source characters in this chunk, 1–2048 (default 2048). Escaped text may return fewer. Requires sourceEntityId.' },
429
431
  geometryEntityIds: { type: 'array', items: { type: 'string' },
430
432
  description: 'Optional: measure 1–8 distinct saved native entity IDs. Requires geometryFrame. Hidden viewport objects and non-mesh roots are explicitly unavailable.' },
431
433
  geometryFrame: { type: 'integer',
432
434
  description: 'Explicit integer frame from 0 to 1000000 for native geometry measurement. Use the frame of the preview being checked. Requires geometryEntityIds.' },
433
- geometryObjectAxes: { type: 'boolean', description: 'Optional true: also measure actual evaluated vertices in physical orthogonal axes of each selected object, including native scale. Requires geometryEntityIds and geometryFrame. Returns objectAxisBoundsMeters/objectAxisExtentMm/objectAxisWorldMatrix and an explicit basis, separate from world-axis spans. Sheared/degenerate bases are unavailable. Never invert a world AABB to recover local extents.' },
435
+ geometryCameraId: { type: 'string', description: 'Optional saved perspective camera ID: project selected actual vertices into its frame using native camera shift and render aspect. Returns normalized image bounds, containedInFrame (default 3% margin) and depth/clip evidence at geometryFrame only. Combine with geometryAssetInstanceIds for entire imported subjects. Does not certify occlusion, collision or an animation path; inspect pixels and every shot. Cannot combine with geometryObjectAxes or geometrySurfaceSamples. No camera is changed.' },
436
+ geometryCameraMargin: { type: 'number', description: 'Optional normalized border margin 0..0.25 (default 0.03); requires geometryCameraId.' },
437
+ geometryAssetInstanceIds: { type: 'array', items: { type: 'string' }, description: 'Optional distinct imported asset-instance IDs, also in geometryEntityIds. Measures the WHOLE evaluated mesh hierarchy as ASSET_MESH_SET, not the empty container origin. Requires geometryFrame. Up to 128 meshes and 2M vertices/polygons per instance; hidden/unsupported/empty hierarchies are unavailable as a whole. Cannot combine with geometryObjectAxes or geometrySurfaceSamples. Use world min Z/max Z to check a horizontal pedestal, then inspect pixels; bounds do not certify contact or clearance. Does not modify assets or transforms.' },
438
+ geometryObjectAxes: { type: 'boolean', description: 'Optional true: also measure actual evaluated vertices in physical orthogonal axes of each selected object, including native scale. Requires geometryEntityIds and geometryFrame; do not combine with geometryAssetInstanceIds or geometryCameraId. Returns objectAxisBoundsMeters/objectAxisExtentMm/objectAxisWorldMatrix and an explicit basis, separate from world-axis spans. Sheared/degenerate bases are unavailable. Never invert a world AABB to recover local extents.' },
434
439
  geometryOnly: { type: 'boolean', description: 'Return only source identity and verified geometry evidence, without the scene catalogue. Requires geometryEntityIds and geometryFrame; cannot be combined with full:true. Ordinary reads keep their existing shape.' },
435
440
  geometryCompareRevision: { type: 'string', description: 'Optional explicit reference revision (e.g. r0004). Requires geometryEntityIds and geometryFrame. Re-measures identical selected parts/frame and computes current-minus-reference native world-axis spans and origin moves. If geometrySurfaceSamples is supplied, compares those same local samples instead. Requested geometryAssemblyPairs also return origin-offset changes. Unavailable evidence stays unavailable; no clearance, global minima or change cause is established.' },
436
441
  geometryAssemblyPairs: { type: 'array', description: 'Optional 1–8 ordered native object-origin pairs. Requires geometryEntityIds containing both ends and geometryFrame. Offsets use world XYZ in millimeters; they are not surface protrusion, contact, clearance or whole-assembly certification.',
@@ -463,6 +468,8 @@ function sceneGet(ctx) {
463
468
  const receipt = await readEntitySource(resolved.studio, args)
464
469
  return { ok: true, text: renderSuccess('Saved entity source chunk. Continue at nextOffset using this revision; verify entityJsonSha256 before concatenating.', receipt, { compact: true }), data: receipt }
465
470
  }
471
+ if ((args.geometryCameraId !== undefined || args.geometryCameraMargin !== undefined) && args.geometryEntityIds === undefined) throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'Camera framing requires geometryEntityIds and geometryFrame.')
472
+ if (args.geometryAssetInstanceIds !== undefined && args.geometryEntityIds === undefined) throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'Asset bounds require geometryEntityIds and geometryFrame.')
466
473
  if (args.geometryObjectAxes !== undefined && typeof args.geometryObjectAxes !== 'boolean') throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'geometryObjectAxes must be boolean.')
467
474
  if (args.geometryObjectAxes === true && args.geometryEntityIds === undefined) throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, 'Object-local bounds require geometryEntityIds and geometryFrame.')
468
475
  if (args.geometryOnly !== undefined && typeof args.geometryOnly !== 'boolean') {
@@ -483,19 +490,20 @@ function sceneGet(ctx) {
483
490
  }
484
491
  if (args.geometryAssemblyPairs !== undefined) validateAssemblyPairs(args.geometryAssemblyPairs, args.geometryEntityIds)
485
492
  if (args.geometryEntityIds !== undefined) validateGeometrySelection({ entityIds: args.geometryEntityIds,
486
- frame: args.geometryFrame, checkpointSha256: '0'.repeat(64), ...(args.geometryObjectAxes === true ? { objectAxes: true } : {}), ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) })
493
+ frame: args.geometryFrame, checkpointSha256: '0'.repeat(64), ...(args.geometryAssetInstanceIds === undefined ? {} : { assetInstanceIds: args.geometryAssetInstanceIds }), ...(args.geometryCameraId === undefined ? {} : { cameraId: args.geometryCameraId }), ...(args.geometryCameraMargin === undefined ? {} : { cameraMargin: args.geometryCameraMargin }), ...(args.geometryObjectAxes === true ? { objectAxes: true } : {}), ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) })
487
494
  const { data, canonicalWarnings } = await canonicalCall(resolved.studio.getScene(args.projectId, {
488
495
  ...definedFields({ revision: args.revision }),
489
496
  full: args.full === true,
490
497
  ...(args.geometryEntityIds === undefined ? {} : { geometry: { entityIds: args.geometryEntityIds, frame: args.geometryFrame,
498
+ ...(args.geometryAssetInstanceIds === undefined ? {} : { assetInstanceIds: args.geometryAssetInstanceIds }), ...(args.geometryCameraId === undefined ? {} : { cameraId: args.geometryCameraId }), ...(args.geometryCameraMargin === undefined ? {} : { cameraMargin: args.geometryCameraMargin }),
491
499
  ...(args.geometryObjectAxes === true ? { objectAxes: true } : {}), ...(args.geometrySurfaceSamples === undefined ? {} : { surfaceSamples: args.geometrySurfaceSamples }) }, signal: exec?.signal }),
492
500
  }), warning)
493
- const hasSamples = args.geometrySurfaceSamples !== undefined, hasObjectAxes = args.geometryObjectAxes === true
501
+ const hasSamples = args.geometrySurfaceSamples !== undefined, hasObjectAxes = args.geometryObjectAxes === true, hasV4Evidence = args.geometryAssetInstanceIds !== undefined || args.geometryCameraId !== undefined
494
502
  const verifyScene = (data, expectedRevision) => {
495
503
  if (expectedRevision !== undefined && data.revision !== expectedRevision) {
496
504
  throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Host returned a different saved revision than requested.')
497
505
  }
498
- if (args.geometryEntityIds !== undefined && (!data.geometryInspection || data.geometryInspection.schemaVersion !== (hasObjectAxes ? 'deepblend.scene-geometry/v3' : hasSamples ? 'deepblend.scene-geometry/v2' : 'deepblend.scene-geometry/v1') ||
506
+ if (args.geometryEntityIds !== undefined && (!data.geometryInspection || data.geometryInspection.schemaVersion !== (hasV4Evidence ? 'deepblend.scene-geometry/v4' : hasObjectAxes ? 'deepblend.scene-geometry/v3' : hasSamples ? 'deepblend.scene-geometry/v2' : 'deepblend.scene-geometry/v1') ||
499
507
  data.geometryInspection.projectId !== args.projectId || data.geometryInspection.sourceRevision !== data.revision ||
500
508
  data.geometryInspection.sourceDigest !== data.digest || data.geometryInspection.sourceUnchanged !== true || data.geometryInspection.frame !== args.geometryFrame ||
501
509
  !Array.isArray(data.geometryInspection.limitations) ||
@@ -503,17 +511,19 @@ function sceneGet(ctx) {
503
511
  throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Host did not return measurements for the requested source and frame. Upgrade DeepBlend before measuring; ordinary scene reads remain available.')
504
512
  }
505
513
  if (data.geometryInspection) verifyGeometryReport({ frame: data.geometryInspection.frame, blenderVersion: data.geometryInspection.blenderVersion, objects: data.geometryInspection.objects,
506
- schemaVersion: hasObjectAxes ? 'deepblend.geometry-inspection/v3' : hasSamples ? 'deepblend.geometry-inspection/v2' : 'deepblend.geometry-inspection/v1',
514
+ schemaVersion: hasV4Evidence ? 'deepblend.geometry-inspection/v4' : hasObjectAxes ? 'deepblend.geometry-inspection/v3' : hasSamples ? 'deepblend.geometry-inspection/v2' : 'deepblend.geometry-inspection/v1',
515
+ ...(args.geometryCameraId === undefined ? {} : { camera: data.geometryInspection.camera }),
507
516
  ...(hasObjectAxes ? { objectAxisBasis: data.geometryInspection.objectAxisBasis } : {}),
508
517
  ...(hasSamples ? { surfaceSamples: data.geometryInspection.surfaceSamples } : {}), checkpointSha256: data.geometryInspection.sourceCheckpointSha256,
509
518
  checkpointUnchanged: data.geometryInspection.sourceUnchanged }, { entityIds: args.geometryEntityIds, frame: args.geometryFrame,
510
- checkpointSha256: data.geometryInspection.sourceCheckpointSha256, ...(hasObjectAxes ? { objectAxes: true } : {}), ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) })
519
+ checkpointSha256: data.geometryInspection.sourceCheckpointSha256, ...(args.geometryAssetInstanceIds === undefined ? {} : { assetInstanceIds: args.geometryAssetInstanceIds }), ...(args.geometryCameraId === undefined ? {} : { cameraId: args.geometryCameraId }), ...(args.geometryCameraMargin === undefined ? {} : { cameraMargin: args.geometryCameraMargin }), ...(hasObjectAxes ? { objectAxes: true } : {}), ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) })
511
520
  }
512
521
  verifyScene(data, args.revision)
513
522
  if (args.geometryAssemblyPairs !== undefined) data.geometryAssemblyOrigins = readAssemblyOrigins(data.geometryInspection, args.geometryAssemblyPairs)
514
523
  if (args.geometryCompareRevision !== undefined) {
515
524
  const reference = await canonicalCall(resolved.studio.getScene(args.projectId, { revision: args.geometryCompareRevision,
516
- full: false, geometry: { entityIds: args.geometryEntityIds, frame: args.geometryFrame, ...(hasObjectAxes ? { objectAxes: true } : {}), ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) }, signal: exec?.signal }), warning)
525
+ full: false, geometry: { entityIds: args.geometryEntityIds, frame: args.geometryFrame,
526
+ ...(args.geometryAssetInstanceIds === undefined ? {} : { assetInstanceIds: args.geometryAssetInstanceIds }), ...(args.geometryCameraId === undefined ? {} : { cameraId: args.geometryCameraId }), ...(args.geometryCameraMargin === undefined ? {} : { cameraMargin: args.geometryCameraMargin }), ...(hasObjectAxes ? { objectAxes: true } : {}), ...(hasSamples ? { surfaceSamples: args.geometrySurfaceSamples } : {}) }, signal: exec?.signal }), warning)
517
527
  verifyScene(reference.data, args.geometryCompareRevision)
518
528
  canonicalWarnings.push(...reference.canonicalWarnings)
519
529
  data.geometryComparison = hasSamples ? compareSurfaceReports(reference.data.geometryInspection, data.geometryInspection) : compareGeometryReports(reference.data.geometryInspection, data.geometryInspection)
@@ -613,7 +623,7 @@ const OPERATION_SUMMARY = [
613
623
  'light.update {lightId, energy?, color?, size?, transform?, spotSize?, spotBlend?, angle?}',
614
624
  'light.remove {lightId}',
615
625
  'camera.add {camera}',
616
- 'camera.update {cameraId, lens?, transform?, targetEntityId?, targetPoint?, clipping?, fStop?}',
626
+ `camera.update {cameraId, ${CAMERA_UPDATE_FIELDS.map(field => `${field}?`).join(', ')}}`,
617
627
  'camera.remove {cameraId}',
618
628
  'animation.track.set {track} — {id, targetEntityId, targetKind?, property, keyframes[]}',
619
629
  'animation.track.remove {trackId}',
@@ -645,6 +655,9 @@ function scenePatch(ctx) {
645
655
  'fails, no revision is created and the current revision is untouched, so a failed patch is always safe ' +
646
656
  'to correct and retry. Retrying an identical patch is also safe — it returns the original outcome ' +
647
657
  'instead of committing twice.\n\n' +
658
+ 'For complex authored geometry, prefer 1–6 coherent operations per call and reuse entityCopies instead of repeating long vertex arrays. This is guidance, not a transaction limit: keep dependent changes atomic. Send strict JSON with complete arrays/objects; never execute a truncated or repaired argument. Read returned validation paths before retrying. ' +
659
+ 'asset.add.asset.license is an object {source,commercialUse,attribution}, unlike the ingestion tool license string. A cube generator size is one scalar, not a three-component vector; use dimensions/transform according to its declared schema.\n\n' +
660
+ CAMERA_TARGET_UPDATE_HINT + '\n\n' +
648
661
  'Optional entityCopies creates 1–16 explicit copies of single saved entities from baseRevision, before operations. ' +
649
662
  'Use {sourceEntityId,newEntityId,transform?} to reuse exact geometry and manual details without repeating vertices; operations may then be []. ' +
650
663
  'Transform components are absolute overrides. All other entity fields and external references are preserved; animation tracks, child assemblies and reference remapping are NOT copied. ' +
@@ -1192,7 +1205,7 @@ function assetIngest(ctx) {
1192
1205
  },
1193
1206
  license: {
1194
1207
  type: 'string',
1195
- description: 'Licence string recorded with the asset. Worth setting for anything downloaded: an ' +
1208
+ description: 'Ingestion licence string (for example CC0). In a later asset.add patch, asset.license must instead be an object with source/commercialUse/attribution; retain the returned descriptor. Worth setting for anything downloaded: an ' +
1196
1209
  'asset whose provenance is unrecorded is one nobody can safely ship.',
1197
1210
  },
1198
1211
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-tool",
3
- "version": "0.15.0",
3
+ "version": "0.18.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.15.0"
14
+ "@deepblend/dsh-blender-contracts": "0.18.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {