@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.
Files changed (3) hide show
  1. package/lib/client.js +1381 -0
  2. package/lib/index.js +599 -0
  3. package/package.json +33 -0
package/lib/client.js ADDED
@@ -0,0 +1,1381 @@
1
+ /**
2
+ * @deepblend/dsh-blender-ui — the Web Client half (SPEC §5.1, §14).
3
+ *
4
+ * This file is a **self-registering CJS factory**, which is what the browser's
5
+ * client module system executes (`dsh-client-modules`): the shell loads the
6
+ * bundle as a script, and the only thing the script does is hand a factory to
7
+ * `window.__ModuleLoader__.load`. There is no top-level `import`, no bundler step
8
+ * and no JSX — dependencies arrive through the factory's synchronous `require`,
9
+ * and elements are built with `React.createElement`.
10
+ *
11
+ * That is not a compromise: `docs/probe-m4-client-loop.log` measures this file's
12
+ * own development loop — one line edited here is live in an already-open page in
13
+ * about 600 ms, with no refresh, no restart and no build.
14
+ *
15
+ * What it renders, and where (every seat below is one this package *adds to*; the
16
+ * shipped console is never shadowed):
17
+ *
18
+ * sidebar.panellist the Blender entry (id `deepblend`)
19
+ * main[key=deepblend] the workbench panel: 项目 / 场景树 /
20
+ * 预览对比 / 任务 / QA / 版本
21
+ * settings.section the Blender settings page (the M0 card)
22
+ * tool.call.toolview[key=blender_*] a card per DeepBlend tool
23
+ * conversation.session.header.utilities a live job chip
24
+ *
25
+ * Authority (SPEC §14.3): this half reads and writes through the Host's HTTP
26
+ * routes only. It keeps a mirror of Host state and its own UI selections, and it
27
+ * can start nothing — there is no route that spawns a process.
28
+ *
29
+ * Owner: DeepBlend Studio — M4
30
+ * Plane: Web Client (browser)
31
+ */
32
+
33
+ window.__ModuleLoader__.load({
34
+ // Must be the PACKAGE NAME: the client module graph addresses this bundle by
35
+ // package identity, and the Host row mounts the same package.
36
+ id: '@deepblend/dsh-blender-ui',
37
+ factory: (require) => {
38
+ var module = { exports: {} }
39
+ var exports = module.exports
40
+
41
+ const react = require('react')
42
+ const h = react.createElement
43
+
44
+ /** The sidebar id, the `main` key and the settings section id — one value. */
45
+ const PANEL_ID = 'deepblend'
46
+ /** Label shown by the sidebar entry, the settings nav and the panel title. */
47
+ const PANEL_LABEL = 'Blender'
48
+ /** The host API this half was written against; a mismatch is a deployment state. */
49
+ const EXPECTED_HOST_API = 4
50
+ /** Polling cadence while something is live; M3 writes progress once a second. */
51
+ const POLL_LIVE_MS = 1500
52
+ /** Polling cadence when nothing is running. */
53
+ const POLL_IDLE_MS = 8000
54
+
55
+ const VIEWS = [
56
+ { id: 'projects', label: '项目' },
57
+ { id: 'scene', label: '场景树' },
58
+ { id: 'preview', label: '预览对比' },
59
+ { id: 'jobs', label: '任务' },
60
+ { id: 'qa', label: 'QA' },
61
+ { id: 'revisions', label: '版本' },
62
+ ]
63
+
64
+ /** Every DeepBlend wire tool name this package draws a card for. */
65
+ const TOOL_CARD_KEYS = [
66
+ 'blender_capabilities', 'blender_project_create', 'blender_project_get',
67
+ 'blender_scene_get', 'blender_scene_patch', 'blender_preview_render',
68
+ 'blender_scene_validate', 'blender_revision_restore', 'blender_asset_ingest',
69
+ 'blender_preview_views', 'blender_visual_review',
70
+ 'blender_visual_autofix', 'blender_final_render', 'blender_export',
71
+ 'blender_job_status', 'blender_job_cancel',
72
+ ]
73
+
74
+ const ROUTES = {
75
+ state: '/deepblend/state',
76
+ projects: '/deepblend/projects',
77
+ capabilities: '/deepblend/capabilities',
78
+ }
79
+
80
+ /** Route for one project's own surfaces. */
81
+ function projectRoute(projectId, suffix) {
82
+ return `/deepblend/projects/${encodeURIComponent(projectId)}${suffix || ''}`
83
+ }
84
+
85
+ /**
86
+ * The URL an artifact is displayed from.
87
+ *
88
+ * The `v` parameter is the artifact's own content digest, and it is
89
+ * load-bearing rather than decorative: a preview is an EMITTED artifact, so
90
+ * re-rendering one replaces the bytes at the SAME path (D28). An `<img>` whose
91
+ * `src` does not change is not re-fetched by the browser, so a panel keyed on
92
+ * the path alone keeps showing the previous render — measured, not assumed
93
+ * (§13.11 of the milestone status: the bytes on disk said red, the panel said
94
+ * otherwise). The digest changes when the picture does, and the browser then
95
+ * has no choice but to fetch it.
96
+ *
97
+ * @param {string} base - the artifact route prefix for this project
98
+ * @param {{ path?: string, sha256?: string|null, bytes?: number|null }} artifact
99
+ */
100
+ function artifactUrl(base, artifact) {
101
+ const path = artifact && artifact.path ? artifact.path : ''
102
+ const version = artifact && artifact.sha256
103
+ ? String(artifact.sha256).slice(0, 12)
104
+ : (artifact && artifact.bytes ? `b${artifact.bytes}` : '0')
105
+ return `${base}${path}?v=${version}`
106
+ }
107
+
108
+ /** Short "when was this produced" for an artifact, or null. */
109
+ function artifactTime(artifact) {
110
+ const at = artifact && artifact.at ? artifact.at : null
111
+ return at === null ? null : formatTime(at)
112
+ }
113
+
114
+ // -------------------------------------------------------------------------
115
+ // Styles. Injected once per document, the way the shipped plugins do it: one
116
+ // <style> tagged with this package, deduped by querySelector.
117
+ // -------------------------------------------------------------------------
118
+
119
+ const CSS = `
120
+ .db-root{display:flex;flex-direction:column;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-size:13px}
121
+ .db-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;flex-wrap:wrap}
122
+ .db-title{font-weight:600}
123
+ .db-muted{color:var(--dsw-alias-label-tertiary)}
124
+ .db-nav{display:flex;gap:2px;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;flex-wrap:wrap}
125
+ .db-nav button{background:0 0;border:0;border-radius:8px;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;padding:5px 10px}
126
+ .db-nav button:hover{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}
127
+ .db-nav button[data-active=true]{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}
128
+ .db-tabs{display:flex;gap:8px;padding:2px 0 8px;flex:none;flex-wrap:wrap;align-items:center}
129
+ .db-body{flex:1;min-height:0;overflow:auto;padding:12px 14px 24px}
130
+ .db-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:10px}
131
+ .db-card h4{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}
132
+ .db-row{display:flex;gap:8px;align-items:baseline;padding:2px 0;line-height:18px}
133
+ .db-row>span:first-child{color:var(--dsw-alias-label-tertiary);flex:none;min-width:82px}
134
+ .db-row>span:last-child{min-width:0;overflow-wrap:anywhere;font-family:var(--dsw-font-mono);font-size:12px}
135
+ .db-btn{background:var(--dsw-alias-fill-l2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:12px;padding:4px 9px}
136
+ .db-btn:disabled{opacity:.5;cursor:default}
137
+ .db-btn[data-tone=primary]{background:var(--dsw-alias-brand-primary,#3b6ef5);border-color:transparent;color:#fff}
138
+ .db-btn[data-tone=danger]{color:var(--dsw-alias-label-error,#e5484d)}
139
+ .db-input,.db-area{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;color:inherit;font:inherit;font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box}
140
+ .db-area{font-family:var(--dsw-font-mono);min-height:150px;white-space:pre;overflow:auto}
141
+ .db-inline{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
142
+ .db-badge{border-radius:6px;font-size:11px;line-height:16px;padding:1px 6px;border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);white-space:nowrap}
143
+ .db-badge[data-tone=ok]{color:var(--dsw-alias-label-success,#2f9e44);border-color:currentColor}
144
+ .db-badge[data-tone=warn]{color:var(--dsw-alias-label-warning,#e8a33d);border-color:currentColor}
145
+ .db-badge[data-tone=bad]{color:var(--dsw-alias-label-error,#e5484d);border-color:currentColor}
146
+ .db-badge[data-tone=live]{color:var(--dsw-alias-brand-primary,#3b6ef5);border-color:currentColor}
147
+ .db-bar{background:var(--dsw-alias-fill-l2);border-radius:999px;height:6px;overflow:hidden;width:100%;margin:4px 0}
148
+ .db-bar>i{background:var(--dsw-alias-brand-primary,#3b6ef5);display:block;height:100%}
149
+ .db-list{list-style:none;margin:0;padding:0}
150
+ .db-list>li{border-bottom:1px solid var(--dsw-alias-border-l1);padding:7px 0}
151
+ .db-list>li:last-child{border-bottom:0}
152
+ .db-kind{color:var(--dsw-alias-label-tertiary);min-width:96px;display:inline-block}
153
+ .db-error{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-label-error,#e5484d);border-radius:10px;color:var(--dsw-alias-label-error,#e5484d);padding:10px 12px;margin-bottom:10px;white-space:pre-wrap}
154
+ .db-grid{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
155
+ .db-shot{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px}
156
+ .db-shot img{display:block;width:100%;height:auto;border-radius:6px;background:#000}
157
+ .db-shot h5{margin:0 0 6px;font-size:12px;font-weight:600}
158
+ .db-chip{display:inline-flex;align-items:center;gap:5px;background:0 0;border:0;border-radius:8px;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px;padding:3px 6px}
159
+ .db-chip:hover{background:var(--dsw-alias-fill-l2);color:var(--dsw-alias-label-primary)}
160
+ .db-dot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-label-tertiary);display:inline-block}
161
+ .db-dot[data-tone=live]{background:var(--dsw-alias-brand-primary,#3b6ef5)}
162
+ .db-dot[data-tone=ok]{background:var(--dsw-alias-label-success,#2f9e44)}
163
+ .db-dot[data-tone=bad]{background:var(--dsw-alias-label-error,#e5484d)}
164
+ .db-kv{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:12px;margin:0}
165
+ .db-kv dt{color:var(--dsw-alias-label-tertiary)}
166
+ .db-kv dd{margin:0;font-family:var(--dsw-font-mono);overflow-wrap:anywhere}
167
+ .db-pre{background:var(--dsw-alias-bg-layer-1);border-radius:8px;font-family:var(--dsw-font-mono);font-size:11px;margin:6px 0 0;max-height:200px;overflow:auto;padding:8px;white-space:pre-wrap}
168
+ `
169
+
170
+ function injectStyles() {
171
+ const tagId = '@deepblend/dsh-blender-ui/workbench.css'
172
+ if (typeof document === 'undefined') return
173
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return
174
+ const tag = document.createElement('style')
175
+ tag.dataset.plugin = '@deepblend/dsh-blender-ui'
176
+ tag.dataset.pluginCss = tagId
177
+ tag.textContent = CSS
178
+ document.head.appendChild(tag)
179
+ }
180
+
181
+ // -------------------------------------------------------------------------
182
+ // Talking to the Host
183
+ // -------------------------------------------------------------------------
184
+
185
+ /**
186
+ * The one place this half notices a deployment older than itself.
187
+ *
188
+ * Both shapes a stale Host produces are covered, because MEASURED against the
189
+ * process that was really running (the M0-era UI half, still serving on 3080):
190
+ *
191
+ * GET /deepblend/capabilities → 200 with the settings card, and no `route`
192
+ * field (its route is registered as a PREFIX
193
+ * of that one path, so every sub-path of it
194
+ * answers the same card)
195
+ * GET /deepblend/state → 404 with an EMPTY body: no content type,
196
+ * nothing to parse
197
+ *
198
+ * The first is a successful wrong answer, the second is not JSON at all, and a
199
+ * panel that only checked `ok` or only caught a parse error would report the
200
+ * wrong problem in one of the two cases. So both end up here.
201
+ *
202
+ * @param {string} expectedRoute
203
+ * @param {{ status?: number, detail?: string, payload?: any }} observed
204
+ */
205
+ function staleHostError(expectedRoute, observed) {
206
+ const version = observed && observed.payload && observed.payload.hostApiVersion
207
+ ? observed.payload.hostApiVersion
208
+ : '未知'
209
+ const detail = observed && observed.detail ? `观察到的响应:${observed.detail}。` : ''
210
+ return {
211
+ code: 'UI_HOST_API_STALE',
212
+ message:
213
+ `本进程里的 blenderUi 比磁盘上的包旧:/deepblend/${expectedRoute} 没有被这一版的宿主回答` +
214
+ `(${detail}hostApiVersion=${version},本 UI 需要 ${EXPECTED_HOST_API})。` +
215
+ '重启 profile(dsh web)即可。',
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Read one Host route, and keep reading it while `intervalMs` is set.
221
+ *
222
+ * A page refresh re-runs exactly this, which is why the panel rebuilds itself
223
+ * from the Host (SPEC §20 M4 「UI 刷新后可从 Host 恢复权威状态」).
224
+ */
225
+ function useHostRoute(path, options) {
226
+ const intervalMs = options && options.intervalMs ? options.intervalMs : 0
227
+ const expectedRoute = options && options.route ? options.route : null
228
+ const [state, setState] = react.useState({ status: 'loading', payload: null, error: null })
229
+
230
+ react.useEffect(() => {
231
+ let live = true
232
+ const load = async () => {
233
+ try {
234
+ const response = await fetch(path, { headers: { accept: 'application/json' } })
235
+ const text = await response.text()
236
+ let payload = null
237
+ try {
238
+ payload = text.length === 0 ? null : JSON.parse(text)
239
+ } catch {
240
+ payload = null
241
+ }
242
+ if (!live) return
243
+ // A route this half declared but the running Host does not serve: either
244
+ // a body with the wrong identity, or a response that is not ours at all.
245
+ if (expectedRoute !== null && (payload === null || payload.route !== expectedRoute)) {
246
+ setState({
247
+ status: 'stale',
248
+ payload: null,
249
+ error: staleHostError(expectedRoute, {
250
+ payload,
251
+ status: response.status,
252
+ detail: payload === null
253
+ ? `HTTP ${response.status},${text.length} 字节,非 JSON`
254
+ : `route=${JSON.stringify(payload.route)}`,
255
+ }),
256
+ })
257
+ return
258
+ }
259
+ if (payload && payload.ok) setState({ status: 'ok', payload, error: null })
260
+ else setState({ status: 'error', payload: null, error: (payload && payload.error) || { code: `HTTP_${response.status}`, message: `HTTP ${response.status}` } })
261
+ } catch (error) {
262
+ if (live) setState({ status: 'error', payload: null, error: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } })
263
+ }
264
+ }
265
+ load()
266
+ if (intervalMs <= 0) return () => { live = false }
267
+ const timer = setInterval(load, intervalMs)
268
+ return () => { live = false; clearInterval(timer) }
269
+ }, [path, intervalMs, expectedRoute])
270
+
271
+ return state
272
+ }
273
+
274
+ /** POST a JSON body and return the parsed payload. Errors come back as data. */
275
+ async function postJson(path, body) {
276
+ try {
277
+ const response = await fetch(path, {
278
+ method: 'POST',
279
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
280
+ body: JSON.stringify(body || {}),
281
+ })
282
+ const payload = await response.json()
283
+ if (payload && payload.ok) return { ok: true, payload }
284
+ return { ok: false, error: (payload && payload.error) || { code: `HTTP_${response.status}`, message: `HTTP ${response.status}` } }
285
+ } catch (error) {
286
+ return { ok: false, error: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } }
287
+ }
288
+ }
289
+
290
+ // -------------------------------------------------------------------------
291
+ // Small pieces
292
+ // -------------------------------------------------------------------------
293
+
294
+ function Badge(props) {
295
+ return h('span', { className: 'db-badge', 'data-tone': props.tone || 'muted', 'data-badge': props.name || undefined }, props.children)
296
+ }
297
+
298
+ function Row(props) {
299
+ return h('div', { className: 'db-row' },
300
+ h('span', null, props.label),
301
+ h('span', null, props.value === null || props.value === undefined || props.value === '' ? '—' : String(props.value)))
302
+ }
303
+
304
+ function Button(props) {
305
+ return h('button', {
306
+ type: 'button',
307
+ className: 'db-btn',
308
+ 'data-tone': props.tone,
309
+ 'data-action': props.action,
310
+ disabled: props.disabled === true,
311
+ onClick: props.onClick,
312
+ }, props.children)
313
+ }
314
+
315
+ function ErrorBox(props) {
316
+ if (props.error === null || props.error === undefined) return null
317
+ return h('div', { className: 'db-error', 'data-deepblend-error': props.error.code || 'error' },
318
+ h('div', null, `${props.error.code || 'ERROR'}: ${props.error.message || ''}`),
319
+ props.error.detail ? h('pre', { className: 'db-pre' }, JSON.stringify(props.error.detail, null, 2)) : null)
320
+ }
321
+
322
+ /** Shorten a digest for display without losing which digest it is. */
323
+ function shortDigest(value) {
324
+ return typeof value === 'string' && value.length > 12 ? `${value.slice(0, 12)}…` : (value || '—')
325
+ }
326
+
327
+ /** A timestamp a human reads at a glance. */
328
+ function formatTime(value) {
329
+ if (value === null || value === undefined || value === '') return '—'
330
+ const date = typeof value === 'number' ? new Date(value) : new Date(String(value))
331
+ if (Number.isNaN(date.getTime())) return String(value)
332
+ return date.toLocaleTimeString()
333
+ }
334
+
335
+ function statusTone(status) {
336
+ if (status === 'completed') return 'ok'
337
+ if (status === 'failed') return 'bad'
338
+ if (status === 'cancelled') return 'warn'
339
+ if (status === 'queued' || status === 'running' || status === 'stopping' || status === 'recovering') return 'live'
340
+ return 'muted'
341
+ }
342
+
343
+ function ProgressBar(props) {
344
+ const percent = Math.max(0, Math.min(100, Number(props.percent) || 0))
345
+ return h('div', { className: 'db-bar', 'data-progress': String(percent) }, h('i', { style: { width: `${percent}%` } }))
346
+ }
347
+
348
+ /** A definition list, used by the settings page and the revision summary. */
349
+ function KeyValues(props) {
350
+ const entries = (props.entries || []).filter(entry => entry !== null && entry !== undefined)
351
+ return h('dl', { className: 'db-kv' }, entries.flatMap((entry, index) => [
352
+ h('dt', { key: `k${index}` }, entry.label),
353
+ h('dd', { key: `v${index}` }, entry.value === null || entry.value === undefined || entry.value === '' ? '—' : String(entry.value)),
354
+ ]))
355
+ }
356
+
357
+ // -------------------------------------------------------------------------
358
+ // Views
359
+ // -------------------------------------------------------------------------
360
+
361
+ /** 项目: the list, the create form, and the selected project's summary. */
362
+ function ProjectsView(props) {
363
+ const [title, setTitle] = react.useState('')
364
+ const [goal, setGoal] = react.useState('')
365
+ const [busy, setBusy] = react.useState(false)
366
+ const [result, setResult] = react.useState(null)
367
+
368
+ const create = async () => {
369
+ setBusy(true)
370
+ const outcome = await postJson(ROUTES.projects, { title, goal: goal.length > 0 ? goal : undefined })
371
+ setBusy(false)
372
+ if (outcome.ok) {
373
+ setTitle('')
374
+ setGoal('')
375
+ setResult({ ok: true, message: `已创建 ${outcome.payload.project.projectId}` })
376
+ props.onCreated(outcome.payload.project.projectId)
377
+ } else {
378
+ setResult({ ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
379
+ }
380
+ }
381
+
382
+ return h('div', { 'data-view': 'projects' },
383
+ h(ErrorBox, { error: props.error }),
384
+ h('div', { className: 'db-card' },
385
+ h('h4', null, '项目'),
386
+ props.projects.length === 0
387
+ ? h('div', { className: 'db-muted' }, '这个工作区还没有项目。')
388
+ : h('ul', { className: 'db-list' }, props.projects.map(project => h('li', { key: project.projectId, 'data-project': project.projectId },
389
+ h('div', { className: 'db-inline' },
390
+ h(Button, {
391
+ tone: project.projectId === props.selectedId ? 'primary' : undefined,
392
+ action: `select-project:${project.projectId}`,
393
+ onClick: () => props.onSelect(project.projectId),
394
+ }, project.title || project.projectId),
395
+ h('span', { className: 'db-muted db-mono' }, project.projectId),
396
+ project.unfinishedJobs > 0 ? h(Badge, { tone: 'live', name: 'unfinished' }, `${project.unfinishedJobs} 个任务在跑`) : null,
397
+ h('span', { className: 'db-muted' }, `${project.revisionCount} 个 revision`),
398
+ ),
399
+ h('div', { className: 'db-muted db-mono' }, `当前 ${project.currentRevision || '—'} · 更新于 ${formatTime(project.updatedAt)}`),
400
+ project.goal ? h('div', { className: 'db-muted' }, project.goal) : null,
401
+ ))),
402
+ h('div', { className: 'db-muted db-mono', style: { marginTop: '6px' } }, `projectsRoot: ${props.projectsRoot || '—'}`),
403
+ ),
404
+
405
+ h('div', { className: 'db-card' },
406
+ h('h4', null, '新建项目(写操作经 Host)'),
407
+ h('div', { className: 'db-inline' },
408
+ h('input', {
409
+ className: 'db-input',
410
+ 'data-field': 'project-title',
411
+ placeholder: '标题,例如 watch-commercial',
412
+ value: title,
413
+ onChange: event => setTitle(event.target.value),
414
+ }),
415
+ h('input', {
416
+ className: 'db-input',
417
+ 'data-field': 'project-goal',
418
+ placeholder: '目标(可选)',
419
+ value: goal,
420
+ onChange: event => setGoal(event.target.value),
421
+ }),
422
+ h(Button, { tone: 'primary', action: 'create-project', disabled: busy || title.trim().length === 0, onClick: create }, busy ? '创建中…' : '创建'),
423
+ ),
424
+ result ? h('div', { 'data-result': result.ok ? 'ok' : 'error', className: result.ok ? 'db-muted' : 'db-error', style: { marginTop: '8px' } }, result.message) : null,
425
+ ),
426
+
427
+ props.state ? h('div', { className: 'db-card' },
428
+ h('h4', null, `当前项目:${props.state.project.title}`),
429
+ h(KeyValues, { entries: [
430
+ { label: 'projectId', value: props.state.project.projectId },
431
+ { label: '当前 revision', value: props.state.currentRevision },
432
+ { label: 'revision 数', value: props.state.project.revisionCount },
433
+ { label: 'digest', value: shortDigest(props.state.scene.digest) },
434
+ { label: '帧范围', value: `${props.state.scene.project.frameStart}–${props.state.scene.project.frameEnd} @ ${props.state.scene.project.fps}fps` },
435
+ { label: '活动相机', value: props.state.scene.project.activeCamera },
436
+ { label: '对象 / 材质 / 灯 / 相机', value: [
437
+ props.state.scene.counts.entities, props.state.scene.counts.materials,
438
+ props.state.scene.counts.lights, props.state.scene.counts.cameras,
439
+ ].join(' / ') },
440
+ ] }),
441
+ h('div', { className: 'db-muted', style: { marginTop: '6px' } }, props.state.qa.summary),
442
+ ) : h('div', { className: 'db-card db-muted' }, '尚未选择项目。'),
443
+ )
444
+ }
445
+
446
+ /** 场景树: the Scene Tree of the revision in view. */
447
+ function SceneView(props) {
448
+ const scene = props.state ? props.state.scene : null
449
+ const [patchText, setPatchText] = react.useState(null)
450
+ const [busy, setBusy] = react.useState(false)
451
+ const [result, setResult] = react.useState(null)
452
+
453
+ const template = scene === null ? '' : JSON.stringify({
454
+ baseRevision: props.state.currentRevision,
455
+ operations: [{
456
+ op: 'entity.transform.update',
457
+ entityId: (scene.nodes.entities[0] || {}).id || 'entity-id',
458
+ rotationEuler: [0, 0, 0.12],
459
+ }],
460
+ }, null, 2)
461
+ const text = patchText === null ? template : patchText
462
+
463
+ const apply = async () => {
464
+ let patch
465
+ try {
466
+ patch = JSON.parse(text)
467
+ } catch (error) {
468
+ setResult({ ok: false, message: `patch 不是合法 JSON:${error.message}` })
469
+ return
470
+ }
471
+ setBusy(true)
472
+ const outcome = await postJson(projectRoute(props.projectId, '/patch'), { patch })
473
+ setBusy(false)
474
+ setResult(outcome.ok
475
+ ? { ok: true, message: `已提交 ${outcome.payload.revision.revision}(digest ${shortDigest(outcome.payload.revision.digest)})` }
476
+ : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
477
+ if (outcome.ok) {
478
+ setPatchText(null)
479
+ props.onChanged()
480
+ }
481
+ }
482
+
483
+ if (scene === null) return h('div', { 'data-view': 'scene', className: 'db-muted' }, '尚未选择项目。')
484
+
485
+ const section = (title, items, render) => h('div', { className: 'db-card', key: title },
486
+ h('h4', null, `${title}(${items.length})`),
487
+ items.length === 0 ? h('div', { className: 'db-muted' }, '空') : h('ul', { className: 'db-list' }, items.map(item => h('li', { key: item.id }, render(item)))))
488
+
489
+ return h('div', { 'data-view': 'scene' },
490
+ h(ErrorBox, { error: props.error }),
491
+ h('div', { className: 'db-card' },
492
+ h('div', { className: 'db-inline' },
493
+ h('strong', null, String(scene.project.title || scene.project.id || '')),
494
+ h(Badge, null, scene.revision),
495
+ h('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(scene.digest)}`),
496
+ h(Badge, null, `world ${scene.world ? `${(scene.world.color || []).join(',')} × ${scene.world.strength}` : '默认'}`),
497
+ ),
498
+ ),
499
+ h('div', { className: 'db-grid' },
500
+ section('实体 entities', scene.nodes.entities, entity => h('div', null,
501
+ h('span', { className: 'db-mono', 'data-node': `entity:${entity.id}` }, entity.id), ' ',
502
+ h('span', { className: 'db-kind' }, entity.shape || entity.kind),
503
+ entity.materialId ? h('span', { className: 'db-muted' }, `材质 ${entity.materialId}`) : null,
504
+ entity.locked ? h(Badge, { tone: 'warn' }, 'locked') : null,
505
+ entity.tags.length > 0 ? h('span', { className: 'db-muted db-mono' }, ` tags=[${entity.tags.join(' ')}]`) : null,
506
+ entity.transform ? h('div', { className: 'db-muted db-mono' }, `loc ${(entity.transform.location || []).map(value => Number(value).toFixed(3)).join(', ')}`) : null)),
507
+ section('材质 materials', scene.nodes.materials, material => h('div', null,
508
+ h('span', { className: 'db-mono', 'data-node': `material:${material.id}` }, material.id), ' ',
509
+ h('span', { className: 'db-kind' }, material.shader),
510
+ material.parameters ? h('span', { className: 'db-muted db-mono' }, Object.entries(material.parameters).slice(0, 4).map(([key, value]) => `${key}=${Array.isArray(value) ? `[${value.join(',')}]` : value}`).join(' ')) : null)),
511
+ section('灯光 lights', scene.nodes.lights, light => h('div', null,
512
+ h('span', { className: 'db-mono', 'data-node': `light:${light.id}` }, light.id), ' ',
513
+ h('span', { className: 'db-kind' }, light.type),
514
+ h('span', { className: 'db-muted' }, `energy ${light.energy}`))),
515
+ section('相机 cameras', scene.nodes.cameras, camera => h('div', null,
516
+ h('span', { className: 'db-mono', 'data-node': `camera:${camera.id}` }, camera.id), ' ',
517
+ camera.isActive ? h(Badge, { tone: 'ok' }, 'active') : null, ' ',
518
+ h('span', { className: 'db-kind' }, camera.role || 'no role'),
519
+ h('span', { className: 'db-muted' }, `lens ${camera.lens}`))),
520
+ section('镜头 shots', scene.nodes.shots, shot => h('div', null,
521
+ h('span', { className: 'db-mono', 'data-node': `shot:${shot.id}` }, shot.id), ' ',
522
+ h('span', { className: 'db-muted' }, `${shot.cameraId} ${(shot.frameRange || []).join('–')}`))),
523
+ section('动画轨道 animationTracks', scene.nodes.animationTracks, track => h('div', null,
524
+ h('span', { className: 'db-mono', 'data-node': `track:${track.id}` }, track.id), ' ',
525
+ h('span', { className: 'db-kind' }, track.targetKind),
526
+ h('span', { className: 'db-muted' }, `${track.targetId} · ${track.property} · ${track.keyframes} 关键帧`))),
527
+ section('资产 assets', scene.nodes.assets, asset => h('div', null,
528
+ h('span', { className: 'db-mono', 'data-node': `asset:${asset.id}` }, asset.id), ' ',
529
+ h('span', { className: 'db-kind' }, asset.type),
530
+ h('span', { className: 'db-muted db-mono' }, String(asset.path || '')))),
531
+ ),
532
+
533
+ h('div', { className: 'db-card' },
534
+ h('h4', null, 'ScenePatch(写操作经 Host,原子提交为一个 revision)'),
535
+ h('textarea', {
536
+ className: 'db-area',
537
+ 'data-field': 'scene-patch',
538
+ value: text,
539
+ spellCheck: false,
540
+ onChange: event => setPatchText(event.target.value),
541
+ }),
542
+ h('div', { className: 'db-inline', style: { marginTop: '8px' } },
543
+ h(Button, { tone: 'primary', action: 'apply-patch', disabled: busy || props.projectId === null, onClick: apply }, busy ? '提交中…' : '提交'),
544
+ h(Button, { action: 'reset-patch', onClick: () => setPatchText(null) }, '重置模板'),
545
+ result ? h('span', { 'data-result': result.ok ? 'ok' : 'error', className: result.ok ? 'db-muted' : 'db-error', style: { border: 0, padding: '0 6px', marginBottom: 0 } }, result.message) : null,
546
+ ),
547
+ ),
548
+ )
549
+ }
550
+
551
+ /** 预览对比: two revisions' contact sheets side by side. */
552
+ function PreviewView(props) {
553
+ const previews = props.previews
554
+ if (previews === null) return h('div', { 'data-view': 'preview', className: 'db-muted' }, '尚未选择项目。')
555
+ const revisions = previews.revisions
556
+ const pick = wanted => (revisions.some(entry => entry.revision === wanted) ? wanted : (((revisions[revisions.length - 1] || {}).revision) || null))
557
+ const left = pick(props.compareLeft)
558
+ const right = pick(props.compareRight)
559
+ const entryOf = id => revisions.find(entry => entry.revision === id) || null
560
+
561
+ /**
562
+ * The newest image to show for a revision.
563
+ *
564
+ * Order of preference: the sheet THIS panel's render just composed, then any
565
+ * contact sheet (a review's), then the newest single view. The artifact is
566
+ * passed through WHOLE with a display label added: an earlier version built a
567
+ * fresh `{ path, kind }` here and threw away the digest and the timestamp —
568
+ * which is how the panel ended up keyed on the path alone and showing a stale
569
+ * render (§13.5B).
570
+ */
571
+ const sheetOf = (entry) => {
572
+ if (entry === null) return null
573
+ const sheets = entry.contactSheets || []
574
+ const current = sheets.find(sheet => sheet.slot === 'preview-current')
575
+ if (current && current.path) return { ...current, label: '本次渲染' }
576
+ const sheet = sheets[sheets.length - 1]
577
+ if (sheet && sheet.path) return { ...sheet, label: 'contact sheet' }
578
+ const list = entry.previews || []
579
+ const preview = list[list.length - 1]
580
+ if (preview && preview.path) return { ...preview, label: preview.kind || 'preview' }
581
+ return null
582
+ }
583
+
584
+ /**
585
+ * The pair a render leaves behind: what it just composed, and what came before.
586
+ *
587
+ * "Before" is the previous generation of the SAME revision when there is one.
588
+ * When there is not — which is the case on the first render after a scene
589
+ * change, and therefore the case the comparison exists for — it falls back to
590
+ * the newest render of an older revision. Without that fallback the axis is
591
+ * empty exactly when a person wants it: they changed something, rendered once,
592
+ * and the left pane would say "nothing to compare yet" while the picture they
593
+ * want to compare against is sitting one revision back.
594
+ *
595
+ * The pane labels say which revision each side came from, so a cross-revision
596
+ * pair cannot be mistaken for two renders of one scene.
597
+ */
598
+ const renderPairOf = (entry, allRevisions) => {
599
+ if (entry === null) return { current: null, previous: null }
600
+ const sheets = entry.contactSheets || []
601
+ const current = sheets.find(sheet => sheet.slot === 'preview-current') ?? null
602
+ let previous = sheets.find(sheet => sheet.slot === 'preview-previous') ?? null
603
+ let previousRevision = entry.revision
604
+ if (previous === null) {
605
+ const index = allRevisions.findIndex(candidate => candidate.revision === entry.revision)
606
+ const older = (index > 0 ? allRevisions.slice(0, index) : []).reverse()
607
+ .find(candidate => (candidate.contactSheets || []).some(sheet => sheet.slot === 'preview-current'))
608
+ if (older !== undefined) {
609
+ previous = (older.contactSheets || []).find(sheet => sheet.slot === 'preview-current')
610
+ previousRevision = older.revision
611
+ }
612
+ }
613
+ return {
614
+ current: current === null ? null : { ...current, label: '本次渲染', sourceRevision: entry.revision },
615
+ previous: previous === null ? null : { ...previous, label: '上一次渲染', sourceRevision: previousRevision },
616
+ }
617
+ }
618
+
619
+ /** One image (or the reason there is none), with its digest and its time. */
620
+ const imagePane = (side, title, artifact, missing) => {
621
+ const when = artifactTime(artifact)
622
+ return h('div', { className: 'db-shot', 'data-compare': side, 'data-compare-kind': artifact === null ? 'empty' : 'image' },
623
+ h('h5', null, title),
624
+ artifact === null
625
+ ? h('div', { className: 'db-muted' }, missing)
626
+ : [
627
+ h('img', {
628
+ key: 'img',
629
+ 'data-artifact': artifact.path,
630
+ 'data-artifact-digest': artifact.sha256 || '',
631
+ 'data-artifact-slot': artifact.slot || '',
632
+ 'data-artifact-revision': artifact.sourceRevision || '',
633
+ 'data-artifact-at': artifact.at || '',
634
+ alt: `${title} ${artifact.path}`,
635
+ src: artifactUrl(props.artifactBase, artifact),
636
+ }),
637
+ h('div', { className: 'db-muted db-mono', key: 'meta' },
638
+ `${artifact.sourceRevision ? `${artifact.sourceRevision} ` : ''}${artifact.slot ? artifact.slot : (artifact.kind || 'artifact')} · ${artifact.sha256 ? String(artifact.sha256).slice(0, 10) : '—'}${when === null ? '' : ` · 渲染于 ${when}`}`),
639
+ ].filter(Boolean))
640
+ }
641
+
642
+ const revisionMeta = entry => h('div', { className: 'db-muted', key: 'meta' }, `${entry.summary || '(无说明)'} · ${formatTime(entry.createdAt)}`)
643
+
644
+ /** The revision axis: two revisions side by side, each one's newest image. */
645
+ const revisionPane = (entry, side) => h('div', { className: 'db-shot', 'data-compare': side },
646
+ h('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? ' (当前)' : ''}`),
647
+ entry === null
648
+ ? h('div', { className: 'db-muted' }, '没有这个 revision')
649
+ : [
650
+ revisionMeta(entry),
651
+ (() => {
652
+ const sheet = sheetOf(entry)
653
+ return sheet === null
654
+ ? h('div', { className: 'db-muted', key: 'none' }, '这个 revision 还没有预览图(渲染一次预览即可)。')
655
+ : h('img', {
656
+ key: 'img',
657
+ 'data-artifact': sheet.path,
658
+ 'data-artifact-digest': sheet.sha256 || '',
659
+ 'data-artifact-slot': sheet.slot || '',
660
+ 'data-artifact-revision': entry.revision,
661
+ 'data-artifact-at': sheet.at || '',
662
+ alt: `${entry.revision} ${sheet.label}`,
663
+ src: artifactUrl(props.artifactBase, sheet),
664
+ })
665
+ })(),
666
+ (() => {
667
+ const sheet = sheetOf(entry)
668
+ const when = artifactTime(sheet)
669
+ return sheet === null ? null : h('div', { className: 'db-muted db-mono', key: 'sheet' },
670
+ `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${when === null ? '' : ` · 渲染于 ${when}`}`)
671
+ })(),
672
+ h('div', { className: 'db-muted db-mono', key: 'counts' }, `previews ${(entry.previews || []).length} · sheets ${(entry.contactSheets || []).length} · reviews ${(entry.reviews || []).length}`),
673
+ (entry.reviews || []).length > 0
674
+ ? h('div', { key: 'score' }, h(Badge, { tone: entry.reviews[entry.reviews.length - 1].pass ? 'ok' : 'warn' }, `review ${entry.reviews[entry.reviews.length - 1].score === undefined ? '—' : entry.reviews[entry.reviews.length - 1].score} 分`))
675
+ : null,
676
+ ].filter(Boolean))
677
+
678
+ // Two axes, because two different questions get asked here:
679
+ //
680
+ // renders 「我刚渲的这一张,和上一张比,变了什么?」 — same revision, one
681
+ // generation apart. This is the default, because it is the question
682
+ // a person has right after clicking render, and because a preview
683
+ // render does not create a revision, so the revision axis could not
684
+ // express it at all (§13.5B).
685
+ // revisions 「这个版本和那个版本比,变了什么?」 — the axis SPEC §14.2 and the
686
+ // M4 brief describe, kept because it is the one that survives a
687
+ // scene change.
688
+ const pair = renderPairOf(entryOf(right), revisions)
689
+ const rendersMode = props.compareMode !== 'revisions'
690
+ const renderPairPanes = [
691
+ imagePane('left', pair.previous === null ? '上一次渲染' : `上一次渲染 · ${pair.previous.sourceRevision}`, pair.previous,
692
+ '还没有上一次渲染:再点一次「渲染预览」,或者先在某个更早的 revision 上渲一次。'),
693
+ imagePane('right', `本次渲染 · ${right}`, pair.current,
694
+ '这个 revision 还没有由面板渲过预览(上方的「渲染预览」会生成第一张)。'),
695
+ ]
696
+
697
+ return h('div', { 'data-view': 'preview' },
698
+ h(ErrorBox, { error: props.error }),
699
+ h('div', { className: 'db-tabs' },
700
+ h(Button, { tone: 'primary', action: 'render-preview', disabled: props.busy || props.projectId === null, onClick: props.onRenderPreview }, props.busy ? '渲染中…' : '渲染预览'),
701
+ props.previewResult ? h('span', { 'data-result': props.previewResult.ok ? 'ok' : 'error', className: props.previewResult.ok ? 'db-muted' : 'db-error', style: { border: 0, padding: '0 6px', marginBottom: 0 } }, props.previewResult.message) : null,
702
+ ),
703
+ h('div', { className: 'db-tabs' },
704
+ h('span', { className: 'db-muted' }, '比较:'),
705
+ h('button', {
706
+ type: 'button', className: 'db-btn', 'data-compare-mode': 'renders', 'data-active': String(rendersMode),
707
+ onClick: () => props.onMode('renders'),
708
+ }, '上一次 vs 本次渲染'),
709
+ h('button', {
710
+ type: 'button', className: 'db-btn', 'data-compare-mode': 'revisions', 'data-active': String(!rendersMode),
711
+ onClick: () => props.onMode('revisions'),
712
+ }, '两个 revision'),
713
+ h('span', { style: { flex: 1 } }),
714
+ rendersMode
715
+ ? h('span', { className: 'db-inline' },
716
+ h('span', { className: 'db-muted' }, '版本'),
717
+ h('select', {
718
+ className: 'db-input', 'data-field': 'compare-revision', style: { width: 'auto' }, value: right || '',
719
+ onChange: event => {
720
+ props.onPick('left', event.target.value)
721
+ props.onPick('right', event.target.value)
722
+ },
723
+ }, revisions.map(entry => h('option', { key: entry.revision, value: entry.revision }, entry.revision))))
724
+ : h('span', { className: 'db-inline' },
725
+ h('select', {
726
+ className: 'db-input', 'data-field': 'compare-left', style: { width: 'auto' }, value: left || '',
727
+ onChange: event => props.onPick('left', event.target.value),
728
+ }, revisions.map(entry => h('option', { key: entry.revision, value: entry.revision }, entry.revision))),
729
+ h('span', { className: 'db-muted' }, '↔'),
730
+ h('select', {
731
+ className: 'db-input', 'data-field': 'compare-right', style: { width: 'auto' }, value: right || '',
732
+ onChange: event => props.onPick('right', event.target.value),
733
+ }, revisions.map(entry => h('option', { key: entry.revision, value: entry.revision }, entry.revision))),
734
+ props.projectId ? h(Button, { action: 'diff', onClick: () => props.onDiff(left, right) }, '看结构差异') : null),
735
+ ),
736
+ h('div', { style: { paddingTop: '2px' } },
737
+ h('div', { className: 'db-grid' }, rendersMode
738
+ ? renderPairPanes
739
+ : [revisionPane(entryOf(left), 'left'), revisionPane(entryOf(right), 'right')]),
740
+ props.diff ? h('div', { className: 'db-card', 'data-diff': props.diff.identical ? 'identical' : 'changed' },
741
+ h('h4', null, `${props.diff.fromRevision} → ${props.diff.toRevision}:${props.diff.identical ? '结构完全相同' : `${props.diff.totalChanges} 处结构变化`}`),
742
+ props.diff.identical ? null : h('div', null,
743
+ Object.entries(props.diff.collections)
744
+ .filter(([, entry]) => entry.added.length + entry.removed.length + entry.changed.length > 0)
745
+ .map(([kind, entry]) => h('div', { key: kind },
746
+ h('strong', null, kind),
747
+ entry.added.length > 0 ? h('div', { className: 'db-muted db-mono' }, `+ ${entry.added.join(', ')}`) : null,
748
+ entry.removed.length > 0 ? h('div', { className: 'db-muted db-mono' }, `- ${entry.removed.join(', ')}`) : null,
749
+ entry.changed.map(change => h('div', { key: change.id, className: 'db-muted db-mono' },
750
+ `~ ${change.id}: ${change.fields.map(field => `${field.field} ${JSON.stringify(field.from)} → ${JSON.stringify(field.to)}`).join('; ')}`)))),
751
+ props.diff.project.length > 0 ? h('div', { className: 'db-muted db-mono' }, `project: ${props.diff.project.map(field => `${field.field} ${JSON.stringify(field.from)} → ${JSON.stringify(field.to)}`).join('; ')}`) : null,
752
+ props.diff.world.length > 0 ? h('div', { className: 'db-muted db-mono' }, `world: ${props.diff.world.map(field => `${field.field} ${JSON.stringify(field.from)} → ${JSON.stringify(field.to)}`).join('; ')}`) : null,
753
+ ),
754
+ ) : null,
755
+ props.diffError ? h(ErrorBox, { error: props.diffError }) : null,
756
+ ),
757
+ )
758
+ }
759
+
760
+ /** 任务: live progress, cancel, resume, and the approval threshold. */
761
+ function JobsView(props) {
762
+ const [frameStart, setFrameStart] = react.useState('')
763
+ const [frameEnd, setFrameEnd] = react.useState('')
764
+ const [profile, setProfile] = react.useState('preview')
765
+ const [busy, setBusy] = react.useState(false)
766
+ const [result, setResult] = react.useState(null)
767
+ const jobs = props.jobs || []
768
+
769
+ const start = async (resumeJobId) => {
770
+ setBusy(true)
771
+ const outcome = await postJson(projectRoute(props.projectId, '/render'), {
772
+ resumeJobId,
773
+ frameStart: frameStart === '' ? undefined : Number(frameStart),
774
+ frameEnd: frameEnd === '' ? undefined : Number(frameEnd),
775
+ profile: resumeJobId === undefined ? profile : undefined,
776
+ })
777
+ setBusy(false)
778
+ setResult(outcome.ok
779
+ ? { kind: 'render', ok: true, message: `任务 ${outcome.payload.job.jobId} 已启动(${outcome.payload.job.frames || '?'} 帧)` }
780
+ : { kind: 'render', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
781
+ props.onChanged()
782
+ }
783
+
784
+ const cancel = async (jobId) => {
785
+ setBusy(true)
786
+ const outcome = await postJson(projectRoute(props.projectId, `/jobs/${encodeURIComponent(jobId)}/cancel`), {})
787
+ setBusy(false)
788
+ setResult(outcome.ok
789
+ ? {
790
+ kind: 'cancel',
791
+ ok: outcome.payload.cancelled.processGone !== false,
792
+ message: outcome.payload.cancelled.processGone === false
793
+ ? '取消已请求,但进程仍在'
794
+ : `已取消 ${jobId},进程实测已消失`,
795
+ }
796
+ : { kind: 'cancel', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
797
+ props.onChanged()
798
+ }
799
+
800
+ if (props.projectId === null) return h('div', { 'data-view': 'jobs', className: 'db-muted' }, '尚未选择项目。')
801
+
802
+ return h('div', { 'data-view': 'jobs' },
803
+ h(ErrorBox, { error: props.error }),
804
+ h('div', { className: 'db-card' },
805
+ h('h4', null, '任务'),
806
+ jobs.length === 0
807
+ ? h('div', { className: 'db-muted' }, '还没有渲染任务。')
808
+ : h('ul', { className: 'db-list' }, jobs.map(job => h('li', { key: job.jobId, 'data-job': job.jobId },
809
+ h('div', { className: 'db-inline' },
810
+ h('span', { className: 'db-mono' }, job.jobId),
811
+ h(Badge, { tone: statusTone(job.status) }, job.status),
812
+ h('span', { className: 'db-muted' }, job.type),
813
+ h('span', { className: 'db-muted db-mono' }, `帧 ${job.frameStart}–${job.frameEnd}`),
814
+ job.approval && job.approval.required ? h(Badge, { tone: 'warn', name: 'approval' }, `需审批:${job.approval.frames} 帧 > 阈值 ${job.approval.threshold}`) : null,
815
+ job.deliverable ? h(Badge, { tone: 'ok' }, '交付已校验') : null,
816
+ job.cancelable ? h(Button, { tone: 'danger', action: `cancel:${job.jobId}`, disabled: busy, onClick: () => cancel(job.jobId) }, '取消') : null,
817
+ job.resumable ? h(Button, { action: `resume:${job.jobId}`, disabled: busy, onClick: () => start(job.jobId) }, '继续渲染') : null,
818
+ ),
819
+ h(ProgressBar, { percent: job.progress.percent }),
820
+ h('div', { className: 'db-muted', 'data-job-detail': job.jobId }, `${job.detail} · ${job.progress.percent}% · 缺失 ${job.progress.missing} · 损坏 ${job.progress.corrupt}`),
821
+ job.errorCode ? h('div', { className: 'db-error', style: { marginTop: '4px' } }, `${job.errorCode}: ${job.message || ''}`) : null,
822
+ job.delivery ? h('div', { className: 'db-muted db-mono' }, `video ${job.delivery.videoPath || ''}`) : null,
823
+ ))),
824
+ ),
825
+
826
+ h('div', { className: 'db-card' },
827
+ h('h4', null, '启动一次交付渲染(写操作经 Host)'),
828
+ h('div', { className: 'db-inline' },
829
+ h('label', { className: 'db-muted' }, '帧起'),
830
+ h('input', { className: 'db-input', 'data-field': 'frame-start', style: { width: '90px' }, value: frameStart, onChange: event => setFrameStart(event.target.value) }),
831
+ h('label', { className: 'db-muted' }, '帧止'),
832
+ h('input', { className: 'db-input', 'data-field': 'frame-end', style: { width: '90px' }, value: frameEnd, onChange: event => setFrameEnd(event.target.value) }),
833
+ h('label', { className: 'db-muted' }, 'profile'),
834
+ h('select', {
835
+ className: 'db-input', 'data-field': 'render-profile', style: { width: 'auto' }, value: profile,
836
+ onChange: event => setProfile(event.target.value),
837
+ },
838
+ h('option', { value: 'preview' }, 'preview'),
839
+ h('option', { value: 'final' }, 'final')),
840
+ h(Button, { tone: 'primary', action: 'start-render', disabled: busy, onClick: () => start(undefined) }, busy ? '提交中…' : '启动'),
841
+ ),
842
+ result ? h('div', { 'data-result': result.ok ? 'ok' : 'error', 'data-result-kind': result.kind || 'render', className: result.ok ? 'db-muted' : 'db-error', style: { marginTop: '8px' } }, result.message) : null,
843
+ ),
844
+ )
845
+ }
846
+
847
+ /** QA: technical validation and the visual review, never merged. */
848
+ function QaView(props) {
849
+ const qa = props.qa
850
+ if (qa === null || qa === undefined) return h('div', { 'data-view': 'qa', className: 'db-muted' }, '尚未选择项目。')
851
+ const issueList = (issues, keyPrefix) => h('ul', { className: 'db-list' }, issues.map((issue, index) => h('li', { key: `${keyPrefix}${index}` },
852
+ h('div', { className: 'db-inline' },
853
+ h(Badge, { tone: issue.severity === 'critical' ? 'bad' : issue.severity === 'major' ? 'warn' : 'muted' }, issue.severity || '—'),
854
+ h('span', { className: 'db-mono' }, issue.code || '—'),
855
+ issue.category ? h('span', { className: 'db-muted' }, issue.category) : null,
856
+ issue.viewId ? h('span', { className: 'db-muted db-mono' }, issue.viewId) : null,
857
+ ),
858
+ h('div', null, issue.evidence || ''))))
859
+
860
+ return h('div', { 'data-view': 'qa' },
861
+ h(ErrorBox, { error: props.error }),
862
+ h('div', { className: 'db-card', 'data-qa-revision': qa.revision },
863
+ h('div', { className: 'db-inline' },
864
+ h('strong', null, `QA · ${qa.revision}`),
865
+ h(Badge, { tone: qa.technical.ok ? 'ok' : 'bad' }, qa.technical.available ? (qa.technical.ok ? '技术校验通过' : `技术错误 ${qa.technical.errorCount}`) : '无技术校验记录'),
866
+ qa.visual.available ? h(Badge, { tone: qa.visual.pass ? 'ok' : 'warn' }, `视觉评分 ${qa.visual.score}`) : h(Badge, null, '未跑视觉评审'),
867
+ qa.semantic.noticeCount > 0 ? h(Badge, { tone: 'warn' }, `${qa.semantic.noticeCount} 条 notices`) : null,
868
+ ),
869
+ h('div', { className: 'db-muted', style: { marginTop: '4px' } }, qa.summary),
870
+ ),
871
+
872
+ h('div', { className: 'db-card' },
873
+ h('h4', null, '技术校验(validation.json)'),
874
+ h(KeyValues, { entries: [
875
+ { label: '引擎', value: qa.technical.engine },
876
+ { label: '活动相机', value: qa.technical.activeCamera },
877
+ { label: '帧范围', value: qa.technical.frameRange ? qa.technical.frameRange.join('–') : null },
878
+ { label: '对象', value: qa.technical.counts ? qa.technical.counts.objects : null },
879
+ { label: '材质', value: qa.technical.counts ? qa.technical.counts.materials : null },
880
+ { label: '相机', value: qa.technical.counts ? qa.technical.counts.cameraObjects : null },
881
+ ] }),
882
+ qa.technical.errors.length === 0
883
+ ? h('div', { className: 'db-muted' }, '没有技术错误。')
884
+ : issueList(qa.technical.errors, 'tech'),
885
+ qa.semantic.notices.length === 0 ? null : h('div', { style: { marginTop: '8px' } },
886
+ h('h4', null, '编译器 notices'),
887
+ h('ul', { className: 'db-list' }, qa.semantic.notices.map((notice, index) => h('li', { key: `n${index}` },
888
+ h('span', { className: 'db-mono' }, notice.code || ''), ' ', notice.message || '')))),
889
+ ),
890
+
891
+ h('div', { className: 'db-card' },
892
+ h('h4', null, '视觉评审(测量 + 模型 finding,两个来源不合并)'),
893
+ qa.visual.available
894
+ ? h('div', null,
895
+ h(KeyValues, { entries: [
896
+ { label: '分数', value: qa.visual.score },
897
+ { label: '通过', value: qa.visual.pass ? '是' : '否' },
898
+ { label: '轮次', value: qa.visual.iteration },
899
+ { label: '主体', value: qa.visual.subjectId },
900
+ { label: '视角数', value: qa.visual.viewCount },
901
+ { label: '审查器', value: qa.visual.reviewerAvailable ? (qa.visual.reviewerModel || '已调用') : (qa.visual.reviewerError || '未调用') },
902
+ ] }),
903
+ qa.visual.measuredIssues.length === 0 ? h('div', { className: 'db-muted' }, '测量没有发现问题。') : issueList(qa.visual.measuredIssues, 'm'),
904
+ qa.visual.findings.length === 0
905
+ ? h('div', { className: 'db-muted' }, qa.visual.reviewerAvailable ? '审查器没有报告 finding。' : '没有第二意见。')
906
+ : issueList(qa.visual.findings, 'f'))
907
+ : h('div', { className: 'db-muted' }, '这个 revision 还没有视觉评审。可以用 blender_visual_review 跑一次。'),
908
+ ),
909
+ )
910
+ }
911
+
912
+ /** 版本: the revision list, restore, and the hand-off to Preview Compare. */
913
+ function RevisionsView(props) {
914
+ const [busy, setBusy] = react.useState(false)
915
+ const [result, setResult] = react.useState(null)
916
+ const revisions = (props.state && props.state.revisions) || []
917
+
918
+ const restore = async (revision) => {
919
+ setBusy(true)
920
+ const outcome = await postJson(projectRoute(props.projectId, '/restore'), { revision })
921
+ setBusy(false)
922
+ setResult(outcome.ok
923
+ ? { ok: true, message: `已恢复 ${revision} 为 ${outcome.payload.revision.revision}` }
924
+ : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
925
+ props.onChanged()
926
+ }
927
+
928
+ if (props.projectId === null) return h('div', { 'data-view': 'revisions', className: 'db-muted' }, '尚未选择项目。')
929
+
930
+ return h('div', { 'data-view': 'revisions' },
931
+ h(ErrorBox, { error: props.error }),
932
+ h('div', { className: 'db-card' },
933
+ h('h4', null, `版本(${revisions.length})`),
934
+ h('ul', { className: 'db-list' }, revisions.map(entry => h('li', { key: entry.revision, 'data-revision': entry.revision },
935
+ h('div', { className: 'db-inline' },
936
+ h('span', { className: 'db-mono' }, entry.revision),
937
+ entry.isCurrent ? h(Badge, { tone: 'ok' }, '当前') : null,
938
+ h('span', { className: 'db-muted' }, entry.kind || '—'),
939
+ h('span', { className: 'db-muted' }, formatTime(entry.createdAt)),
940
+ h('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(entry.digest)}`),
941
+ h(Button, { action: `compare-from:${entry.revision}`, onClick: () => props.onCompare(entry.revision) }, '对比'),
942
+ entry.isCurrent ? null : h(Button, { action: `restore:${entry.revision}`, disabled: busy, onClick: () => restore(entry.revision) }, '恢复'),
943
+ ),
944
+ h('div', { className: 'db-muted' }, entry.summary || '(无说明)'),
945
+ h('div', { className: 'db-muted db-mono' }, `previews ${(entry.previews || []).length} · validation ${entry.validation ? (entry.validation.ok ? 'ok' : `${entry.validation.errorCount} errors`) : '—'}`),
946
+ ))),
947
+ result ? h('div', { 'data-result': result.ok ? 'ok' : 'error', className: result.ok ? 'db-muted' : 'db-error', style: { marginTop: '8px' } }, result.message) : null,
948
+ ),
949
+ )
950
+ }
951
+
952
+ /**
953
+ * The panel: a header, the view nav, and the active view.
954
+ *
955
+ * The selected project and the compare pair are LOCAL UI state (SPEC §14.3).
956
+ * Everything they point at is read from the Host on every render pass, so the
957
+ * mirror cannot outlive the truth.
958
+ */
959
+ function WorkbenchPanel() {
960
+ const [view, setView] = react.useState('projects')
961
+ const [projectId, setProjectId] = react.useState(null)
962
+ const [compareLeft, setCompareLeft] = react.useState(null)
963
+ const [compareRight, setCompareRight] = react.useState(null)
964
+ const [diff, setDiff] = react.useState(null)
965
+ const [diffError, setDiffError] = react.useState(null)
966
+ const [previewBusy, setPreviewBusy] = react.useState(false)
967
+ const [previewResult, setPreviewResult] = react.useState(null)
968
+ // Which pair Preview Compare shows. Local UI state, like the rest of this
969
+ // panel's selections (SPEC §14.3): it decides what is DISPLAYED, never what is
970
+ // true.
971
+ const [compareMode, setCompareMode] = react.useState('renders')
972
+ const [tick, setTick] = react.useState(0)
973
+
974
+ const statePath = projectId === null
975
+ ? `${ROUTES.state}?t=${tick}`
976
+ : `${ROUTES.state}?projectId=${encodeURIComponent(projectId)}&t=${tick}`
977
+ const state = useHostRoute(statePath, { route: 'state', intervalMs: POLL_IDLE_MS })
978
+ const payload = state.payload
979
+ const selected = payload && payload.selected ? payload.selected : null
980
+ const activeProjectId = projectId !== null
981
+ ? projectId
982
+ : (payload && payload.projects && payload.projects[0] ? payload.projects[0].projectId : null)
983
+
984
+ const liveJobs = selected !== null && (selected.unfinishedJobs || []).length > 0
985
+ const jobs = useHostRoute(
986
+ activeProjectId === null ? `${ROUTES.state}?t=${tick}` : `${projectRoute(activeProjectId, '/jobs')}?t=${tick}`,
987
+ { route: activeProjectId === null ? 'state' : 'project.jobs', intervalMs: liveJobs ? POLL_LIVE_MS : POLL_IDLE_MS },
988
+ )
989
+ const previews = useHostRoute(
990
+ activeProjectId === null ? `${ROUTES.state}?t=${tick}` : `${projectRoute(activeProjectId, '/previews')}?t=${tick}`,
991
+ { route: activeProjectId === null ? 'state' : 'project.previews', intervalMs: 0 },
992
+ )
993
+
994
+ /** A write just happened: re-run every read, including the view-specific ones. */
995
+ const reload = () => setTick(value => value + 1)
996
+
997
+ const error = state.error || jobs.error || previews.error
998
+ const jobPayload = jobs.payload
999
+ const previewPayload = previews.payload
1000
+ const stale = state.status === 'stale' || jobs.status === 'stale' || previews.status === 'stale'
1001
+
1002
+ const body = (() => {
1003
+ if (stale) return h('div', { className: 'db-body' }, h(ErrorBox, { error: state.error }))
1004
+ if (payload === null) return h('div', { className: 'db-body db-muted' }, '读取 Host 状态…')
1005
+ switch (view) {
1006
+ case 'scene':
1007
+ return h('div', { className: 'db-body' }, h(SceneView, {
1008
+ state: selected,
1009
+ projectId: activeProjectId,
1010
+ error: state.error,
1011
+ onChanged: reload,
1012
+ }))
1013
+ case 'preview':
1014
+ return h('div', { className: 'db-body' }, h(PreviewView, {
1015
+ previews: previewPayload && previewPayload.previews ? previewPayload.previews : null,
1016
+ artifactBase: previewPayload && previewPayload.artifactBase ? previewPayload.artifactBase : '',
1017
+ projectId: activeProjectId,
1018
+ busy: previewBusy,
1019
+ previewResult,
1020
+ compareMode,
1021
+ onMode: setCompareMode,
1022
+ onRenderPreview: async () => {
1023
+ setPreviewBusy(true)
1024
+ setPreviewResult(null)
1025
+ const outcome = await postJson(projectRoute(activeProjectId, '/preview'), {})
1026
+ setPreviewBusy(false)
1027
+ setPreviewResult(outcome.ok
1028
+ ? {
1029
+ ok: true,
1030
+ message: `已渲染 ${outcome.payload.preview.views.length} 个视角 → 合成 ${outcome.payload.preview.revision} 的 contact sheet`
1031
+ + (outcome.payload.preview.sheets && outcome.payload.preview.sheets.previous
1032
+ ? ';上一张已留作「上一次渲染」,可以直接并排比较'
1033
+ : '(这是第一张;再渲染一次就能并排比较前后)')
1034
+ + '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
1035
+ }
1036
+ : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
1037
+ reload()
1038
+ },
1039
+ compareLeft: compareLeft || (selected ? selected.currentRevision : null),
1040
+ compareRight: compareRight || (selected ? selected.currentRevision : null),
1041
+ diff,
1042
+ diffError,
1043
+ onPick: (side, revision) => {
1044
+ setDiff(null)
1045
+ setDiffError(null)
1046
+ if (side === 'left') setCompareLeft(revision)
1047
+ else setCompareRight(revision)
1048
+ },
1049
+ onDiff: async (from, to) => {
1050
+ setDiffError(null)
1051
+ try {
1052
+ const response = await fetch(`${projectRoute(activeProjectId, '/diff')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { headers: { accept: 'application/json' } })
1053
+ const parsed = await response.json()
1054
+ if (parsed && parsed.ok) setDiff(parsed.diff)
1055
+ else {
1056
+ setDiff(null)
1057
+ setDiffError((parsed && parsed.error) || { code: `HTTP_${response.status}`, message: 'diff 失败' })
1058
+ }
1059
+ } catch (error2) {
1060
+ setDiff(null)
1061
+ setDiffError({ code: 'UI_FETCH_FAILED', message: String((error2 && error2.message) || error2) })
1062
+ }
1063
+ },
1064
+ }))
1065
+ case 'jobs':
1066
+ return h('div', { className: 'db-body' }, h(JobsView, {
1067
+ jobs: jobPayload && jobPayload.jobs ? jobPayload.jobs : [],
1068
+ projectId: activeProjectId,
1069
+ error: jobs.error,
1070
+ onChanged: reload,
1071
+ }))
1072
+ case 'qa':
1073
+ return h('div', { className: 'db-body' }, h(QaView, { qa: selected ? selected.qa : null, error: state.error }))
1074
+ case 'revisions':
1075
+ return h('div', { className: 'db-body' }, h(RevisionsView, {
1076
+ state: selected,
1077
+ projectId: activeProjectId,
1078
+ error: state.error,
1079
+ onChanged: reload,
1080
+ onCompare: revision => {
1081
+ setCompareLeft(revision)
1082
+ setView('preview')
1083
+ },
1084
+ }))
1085
+ default:
1086
+ return h('div', { className: 'db-body' }, h(ProjectsView, {
1087
+ projects: payload.projects || [],
1088
+ projectsRoot: payload.projectsRoot,
1089
+ selectedId: activeProjectId,
1090
+ state: selected,
1091
+ error: state.error,
1092
+ onSelect: (id) => {
1093
+ setProjectId(id)
1094
+ setDiff(null)
1095
+ setCompareLeft(null)
1096
+ setCompareRight(null)
1097
+ },
1098
+ onCreated: (id) => {
1099
+ setProjectId(id)
1100
+ reload()
1101
+ },
1102
+ }))
1103
+ }
1104
+ })()
1105
+
1106
+ return h('div', { className: 'db-root', 'data-deepblend-panel': PANEL_ID },
1107
+ h('div', { className: 'db-head' },
1108
+ h('span', { className: 'db-title' }, `${PANEL_LABEL} 工作台`),
1109
+ selected ? h('span', { className: 'db-muted' }, selected.project.title) : null,
1110
+ selected ? h(Badge, null, selected.currentRevision || '—') : null,
1111
+ selected && (selected.unfinishedJobs || []).length > 0
1112
+ ? h(Badge, { tone: 'live', name: 'unfinished' }, `${selected.unfinishedJobs.length} 个任务在跑`)
1113
+ : null,
1114
+ payload && payload.hostApiVersion !== EXPECTED_HOST_API
1115
+ ? h(Badge, { tone: 'warn', name: 'api' }, `hostApiVersion ${payload.hostApiVersion} ≠ ${EXPECTED_HOST_API}`)
1116
+ : null,
1117
+ h('span', { style: { flex: 1 } }),
1118
+ h(Button, { action: 'reload', onClick: reload }, '刷新'),
1119
+ ),
1120
+ h('div', { className: 'db-nav' }, VIEWS.map(entry => h('button', {
1121
+ key: entry.id,
1122
+ type: 'button',
1123
+ 'data-view-tab': entry.id,
1124
+ 'data-active': String(view === entry.id),
1125
+ onClick: () => setView(entry.id),
1126
+ }, entry.label))),
1127
+ error !== null && error !== undefined && !stale ? h('div', { style: { padding: '8px 14px 0' } }, h(ErrorBox, { error })) : null,
1128
+ body,
1129
+ )
1130
+ }
1131
+
1132
+ /** The settings page: the M0 card, now with a seat. */
1133
+ function BlenderSettingsPage() {
1134
+ const state = useHostRoute(ROUTES.capabilities, { route: 'capabilities', intervalMs: 0 })
1135
+ const payload = state.payload
1136
+ const card = payload ? payload.card : null
1137
+
1138
+ return h('div', { 'data-deepblend-settings': PANEL_ID, style: { padding: '4px 2px' } },
1139
+ state.error !== null && state.error !== undefined ? h(ErrorBox, { error: state.error }) : null,
1140
+ state.status === 'loading' ? h('div', { className: 'db-muted' }, '检测 Blender…') : null,
1141
+ card === null ? null : h('div', null,
1142
+ h('div', { className: 'db-inline' },
1143
+ h('strong', null, card.title),
1144
+ h(Badge, { tone: card.status === 'ready' ? 'ok' : 'bad' }, card.statusLabel),
1145
+ card.probedAt ? h('span', { className: 'db-muted' }, `探测于 ${formatTime(card.probedAt)}`) : null,
1146
+ ),
1147
+ h('div', { className: 'db-card', style: { marginTop: '8px' } },
1148
+ card.rows.map(row => h(Row, { key: row.label, label: row.label, value: row.value }))),
1149
+ card.warnings.length === 0 ? null : h('div', { className: 'db-card' },
1150
+ h('h4', null, '警告'),
1151
+ h('ul', { className: 'db-list' }, card.warnings.map((entry, index) => h('li', { key: `w${index}` },
1152
+ h('span', { className: 'db-mono' }, entry.code || ''), ' ', entry.message || '')))),
1153
+ h('div', { className: 'db-muted db-mono' }, `hostApiVersion ${payload.hostApiVersion}`),
1154
+ ),
1155
+ )
1156
+ }
1157
+
1158
+ // -------------------------------------------------------------------------
1159
+ // Tool cards (SPEC §14.2 "Tool Result Cards")
1160
+ // -------------------------------------------------------------------------
1161
+
1162
+ /** The tool's own arguments — the harness records them verbatim. */
1163
+ function callArgs(block) {
1164
+ const settled = block !== null && block !== undefined && typeof block === 'object' && 'kind' in block
1165
+ return (settled ? (block.call && block.call.argsRaw) : (block && block.argsRaw)) || ''
1166
+ }
1167
+
1168
+ function parseArgs(raw) {
1169
+ try {
1170
+ const parsed = typeof raw === 'string' && raw.length > 0 ? JSON.parse(raw) : null
1171
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
1172
+ } catch {
1173
+ return {}
1174
+ }
1175
+ }
1176
+
1177
+ /** The settled result's text, for the expandable half of a card. */
1178
+ function resultText(block) {
1179
+ if (block === null || block === undefined) return ''
1180
+ if (!('kind' in block)) return ''
1181
+ const parts = (block.content || []).map(item => (item.type === 'text' ? item.text : `<${item.type}>`))
1182
+ const text = parts.join('\n')
1183
+ if (text.length > 0) return text
1184
+ return block.error ? `${block.error.name || 'error'}: ${block.error.code || ''}` : ''
1185
+ }
1186
+
1187
+ /**
1188
+ * A DeepBlend tool card.
1189
+ *
1190
+ * It never parses prose for ids: `projectId` / `jobId` come from the call's own
1191
+ * JSON arguments, and everything shown beyond that is read back from the Host
1192
+ * — which is also why a card stays correct while the render it describes is
1193
+ * still running.
1194
+ */
1195
+ function BlenderToolCard(props) {
1196
+ const toolName = props.toolName
1197
+ const args = parseArgs(callArgs(props.block))
1198
+ const projectId = typeof args.projectId === 'string' ? args.projectId : null
1199
+ const jobId = typeof args.jobId === 'string' ? args.jobId : (typeof args.resumeJobId === 'string' ? args.resumeJobId : null)
1200
+ const settled = props.block !== null && props.block !== undefined && 'kind' in props.block
1201
+ const [expanded, setExpanded] = react.useState(false)
1202
+
1203
+ const jobShaped = ['blender_final_render', 'blender_export', 'blender_job_status', 'blender_job_cancel'].includes(toolName)
1204
+ const reviewShaped = ['blender_visual_review', 'blender_visual_autofix', 'blender_preview_views'].includes(toolName)
1205
+ const canReadJob = projectId !== null && jobId !== null
1206
+ const idle = `${ROUTES.state}?t=0`
1207
+
1208
+ const jobState = useHostRoute(
1209
+ canReadJob ? projectRoute(projectId, `/jobs/${encodeURIComponent(jobId)}`) : idle,
1210
+ { route: canReadJob ? 'project.job' : 'state', intervalMs: jobShaped && canReadJob ? POLL_LIVE_MS : 0 },
1211
+ )
1212
+ const qaState = useHostRoute(
1213
+ projectId === null ? idle : projectRoute(projectId, '/qa'),
1214
+ { route: projectId === null ? 'state' : 'project.qa', intervalMs: reviewShaped && projectId !== null ? POLL_IDLE_MS : 0 },
1215
+ )
1216
+ const previewState = useHostRoute(
1217
+ projectId === null ? idle : projectRoute(projectId, '/previews'),
1218
+ { route: projectId === null ? 'state' : 'project.previews', intervalMs: 0 },
1219
+ )
1220
+
1221
+ const job = jobState.payload && jobState.payload.job ? jobState.payload.job : null
1222
+ const qa = projectId !== null && qaState.payload && qaState.payload.qa ? qaState.payload.qa : null
1223
+ const previews = projectId !== null && previewState.payload && previewState.payload.previews ? previewState.payload.previews : null
1224
+ const artifactBase = previewState.payload && previewState.payload.artifactBase ? previewState.payload.artifactBase : ''
1225
+
1226
+ const sheet = (() => {
1227
+ if (previews === null) return null
1228
+ const revision = (qa && qa.revision) || args.baseRevision || null
1229
+ const entry = previews.revisions.find(candidate => candidate.revision === revision) || previews.revisions[previews.revisions.length - 1]
1230
+ if (entry === undefined) return null
1231
+ const sheets = entry.contactSheets || []
1232
+ const pick = sheets[sheets.length - 1] || (entry.previews || [])[(entry.previews || []).length - 1]
1233
+ return pick && pick.path ? pick : null
1234
+ })()
1235
+
1236
+ return h('div', {
1237
+ className: 'db-card',
1238
+ 'data-tool-card': toolName,
1239
+ 'data-tool-state': settled ? (props.block.isError ? 'error' : 'ok') : 'running',
1240
+ style: { marginBottom: '6px' },
1241
+ },
1242
+ h('div', { className: 'db-inline' },
1243
+ h('span', { className: 'db-dot', 'data-tone': settled ? (props.block.isError ? 'bad' : 'ok') : 'live' }),
1244
+ h('strong', { className: 'db-mono' }, toolName),
1245
+ projectId ? h('span', { className: 'db-muted db-mono' }, projectId) : null,
1246
+ jobId ? h(Badge, null, jobId) : null,
1247
+ Array.isArray(args.operations) ? h(Badge, null, `${args.operations.length} 个操作`) : null,
1248
+ job && job.approval && job.approval.required ? h(Badge, { tone: 'warn', name: 'approval' }, `需审批:${job.approval.frames} 帧 > ${job.approval.threshold}`) : null,
1249
+ h('span', { style: { flex: 1 } }),
1250
+ settled ? h('button', { type: 'button', className: 'db-chip', 'data-action': 'toggle-card', onClick: () => setExpanded(value => !value) }, expanded ? '收起' : '详情') : null,
1251
+ ),
1252
+
1253
+ jobShaped && job !== null ? h('div', { 'data-tool-job': job.jobId },
1254
+ h('div', { className: 'db-inline' },
1255
+ h(Badge, { tone: statusTone(job.status) }, job.status),
1256
+ h('span', { className: 'db-muted' }, `${job.progress.completed}/${job.progress.expected} 帧 · ${job.progress.percent}%`),
1257
+ job.estimatedRemainingMs ? h('span', { className: 'db-muted' }, `预计 ${Math.round(job.estimatedRemainingMs / 1000)} s`) : null,
1258
+ ),
1259
+ h(ProgressBar, { percent: job.progress.percent }),
1260
+ h('div', { className: 'db-muted' }, job.detail),
1261
+ job.delivery ? h('div', { className: 'db-muted db-mono' }, String(job.delivery.videoPath || '')) : null,
1262
+ ) : null,
1263
+
1264
+ reviewShaped && qa !== null ? h('div', { 'data-tool-qa': qa.revision },
1265
+ h('div', { className: 'db-inline' },
1266
+ qa.visual.available ? h(Badge, { tone: qa.visual.pass ? 'ok' : 'warn' }, `视觉评分 ${qa.visual.score}`) : h(Badge, null, '未跑视觉评审'),
1267
+ h(Badge, { tone: qa.technical.ok ? 'ok' : 'bad' }, qa.technical.available ? (qa.technical.ok ? '技术校验通过' : `${qa.technical.errorCount} 个技术错误`) : '无记录'),
1268
+ h('span', { className: 'db-muted' }, `测量 ${qa.visual.measuredIssueCount} · 模型 finding ${qa.visual.findingCount}`),
1269
+ ),
1270
+ qa.visual.measuredIssues.slice(0, 4).map((issue, index) => h('div', { key: `i${index}`, className: 'db-muted db-mono' },
1271
+ `[${issue.severity}] ${issue.code} ${issue.viewId || ''} :: ${issue.evidence || ''}`)),
1272
+ qa.visual.reviewerError ? h('div', { className: 'db-error' }, `审查器失败:${qa.visual.reviewerError}`) : null,
1273
+ ) : null,
1274
+
1275
+ reviewShaped && sheet !== null ? h('div', { style: { marginTop: '6px' } },
1276
+ h('img', {
1277
+ 'data-tool-sheet': sheet.path,
1278
+ 'data-artifact-digest': sheet.sha256 || '',
1279
+ alt: 'contact sheet',
1280
+ src: artifactUrl(artifactBase, sheet),
1281
+ style: { width: '100%', borderRadius: '6px', background: '#000' },
1282
+ }),
1283
+ h('div', { className: 'db-muted db-mono' }, `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${artifactTime(sheet) === null ? '' : ` · 渲染于 ${artifactTime(sheet)}`}`)) : null,
1284
+
1285
+ expanded || !settled ? h('pre', { className: 'db-pre' }, (resultText(props.block) || '(运行中…)').slice(0, 4000)) : null,
1286
+ )
1287
+ }
1288
+
1289
+ /** The session-header chip: how many renders are live, right now. */
1290
+ function SessionJobsChip() {
1291
+ const state = useHostRoute(ROUTES.projects, { route: 'projects.list', intervalMs: POLL_LIVE_MS })
1292
+ if (state.payload === null) return null
1293
+ const projects = state.payload.projects || []
1294
+ const unfinished = projects.reduce((sum, project) => sum + (project.unfinishedJobs || 0), 0)
1295
+ return h('span', {
1296
+ className: 'db-chip',
1297
+ 'data-deepblend-chip': 'jobs',
1298
+ title: projects.map(project => `${project.projectId}: ${project.unfinishedJobs || 0}`).join('\n'),
1299
+ },
1300
+ h('span', { className: 'db-dot', 'data-tone': unfinished > 0 ? 'live' : 'ok' }),
1301
+ unfinished > 0 ? `${unfinished} 个渲染在跑` : '无渲染任务',
1302
+ )
1303
+ }
1304
+
1305
+ // -------------------------------------------------------------------------
1306
+ // Registration
1307
+ // -------------------------------------------------------------------------
1308
+
1309
+ /**
1310
+ * The sidebar glyph, drawn from the owner's `{ size, active }` and nothing else.
1311
+ *
1312
+ * An isometric cube rather than a brand mark: the owner renders it at 16–18 px
1313
+ * beside the console's own icons, where a cube still reads as "3D" and a logo
1314
+ * detail turns to mush. It inherits `currentColor`, so hover and selected
1315
+ * states are the shell's.
1316
+ */
1317
+ function PanelIcon(props) {
1318
+ const size = props && props.size ? props.size : 18
1319
+ const active = Boolean(props && props.active)
1320
+ return h('svg', {
1321
+ width: size,
1322
+ height: size,
1323
+ viewBox: '0 0 24 24',
1324
+ fill: 'none',
1325
+ stroke: 'currentColor',
1326
+ strokeWidth: active ? 1.9 : 1.5,
1327
+ strokeLinecap: 'round',
1328
+ strokeLinejoin: 'round',
1329
+ 'data-deepblend-icon': 'blender',
1330
+ },
1331
+ h('path', { d: 'M12 2.7l8 4.6v9.4l-8 4.6-8-4.6V7.3z' }),
1332
+ h('path', { d: 'M4 7.3l8 4.6 8-4.6' }),
1333
+ h('path', { d: 'M12 11.9v9.4' }),
1334
+ )
1335
+ }
1336
+
1337
+ /** Client services this plugin needs; `slots` is the Slot registry. */
1338
+ const inject = ['slots']
1339
+
1340
+ /**
1341
+ * Register every seat this package owns.
1342
+ *
1343
+ * Each registration is additive: the sidebar list gains one entry, `main`
1344
+ * gains one key (`conversation` is untouched — it hosts the whole conversation
1345
+ * tree, tool cards included), the settings list gains one page, and each
1346
+ * `tool.call.toolview` key is unclaimed today.
1347
+ */
1348
+ function apply(ctx) {
1349
+ ctx.effect(() => injectStyles(), 'deepblend-ui: styles')
1350
+
1351
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
1352
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 100, label: () => PANEL_LABEL },
1353
+ PanelIcon,
1354
+ ))
1355
+
1356
+ ctx.slots.inject('main', () => ctx.slots.register(
1357
+ { name: 'main', key: PANEL_ID },
1358
+ WorkbenchPanel,
1359
+ ))
1360
+
1361
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
1362
+ { name: 'settings.section', id: PANEL_ID, order: 50, label: () => PANEL_LABEL },
1363
+ BlenderSettingsPage,
1364
+ ))
1365
+
1366
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register(
1367
+ { name: 'conversation.session.header.utilities', id: PANEL_ID, order: 60, label: () => PANEL_LABEL },
1368
+ SessionJobsChip,
1369
+ ))
1370
+
1371
+ ctx.slots.inject('tool.call.toolview', () => TOOL_CARD_KEYS.map(key => ctx.slots.register(
1372
+ { name: 'tool.call.toolview', key },
1373
+ BlenderToolCard,
1374
+ )))
1375
+ }
1376
+
1377
+ exports.apply = apply
1378
+ exports.inject = inject
1379
+ return module.exports
1380
+ },
1381
+ })