@deepblend/dsh-blender-contracts 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/asset-content.js +134 -0
- package/lib/bitmap-font.js +149 -0
- package/lib/canonical.js +98 -0
- package/lib/config-surface.js +88 -0
- package/lib/contact-sheet.js +233 -0
- package/lib/deployment-paths.js +153 -0
- package/lib/index.js +829 -0
- package/lib/json-schema.js +337 -0
- package/lib/png.js +452 -0
- package/lib/projections.js +209 -0
- package/lib/redact.js +53 -0
- package/lib/render-job.js +459 -0
- package/lib/scene-patch.js +947 -0
- package/lib/scene-spec.js +901 -0
- package/lib/schemas/job-result.schema.json +295 -0
- package/lib/schemas/scene-patch.schema.json +1307 -0
- package/lib/schemas/scene-spec.schema.json +791 -0
- package/lib/ui-api.js +695 -0
- package/lib/visual-composition.js +574 -0
- package/lib/visual-issue.js +738 -0
- package/lib/visual-loop.js +401 -0
- package/package.json +19 -0
package/lib/ui-api.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workbench UI's contract: the HTTP route vocabulary and the view models
|
|
3
|
+
* behind it (SPEC §14.2–§14.4).
|
|
4
|
+
*
|
|
5
|
+
* Why this lives in `contracts` and not in the UI package:
|
|
6
|
+
*
|
|
7
|
+
* - **One place per word.** This repository has already paid five times for the
|
|
8
|
+
* same vocabulary written twice (D38, D43, D57, D60). M4 adds route paths,
|
|
9
|
+
* view ids, tool-card keys and the `dsh.client` id — so a path is spelled here
|
|
10
|
+
* once, and the Host router, the client plugin and the tests all read it.
|
|
11
|
+
* - **Testable without a browser.** Everything here is a pure function of Host
|
|
12
|
+
* data. The scene tree, the revision diff, the QA view and the approval badge
|
|
13
|
+
* are therefore covered by contract tests instead of by clicking.
|
|
14
|
+
* - **The browser holds no authority.** SPEC §14.3: the Host service owns the
|
|
15
|
+
* state; the client keeps a mirror and its own UI selections. A view model
|
|
16
|
+
* built here is built by the Host, per request, from `blenderStudio`.
|
|
17
|
+
*
|
|
18
|
+
* Plane: contracts (pure data and rules). No I/O, no Blender, no Cordis.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { RENDER_JOB_TERMINAL_STATUSES } from './render-job.js'
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Routes
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Every UI route shares this prefix; the M0 settings route already did. */
|
|
28
|
+
export const UI_ROUTE_PREFIX = '/deepblend'
|
|
29
|
+
|
|
30
|
+
/** Marker in a `path` that captures the remainder of the request path. */
|
|
31
|
+
export const UI_REST_MARKER = '*'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The complete route table.
|
|
35
|
+
*
|
|
36
|
+
* `write: true` marks a route that changes something on disk. Every one of them
|
|
37
|
+
* is served by calling `blenderStudio` — the browser never touches a file, and
|
|
38
|
+
* `deepblend/tests/contract/ui-api.test.mjs` asserts that the handler table has
|
|
39
|
+
* exactly these ids and nothing else (a closed set, SPEC §20 M4 "所有写操作经过
|
|
40
|
+
* Host").
|
|
41
|
+
*
|
|
42
|
+
* @type {ReadonlyArray<{ id: string, method: string, path: string, write: boolean, summary: string }>}
|
|
43
|
+
*/
|
|
44
|
+
export const UI_ROUTES = Object.freeze([
|
|
45
|
+
{ id: 'capabilities', method: 'GET', path: '/deepblend/capabilities', write: false, summary: 'Blender capabilities and the settings card.' },
|
|
46
|
+
{ id: 'state', method: 'GET', path: '/deepblend/state', write: false, summary: 'Everything the panel needs to render itself from scratch.' },
|
|
47
|
+
{ id: 'projects.list', method: 'GET', path: '/deepblend/projects', write: false, summary: 'Every project in the store.' },
|
|
48
|
+
{ id: 'projects.create', method: 'POST', path: '/deepblend/projects', write: true, summary: 'Create a project (title, optional seed scene).' },
|
|
49
|
+
{ id: 'project.overview', method: 'GET', path: '/deepblend/projects/:projectId', write: false, summary: 'One project: summary, revisions, current digest.' },
|
|
50
|
+
{ id: 'project.scene', method: 'GET', path: '/deepblend/projects/:projectId/scene', write: false, summary: 'The Scene Tree of a revision.' },
|
|
51
|
+
{ id: 'project.revisions', method: 'GET', path: '/deepblend/projects/:projectId/revisions', write: false, summary: 'Every revision with its manifest and QA verdict.' },
|
|
52
|
+
{ id: 'project.revision', method: 'GET', path: '/deepblend/projects/:projectId/revisions/:revision', write: false, summary: 'One revision in full: manifest, QA, previews, operations.' },
|
|
53
|
+
{ id: 'project.diff', method: 'GET', path: '/deepblend/projects/:projectId/diff', write: false, summary: 'Structural diff between two revisions (?from=&to=).' },
|
|
54
|
+
{ id: 'project.qa', method: 'GET', path: '/deepblend/projects/:projectId/qa', write: false, summary: 'The QA view of a revision (?revision=).' },
|
|
55
|
+
{ id: 'project.previews', method: 'GET', path: '/deepblend/projects/:projectId/previews', write: false, summary: 'Preview sets per revision, for Preview Compare.' },
|
|
56
|
+
{ id: 'project.preview', method: 'POST', path: '/deepblend/projects/:projectId/preview', write: true, summary: 'Render the low-cost multi-view preview (and its contact sheet).' },
|
|
57
|
+
{ id: 'project.patch', method: 'POST', path: '/deepblend/projects/:projectId/patch', write: true, summary: 'Apply a ScenePatch as one atomic revision.' },
|
|
58
|
+
{ id: 'project.restore', method: 'POST', path: '/deepblend/projects/:projectId/restore', write: true, summary: 'Restore an earlier revision as a new revision.' },
|
|
59
|
+
{ id: 'project.jobs', method: 'GET', path: '/deepblend/projects/:projectId/jobs', write: false, summary: 'Render/export jobs of a project.' },
|
|
60
|
+
{ id: 'project.job', method: 'GET', path: '/deepblend/projects/:projectId/jobs/:jobId', write: false, summary: 'One job with its live progress.' },
|
|
61
|
+
{ id: 'project.job.cancel', method: 'POST', path: '/deepblend/projects/:projectId/jobs/:jobId/cancel', write: true, summary: 'Cancel a running job and verify the process is gone.' },
|
|
62
|
+
{ id: 'project.render', method: 'POST', path: '/deepblend/projects/:projectId/render', write: true, summary: 'Start a delivery render (or resume one).' },
|
|
63
|
+
{ id: 'artifacts.open', method: 'GET', path: '/deepblend/artifacts/:projectId/*', write: false, summary: 'Serve one project-relative artifact (a preview PNG).' },
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decode the tail a `*` captured.
|
|
68
|
+
*
|
|
69
|
+
* The tail is the ONE route parameter that is handed to a path guard
|
|
70
|
+
* (`readArtifact` → `resolveInside`), and a guard that receives `%2e%2e%2f` text
|
|
71
|
+
* cannot recognize it as `..` — it would look for a file literally named that.
|
|
72
|
+
* Decoding here means the guard sees the path the request actually names, which
|
|
73
|
+
* is the only form in which refusing it is meaningful. A malformed escape is
|
|
74
|
+
* kept verbatim: it names no file either way, and the guard answers 404 rather
|
|
75
|
+
* than the router throwing before a response exists.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} segments
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function decodePathTail(segments) {
|
|
81
|
+
const raw = segments.join('/')
|
|
82
|
+
try {
|
|
83
|
+
return decodeURIComponent(raw)
|
|
84
|
+
} catch {
|
|
85
|
+
return raw
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compile one route path into a matcher.
|
|
91
|
+
*
|
|
92
|
+
* Small on purpose: segment-wise, `:name` captures one segment, `*` captures the
|
|
93
|
+
* rest. A hand-written router keeps the route table the single source of truth
|
|
94
|
+
* (a framework would want its own spelling of the same paths).
|
|
95
|
+
*
|
|
96
|
+
* @param {string} path
|
|
97
|
+
* @returns {(segments: string[]) => Record<string, string> | null}
|
|
98
|
+
*/
|
|
99
|
+
function compilePath(path) {
|
|
100
|
+
const parts = path.split('/').filter(part => part.length > 0)
|
|
101
|
+
return (segments) => {
|
|
102
|
+
/** @type {Record<string, string>} */
|
|
103
|
+
const params = {}
|
|
104
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
105
|
+
const part = parts[index]
|
|
106
|
+
if (part === UI_REST_MARKER) {
|
|
107
|
+
if (index >= segments.length) return null
|
|
108
|
+
params.rest = decodePathTail(segments.slice(index))
|
|
109
|
+
return params
|
|
110
|
+
}
|
|
111
|
+
const segment = segments[index]
|
|
112
|
+
if (segment === undefined) return null
|
|
113
|
+
if (part.startsWith(':')) params[part.slice(1)] = decodeURIComponent(segment)
|
|
114
|
+
else if (part !== segment) return null
|
|
115
|
+
}
|
|
116
|
+
return segments.length === parts.length ? params : null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const COMPILED_ROUTES = UI_ROUTES.map(route => ({ route, match: compilePath(route.path) }))
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a request to a route.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} method - HTTP method; only the table's own methods match.
|
|
126
|
+
* @param {string} pathname - request path without the query string.
|
|
127
|
+
* @returns {{ route: object, params: Record<string, string> } | null}
|
|
128
|
+
* @throws {Error} when the table contains two routes with one id (a programming error).
|
|
129
|
+
*/
|
|
130
|
+
export function matchUiRoute(method, pathname) {
|
|
131
|
+
const segments = String(pathname ?? '').split('/').filter(part => part.length > 0)
|
|
132
|
+
for (const entry of COMPILED_ROUTES) {
|
|
133
|
+
if (entry.route.method !== method) continue
|
|
134
|
+
const params = entry.match(segments)
|
|
135
|
+
if (params !== null) return { route: entry.route, params }
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The routes a browser may POST to. Used by the acceptance test as a closed set. */
|
|
141
|
+
export function writeRouteIds() {
|
|
142
|
+
return UI_ROUTES.filter(route => route.write).map(route => route.id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Panel vocabulary
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The M4 deliverables, as the panel's own navigation.
|
|
151
|
+
*
|
|
152
|
+
* SPEC §20 M4 lists nine items; the sidebar entry, the tool cards, the settings
|
|
153
|
+
* page and the approval display are seats elsewhere (they are not panel views).
|
|
154
|
+
* The panel therefore renders these six, and `deepblend/tests/contract/ui-api.test.mjs`
|
|
155
|
+
* asserts that every deliverable is reachable from exactly one of the two lists.
|
|
156
|
+
*/
|
|
157
|
+
export const UI_PANEL_VIEWS = Object.freeze([
|
|
158
|
+
{ id: 'projects', label: '项目', summary: 'Projects and the create form (SPEC §20 M4 "不进入文件系统即可管理项目").' },
|
|
159
|
+
{ id: 'scene', label: '场景树', summary: 'Scene Tree of a revision.' },
|
|
160
|
+
{ id: 'preview', label: '预览对比', summary: 'Preview Compare: two revisions side by side.' },
|
|
161
|
+
{ id: 'jobs', label: '任务', summary: 'Jobs with live progress, cancel and the approval threshold.' },
|
|
162
|
+
{ id: 'qa', label: 'QA', summary: 'Technical and measured issues of a revision.' },
|
|
163
|
+
{ id: 'revisions', label: '版本', summary: 'Revisions, their diffs and restore.' },
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The two slots a preview render writes its contact sheet into.
|
|
168
|
+
*
|
|
169
|
+
* A preview render REPLACES its own image (D28/D69), so on its own it can only ever
|
|
170
|
+
* show you the present. Keeping one generation back is what makes SPEC §14.2's
|
|
171
|
+
* 「Preview 前后对比」 mean something for a change you just made: the pair is
|
|
172
|
+
* *上一次渲染 / 本次渲染* of one revision — a different axis from comparing two
|
|
173
|
+
* revisions, and the one a person reaches for right after clicking render.
|
|
174
|
+
*
|
|
175
|
+
* One spelling, read by the Host (which writes the slots), the client (which
|
|
176
|
+
* displays them) and the suite that asserts the pair exists.
|
|
177
|
+
*/
|
|
178
|
+
export const PREVIEW_SHEET_SLOTS = Object.freeze({
|
|
179
|
+
/** The sheet this render just composed. */
|
|
180
|
+
current: 'preview-current',
|
|
181
|
+
/** The sheet the previous render composed, kept for comparison. */
|
|
182
|
+
previous: 'preview-previous',
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
/** The sidebar entry id — also the `main` panel key it selects. */
|
|
186
|
+
export const UI_PANEL_ID = 'deepblend'
|
|
187
|
+
|
|
188
|
+
/** Label of the sidebar entry, the settings page and the panel itself. */
|
|
189
|
+
export const UI_PANEL_LABEL = 'Blender'
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// View models
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/** Deep-clone through JSON so a view model never shares a live object. */
|
|
196
|
+
function clone(value) {
|
|
197
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Stable stringify with sorted keys, for comparing two items field by field. */
|
|
201
|
+
function canonical(value) {
|
|
202
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
203
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
204
|
+
const keys = Object.keys(value).sort()
|
|
205
|
+
return `{${keys.map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The fields of two objects that differ, as `{ field, from, to }`. */
|
|
209
|
+
function changedFields(before, after) {
|
|
210
|
+
const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort()
|
|
211
|
+
const changes = []
|
|
212
|
+
for (const key of keys) {
|
|
213
|
+
const from = before?.[key]
|
|
214
|
+
const to = after?.[key]
|
|
215
|
+
if (canonical(from) === canonical(to)) continue
|
|
216
|
+
changes.push({ field: key, from: clone(from) ?? null, to: clone(to) ?? null })
|
|
217
|
+
}
|
|
218
|
+
return changes
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One comparable collection in a SceneSpec: how the diff addresses it.
|
|
223
|
+
*
|
|
224
|
+
* The key is the entity kind from the document, so a diff of two revisions says
|
|
225
|
+
* `entities/watch-body` rather than "item 4".
|
|
226
|
+
*/
|
|
227
|
+
const DIFF_COLLECTIONS = Object.freeze([
|
|
228
|
+
{ key: 'entities', idField: 'id' },
|
|
229
|
+
{ key: 'materials', idField: 'id' },
|
|
230
|
+
{ key: 'lights', idField: 'id' },
|
|
231
|
+
{ key: 'cameras', idField: 'id' },
|
|
232
|
+
{ key: 'shots', idField: 'id' },
|
|
233
|
+
{ key: 'animationTracks', idField: 'id' },
|
|
234
|
+
{ key: 'assets', idField: 'id' },
|
|
235
|
+
])
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Structural diff of two SceneSpec documents (SPEC §14.4 `diffRevisions`).
|
|
239
|
+
*
|
|
240
|
+
* Deliberately structural rather than textual: "r0020 added 7 animation tracks"
|
|
241
|
+
* is what a human comparing revisions needs, while a text diff of two 400-line
|
|
242
|
+
* JSON documents is unreadable and reorders on every write.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} from - the older spec
|
|
245
|
+
* @param {object} to - the newer spec
|
|
246
|
+
* @param {{ fromRevision?: string, toRevision?: string }} [context]
|
|
247
|
+
* @returns {Record<string, unknown>}
|
|
248
|
+
*/
|
|
249
|
+
export function buildRevisionDiff(from, to, context = {}) {
|
|
250
|
+
/** @type {Record<string, unknown>} */
|
|
251
|
+
const collections = {}
|
|
252
|
+
for (const { key, idField } of DIFF_COLLECTIONS) {
|
|
253
|
+
const before = new Map((from?.[key] ?? []).map(item => [item[idField], item]))
|
|
254
|
+
const after = new Map((to?.[key] ?? []).map(item => [item[idField], item]))
|
|
255
|
+
const added = [...after.keys()].filter(id => !before.has(id)).sort()
|
|
256
|
+
const removed = [...before.keys()].filter(id => !after.has(id)).sort()
|
|
257
|
+
const changed = []
|
|
258
|
+
for (const id of [...after.keys()].filter(candidate => before.has(candidate)).sort()) {
|
|
259
|
+
const fields = changedFields(before.get(id), after.get(id))
|
|
260
|
+
if (fields.length > 0) changed.push({ id, fields })
|
|
261
|
+
}
|
|
262
|
+
collections[key] = { added, removed, changed }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const projectChanges = changedFields(from?.project, to?.project)
|
|
266
|
+
const worldChanges = changedFields(from?.world ?? null, to?.world ?? null)
|
|
267
|
+
const renderProfileChanges = changedFields(from?.renderProfiles, to?.renderProfiles)
|
|
268
|
+
|
|
269
|
+
const totalChanges = Object.values(collections)
|
|
270
|
+
.reduce((sum, entry) => sum + entry.added.length + entry.removed.length + entry.changed.length, 0)
|
|
271
|
+
+ projectChanges.length + worldChanges.length + renderProfileChanges.length
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
fromRevision: context.fromRevision ?? null,
|
|
275
|
+
toRevision: context.toRevision ?? null,
|
|
276
|
+
identical: totalChanges === 0,
|
|
277
|
+
totalChanges,
|
|
278
|
+
collections,
|
|
279
|
+
project: projectChanges,
|
|
280
|
+
world: worldChanges,
|
|
281
|
+
renderProfiles: renderProfileChanges,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The Scene Tree (SPEC §14.2 "Scene Tree").
|
|
287
|
+
*
|
|
288
|
+
* One tree of the SceneSpec's own collections under their Blender collection
|
|
289
|
+
* names, because those names are what the compiled `.blend` actually contains
|
|
290
|
+
* (`collectionNameForKind`). A tree that invented its own grouping would send a
|
|
291
|
+
* human looking for a collection that does not exist in the outliner.
|
|
292
|
+
*
|
|
293
|
+
* @param {object} spec - a SceneSpec document
|
|
294
|
+
* @param {{ revision?: string, digest?: string, compiled?: object|null }} [context]
|
|
295
|
+
* @returns {Record<string, unknown>}
|
|
296
|
+
*/
|
|
297
|
+
export function buildSceneTree(spec, context = {}) {
|
|
298
|
+
const entities = spec?.entities ?? []
|
|
299
|
+
const lights = spec?.lights ?? []
|
|
300
|
+
const cameras = spec?.cameras ?? []
|
|
301
|
+
const materials = spec?.materials ?? []
|
|
302
|
+
const shots = spec?.shots ?? []
|
|
303
|
+
const tracks = spec?.animationTracks ?? []
|
|
304
|
+
const assets = spec?.assets ?? []
|
|
305
|
+
|
|
306
|
+
/** @type {Record<string, unknown[]>} */
|
|
307
|
+
const nodes = {
|
|
308
|
+
entities: entities.map(entity => ({
|
|
309
|
+
id: entity.id,
|
|
310
|
+
kind: entity.type,
|
|
311
|
+
shape: entity.generator?.shape ?? null,
|
|
312
|
+
assetId: entity.assetId ?? null,
|
|
313
|
+
materialId: entity.materialId ?? null,
|
|
314
|
+
tags: entity.tags ?? [],
|
|
315
|
+
locked: entity.locked === true,
|
|
316
|
+
transform: clone(entity.transform ?? null),
|
|
317
|
+
detail: entity.detail ?? null,
|
|
318
|
+
})),
|
|
319
|
+
materials: materials.map(material => ({
|
|
320
|
+
id: material.id,
|
|
321
|
+
shader: material.shader ?? null,
|
|
322
|
+
parameters: clone(material.parameters ?? null),
|
|
323
|
+
})),
|
|
324
|
+
lights: lights.map(light => ({
|
|
325
|
+
id: light.id,
|
|
326
|
+
type: light.type ?? null,
|
|
327
|
+
energy: light.energy ?? null,
|
|
328
|
+
transform: clone(light.transform ?? null),
|
|
329
|
+
})),
|
|
330
|
+
cameras: cameras.map(camera => ({
|
|
331
|
+
id: camera.id,
|
|
332
|
+
role: camera.role ?? null,
|
|
333
|
+
lens: camera.lens ?? null,
|
|
334
|
+
targetEntityId: camera.targetEntityId ?? null,
|
|
335
|
+
isActive: (spec?.project?.activeCamera ?? null) === camera.id,
|
|
336
|
+
})),
|
|
337
|
+
shots: shots.map(shot => ({ id: shot.id, cameraId: shot.cameraId ?? null, frameRange: clone(shot.frameRange ?? null), description: shot.description ?? null })),
|
|
338
|
+
animationTracks: tracks.map(track => ({
|
|
339
|
+
id: track.id,
|
|
340
|
+
targetKind: track.targetKind ?? 'entity',
|
|
341
|
+
targetId: track.targetEntityId ?? track.targetCameraId ?? track.targetMaterialId ?? null,
|
|
342
|
+
property: track.property ?? null,
|
|
343
|
+
keyframes: (track.keyframes ?? []).length,
|
|
344
|
+
frameRange: (track.keyframes ?? []).length === 0
|
|
345
|
+
? null
|
|
346
|
+
: [track.keyframes[0].frame, track.keyframes[track.keyframes.length - 1].frame],
|
|
347
|
+
})),
|
|
348
|
+
assets: assets.map(asset => ({ id: asset.id, type: asset.type ?? null, path: asset.path ?? null, sha256: asset.sha256 ?? null })),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
revision: context.revision ?? null,
|
|
353
|
+
digest: context.digest ?? null,
|
|
354
|
+
project: {
|
|
355
|
+
id: spec?.project?.id ?? null,
|
|
356
|
+
title: spec?.project?.title ?? null,
|
|
357
|
+
fps: spec?.project?.fps ?? null,
|
|
358
|
+
frameStart: spec?.project?.frameStart ?? null,
|
|
359
|
+
frameEnd: spec?.project?.frameEnd ?? null,
|
|
360
|
+
activeCamera: spec?.project?.activeCamera ?? null,
|
|
361
|
+
},
|
|
362
|
+
world: clone(spec?.world ?? null),
|
|
363
|
+
counts: {
|
|
364
|
+
entities: nodes.entities.length,
|
|
365
|
+
materials: nodes.materials.length,
|
|
366
|
+
lights: nodes.lights.length,
|
|
367
|
+
cameras: nodes.cameras.length,
|
|
368
|
+
shots: nodes.shots.length,
|
|
369
|
+
animationTracks: nodes.animationTracks.length,
|
|
370
|
+
assets: nodes.assets.length,
|
|
371
|
+
},
|
|
372
|
+
nodes,
|
|
373
|
+
compiledAvailable: context.compiled !== undefined && context.compiled !== null,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The QA view (SPEC §14.2 "QA Issues").
|
|
379
|
+
*
|
|
380
|
+
* Two independent sources, reported side by side and never merged: the technical
|
|
381
|
+
* validation stored with the revision, and the measured issues of the most recent
|
|
382
|
+
* visual review. Merging them would hide which one said what — and M2's whole
|
|
383
|
+
* argument is that they are separate evidence. The field names are the ones the
|
|
384
|
+
* producers actually write (`validation.json`: `{ semantic, technical }`; the
|
|
385
|
+
* review document: `issues` measured here, `reported` authored by the model).
|
|
386
|
+
*
|
|
387
|
+
* @param {object} input
|
|
388
|
+
* @param {string} input.projectId
|
|
389
|
+
* @param {string} input.revision
|
|
390
|
+
* @param {object|null} [input.validation] - `revisions/<r>/validation.json`
|
|
391
|
+
* @param {object|null} [input.review] - the latest recorded review document
|
|
392
|
+
* @returns {Record<string, unknown>}
|
|
393
|
+
*/
|
|
394
|
+
export function buildQaView({ projectId, revision, validation = null, review = null }) {
|
|
395
|
+
const technical = validation?.technical ?? null
|
|
396
|
+
const semantic = validation?.semantic ?? null
|
|
397
|
+
const errors = technical?.errors ?? []
|
|
398
|
+
const notices = semantic?.notices ?? []
|
|
399
|
+
const measured = review?.issues ?? []
|
|
400
|
+
const reported = review?.reported ?? []
|
|
401
|
+
const rejected = review?.rejected ?? []
|
|
402
|
+
const reviewer = review?.reviewer ?? null
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
projectId,
|
|
406
|
+
revision,
|
|
407
|
+
technical: {
|
|
408
|
+
available: technical !== null,
|
|
409
|
+
ok: technical?.ok !== false,
|
|
410
|
+
errorCount: errors.length,
|
|
411
|
+
errors: errors.map(entry => ({
|
|
412
|
+
code: entry.code ?? null,
|
|
413
|
+
message: entry.message ?? null,
|
|
414
|
+
detail: clone(entry.detail ?? null),
|
|
415
|
+
})),
|
|
416
|
+
counts: clone(technical?.counts ?? null),
|
|
417
|
+
frameRange: clone(technical?.frameRange ?? null),
|
|
418
|
+
engine: technical?.engine ?? null,
|
|
419
|
+
activeCamera: technical?.activeCamera ?? null,
|
|
420
|
+
},
|
|
421
|
+
semantic: {
|
|
422
|
+
ok: semantic?.ok !== false,
|
|
423
|
+
noticeCount: notices.length,
|
|
424
|
+
notices: notices.map(entry => ({ code: entry.code ?? null, message: entry.message ?? null })),
|
|
425
|
+
},
|
|
426
|
+
visual: {
|
|
427
|
+
available: review !== null,
|
|
428
|
+
score: review?.score ?? null,
|
|
429
|
+
pass: review?.pass ?? null,
|
|
430
|
+
iteration: review?.iteration ?? null,
|
|
431
|
+
subjectId: review?.subjectId ?? null,
|
|
432
|
+
viewCount: review?.viewCount ?? null,
|
|
433
|
+
sheet: clone(review?.sheet ?? null),
|
|
434
|
+
measuredIssueCount: measured.length,
|
|
435
|
+
measuredIssues: measured.map(issue => ({
|
|
436
|
+
code: issue.code ?? null,
|
|
437
|
+
severity: issue.severity ?? null,
|
|
438
|
+
category: issue.category ?? null,
|
|
439
|
+
viewId: issue.viewId ?? null,
|
|
440
|
+
objectIds: issue.objectIds ?? (issue.objectId ? [issue.objectId] : []),
|
|
441
|
+
evidence: issue.evidence ?? null,
|
|
442
|
+
})),
|
|
443
|
+
reviewerAvailable: reviewer !== null,
|
|
444
|
+
reviewerError: reviewer?.error ? String(reviewer.error.message ?? reviewer.error) : null,
|
|
445
|
+
reviewerModel: reviewer?.model ?? null,
|
|
446
|
+
findingCount: reported.length,
|
|
447
|
+
findings: reported.map(finding => ({
|
|
448
|
+
viewId: finding.viewId ?? null,
|
|
449
|
+
category: finding.category ?? null,
|
|
450
|
+
severity: finding.severity ?? null,
|
|
451
|
+
evidence: finding.evidence ?? null,
|
|
452
|
+
confidence: finding.confidence ?? null,
|
|
453
|
+
})),
|
|
454
|
+
rejectedFindingCount: rejected.length,
|
|
455
|
+
},
|
|
456
|
+
issueCount: errors.length + measured.length + reported.length,
|
|
457
|
+
summary: validation === null && review === null
|
|
458
|
+
? '这个 revision 还没有 QA 记录:技术校验随 revision 一起写入,视觉评审需要显式运行。'
|
|
459
|
+
: `技术错误 ${errors.length} 条;测量问题 ${measured.length} 条;审查器 finding ${reported.length} 条。`,
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The approval display (SPEC §14.2 "Approval", §15.1).
|
|
465
|
+
*
|
|
466
|
+
* M3 already records the cost threshold on a delivery job; M4 shows it. Nothing
|
|
467
|
+
* here gates anything — the plane that can block a start belongs to the harness
|
|
468
|
+
* approval prompt, which is M5 — and saying so in the payload is the point: a UI
|
|
469
|
+
* that implied it had blocked a render would be lying.
|
|
470
|
+
*
|
|
471
|
+
* @param {object} input
|
|
472
|
+
* @param {object} input.record - the stored render-job record
|
|
473
|
+
* @param {number} input.threshold - `requireApprovalAboveFrames`
|
|
474
|
+
* @returns {Record<string, unknown>}
|
|
475
|
+
*/
|
|
476
|
+
export function buildApprovalView({ record, threshold }) {
|
|
477
|
+
const frames = record?.expectedFrames ?? 0
|
|
478
|
+
const required = typeof threshold === 'number' && frames > threshold
|
|
479
|
+
const recorded = (record?.warnings ?? []).find(entry => entry?.detail?.threshold === threshold) ?? null
|
|
480
|
+
return {
|
|
481
|
+
required,
|
|
482
|
+
threshold,
|
|
483
|
+
frames,
|
|
484
|
+
// The warning M3 wrote is the record that the requirement was noticed; the
|
|
485
|
+
// UI reports the same fact rather than recomputing an opinion about it.
|
|
486
|
+
recorded: recorded !== null,
|
|
487
|
+
recordedMessage: recorded?.message ?? null,
|
|
488
|
+
// M5 changed what this word means. Through M4 the threshold was a NOTE attached
|
|
489
|
+
// to a job that had already started, and the view said `display-only` so that a
|
|
490
|
+
// panel could not imply it had gated anything. The gate now exists and lives in
|
|
491
|
+
// the host: an over-threshold render that arrives without a grant is refused with
|
|
492
|
+
// `RENDER_APPROVAL_REQUIRED` before a job is allocated, and the model obtains the
|
|
493
|
+
// grant through the harness's own approval plane.
|
|
494
|
+
plane: 'enforced',
|
|
495
|
+
note: '阈值以上,Host 会拒绝没有授权的渲染(RENDER_APPROVAL_REQUIRED,且不分配任何 job);' +
|
|
496
|
+
'模型侧经 harness 审批平面取得授权后才会重提。',
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* One job, as the Jobs view renders it.
|
|
502
|
+
*
|
|
503
|
+
* @param {object} job - canonical job from `blenderStudio`
|
|
504
|
+
* @param {{ threshold: number }} options
|
|
505
|
+
* @returns {Record<string, unknown>}
|
|
506
|
+
*/
|
|
507
|
+
export function buildJobView(job, options) {
|
|
508
|
+
const expected = job.expectedFrames ?? 0
|
|
509
|
+
const completed = job.completedFrames ?? 0
|
|
510
|
+
const live = !RENDER_JOB_TERMINAL_STATUSES.includes(job.status)
|
|
511
|
+
return {
|
|
512
|
+
...clone(job),
|
|
513
|
+
progress: {
|
|
514
|
+
completed,
|
|
515
|
+
expected,
|
|
516
|
+
percent: expected === 0 ? 0 : Math.round((completed / expected) * 1000) / 10,
|
|
517
|
+
missing: (job.missingFrames ?? []).length,
|
|
518
|
+
corrupt: (job.corruptFrames ?? []).length,
|
|
519
|
+
live,
|
|
520
|
+
},
|
|
521
|
+
// Both answers come from the state machine in `render-job.js` rather than
|
|
522
|
+
// from a list written here: a second opinion about which statuses are
|
|
523
|
+
// terminal is exactly the copy that rots (D38, D43, D57, D60).
|
|
524
|
+
cancelable: live,
|
|
525
|
+
resumable: ['failed', 'cancelled', 'recovering'].includes(job.status),
|
|
526
|
+
deliverable: job.status === 'completed' && job.delivery !== null,
|
|
527
|
+
approval: buildApprovalView({ record: { expectedFrames: expected, warnings: job.warnings ?? [] }, threshold: options?.threshold ?? Number.POSITIVE_INFINITY }),
|
|
528
|
+
detail: describeJobForHuman(job),
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* One line a human can read without knowing the state machine.
|
|
534
|
+
*
|
|
535
|
+
* @param {object} job
|
|
536
|
+
* @returns {string}
|
|
537
|
+
*/
|
|
538
|
+
export function describeJobForHuman(job) {
|
|
539
|
+
const frames = `${job.completedFrames ?? 0}/${job.expectedFrames ?? 0} 帧`
|
|
540
|
+
switch (job.status) {
|
|
541
|
+
case 'queued': return `排队中:${frames}`
|
|
542
|
+
case 'running': return job.estimatedRemainingMs === null || job.estimatedRemainingMs === undefined
|
|
543
|
+
? `渲染中:${frames}`
|
|
544
|
+
: `渲染中:${frames},预计还剩 ${formatDuration(job.estimatedRemainingMs)}`
|
|
545
|
+
case 'stopping': return `正在停止:${frames}`
|
|
546
|
+
case 'recovering': return `进程已消失,可从缺失帧继续:${frames}`
|
|
547
|
+
case 'completed': return job.delivery === null ? `已完成:${frames}` : `已交付:${frames},视频已校验`
|
|
548
|
+
case 'failed': return `失败(${job.errorCode ?? '未分类'}):${frames},可继续渲染`
|
|
549
|
+
case 'cancelled': return `已取消:${frames},可继续渲染`
|
|
550
|
+
default: return `${job.status}:${frames}`
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Human duration from milliseconds. @param {number} ms */
|
|
555
|
+
export function formatDuration(ms) {
|
|
556
|
+
if (!Number.isFinite(ms) || ms < 0) return '—'
|
|
557
|
+
const seconds = Math.round(ms / 1000)
|
|
558
|
+
if (seconds < 60) return `${seconds} 秒`
|
|
559
|
+
const minutes = Math.floor(seconds / 60)
|
|
560
|
+
if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
|
|
561
|
+
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The settings card (SPEC §14.2 "Blender 能力设置卡").
|
|
566
|
+
*
|
|
567
|
+
* Built from canonical capabilities and nothing else, so the card can never
|
|
568
|
+
* disagree with what `blender_capabilities` reports to the model.
|
|
569
|
+
*
|
|
570
|
+
* @param {Record<string, any>} data - canonical capabilities
|
|
571
|
+
* @returns {Record<string, unknown>}
|
|
572
|
+
*/
|
|
573
|
+
export function buildSettingsCard(data) {
|
|
574
|
+
const engines = Object.entries(data?.engines ?? {})
|
|
575
|
+
.filter(([, probe]) => probe?.available)
|
|
576
|
+
.map(([id]) => id)
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
title: 'Blender',
|
|
580
|
+
status: data?.installed ? 'ready' : 'missing',
|
|
581
|
+
statusLabel: data?.installed ? '可用' : '未安装',
|
|
582
|
+
rows: [
|
|
583
|
+
{ label: '可执行文件', value: data?.executable?.resolved ?? '未解析到' },
|
|
584
|
+
{ label: '配置路径', value: data?.executable?.requested ?? '—' },
|
|
585
|
+
// What to do about it, when the host has something to say. It is omitted rather than rendered
|
|
586
|
+
// empty when there is no advice, because a blank row in a status card reads as a missing field.
|
|
587
|
+
...(typeof data?.executable?.advice === 'string' && data.executable.advice.length > 0
|
|
588
|
+
? [{ label: '下一步', value: data.executable.advice }]
|
|
589
|
+
: []),
|
|
590
|
+
{ label: '版本', value: data?.version ?? '—' },
|
|
591
|
+
{ label: '内嵌 Python', value: data?.pythonVersion ?? '—' },
|
|
592
|
+
{ label: '可用引擎', value: engines.length > 0 ? engines.join(', ') : '无' },
|
|
593
|
+
{ label: '首选引擎', value: data?.bestAvailableEngine ?? '—' },
|
|
594
|
+
{
|
|
595
|
+
label: 'GPU',
|
|
596
|
+
value: data?.gpu?.available
|
|
597
|
+
? `${(data.gpu.devices ?? []).join(', ')}${data.gpu.preferredBackend ? ` (${data.gpu.preferredBackend})` : ''}`
|
|
598
|
+
: '未检测到(CPU 渲染)',
|
|
599
|
+
},
|
|
600
|
+
{ label: '导入格式', value: (data?.formats?.import ?? []).join(', ') || '无' },
|
|
601
|
+
{ label: '导出格式', value: (data?.formats?.export ?? []).join(', ') || '无' },
|
|
602
|
+
{
|
|
603
|
+
label: '无头渲染自检',
|
|
604
|
+
value: data?.renderSmokeTest
|
|
605
|
+
? (data.renderSmokeTest.ok
|
|
606
|
+
? `通过(${data.renderSmokeTest.engine},${data.renderSmokeTest.bytes} 字节)`
|
|
607
|
+
: `失败${data.renderSmokeTest.error ? `:${data.renderSmokeTest.error}` : ''}`)
|
|
608
|
+
: '—',
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
warnings: (data?.warnings ?? []).map(entry => ({ code: entry.code, message: entry.message })),
|
|
612
|
+
probedAt: data?.probedAt ?? null,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The project list entry.
|
|
618
|
+
*
|
|
619
|
+
* @param {object} record - the stored project record
|
|
620
|
+
* @param {{ sceneSummary?: object|null, jobs?: object[] }} [extra]
|
|
621
|
+
* @returns {Record<string, unknown>}
|
|
622
|
+
*/
|
|
623
|
+
export function buildProjectView(record, extra = {}) {
|
|
624
|
+
const jobs = extra.jobs ?? []
|
|
625
|
+
return {
|
|
626
|
+
projectId: record.projectId ?? record.id ?? null,
|
|
627
|
+
title: record.title ?? null,
|
|
628
|
+
currentRevision: record.currentRevision ?? null,
|
|
629
|
+
revisionCount: record.revisionCount ?? 0,
|
|
630
|
+
createdAt: record.createdAt ?? null,
|
|
631
|
+
updatedAt: record.updatedAt ?? null,
|
|
632
|
+
goal: record.goal ?? null,
|
|
633
|
+
scene: clone(extra.sceneSummary ?? null),
|
|
634
|
+
jobs: jobs.map(job => ({ jobId: job.jobId, status: job.status, type: job.type, revisionId: job.revisionId })),
|
|
635
|
+
// Counted here, from the state machine, so a status chip in the UI is a
|
|
636
|
+
// number the Host computed rather than a second list of "live" statuses in
|
|
637
|
+
// the browser.
|
|
638
|
+
unfinishedJobs: jobs.filter(job => !RENDER_JOB_TERMINAL_STATUSES.includes(job.status)).length,
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The tool-card contract (SPEC §14.2 "Tool Result Cards").
|
|
644
|
+
*
|
|
645
|
+
* A card never parses prose out of a tool result: it reads the tool's own JSON
|
|
646
|
+
* arguments (which the harness records verbatim) for the ids, and then asks the
|
|
647
|
+
* Host for the authoritative state. `deepblend/tests/contract/ui-api.test.mjs`
|
|
648
|
+
* covers the extraction, because a card that silently rendered nothing is exactly
|
|
649
|
+
* the failure mode that looks like "the tool returned nothing interesting".
|
|
650
|
+
*
|
|
651
|
+
* @param {string} argsRaw - the JSON string the model sent for the call
|
|
652
|
+
* @returns {{ projectId: string|null, jobId: string|null, revision: string|null, operationCount: number|null }}
|
|
653
|
+
*/
|
|
654
|
+
export function parseToolCallTarget(argsRaw) {
|
|
655
|
+
let args = null
|
|
656
|
+
try {
|
|
657
|
+
args = typeof argsRaw === 'string' && argsRaw.length > 0 ? JSON.parse(argsRaw) : null
|
|
658
|
+
} catch {
|
|
659
|
+
args = null
|
|
660
|
+
}
|
|
661
|
+
if (args === null || typeof args !== 'object') return { projectId: null, jobId: null, revision: null, operationCount: null }
|
|
662
|
+
return {
|
|
663
|
+
projectId: typeof args.projectId === 'string' ? args.projectId : null,
|
|
664
|
+
jobId: typeof args.jobId === 'string' ? args.jobId : (typeof args.resumeJobId === 'string' ? args.resumeJobId : null),
|
|
665
|
+
revision: typeof args.baseRevision === 'string' ? args.baseRevision : (typeof args.revision === 'string' ? args.revision : null),
|
|
666
|
+
operationCount: Array.isArray(args.operations) ? args.operations.length : null,
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The wire tool names this package renders a card for.
|
|
672
|
+
*
|
|
673
|
+
* One list, read by the client plugin (registration keys) and by the tests. It is
|
|
674
|
+
* every DeepBlend tool that exists: a card for a tool that does not exist would
|
|
675
|
+
* never render, and a tool without a card falls back to the generic row (which is
|
|
676
|
+
* what the shipped console does for unknown tools).
|
|
677
|
+
*/
|
|
678
|
+
export const UI_TOOL_CARD_KEYS = Object.freeze([
|
|
679
|
+
'blender_capabilities',
|
|
680
|
+
'blender_project_create',
|
|
681
|
+
'blender_project_get',
|
|
682
|
+
'blender_scene_get',
|
|
683
|
+
'blender_scene_patch',
|
|
684
|
+
'blender_preview_render',
|
|
685
|
+
'blender_scene_validate',
|
|
686
|
+
'blender_revision_restore',
|
|
687
|
+
'blender_asset_ingest',
|
|
688
|
+
'blender_preview_views',
|
|
689
|
+
'blender_visual_review',
|
|
690
|
+
'blender_visual_autofix',
|
|
691
|
+
'blender_final_render',
|
|
692
|
+
'blender_export',
|
|
693
|
+
'blender_job_status',
|
|
694
|
+
'blender_job_cancel',
|
|
695
|
+
])
|