@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/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
+ ])