@deepblend/dsh-blender-ui 0.1.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/client.js +1381 -0
- package/lib/index.js +599 -0
- package/package.json +33 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deepblend/dsh-blender-ui
|
|
3
|
+
*
|
|
4
|
+
* The DeepBlend **Host half** of the workbench UI (SPEC §5.1, §14).
|
|
5
|
+
*
|
|
6
|
+
* It owns exactly one thing: the typed HTTP surface the browser half reads and
|
|
7
|
+
* writes through. Every handler answers by calling the authoritative
|
|
8
|
+
* `blenderStudio` Host service; nothing here reads a project file, starts a
|
|
9
|
+
* process, or keeps a cache. That is what makes SPEC §14.3 and the M4 acceptance
|
|
10
|
+
* conditions true by construction rather than by review:
|
|
11
|
+
*
|
|
12
|
+
* 不进入文件系统即可管理项目 the browser only ever calls these routes
|
|
13
|
+
* UI 刷新后可从 Host 恢复权威状态 every response is computed per request
|
|
14
|
+
* 浏览器不直接启动 Blender no route exposes an execution entry
|
|
15
|
+
* 所有写操作经过 Host the four write routes call blenderStudio
|
|
16
|
+
*
|
|
17
|
+
* The route table is `UI_ROUTES` in `@deepblend/dsh-blender-contracts`: a path is
|
|
18
|
+
* spelled once, and the handler table below is asserted to be exactly that set —
|
|
19
|
+
* a closed set, because a handler with no route in the table would be an
|
|
20
|
+
* unlisted capability.
|
|
21
|
+
*
|
|
22
|
+
* Owner: DeepBlend Studio — M0 (settings card) / M4 (the workbench API)
|
|
23
|
+
* Plane: Host composition (the Web Client half is `./client.js`)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
27
|
+
import z from '@deepseek-ai/schemastery'
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
BlenderError,
|
|
31
|
+
BlenderErrorCode,
|
|
32
|
+
HOST_API_VERSION,
|
|
33
|
+
UI_PANEL_ID,
|
|
34
|
+
UI_ROUTES,
|
|
35
|
+
UI_ROUTE_PREFIX,
|
|
36
|
+
buildJobView,
|
|
37
|
+
buildProjectView,
|
|
38
|
+
buildQaView,
|
|
39
|
+
buildRevisionDiff,
|
|
40
|
+
buildSceneTree,
|
|
41
|
+
buildSettingsCard,
|
|
42
|
+
matchUiRoute,
|
|
43
|
+
assertKnownConfigKeys,
|
|
44
|
+
} from '@deepblend/dsh-blender-contracts'
|
|
45
|
+
|
|
46
|
+
/** Route path serving the settings card document (M0's surface, kept verbatim). */
|
|
47
|
+
export const CAPABILITIES_ROUTE = '/deepblend/capabilities'
|
|
48
|
+
|
|
49
|
+
/** Service key for the UI-facing host controller. */
|
|
50
|
+
export const BLENDER_UI_SERVICE = 'blenderUi'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Configuration schema.
|
|
54
|
+
*
|
|
55
|
+
* Named `UiConfig`, NOT `Config`: the class below declares `static Config`, so a
|
|
56
|
+
* module-scope `Config` would be shadowed inside the class body and
|
|
57
|
+
* `static Config = Config` would throw a temporal-dead-zone ReferenceError at
|
|
58
|
+
* module evaluation.
|
|
59
|
+
*/
|
|
60
|
+
export const UiConfig = z.object({
|
|
61
|
+
/** Register the HTTP routes on the composed web server. */
|
|
62
|
+
serveRoute: z.boolean().default(true),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** Body ceiling for a POSTed document (a ScenePatch is small; a whole spec is not huge). */
|
|
66
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
67
|
+
|
|
68
|
+
export default class BlenderUiHost extends Service {
|
|
69
|
+
/**
|
|
70
|
+
* `blenderStudio` is a hard dependency: the API has no authority without it.
|
|
71
|
+
*
|
|
72
|
+
* `webServer` is deliberately NOT declared here. It is optional (a headless
|
|
73
|
+
* profile composes none), and it is not guaranteed to be up when this row
|
|
74
|
+
* activates — the UI row necessarily activates *after* `blenderStudio`, which
|
|
75
|
+
* activates after `blenderRuntime` waits on `subprocess`, so the order is not
|
|
76
|
+
* ours to fix. Reading it once in the constructor silently registered no route
|
|
77
|
+
* whenever the server had not started yet, which is why the endpoint answered
|
|
78
|
+
* 404 on a real boot while every service still resolved.
|
|
79
|
+
*
|
|
80
|
+
* `ctx.inject` below is the correct seam: it runs the callback now if the
|
|
81
|
+
* service is already present, and otherwise re-runs it when `webServer`
|
|
82
|
+
* arrives.
|
|
83
|
+
*/
|
|
84
|
+
static inject = ['blenderStudio']
|
|
85
|
+
|
|
86
|
+
static Config = UiConfig
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
90
|
+
* @param {import('z').infer<typeof UiConfig>} config
|
|
91
|
+
*/
|
|
92
|
+
constructor(ctx, config) {
|
|
93
|
+
super(ctx, BLENDER_UI_SERVICE)
|
|
94
|
+
// Same rule as the other two rows (SPEC §17's nested shape is accepted by the schema and read by
|
|
95
|
+
// nobody): a key this row does not read stops the composition instead of doing nothing.
|
|
96
|
+
assertKnownConfigKeys(UiConfig, config, 'deepblend-blender-ui')
|
|
97
|
+
this.config = config
|
|
98
|
+
this._routeDisposer = null
|
|
99
|
+
|
|
100
|
+
// One handler per route id, and no other way in. A test asserts the key set
|
|
101
|
+
// equals UI_ROUTES exactly, in both directions.
|
|
102
|
+
this._handlers = createHandlers(this.ctx)
|
|
103
|
+
|
|
104
|
+
if (!config.serveRoute) return
|
|
105
|
+
|
|
106
|
+
ctx.inject(['webServer'], (serverCtx) => {
|
|
107
|
+
// Route registration belongs to this fiber, so unmounting the row removes
|
|
108
|
+
// the route (SPEC §4.4 / lifecycle: every side effect is reversible).
|
|
109
|
+
const dispose = serverCtx.webServer.register({
|
|
110
|
+
kind: 'prefix',
|
|
111
|
+
path: UI_ROUTE_PREFIX,
|
|
112
|
+
handler: (request, response) => this._handle(request, response),
|
|
113
|
+
})
|
|
114
|
+
this._routeDisposer = dispose
|
|
115
|
+
return () => {
|
|
116
|
+
this._routeDisposer = null
|
|
117
|
+
dispose()
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The handler table, exposed for the closed-set assertion. */
|
|
123
|
+
get handlers() {
|
|
124
|
+
return this._handlers
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Serve one UI request.
|
|
129
|
+
*
|
|
130
|
+
* Always answers with a structured JSON body: a failure the UI can render
|
|
131
|
+
* (`{ ok: false, error: { code, message } }`) is worth more than a transport
|
|
132
|
+
* error, and the browser half has no other source of truth to fall back on.
|
|
133
|
+
*
|
|
134
|
+
* @param {import('node:http').IncomingMessage} request
|
|
135
|
+
* @param {import('node:http').ServerResponse} response
|
|
136
|
+
* @returns {Promise<void>}
|
|
137
|
+
*/
|
|
138
|
+
async _handle(request, response) {
|
|
139
|
+
const url = new URL(request.url ?? '/', 'http://localhost')
|
|
140
|
+
const matched = matchUiRoute(request.method ?? 'GET', url.pathname)
|
|
141
|
+
|
|
142
|
+
if (matched === null) {
|
|
143
|
+
this._sendJson(response, 404, {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: {
|
|
146
|
+
code: BlenderErrorCode.UI_ROUTE_NOT_FOUND,
|
|
147
|
+
message: `${request.method} ${url.pathname} is not a DeepBlend route.`,
|
|
148
|
+
detail: { routes: UI_ROUTES.map(route => `${route.method} ${route.path}`) },
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
if (matched.route.id === 'artifacts.open') {
|
|
156
|
+
await this._sendArtifact(request, response, matched.params)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const body = await readRequestBody(request)
|
|
160
|
+
const result = await this._handlers[matched.route.id]({
|
|
161
|
+
params: matched.params,
|
|
162
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
163
|
+
body,
|
|
164
|
+
})
|
|
165
|
+
this._sendJson(response, 200, { ok: true, route: matched.route.id, hostApiVersion: HOST_API_VERSION, ...result })
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
const error = cause instanceof BlenderError
|
|
168
|
+
? cause
|
|
169
|
+
: new BlenderError(
|
|
170
|
+
BlenderErrorCode.UI_REQUEST_FAILED,
|
|
171
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
172
|
+
)
|
|
173
|
+
this._sendJson(response, statusForError(error), {
|
|
174
|
+
ok: false,
|
|
175
|
+
route: matched.route.id,
|
|
176
|
+
hostApiVersion: HOST_API_VERSION,
|
|
177
|
+
error: error.toJSON(),
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Serve one artifact as bytes.
|
|
184
|
+
*
|
|
185
|
+
* The only route whose response is not JSON, and the only one where a URL
|
|
186
|
+
* parameter reaches the filesystem — so the project id and the path are both
|
|
187
|
+
* handed to `blenderStudio.readArtifact`, which resolves them inside the
|
|
188
|
+
* project directory and refuses anything else (SPEC §15.2).
|
|
189
|
+
*
|
|
190
|
+
* @param {import('node:http').IncomingMessage} request
|
|
191
|
+
* @param {import('node:http').ServerResponse} response
|
|
192
|
+
* @param {Record<string, string>} params
|
|
193
|
+
*/
|
|
194
|
+
async _sendArtifact(request, response, params) {
|
|
195
|
+
const artifact = await this.ctx.blenderStudio.readArtifact({ projectId: params.projectId, path: params.rest })
|
|
196
|
+
response.setHeader('content-type', artifact.contentType)
|
|
197
|
+
// A re-rendered view must never be shown from a cache: the sheet for a
|
|
198
|
+
// revision that was re-reviewed is a different file with the same path.
|
|
199
|
+
response.setHeader('cache-control', 'no-store')
|
|
200
|
+
response.setHeader('content-length', String(artifact.size))
|
|
201
|
+
response.end(request.method === 'HEAD' ? undefined : artifact.bytes)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {import('node:http').ServerResponse} response
|
|
206
|
+
* @param {number} status
|
|
207
|
+
* @param {Record<string, unknown>} payload
|
|
208
|
+
*/
|
|
209
|
+
_sendJson(response, status, payload) {
|
|
210
|
+
const body = JSON.stringify(payload)
|
|
211
|
+
response.setHeader('content-type', 'application/json; charset=utf-8')
|
|
212
|
+
// Nothing here may be cached: every value is a live read of Host state, and a
|
|
213
|
+
// cached copy would let the UI disagree with the tool after a write
|
|
214
|
+
// (SPEC §14.3 「刷新后可从 Host 恢复权威状态」).
|
|
215
|
+
response.setHeader('cache-control', 'no-store')
|
|
216
|
+
response.statusCode = status
|
|
217
|
+
response.end(body)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The settings-card view model.
|
|
222
|
+
*
|
|
223
|
+
* Kept server-side so the browser holds no derivation logic and the card can
|
|
224
|
+
* never drift from what the tool reports (SPEC §14.3).
|
|
225
|
+
*
|
|
226
|
+
* @param {Record<string, any>} data - canonical capabilities
|
|
227
|
+
* @returns {Record<string, unknown>}
|
|
228
|
+
*/
|
|
229
|
+
static buildCard(data) {
|
|
230
|
+
return buildSettingsCard(data)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Where a coded failure lands on the wire.
|
|
236
|
+
*
|
|
237
|
+
* A missing project or revision is a 404 — the UI has to tell "not there" from
|
|
238
|
+
* "the Host refused", and both are normal answers rather than crashes.
|
|
239
|
+
*
|
|
240
|
+
* @param {BlenderError} error
|
|
241
|
+
* @returns {number}
|
|
242
|
+
*/
|
|
243
|
+
export function statusForError(error) {
|
|
244
|
+
switch (error.code) {
|
|
245
|
+
case BlenderErrorCode.PROJECT_NOT_FOUND:
|
|
246
|
+
case BlenderErrorCode.REVISION_NOT_FOUND:
|
|
247
|
+
case BlenderErrorCode.RENDER_JOB_NOT_FOUND:
|
|
248
|
+
case BlenderErrorCode.ARTIFACT_NOT_FOUND:
|
|
249
|
+
case BlenderErrorCode.UI_ROUTE_NOT_FOUND:
|
|
250
|
+
return 404
|
|
251
|
+
case BlenderErrorCode.PATH_OUTSIDE_WORKSPACE:
|
|
252
|
+
case BlenderErrorCode.SCENE_PATCH_INVALID:
|
|
253
|
+
return 400
|
|
254
|
+
case BlenderErrorCode.RENDER_JOB_CONFLICT:
|
|
255
|
+
return 409
|
|
256
|
+
default:
|
|
257
|
+
return 500
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read a request body as JSON.
|
|
263
|
+
*
|
|
264
|
+
* @param {import('node:http').IncomingMessage} request
|
|
265
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
266
|
+
*/
|
|
267
|
+
async function readRequestBody(request) {
|
|
268
|
+
if (request.method === 'GET' || request.method === 'HEAD') return {}
|
|
269
|
+
const chunks = []
|
|
270
|
+
let size = 0
|
|
271
|
+
for await (const chunk of request) {
|
|
272
|
+
size += chunk.length
|
|
273
|
+
if (size > MAX_BODY_BYTES) {
|
|
274
|
+
throw new BlenderError(
|
|
275
|
+
BlenderErrorCode.SCENE_PATCH_INVALID,
|
|
276
|
+
`The request body exceeded ${MAX_BODY_BYTES} bytes.`,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
chunks.push(chunk)
|
|
280
|
+
}
|
|
281
|
+
if (size === 0) return {}
|
|
282
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
283
|
+
let parsed
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(text)
|
|
286
|
+
} catch (cause) {
|
|
287
|
+
throw new BlenderError(
|
|
288
|
+
BlenderErrorCode.SCENE_PATCH_INVALID,
|
|
289
|
+
`The request body is not JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
293
|
+
throw new BlenderError(BlenderErrorCode.SCENE_PATCH_INVALID, 'The request body is not a JSON object.')
|
|
294
|
+
}
|
|
295
|
+
return parsed
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The handler table: one function per route id.
|
|
300
|
+
*
|
|
301
|
+
* Every entry calls `blenderStudio` and projects its answer with a pure builder
|
|
302
|
+
* from the contracts package. There is no filesystem call, no process spawn and
|
|
303
|
+
* no `eval` anywhere in this file — which is what the M4 acceptance condition
|
|
304
|
+
* 「浏览器不直接启动 Blender」 means when it is written as a test rather than as
|
|
305
|
+
* a sentence.
|
|
306
|
+
*
|
|
307
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
308
|
+
* @returns {Record<string, (input: { params: Record<string, string>, query: Record<string, string>, body: Record<string, unknown> }) => Promise<Record<string, unknown>> | Record<string, unknown>>}
|
|
309
|
+
*/
|
|
310
|
+
export function createHandlers(ctx) {
|
|
311
|
+
const studio = () => ctx.blenderStudio
|
|
312
|
+
const approvalThreshold = () => studio().config?.requireApprovalAboveFrames ?? Number.POSITIVE_INFINITY
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
/** SPEC §14.4 `blenderSettings.getCapabilities`; M0's route, unchanged in shape. */
|
|
316
|
+
capabilities: async ({ query }) => {
|
|
317
|
+
const refresh = query.refresh === '1'
|
|
318
|
+
try {
|
|
319
|
+
const data = await studio().describeCapabilities({ refresh })
|
|
320
|
+
return { card: buildSettingsCard(data), data }
|
|
321
|
+
} catch (cause) {
|
|
322
|
+
// A probe failure is still structured, so the card renders the reason
|
|
323
|
+
// instead of the browser showing a transport error. M0 behaviour, kept:
|
|
324
|
+
// a Blender-less machine is a normal state, not an error page.
|
|
325
|
+
const error = cause instanceof BlenderError
|
|
326
|
+
? cause
|
|
327
|
+
: new BlenderError(
|
|
328
|
+
BlenderErrorCode.CAPABILITY_PROBE_FAILED,
|
|
329
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
330
|
+
)
|
|
331
|
+
return { card: null, data: null, error: error.toJSON() }
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
/** Everything the panel needs to render itself from scratch, in one request. */
|
|
336
|
+
state: async ({ query }) => {
|
|
337
|
+
const listed = await studio().listProjects()
|
|
338
|
+
const projectId = query.projectId ?? listed.projects[0]?.projectId ?? null
|
|
339
|
+
return {
|
|
340
|
+
panelId: UI_PANEL_ID,
|
|
341
|
+
projects: listed.projects.map(record => buildProjectView(record)),
|
|
342
|
+
projectsRoot: listed.projectsRoot,
|
|
343
|
+
selected: projectId === null
|
|
344
|
+
? null
|
|
345
|
+
: await buildProjectState(studio(), projectId, query.revision ?? undefined, approvalThreshold()),
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
'projects.list': async () => {
|
|
350
|
+
const listed = await studio().listProjects()
|
|
351
|
+
return { projects: listed.projects.map(record => buildProjectView(record)), projectsRoot: listed.projectsRoot }
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
'projects.create': async ({ body }) => {
|
|
355
|
+
const created = await studio().createProject({
|
|
356
|
+
title: body.title,
|
|
357
|
+
goal: body.goal,
|
|
358
|
+
sceneSpec: body.sceneSpec,
|
|
359
|
+
projectId: body.projectId,
|
|
360
|
+
saveCheckpoint: true,
|
|
361
|
+
renderPreview: body.renderPreview === true,
|
|
362
|
+
})
|
|
363
|
+
return { project: created }
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
'project.overview': async ({ params, query }) => buildProjectState(studio(), params.projectId, query.revision, approvalThreshold()),
|
|
367
|
+
|
|
368
|
+
'project.scene': async ({ params, query }) => {
|
|
369
|
+
const detail = await studio().getScene(params.projectId, { revision: query.revision, full: true })
|
|
370
|
+
return {
|
|
371
|
+
revision: detail.revision,
|
|
372
|
+
digest: detail.digest,
|
|
373
|
+
scene: buildSceneTree(detail.spec, {
|
|
374
|
+
revision: detail.revision,
|
|
375
|
+
digest: detail.digest,
|
|
376
|
+
compiled: detail.compiledSpec ?? null,
|
|
377
|
+
}),
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
'project.revisions': async ({ params }) => {
|
|
382
|
+
const project = await studio().getProject(params.projectId)
|
|
383
|
+
return { projectId: params.projectId, currentRevision: project.currentRevision ?? null, revisions: project.revisions }
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
'project.revision': async ({ params }) => {
|
|
387
|
+
const detail = await studio().getRevisionDetail({ projectId: params.projectId, revision: params.revision })
|
|
388
|
+
return { detail }
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
'project.diff': async ({ params, query }) => {
|
|
392
|
+
const pair = await studio().readRevisionPair({ projectId: params.projectId, from: query.from, to: query.to })
|
|
393
|
+
return {
|
|
394
|
+
fromRevision: pair.fromRevision,
|
|
395
|
+
toRevision: pair.toRevision,
|
|
396
|
+
diff: buildRevisionDiff(pair.from, pair.to, { fromRevision: pair.fromRevision, toRevision: pair.toRevision }),
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
'project.qa': async ({ params, query }) => {
|
|
401
|
+
const record = await studio().getQaRecord({ projectId: params.projectId, revision: query.revision })
|
|
402
|
+
return {
|
|
403
|
+
qa: buildQaView({
|
|
404
|
+
projectId: record.projectId,
|
|
405
|
+
revision: record.revision,
|
|
406
|
+
validation: record.validation,
|
|
407
|
+
review: record.review,
|
|
408
|
+
}),
|
|
409
|
+
reviewCount: record.reviewCount,
|
|
410
|
+
reviewArtifact: record.reviewArtifact,
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
'project.previews': async ({ params }) => {
|
|
415
|
+
const sets = await studio().listPreviewSets({ projectId: params.projectId })
|
|
416
|
+
return { previews: sets, artifactBase: `${UI_ROUTE_PREFIX}/artifacts/${encodeURIComponent(params.projectId)}/` }
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
'project.preview': async ({ params, body }) => {
|
|
420
|
+
// `renderViews` answers with the rendered PNG bytes in `pngs`, which is not
|
|
421
|
+
// lossless JSON and is not what the browser needs: the panel displays the
|
|
422
|
+
// images through the artifact route, so the response carries the paths,
|
|
423
|
+
// the measurements and the warnings — never the buffers.
|
|
424
|
+
const result = await studio().renderViews({
|
|
425
|
+
projectId: params.projectId,
|
|
426
|
+
revision: body.revision,
|
|
427
|
+
samples: numberOrUndefined(body.samples),
|
|
428
|
+
maxViews: numberOrUndefined(body.maxViews),
|
|
429
|
+
reason: body.reason ?? 'preview rendered from the workbench UI',
|
|
430
|
+
})
|
|
431
|
+
return {
|
|
432
|
+
preview: {
|
|
433
|
+
projectId: params.projectId,
|
|
434
|
+
revision: result.revision ?? body.revision ?? null,
|
|
435
|
+
digest: result.digest ?? null,
|
|
436
|
+
profile: result.profile ?? null,
|
|
437
|
+
artifacts: result.artifacts ?? [],
|
|
438
|
+
// The pair the panel compares: the sheet this render composed and the one
|
|
439
|
+
// the previous render left behind (no `pngs` — the browser displays images
|
|
440
|
+
// through the artifact route, never as a JSON body).
|
|
441
|
+
sheets: result.previewSheets ?? null,
|
|
442
|
+
views: (result.views ?? []).map(view => ({
|
|
443
|
+
viewId: view.viewId,
|
|
444
|
+
role: view.role ?? null,
|
|
445
|
+
cameraId: view.cameraId ?? null,
|
|
446
|
+
frame: view.frame ?? null,
|
|
447
|
+
path: view.path ?? null,
|
|
448
|
+
caption: view.caption ?? null,
|
|
449
|
+
})),
|
|
450
|
+
warnings: result.warnings ?? [],
|
|
451
|
+
durationMs: result.durationMs ?? null,
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
'project.patch': async ({ params, body }) => {
|
|
457
|
+
// The patch is passed through verbatim: the Host validates it against the
|
|
458
|
+
// ScenePatch schema and the base revision, and it is the only writer.
|
|
459
|
+
const patch = { ...(body.patch ?? body), projectId: params.projectId }
|
|
460
|
+
const result = await studio().applyScenePatch(patch)
|
|
461
|
+
return { revision: result }
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
'project.restore': async ({ params, body }) => {
|
|
465
|
+
const result = await studio().restoreRevision({
|
|
466
|
+
projectId: params.projectId,
|
|
467
|
+
revision: body.revision,
|
|
468
|
+
reason: body.reason ?? 'restored from the workbench UI',
|
|
469
|
+
actor: body.actor ?? 'ui',
|
|
470
|
+
})
|
|
471
|
+
return { revision: result }
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
'project.jobs': async ({ params }) => {
|
|
475
|
+
const jobs = await studio().listJobs({ projectId: params.projectId })
|
|
476
|
+
return {
|
|
477
|
+
projectId: params.projectId,
|
|
478
|
+
jobs: jobs.jobs.map(job => buildJobView(job, { threshold: approvalThreshold() })),
|
|
479
|
+
unfinished: jobs.unfinished,
|
|
480
|
+
recovery: jobs.recovery,
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
'project.job': async ({ params }) => {
|
|
485
|
+
const job = await studio().getJob({ projectId: params.projectId, jobId: params.jobId })
|
|
486
|
+
return { job: buildJobView(job.renderJob ?? job, { threshold: approvalThreshold() }) }
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
'project.job.cancel': async ({ params, body }) => {
|
|
490
|
+
const result = await studio().cancelJob({
|
|
491
|
+
projectId: params.projectId,
|
|
492
|
+
jobId: params.jobId,
|
|
493
|
+
reason: body.reason ?? 'cancelled from the workbench UI',
|
|
494
|
+
})
|
|
495
|
+
return { cancelled: result }
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
'project.render': async ({ params, body }) => {
|
|
499
|
+
const resumeJobId = typeof body.resumeJobId === 'string' && body.resumeJobId.length > 0 ? body.resumeJobId : null
|
|
500
|
+
const request = {
|
|
501
|
+
projectId: params.projectId,
|
|
502
|
+
revision: body.revision,
|
|
503
|
+
frameStart: numberOrUndefined(body.frameStart),
|
|
504
|
+
frameEnd: numberOrUndefined(body.frameEnd),
|
|
505
|
+
samples: numberOrUndefined(body.samples),
|
|
506
|
+
profile: body.profile,
|
|
507
|
+
reason: body.reason ?? 'started from the workbench UI',
|
|
508
|
+
}
|
|
509
|
+
// A resume is a different host entry point, and the UI must not have to
|
|
510
|
+
// know which one to call: `resumeJobId` is the caller's whole intent.
|
|
511
|
+
//
|
|
512
|
+
// The parameter names differ on purpose on the two sides of the boundary —
|
|
513
|
+
// the tool/UI vocabulary says `resumeJobId` because that is what it MEANS,
|
|
514
|
+
// while `resumeRenderJob` takes `jobId` because that is what it USES. The
|
|
515
|
+
// mapping is therefore here, in one place, and asserted by the plane suite:
|
|
516
|
+
// passing `resumeJobId` straight through reads as a missing job id at the
|
|
517
|
+
// host, which a user would see as "RENDER_JOB_NOT_FOUND" on a job that is
|
|
518
|
+
// sitting right there in the panel.
|
|
519
|
+
const result = resumeJobId === null
|
|
520
|
+
? await studio().startFinalRender(request)
|
|
521
|
+
: await studio().resumeRenderJob({ ...request, jobId: resumeJobId })
|
|
522
|
+
return { job: result, resumed: resumeJobId !== null }
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* One project's whole panel state: summary, scene, revisions, jobs and QA.
|
|
529
|
+
*
|
|
530
|
+
* Assembled from separate Host reads rather than from a Host-side aggregate, so
|
|
531
|
+
* each answer is the same one the corresponding tool would get.
|
|
532
|
+
*
|
|
533
|
+
* @param {any} studio
|
|
534
|
+
* @param {string} projectId
|
|
535
|
+
* @param {string|undefined} revision
|
|
536
|
+
* @param {number} threshold
|
|
537
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
538
|
+
*/
|
|
539
|
+
async function buildProjectState(studio, projectId, revision, threshold) {
|
|
540
|
+
const overview = await studio.getProject(projectId, revision === undefined ? {} : { revision })
|
|
541
|
+
const listed = await studio.listProjects()
|
|
542
|
+
const record = listed.projects.find(entry => entry.projectId === projectId) ?? null
|
|
543
|
+
const requested = revision ?? overview.currentRevision
|
|
544
|
+
// `full: true` is load-bearing. `getScene` returns a DIGEST by default (the
|
|
545
|
+
// model-facing rule: do not spend context on a document nobody asked for), and
|
|
546
|
+
// buildSceneTree fed with a digest renders an empty tree — a silent wrong
|
|
547
|
+
// answer, "this project has 0 entities", for a project with 17 of them. The
|
|
548
|
+
// workbench asks for the document because the Scene Tree IS the document.
|
|
549
|
+
const scene = await studio.getScene(projectId, { revision: requested, full: true })
|
|
550
|
+
const jobs = await studio.listJobs({ projectId })
|
|
551
|
+
const qa = await studio.getQaRecord({ projectId, revision: scene.revision })
|
|
552
|
+
return {
|
|
553
|
+
project: buildProjectView({
|
|
554
|
+
projectId,
|
|
555
|
+
title: overview.title,
|
|
556
|
+
goal: record?.goal ?? null,
|
|
557
|
+
currentRevision: overview.currentRevision,
|
|
558
|
+
revisionCount: overview.revisionCount,
|
|
559
|
+
createdAt: record?.createdAt ?? null,
|
|
560
|
+
updatedAt: record?.updatedAt ?? null,
|
|
561
|
+
}, { sceneSummary: overview.scene ?? null, jobs: jobs.jobs }),
|
|
562
|
+
// The revision everything below was read at: the scene's own answer, so the
|
|
563
|
+
// panel can never label one revision's tree with another revision's id.
|
|
564
|
+
currentRevision: scene.revision,
|
|
565
|
+
scene: buildSceneTree(scene.spec, {
|
|
566
|
+
revision: scene.revision,
|
|
567
|
+
digest: scene.digest,
|
|
568
|
+
compiled: scene.compiledSpec ?? null,
|
|
569
|
+
}),
|
|
570
|
+
revisions: overview.revisions ?? [],
|
|
571
|
+
jobs: jobs.jobs.map(job => buildJobView(job, { threshold })),
|
|
572
|
+
unfinishedJobs: jobs.unfinished,
|
|
573
|
+
qa: buildQaView({
|
|
574
|
+
projectId,
|
|
575
|
+
revision: qa.revision,
|
|
576
|
+
validation: qa.validation,
|
|
577
|
+
review: qa.review,
|
|
578
|
+
}),
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A query/body value that is a finite number, or undefined. @param {unknown} value */
|
|
583
|
+
function numberOrUndefined(value) {
|
|
584
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
585
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
586
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Build the settings-card view model from canonical capabilities.
|
|
591
|
+
*
|
|
592
|
+
* Re-exported from the contracts package because the card is a view model, and
|
|
593
|
+
* this repository has paid five times for one vocabulary written twice (D38,
|
|
594
|
+
* D43, D57, D60) — the sixth copy is the one nobody runs.
|
|
595
|
+
*
|
|
596
|
+
* @param {Record<string, any>} data
|
|
597
|
+
* @returns {Record<string, unknown>}
|
|
598
|
+
*/
|
|
599
|
+
export { buildSettingsCard }
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepblend/dsh-blender-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"client": {
|
|
18
|
+
"platform": "web"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@deepblend/dsh-blender-contracts": "0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/pearjelly/deep-blend.git"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": ">=4.0.0 <5",
|
|
31
|
+
"@deepseek-ai/schemastery": ">=3.18.0 <4"
|
|
32
|
+
}
|
|
33
|
+
}
|