@deepblend/dsh-blender-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +1381 -0
- package/lib/index.js +599 -0
- package/package.json +33 -0
package/lib/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
|
+
})
|