@deepblend/dsh-blender-tool 0.2.19 → 0.3.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/inspection.js +102 -0
- package/lib/shared.js +15 -10
- package/lib/tools.js +122 -30
- package/lib/visual-tools.js +15 -6
- package/package.json +2 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { BlenderError, BlenderErrorCode, warning } from '@deepblend/dsh-blender-contracts'
|
|
3
|
+
import { canonicalCall, definedFields, persistImage, renderSuccess } from './shared.js'
|
|
4
|
+
|
|
5
|
+
const REQUIRED_HOST_API = 6
|
|
6
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
function aborted(signal) {
|
|
9
|
+
if (signal?.aborted) {
|
|
10
|
+
throw new BlenderError(BlenderErrorCode.ABORTED,
|
|
11
|
+
'Inspection cancelled. A diagnostic already published by the Host may remain in the revision.')
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function receiptError(message) {
|
|
16
|
+
throw new BlenderError(BlenderErrorCode.SCRIPT_ERROR, `Inspection receipt verification failed: ${message}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Render and attach one revision-bound inspection. All IO happens during execute. */
|
|
20
|
+
export async function renderInspection(resolved, request) {
|
|
21
|
+
const { studio, attachments } = resolved
|
|
22
|
+
const { projectId, revision, mode, cameraId, frame, signal } = request
|
|
23
|
+
aborted(signal)
|
|
24
|
+
if (!['beauty', 'clay'].includes(mode) || typeof revision !== 'string' || !revision.trim() ||
|
|
25
|
+
typeof cameraId !== 'string' || !cameraId.trim() || !Number.isSafeInteger(frame)) {
|
|
26
|
+
throw new BlenderError(BlenderErrorCode.RENDER_RANGE_INVALID,
|
|
27
|
+
'An isolated inspection requires mode beauty or clay, an explicit revision, cameraId and integer frame.')
|
|
28
|
+
}
|
|
29
|
+
const reported = typeof studio.hostApiVersion === 'function' ? studio.hostApiVersion() : null
|
|
30
|
+
const missingMethods = ['renderViews', 'readArtifact'].filter(name => typeof studio[name] !== 'function')
|
|
31
|
+
if (!Number.isFinite(reported) || reported < REQUIRED_HOST_API || missingMethods.length) {
|
|
32
|
+
throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE,
|
|
33
|
+
'Isolated inspections require DeepBlend Host API 6 or newer. Upgrade the Host if needed, then restart the profile.',
|
|
34
|
+
{ detail: { requiredHostApiVersion: REQUIRED_HOST_API,
|
|
35
|
+
reportedHostApiVersion: Number.isFinite(reported) ? reported : null, missingMethods,
|
|
36
|
+
fix: 'upgrade the DeepBlend Host if needed, then restart the profile' } })
|
|
37
|
+
}
|
|
38
|
+
const { data, canonicalWarnings } = await canonicalCall(studio.renderViews({
|
|
39
|
+
...definedFields({ projectId, revision, mode, width: request.width, height: request.height, samples: request.samples }),
|
|
40
|
+
views: [{ id: 'inspection', cameraId, frame }], signal,
|
|
41
|
+
}), warning)
|
|
42
|
+
aborted(signal)
|
|
43
|
+
if (data.schemaVersion !== 'deepblend.diagnostic/v1' || data.projectId !== projectId ||
|
|
44
|
+
data.revision !== revision || data.sourceRevision !== revision || data.mode !== mode ||
|
|
45
|
+
typeof data.sourceDigest !== 'string' || !data.sourceDigest ||
|
|
46
|
+
!Array.isArray(data.artifacts) || data.artifacts.length !== 1) receiptError('source or mode does not match the request.')
|
|
47
|
+
const artifact = data.artifacts[0]
|
|
48
|
+
if (artifact?.kind !== 'diagnostic' || artifact.mode !== mode || artifact.sourceRevision !== revision ||
|
|
49
|
+
artifact.sourceDigest !== data.sourceDigest || artifact.viewId !== 'inspection' ||
|
|
50
|
+
artifact.cameraId !== cameraId || artifact.frame !== frame || artifact.mime !== 'image/png' ||
|
|
51
|
+
typeof artifact.path !== 'string' || !artifact.path.startsWith(`revisions/${revision}/diagnostics/`) ||
|
|
52
|
+
artifact.path.split('/').some(part => !part || part === '.' || part === '..') ||
|
|
53
|
+
typeof artifact.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.sha256) ||
|
|
54
|
+
!Number.isSafeInteger(artifact.bytes) || artifact.bytes <= 0 || artifact.bytes > MAX_IMAGE_BYTES ||
|
|
55
|
+
!Number.isSafeInteger(artifact.width) || artifact.width <= 0 || artifact.width > 2048 ||
|
|
56
|
+
!Number.isSafeInteger(artifact.height) || artifact.height <= 0 || artifact.height > 2048) {
|
|
57
|
+
receiptError('artifact does not match the requested view or image limits.')
|
|
58
|
+
}
|
|
59
|
+
const read = await studio.readArtifact({ projectId, path: artifact.path })
|
|
60
|
+
aborted(signal)
|
|
61
|
+
if (read?.path !== artifact.path || read.contentType !== 'image/png' || !Buffer.isBuffer(read.bytes) ||
|
|
62
|
+
read.size !== artifact.bytes || read.bytes.length !== artifact.bytes ||
|
|
63
|
+
createHash('sha256').update(read.bytes).digest('hex') !== artifact.sha256) {
|
|
64
|
+
receiptError('the stored PNG differs from the published artifact.')
|
|
65
|
+
}
|
|
66
|
+
let image = null
|
|
67
|
+
let note = null
|
|
68
|
+
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`)
|
|
80
|
+
aborted(signal)
|
|
81
|
+
image = attached.image
|
|
82
|
+
note = attached.note
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
aborted(signal)
|
|
85
|
+
image = null
|
|
86
|
+
// The render succeeded. Do not prompt an expensive re-render for an attachment failure.
|
|
87
|
+
note = `The inspection PNG was rendered and verified, but could not be attached: ${cause?.message ?? String(cause)}. ` +
|
|
88
|
+
'The model cannot see it in this result. Its project-relative path is in the payload; read it with a file-read tool.'
|
|
89
|
+
}
|
|
90
|
+
aborted(signal)
|
|
91
|
+
const notes = [
|
|
92
|
+
`Source revision: ${revision}; mode: ${mode}; camera: ${cameraId}; frame: ${frame}.`,
|
|
93
|
+
`Image: ${artifact.path} (${artifact.width}x${artifact.height}, sha256 ${artifact.sha256}).`,
|
|
94
|
+
'This independent inspection was rebuilt from SceneSpec. The source revision and normal previews are unchanged.',
|
|
95
|
+
'It does not establish technical or artistic approval.',
|
|
96
|
+
...(data.limitations ?? []),
|
|
97
|
+
...(note ? [note] : []),
|
|
98
|
+
]
|
|
99
|
+
return { ok: true, data, image,
|
|
100
|
+
text: renderSuccess(`Rendered ${mode} inspection of ${revision}.`, data,
|
|
101
|
+
{ notes, warnings: [...(data.warnings ?? []), ...canonicalWarnings] }) }
|
|
102
|
+
}
|
package/lib/shared.js
CHANGED
|
@@ -205,16 +205,21 @@ export const TOOL_OUTPUT_WITH_IMAGE = {
|
|
|
205
205
|
text: { type: 'string', required: true },
|
|
206
206
|
data: { type: 'object', additionalProperties: true, required: true },
|
|
207
207
|
image: {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
208
|
+
oneOf: [
|
|
209
|
+
{ type: 'null' },
|
|
210
|
+
{
|
|
211
|
+
type: 'object',
|
|
212
|
+
additionalProperties: true,
|
|
213
|
+
properties: {
|
|
214
|
+
attachmentId: { type: 'string', required: true },
|
|
215
|
+
mediaType: { type: 'string', required: true },
|
|
216
|
+
bytes: { type: 'number', required: true },
|
|
217
|
+
width: { type: 'number', required: true },
|
|
218
|
+
height: { type: 'number', required: true },
|
|
219
|
+
name: { type: 'string' },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
],
|
|
218
223
|
},
|
|
219
224
|
},
|
|
220
225
|
},
|
package/lib/tools.js
CHANGED
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
30
|
+
import { renderInspection } from './inspection.js'
|
|
30
31
|
|
|
31
32
|
import {
|
|
32
33
|
SCENE_OPERATION_NAMES,
|
|
34
|
+
BlenderError,
|
|
33
35
|
BlenderErrorCode,
|
|
34
36
|
BlenderWarningCode,
|
|
35
37
|
warning,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
|
|
38
40
|
import {
|
|
39
41
|
TOOL_OUTPUT,
|
|
42
|
+
TOOL_OUTPUT_WITH_IMAGE,
|
|
40
43
|
canonicalCall,
|
|
41
44
|
definedFields,
|
|
42
45
|
describeRevision,
|
|
@@ -57,15 +60,25 @@ const SCENE_SPEC_PARAMETER = {
|
|
|
57
60
|
type: 'object',
|
|
58
61
|
additionalProperties: true,
|
|
59
62
|
description:
|
|
60
|
-
'A complete SceneSpec v1 document to seed the project with. Omit
|
|
63
|
+
'A complete SceneSpec v1 document to seed the project with. Mutually exclusive with recipe. Omit both to start from a minimal, ' +
|
|
61
64
|
'immediately renderable scaffold (one cube, one camera, one area light, both render profiles), ' +
|
|
62
65
|
'then build the scene with blender_scene_patch — that route is usually better, because each patch ' +
|
|
63
66
|
'is a reviewable revision instead of one large opaque document. ' +
|
|
64
67
|
'Top-level keys: schemaVersion, project, assets[], materials[], entities[], lights[], cameras[], ' +
|
|
65
|
-
'shots[], animationTracks[], renderProfiles{preview,final}. ' +
|
|
68
|
+
'shots[], animationTracks[], renderProfiles{preview,final}. project.reviewSubjectId optionally selects an existing non-empty main review entity without moving cameras. ' +
|
|
66
69
|
'A generator entity is `{id,type:"generator",generator:{shape,size|radius|depth|majorRadius|minorRadius,bevel?},' +
|
|
67
70
|
'materialId,transform:{location,rotationEuler,scale}}` where shape is one of ' +
|
|
68
|
-
'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus.
|
|
71
|
+
'cube, rounded_box, uv_sphere, cylinder, cone, plane, torus, lathe, curve, handled_cup. handled_cup builds one closed cup/handle mesh with flared shared-boundary roots. ' +
|
|
72
|
+
'Defaults are radius 0.04, height 0.105, wallThickness 0.003, baseThickness 0.005; optional handleRadius, handleLower, handleUpper, rootRadius, rootLength and mesh segments obey coupled constraints. Inspect root highlights; mesh checks do not certify aesthetic quality. Curve sweeps a round section ' +
|
|
73
|
+
'of radius (default 0.01) along path:[[x,y,z],…]; pathInterpolation is poly or bezier, pathClosed closes ' +
|
|
74
|
+
'the loop, and capEnds seals open endpoints. Lathe uses profile:[[radius,height],…] ' +
|
|
75
|
+
'around local Z, segments (default 96), capEnds (default true) and optional closedProfile for hollow ' +
|
|
76
|
+
'sections. Use enough profile points to describe smooth shoulders and rounded lips; points form straight segments. ' +
|
|
77
|
+
'Entity modifiers[] apply in order: {type:"solidify",thickness,offset?}, {type:"mirror",axis:"x"|"y"|"z",merge?}, ' +
|
|
78
|
+
'{type:"array",count,offset:[x,y,z]}, {type:"boolean",operation:"union"|"difference"|"intersect",targetEntityId}, ' +
|
|
79
|
+
'or {type:"bevel",width,segments?,angle?,miterInner?:"arc"|"sharp"}. Place bevel after boolean to round its new edges; segments default 4, angle defaults 30 degrees, miterInner defaults arc for compatibility; sharp can avoid inner-corner artifacts around curved bores. ' +
|
|
80
|
+
'Boolean operands must be separate single-mesh entities; visible:false hides an operand while preserving its geometry for the operation. ' +
|
|
81
|
+
'Material parameters use Blender 5 socket ' +
|
|
69
82
|
'names: baseColor [r,g,b,a], metallic, roughness, ior, alpha, emissionColor, emissionStrength, ' +
|
|
70
83
|
'coatWeight, transmissionWeight. A camera aims itself at `targetEntityId` (or `targetPoint`) and needs ' +
|
|
71
84
|
'`transform.location` for framing.',
|
|
@@ -81,6 +94,7 @@ const SCENE_SPEC_PARAMETER = {
|
|
|
81
94
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
82
95
|
*/
|
|
83
96
|
export function apply(ctx) {
|
|
97
|
+
ctx.tools.register(recipeList(ctx))
|
|
84
98
|
ctx.tools.register(projectCreate(ctx))
|
|
85
99
|
ctx.tools.register(projectGet(ctx))
|
|
86
100
|
ctx.tools.register(sceneGet(ctx))
|
|
@@ -91,6 +105,33 @@ export function apply(ctx) {
|
|
|
91
105
|
ctx.tools.register(assetIngest(ctx))
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
/** Read verified local package metadata through the Host; never load a package in the tool plane. */
|
|
109
|
+
function recipeList(ctx) {
|
|
110
|
+
return defineTool({
|
|
111
|
+
name: 'blender_recipe_list',
|
|
112
|
+
description:
|
|
113
|
+
'List locally available, validated product recipes without launching Blender or creating a project. ' +
|
|
114
|
+
'Returns recipes with id, version, digest, title, author, declared license, source, preview URL and ' +
|
|
115
|
+
'finite parameter definitions, plus errors for packages that could not be accepted. Content hashes ' +
|
|
116
|
+
'check loaded bytes; they do not certify authorship or artistic quality. Use the returned id, version ' +
|
|
117
|
+
'and digest together in blender_project_create.recipe. Only pass parameter IDs and ranges declared ' +
|
|
118
|
+
'by that recipe. This tool never downloads or installs anything.',
|
|
119
|
+
parameters: {},
|
|
120
|
+
output: TOOL_OUTPUT,
|
|
121
|
+
async execute() {
|
|
122
|
+
const resolved = resolveStudio(ctx)
|
|
123
|
+
if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
|
|
124
|
+
try {
|
|
125
|
+
const { data, canonicalWarnings } = await canonicalCall(resolved.studio.listRecipes(), warning)
|
|
126
|
+
return { ok: true, text: renderSuccess('Available product recipes.', data, { warnings: canonicalWarnings }), data }
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
return { ok: false, ...renderFailure(cause, 'RECIPE_LIST_FAILED') }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
presentCall: () => ({ card: 'generic', title: 'List product recipes', kind: 'read' }),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
94
135
|
// ---------------------------------------------------------------------------
|
|
95
136
|
// project_create
|
|
96
137
|
// ---------------------------------------------------------------------------
|
|
@@ -102,7 +143,9 @@ function projectCreate(ctx) {
|
|
|
102
143
|
'Create a DeepBlend 3D project and commit its first revision. Returns the projectId and the initial ' +
|
|
103
144
|
'revision id — keep BOTH, because every later write must name the project and the revision it read. ' +
|
|
104
145
|
'Give it the operator\'s brief as `goal`: it is stored with the project and never parsed, so write it ' +
|
|
105
|
-
'in full.
|
|
146
|
+
'in full. For an existing product design, call blender_recipe_list and pass its id/version/digest ' +
|
|
147
|
+
'as recipe, with only its declared parameter overrides. recipe and sceneSpec are mutually exclusive. ' +
|
|
148
|
+
'Omit both to start from a minimal renderable scaffold and build up with ' +
|
|
106
149
|
'blender_scene_patch; pass one only when you already know the whole scene. ' +
|
|
107
150
|
'Set saveCheckpoint:false to skip the Blender compile and store the SceneSpec alone. The revision then ' +
|
|
108
151
|
'has no .blend of its own, and the first render of it compiles one from the spec — which costs a few ' +
|
|
@@ -122,6 +165,16 @@ function projectCreate(ctx) {
|
|
|
122
165
|
'The operator\'s natural-language brief, stored verbatim for audit. It never affects compilation.',
|
|
123
166
|
},
|
|
124
167
|
sceneSpec: SCENE_SPEC_PARAMETER,
|
|
168
|
+
recipe: {
|
|
169
|
+
type: 'object', additionalProperties: false,
|
|
170
|
+
description: 'An exact local recipe selection returned by blender_recipe_list. The Host rechecks package bytes before creating the project; do not combine with sceneSpec.',
|
|
171
|
+
properties: {
|
|
172
|
+
id: { type: 'string', required: true, description: 'Recipe id from the catalog.' },
|
|
173
|
+
version: { type: 'string', required: true, description: 'Recipe version from the catalog.' },
|
|
174
|
+
digest: { type: 'string', required: true, description: 'Exact current catalog digest; refresh the catalog if the package changed.' },
|
|
175
|
+
parameters: { type: 'object', additionalProperties: true, description: 'Optional parameter-ID/value overrides from this recipe. Unknown keys or invalid ranges are refused by the Host; omitted values use defaults. Color values are three scene-linear RGB numbers.' },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
125
178
|
projectId: {
|
|
126
179
|
type: 'string',
|
|
127
180
|
description:
|
|
@@ -132,7 +185,7 @@ function projectCreate(ctx) {
|
|
|
132
185
|
type: 'boolean',
|
|
133
186
|
description:
|
|
134
187
|
'Compile the scene in Blender and store `<revision>/scene.blend` as a checkpoint. Default true. ' +
|
|
135
|
-
'
|
|
188
|
+
'If false, the first preview compiles this revision lazily before rendering.',
|
|
136
189
|
},
|
|
137
190
|
renderPreview: {
|
|
138
191
|
type: 'boolean',
|
|
@@ -145,11 +198,13 @@ function projectCreate(ctx) {
|
|
|
145
198
|
const resolved = resolveStudio(ctx)
|
|
146
199
|
if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
|
|
147
200
|
try {
|
|
201
|
+
if (args.recipe !== undefined && args.sceneSpec !== undefined) throw new BlenderError('RECIPE_REQUEST_INVALID', 'Pass either recipe or sceneSpec, not both')
|
|
148
202
|
const { data, canonicalWarnings } = await canonicalCall(resolved.studio.createProject({
|
|
149
203
|
...definedFields({
|
|
150
204
|
title: args.title,
|
|
151
205
|
goal: args.goal,
|
|
152
206
|
sceneSpec: args.sceneSpec,
|
|
207
|
+
recipe: args.recipe,
|
|
153
208
|
projectId: args.projectId,
|
|
154
209
|
saveCheckpoint: args.saveCheckpoint,
|
|
155
210
|
}),
|
|
@@ -162,7 +217,7 @@ function projectCreate(ctx) {
|
|
|
162
217
|
`Digest: ${data.revision.digest}`,
|
|
163
218
|
]
|
|
164
219
|
if (data.revision.checkpoint === null) {
|
|
165
|
-
notes.push('No checkpoint was saved
|
|
220
|
+
notes.push('No checkpoint was saved. The first preview will compile this revision lazily.')
|
|
166
221
|
}
|
|
167
222
|
if (Array.isArray(data.warnings) && data.warnings.length > 0) {
|
|
168
223
|
notes.push('')
|
|
@@ -303,6 +358,7 @@ function sceneGet(ctx) {
|
|
|
303
358
|
)
|
|
304
359
|
}
|
|
305
360
|
notes.push('')
|
|
361
|
+
if (data.assetParts?.length) notes.push(`Imported mesh parts (use sourceMaterialSlots indices for binding; materialSlots are the current result): ${JSON.stringify(data.assetParts)}`, '')
|
|
306
362
|
notes.push('Cameras:')
|
|
307
363
|
for (const camera of data.cameras ?? []) {
|
|
308
364
|
notes.push(
|
|
@@ -360,8 +416,8 @@ const OPERATION_SUMMARY = [
|
|
|
360
416
|
'entity.add {entity} — full entity object',
|
|
361
417
|
'entity.remove {entityId}',
|
|
362
418
|
'entity.material.set {entityId, materialId|null} — null restores the default material',
|
|
363
|
-
'material.add {material} — {id, shader, parameters?}',
|
|
364
|
-
'material.parameter.update {materialId, parameter, value} — parameter is
|
|
419
|
+
'material.add {material} — {id, shader, parameters?, tangent?}',
|
|
420
|
+
'material.parameter.update {materialId, parameter, value} — SceneSpec camelCase parameter; anisotropic and anisotropicRotation use [0,1], rotation is turns; nonzero values/animation require explicit tangent and active anisotropy requires Cycles',
|
|
365
421
|
'light.add {light}',
|
|
366
422
|
'light.update {lightId, energy?, color?, size?, transform?, spotSize?, spotBlend?, angle?}',
|
|
367
423
|
'light.remove {lightId}',
|
|
@@ -374,10 +430,17 @@ const OPERATION_SUMMARY = [
|
|
|
374
430
|
'shot.remove {shotId}',
|
|
375
431
|
'project.frameRange.set {frameStart, frameEnd, fps?}',
|
|
376
432
|
'render.profile.set {profileName, profile} — profileName is "preview" or "final"',
|
|
377
|
-
'world.set {world} — {color?, strength?};
|
|
433
|
+
'world.set {world} — {color?, strength?, environment?:{assetId,rotation?}}; rotation is world-Z radians; environment uses a declared image',
|
|
378
434
|
'asset.add {asset} — {id, type, path, sha256, license?}; the file must be ingested first',
|
|
379
435
|
'asset.remove {assetId} — removing the last one makes the key disappear, not an empty list',
|
|
380
436
|
'material.texture.set {materialId, texture} — {type: noise|wave|voronoi, scale, …}, or null to remove it',
|
|
437
|
+
'entity.generator.set {entityId, generator} — replace generator geometry; preserve identity, material and animation',
|
|
438
|
+
'entity.modifiers.set {entityId, modifiers} — replace the ordered modeling stack; [] removes it',
|
|
439
|
+
'material.images.set {materialId, images} — replace PBR image maps, or null to remove; baseColor/roughness/metallic/normal/alpha/emissionColor bindings name assetId with optional scale, offset, uvMap; scalar maps support channel r/g/b/a, normals support strength',
|
|
440
|
+
'entity.materialBindings.set {entityId, materialBindings} — replace imported mesh bindings; copy partId and sourceMaterialSlots index from scene_get.assetParts; each {partId,materialId,slotIndex?}; [] clears; whole-part then slot override',
|
|
441
|
+
'material.tangent.set {materialId, tangent} — {mode:uv,uvMap:UVMap} uses named mesh UVs; {mode:radial,axis:x|y|z} uses object-local cylindrical tangent; null clears after disabling anisotropy and rotation/animation',
|
|
442
|
+
'project.brief.set {goal, referenceImages} — replace written goal and up to four reference bindings; each {id,assetId,sha256,label,purposes,notes?}, purposes geometry/materials/lighting/goalFit; bind immutable ingested PNG/JPEG assets; empty goal and [] clear',
|
|
443
|
+
'project.reviewSubject.set {entityId} — save the main review entity; null restores automatic selection; entity must exist and be non-empty; does not move any camera',
|
|
381
444
|
].join('\n ')
|
|
382
445
|
|
|
383
446
|
function scenePatch(ctx) {
|
|
@@ -391,8 +454,8 @@ function scenePatch(ctx) {
|
|
|
391
454
|
'fails, no revision is created and the current revision is untouched, so a failed patch is always safe ' +
|
|
392
455
|
'to correct and retry. Retrying an identical patch is also safe — it returns the original outcome ' +
|
|
393
456
|
'instead of committing twice.\n\n' +
|
|
394
|
-
'
|
|
395
|
-
'
|
|
457
|
+
'saveCheckpoint (default true) compiles this revision now. If false, the first preview compiles ' +
|
|
458
|
+
'the revision lazily before rendering.\n\n' +
|
|
396
459
|
'Operations (each object needs an "op" key):\n ' + OPERATION_SUMMARY,
|
|
397
460
|
parameters: {
|
|
398
461
|
projectId: { type: 'string', required: true, description: 'The project id.' },
|
|
@@ -410,8 +473,8 @@ function scenePatch(ctx) {
|
|
|
410
473
|
items: { type: 'object', additionalProperties: true },
|
|
411
474
|
description:
|
|
412
475
|
'Ordered list of operations. Each needs an "op" key naming the operation (see the tool description). ' +
|
|
413
|
-
'Later operations see the result of earlier ones
|
|
414
|
-
'
|
|
476
|
+
'Later operations see the result of earlier ones: remove or retarget every shot that uses a camera ' +
|
|
477
|
+
'before removing that camera.',
|
|
415
478
|
},
|
|
416
479
|
note: {
|
|
417
480
|
type: 'string',
|
|
@@ -489,7 +552,7 @@ function scenePatch(ctx) {
|
|
|
489
552
|
if (data.checkpoint !== null && data.checkpoint !== undefined) {
|
|
490
553
|
notes.push(`Checkpoint: ${data.checkpoint}`)
|
|
491
554
|
} else {
|
|
492
|
-
notes.push('No checkpoint was saved
|
|
555
|
+
notes.push('No checkpoint was saved. The first preview will compile this revision lazily.')
|
|
493
556
|
}
|
|
494
557
|
if ((data.previews ?? []).length > 0) {
|
|
495
558
|
for (const preview of data.previews) {
|
|
@@ -534,9 +597,14 @@ function previewRender(ctx) {
|
|
|
534
597
|
'directly; if it does not, the revision is compiled from its SceneSpec into a scratch directory first ' +
|
|
535
598
|
'(slower, and reported as a warning). Prefer committing patches with saveCheckpoint:true so previews ' +
|
|
536
599
|
'stay cheap. Returns the project-relative path of the image, its dimensions, the engine actually used, ' +
|
|
537
|
-
'and the frame that was rendered.'
|
|
600
|
+
'and the frame that was rendered. Set mode beauty or clay for an independent inspection rebuilt from ' +
|
|
601
|
+
'SceneSpec with an image attachment; this requires Host API 6 and explicit revision, cameraId and frame. ' +
|
|
602
|
+
'Clay replaces subject surface materials with gray, removing transparency, emission and textures; ' +
|
|
603
|
+
'imported shader displacement may change the rendered shape. It does not approve technical or artistic quality.',
|
|
538
604
|
parameters: {
|
|
539
605
|
projectId: { type: 'string', required: true, description: 'The project id.' },
|
|
606
|
+
mode: { type: 'string', enum: ['beauty', 'clay'],
|
|
607
|
+
description: 'Independent fixed-view inspection with an image attachment. Requires explicit revision, cameraId and frame. Omit for normal checkpoint preview.' },
|
|
540
608
|
revision: { type: 'string', description: 'Revision to render. Defaults to the current one.' },
|
|
541
609
|
cameraId: {
|
|
542
610
|
type: 'string',
|
|
@@ -557,11 +625,16 @@ function previewRender(ctx) {
|
|
|
557
625
|
width: { type: 'integer', description: 'Override preview width in pixels.' },
|
|
558
626
|
height: { type: 'integer', description: 'Override preview height in pixels.' },
|
|
559
627
|
},
|
|
560
|
-
output:
|
|
628
|
+
output: TOOL_OUTPUT_WITH_IMAGE,
|
|
561
629
|
async execute(args, exec) {
|
|
562
630
|
const resolved = resolveStudio(ctx)
|
|
563
631
|
if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
|
|
564
632
|
try {
|
|
633
|
+
if (args.mode !== undefined) return await renderInspection(resolved, {
|
|
634
|
+
projectId: args.projectId, revision: args.revision, mode: args.mode,
|
|
635
|
+
cameraId: args.cameraId, frame: args.frame, width: args.width, height: args.height,
|
|
636
|
+
samples: args.samples, signal: exec.signal,
|
|
637
|
+
})
|
|
565
638
|
const { data, canonicalWarnings } = await canonicalCall(resolved.studio.renderPreview({
|
|
566
639
|
...definedFields({
|
|
567
640
|
projectId: args.projectId,
|
|
@@ -599,7 +672,9 @@ function previewRender(ctx) {
|
|
|
599
672
|
},
|
|
600
673
|
presentCall: args => ({
|
|
601
674
|
card: 'generic',
|
|
602
|
-
title:
|
|
675
|
+
title: args?.mode
|
|
676
|
+
? `Render ${args.mode} inspection of "${args.projectId}" · ${args.revision ?? '?'} · ${args.cameraId ?? '?'} · frame ${args.frame ?? '?'}`
|
|
677
|
+
: `Render preview of "${args?.projectId ?? ''}"${args?.cameraId ? ` from ${args.cameraId}` : ''}`,
|
|
603
678
|
kind: 'other',
|
|
604
679
|
}),
|
|
605
680
|
})
|
|
@@ -742,6 +817,8 @@ function revisionRestore(ctx) {
|
|
|
742
817
|
description:
|
|
743
818
|
'Move a project back to an earlier revision. Read blender_project_get first and pass the exact ' +
|
|
744
819
|
'revision id from its history — this does not take an index, a timestamp or "the previous one". ' +
|
|
820
|
+
'Pass the currentRevision you just read as expectedCurrentRevision so the Host refuses the restore ' +
|
|
821
|
+
'if another edit changed the current revision. ' +
|
|
745
822
|
'Nothing is deleted: the revisions after the target stay in the history, and the revision you ' +
|
|
746
823
|
'leave is still there to restore again. Requires confirm:true, because the scene the model is ' +
|
|
747
824
|
'reasoning about changes underneath it — after a restore, re-read with blender_scene_get before ' +
|
|
@@ -753,6 +830,11 @@ function revisionRestore(ctx) {
|
|
|
753
830
|
required: true,
|
|
754
831
|
description: 'The revision to move the project to, exactly as blender_project_get reports it (e.g. "r0003").',
|
|
755
832
|
},
|
|
833
|
+
expectedCurrentRevision: {
|
|
834
|
+
type: 'string',
|
|
835
|
+
description: 'The currentRevision just read from blender_project_get. If the current pointer changed, ' +
|
|
836
|
+
'the Host refuses with REVISION_CONFLICT before moving it. Optional for older callers; supply it to protect other edits.',
|
|
837
|
+
},
|
|
756
838
|
confirm: {
|
|
757
839
|
type: 'boolean',
|
|
758
840
|
required: true,
|
|
@@ -765,20 +847,17 @@ function revisionRestore(ctx) {
|
|
|
765
847
|
const resolved = resolveStudio(ctx)
|
|
766
848
|
if (resolved.unavailable !== undefined) return { ok: false, ...resolved.unavailable }
|
|
767
849
|
|
|
768
|
-
// NOTE ON THE ABSENT `confirm` CHECK. An earlier version of this tool ran a
|
|
769
|
-
// hand-written refusal when `args.confirm !== true`. It was unreachable: the
|
|
770
|
-
// parameter is declared `required`, so the harness rejects a call that omits it
|
|
771
|
-
// with `INVALID_ARGS` before `execute` is entered — measured while writing this
|
|
772
|
-
// suite, and it is why the branch is gone. A guard that cannot fire reads like
|
|
773
|
-
// protection and is not, which is the same defect as a test that cannot fail.
|
|
774
|
-
// The confirmation this tool offers IS the schema requirement, and
|
|
775
|
-
// `composition/tool-plane-m3.e2e.mjs` asserts both halves: that it is declared,
|
|
776
|
-
// and that omitting it never reaches the host.
|
|
777
850
|
try {
|
|
851
|
+
// A required boolean still accepts false. Refuse it before calling the Host.
|
|
852
|
+
if (args.confirm !== true) {
|
|
853
|
+
throw new BlenderError('REVISION_RESTORE_CONFIRMATION_REQUIRED',
|
|
854
|
+
'Restore requires confirm:true. No revision was changed.')
|
|
855
|
+
}
|
|
778
856
|
const { data, canonicalWarnings } = await canonicalCall(
|
|
779
857
|
resolved.studio.restoreRevision(definedFields({
|
|
780
858
|
projectId: args.projectId,
|
|
781
859
|
revision: args.revision,
|
|
860
|
+
expectedCurrentRevision: args.expectedCurrentRevision,
|
|
782
861
|
})),
|
|
783
862
|
warning,
|
|
784
863
|
)
|
|
@@ -809,6 +888,17 @@ function revisionRestore(ctx) {
|
|
|
809
888
|
}
|
|
810
889
|
} catch (cause) {
|
|
811
890
|
const failure = renderFailure(cause, 'REVISION_RESTORE_FAILED')
|
|
891
|
+
if (failure.data.errorCode === BlenderErrorCode.REVISION_CONFLICT) {
|
|
892
|
+
failure.text = [
|
|
893
|
+
'DeepBlend restore refused.',
|
|
894
|
+
`errorCode: ${failure.data.errorCode}`,
|
|
895
|
+
`message: ${failure.data.message}`,
|
|
896
|
+
...(failure.data.detail == null ? [] : [`detail: ${JSON.stringify(failure.data.detail)}`]),
|
|
897
|
+
'',
|
|
898
|
+
'Read blender_project_get again and review the intervening changes before deciding whether to restore. ' +
|
|
899
|
+
'If restoring is still intended, pass its currentRevision as expectedCurrentRevision.',
|
|
900
|
+
].join('\n')
|
|
901
|
+
}
|
|
812
902
|
return { ok: false, ...failure }
|
|
813
903
|
}
|
|
814
904
|
},
|
|
@@ -858,13 +948,13 @@ function assetIngest(ctx) {
|
|
|
858
948
|
'Bring a 3D model file into a project, so a scene can instantiate it. Give EITHER sourcePath (a ' +
|
|
859
949
|
'file already on this machine) OR sourceUrl (an http/https address); a local file is imported ' +
|
|
860
950
|
'directly, a remote one needs the operator\'s approval first, so it will pause and ask. ' +
|
|
861
|
-
'\n\nThis does NOT change the scene. It copies the bytes into
|
|
951
|
+
'\n\nThis does NOT change the scene. It copies the bytes into assets/raw/ or a locked glTF resource bundle, records ' +
|
|
862
952
|
'them in assets/manifest.json, and returns an assetId, a project-relative path and a sha256. ' +
|
|
863
953
|
'Declare it with blender_scene_patch {op: "asset.add", asset: {...}} and then add an entity of ' +
|
|
864
954
|
'type "asset-instance" with that assetId — those two steps are what put it in the scene, and ' +
|
|
865
955
|
'blender_scene_validate will tell you if the format cannot be imported by this Blender build. ' +
|
|
866
|
-
'\n\nSupported formats: glb, gltf, fbx, obj, usd, blend. The size ceiling is the deployment\'s ' +
|
|
867
|
-
'assetMaxBytes (SPEC §15).',
|
|
956
|
+
'\n\nSupported formats: glb, gltf, fbx, obj, usd, blend, png, jpg, jpeg, hdr, exr. The size ceiling is the deployment\'s ' +
|
|
957
|
+
'assetMaxBytes (SPEC §15). Local glTF/GLB includes buffers/images; OBJ includes MTL files and referenced textures. sourceRoot selects their containing directory. Remote external dependencies are refused.',
|
|
868
958
|
parameters: {
|
|
869
959
|
projectId: { type: 'string', required: true, description: 'The project to bring the asset into.' },
|
|
870
960
|
sourcePath: {
|
|
@@ -872,6 +962,7 @@ function assetIngest(ctx) {
|
|
|
872
962
|
description: 'Absolute path to a local file. No approval needed. Use this whenever the file is ' +
|
|
873
963
|
'already on the machine — it is faster and it does not leave the machine.',
|
|
874
964
|
},
|
|
965
|
+
sourceRoot: { type: 'string', description: 'Optional local glTF/GLB/OBJ resource root containing the model and all relative buffer, material and image files. Defaults to its directory; files outside this root are refused.' },
|
|
875
966
|
sourceUrl: {
|
|
876
967
|
type: 'string',
|
|
877
968
|
description: 'An http or https URL. Requires the operator\'s approval, which this tool asks for ' +
|
|
@@ -884,7 +975,7 @@ function assetIngest(ctx) {
|
|
|
884
975
|
},
|
|
885
976
|
type: {
|
|
886
977
|
type: 'string',
|
|
887
|
-
description: 'Asset type, when the file extension does not say it. One of glb, gltf, fbx, obj, usd, blend.',
|
|
978
|
+
description: 'Asset type, when the file extension does not say it. One of glb, gltf, fbx, obj, usd, blend, png, jpg, jpeg, hdr, exr.',
|
|
888
979
|
},
|
|
889
980
|
license: {
|
|
890
981
|
type: 'string',
|
|
@@ -900,6 +991,7 @@ function assetIngest(ctx) {
|
|
|
900
991
|
const ingestRequest = {
|
|
901
992
|
projectId: args.projectId,
|
|
902
993
|
sourcePath: args.sourcePath,
|
|
994
|
+
sourceRoot: args.sourceRoot,
|
|
903
995
|
sourceUrl: args.sourceUrl,
|
|
904
996
|
assetId: args.assetId,
|
|
905
997
|
type: args.type,
|
package/lib/visual-tools.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* they are validated against the review they claim to describe.
|
|
21
21
|
*
|
|
22
22
|
* `blender_visual_autofix` the host-owned repair LOOP: propose, apply, re-render,
|
|
23
|
-
* re-score, and adopt
|
|
23
|
+
* re-score, and adopt supported artistic improvements without technical regression, bounded by the iteration cap
|
|
24
24
|
* and the repeated-issue rule. It returns a handover package when it stops short
|
|
25
25
|
* of passing.
|
|
26
26
|
*
|
|
@@ -279,7 +279,8 @@ export function describeReviewNotes(data) {
|
|
|
279
279
|
const reviewerUnavailable = reviewerError !== null && reviewerError !== undefined
|
|
280
280
|
const notes = [
|
|
281
281
|
`Revision: ${data.revision} (rendered from ${data.checkpointRevision})`,
|
|
282
|
-
`Score: ${data.score}/100 — ${data.pass ? 'PASSES' : 'does NOT pass'} the
|
|
282
|
+
`Score: ${data.score}/100 — ${data.pass ? 'PASSES' : 'does NOT pass'} the technical threshold`,
|
|
283
|
+
`Artistic: ${data.artistic?.status ?? 'unassessable'} — geometry, materials, lighting and goal fit`,
|
|
283
284
|
`Subject: ${data.subjectId ?? '(none tagged)'}`,
|
|
284
285
|
`Parts: ${(data.parts ?? []).length > 0 ? data.parts.join(', ') + ' (declared subject-part: they ARE the subject, so they cannot be in its way)' : '(none declared; only the subject is judged for occlusion)'}`,
|
|
285
286
|
'',
|
|
@@ -318,6 +319,9 @@ export function describeReviewNotes(data) {
|
|
|
318
319
|
notes.push(`The reviewer proposed ${data.suggestedOperations.length} ScenePatch operation(s); apply them with`)
|
|
319
320
|
notes.push('blender_scene_patch if you agree, or run blender_visual_autofix to let the host try them.')
|
|
320
321
|
}
|
|
322
|
+
for (const [dimension, assessment] of Object.entries(data.artistic?.dimensions ?? {})) {
|
|
323
|
+
notes.push(`Artistic ${dimension}: ${assessment.status} — ${assessment.evidence ?? 'insufficient evidence'}`)
|
|
324
|
+
}
|
|
321
325
|
return notes
|
|
322
326
|
}
|
|
323
327
|
|
|
@@ -336,7 +340,8 @@ export function describeReviewNotes(data) {
|
|
|
336
340
|
*/
|
|
337
341
|
export function describeLoopNotes(data) {
|
|
338
342
|
const notes = [
|
|
339
|
-
`Score: ${data.startScore} -> ${data.finalScore} (${data.passed ? 'PASSES' : '
|
|
343
|
+
`Score: ${data.startScore} -> ${data.finalScore} (${data.passed ? 'PASSES technical and artistic review' : 'review incomplete or needs work'})`,
|
|
344
|
+
`Artistic: ${data.artistic?.status ?? 'unassessable'}`,
|
|
340
345
|
`Revision: ${data.startRevision} -> ${data.finalRevision}`,
|
|
341
346
|
`Rounds: ${data.iterations} of ${data.maxIterations} used`,
|
|
342
347
|
`Stopped: ${data.stopReason}`,
|
|
@@ -353,6 +358,9 @@ export function describeLoopNotes(data) {
|
|
|
353
358
|
notes.push(` saw: [${finding.category}] ${finding.evidence}`)
|
|
354
359
|
}
|
|
355
360
|
}
|
|
361
|
+
for (const [dimension, assessment] of Object.entries(data.artistic?.dimensions ?? {})) {
|
|
362
|
+
notes.push(`Artistic ${dimension}: ${assessment.status} — ${assessment.evidence ?? 'insufficient evidence'}`)
|
|
363
|
+
}
|
|
356
364
|
if ((data.openIssues ?? []).length > 0) {
|
|
357
365
|
notes.push('')
|
|
358
366
|
notes.push('Still open:')
|
|
@@ -381,12 +389,13 @@ function visualAutofix(ctx) {
|
|
|
381
389
|
name: 'blender_visual_autofix',
|
|
382
390
|
description:
|
|
383
391
|
'Run the automated visual repair loop on a revision: review, propose a ScenePatch, commit it, render ' +
|
|
384
|
-
'and measure again
|
|
385
|
-
|
|
392
|
+
'and measure again. Keep technical improvements without artistic regression, or evidenced artistic ' +
|
|
393
|
+
'improvements with no technical regression. Passing requires both the technical threshold and ' +
|
|
394
|
+
'an evidenced artistic review. Stops at the iteration cap or repeated unresolved findings. ' +
|
|
386
395
|
'Because it stops on its own, a handover package comes back whenever it stopped short of passing: the ' +
|
|
387
396
|
'revision to work from (never a polluted one), the open issues with their measurements, and concrete ' +
|
|
388
397
|
'next steps.\n\n' +
|
|
389
|
-
'A round that
|
|
398
|
+
'A round that improves neither technical nor artistic quality is rolled back — the revision stays in the history, but the ' +
|
|
390
399
|
'project does not move to a worse one. Every round is reported, including the ones that failed, so you ' +
|
|
391
400
|
'can see what was already tried before proposing another change yourself.\n\n' +
|
|
392
401
|
MEASUREMENT_GLOSSARY,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-tool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.3.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|