@deepblend/dsh-blender-ui 0.1.0 → 0.2.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 +1226 -725
- package/lib/index.js +144 -0
- package/package.json +2 -2
package/lib/client.js
CHANGED
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @deepblend/dsh-blender-ui — the Web Client half (SPEC §5.1, §14).
|
|
2
|
+
* @deepblend/dsh-blender-ui — the Web Client half (SPEC §5.1, §14, §20 M6).
|
|
3
3
|
*
|
|
4
4
|
* This file is a **self-registering CJS factory**, which is what the browser's
|
|
5
5
|
* client module system executes (`dsh-client-modules`): the shell loads the
|
|
6
6
|
* bundle as a script, and the only thing the script does is hand a factory to
|
|
7
7
|
* `window.__ModuleLoader__.load`. There is no top-level `import`, no bundler step
|
|
8
8
|
* and no JSX — dependencies arrive through the factory's synchronous `require`,
|
|
9
|
-
* and elements are built
|
|
9
|
+
* and elements are built through one element vocabulary defined here.
|
|
10
10
|
*
|
|
11
11
|
* That is not a compromise: `docs/probe-m4-client-loop.log` measures this file's
|
|
12
12
|
* own development loop — one line edited here is live in an already-open page in
|
|
13
13
|
* about 600 ms, with no refresh, no restart and no build.
|
|
14
14
|
*
|
|
15
|
+
* ONE BUNDLE, TWO FACES (SPEC §20 M6 「独立全屏工作台」)
|
|
16
|
+
* -----------------------------------------------------
|
|
17
|
+
* The six tabs of the workbench are rendered by ONE implementation, and this file
|
|
18
|
+
* is where it lives:
|
|
19
|
+
*
|
|
20
|
+
* §C–§H the React-free core: the element vocabulary, the display helpers, the
|
|
21
|
+
* framework-free store (state, polling, actions) and the six views, all
|
|
22
|
+
* of them pure functions returning a **descriptor tree** — plain
|
|
23
|
+
* `{ tag, props, children }` data, with no React in it anywhere.
|
|
24
|
+
* §I two GENERIC bindings of that tree: `toReact` (for the console) and
|
|
25
|
+
* `toDom` (for the standalone page). Neither knows a single DeepBlend
|
|
26
|
+
* noun — no tab id, no route, no field name. That is the assertion
|
|
27
|
+
* `contract/workbench-page.test.mjs` makes, and it is the whole reason
|
|
28
|
+
* the standalone page is not a second implementation of the workbench.
|
|
29
|
+
* §K the DSH console seats, which are React and stay React: the sidebar
|
|
30
|
+
* entry, the settings page, the tool cards and the session chip.
|
|
31
|
+
*
|
|
32
|
+
* The standalone page at `GET /deepblend/workbench` does NOT get a copy of any of
|
|
33
|
+
* this. Its document (built by the Host half, `./index.js`) carries the ordinary
|
|
34
|
+
* boot injections, and its ~10-line bootstrap calls
|
|
35
|
+
* `window.__ModuleLoader__.create(...)` and imports **this same bundle** out of
|
|
36
|
+
* `window.__DSH_BOOT__` — the very graph row the console loads. `mountStandalone`
|
|
37
|
+
* (§J) is then the only thing that differs between the two faces.
|
|
38
|
+
*
|
|
15
39
|
* What it renders, and where (every seat below is one this package *adds to*; the
|
|
16
40
|
* shipped console is never shadowed):
|
|
17
41
|
*
|
|
@@ -26,22 +50,50 @@
|
|
|
26
50
|
* routes only. It keeps a mirror of Host state and its own UI selections, and it
|
|
27
51
|
* can start nothing — there is no route that spawns a process.
|
|
28
52
|
*
|
|
29
|
-
* Owner: DeepBlend Studio — M4
|
|
53
|
+
* Owner: DeepBlend Studio — M4 (the workbench) / M6 (the standalone face)
|
|
30
54
|
* Plane: Web Client (browser)
|
|
31
55
|
*/
|
|
32
56
|
|
|
33
57
|
window.__ModuleLoader__.load({
|
|
34
58
|
// Must be the PACKAGE NAME: the client module graph addresses this bundle by
|
|
35
|
-
// package identity, and the Host row mounts the same package.
|
|
59
|
+
// package identity, and the Host row mounts the same package. The standalone
|
|
60
|
+
// page imports it by this id too, which is what makes "the same bundle" a fact
|
|
61
|
+
// rather than a claim.
|
|
36
62
|
id: '@deepblend/dsh-blender-ui',
|
|
37
63
|
factory: (require) => {
|
|
38
64
|
var module = { exports: {} }
|
|
39
65
|
var exports = module.exports
|
|
40
66
|
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
// =========================================================================
|
|
68
|
+
// §A React, read lazily — and only by the console face
|
|
69
|
+
// =========================================================================
|
|
70
|
+
//
|
|
71
|
+
// `react` is a platform SEED WORD: the console's boot supplies it
|
|
72
|
+
// (`PLATFORM_MODULES` in the shell bundle), and the standalone page does not
|
|
73
|
+
// — its `create({ staticModules: {} })` has no seed at all. Reading React at
|
|
74
|
+
// factory time would therefore make this bundle unusable to the standalone
|
|
75
|
+
// page, and reading it here would make the standalone page need React in
|
|
76
|
+
// order to render a page that never calls a hook.
|
|
77
|
+
//
|
|
78
|
+
// So it is read on first use, which happens only inside the console seats in
|
|
79
|
+
// §K. The core in §C–§J never touches it, and `workbench-page.test.mjs`
|
|
80
|
+
// asserts that a factory with a `require` that throws on every specifier can
|
|
81
|
+
// still build and render the whole standalone page.
|
|
82
|
+
|
|
83
|
+
let reactModule = null
|
|
84
|
+
/** The React namespace, or a loud failure naming who wanted it. @returns {any} */
|
|
85
|
+
function react() {
|
|
86
|
+
if (reactModule === null) reactModule = require('react')
|
|
87
|
+
return reactModule
|
|
88
|
+
}
|
|
89
|
+
/** `createElement`, spelled as a function so the require stays lazy. */
|
|
90
|
+
const h = (...args) => react().createElement(...args)
|
|
91
|
+
|
|
92
|
+
// =========================================================================
|
|
93
|
+
// §B Vocabulary
|
|
94
|
+
// =========================================================================
|
|
43
95
|
|
|
44
|
-
/** The sidebar id, the `main` key
|
|
96
|
+
/** The sidebar id, the `main` key, the settings section id and the panel marker. */
|
|
45
97
|
const PANEL_ID = 'deepblend'
|
|
46
98
|
/** Label shown by the sidebar entry, the settings nav and the panel title. */
|
|
47
99
|
const PANEL_LABEL = 'Blender'
|
|
@@ -111,10 +163,10 @@ window.__ModuleLoader__.load({
|
|
|
111
163
|
return at === null ? null : formatTime(at)
|
|
112
164
|
}
|
|
113
165
|
|
|
114
|
-
//
|
|
115
|
-
// Styles. Injected once per document, the way the shipped plugins do it:
|
|
116
|
-
// <style> tagged with this package, deduped by querySelector.
|
|
117
|
-
//
|
|
166
|
+
// =========================================================================
|
|
167
|
+
// §C Styles. Injected once per document, the way the shipped plugins do it:
|
|
168
|
+
// one <style> tagged with this package, deduped by querySelector.
|
|
169
|
+
// =========================================================================
|
|
118
170
|
|
|
119
171
|
const CSS = `
|
|
120
172
|
.db-root{display:flex;flex-direction:column;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-size:13px}
|
|
@@ -167,20 +219,159 @@ window.__ModuleLoader__.load({
|
|
|
167
219
|
.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
220
|
`
|
|
169
221
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
222
|
+
/**
|
|
223
|
+
* The theme tokens the workbench CSS reads, for a document that has no console.
|
|
224
|
+
*
|
|
225
|
+
* Inside the console these come from the shell's own stylesheets; on the
|
|
226
|
+
* standalone page there is no shell, and every `var(--dsw-alias-…)` would
|
|
227
|
+
* resolve to nothing — the page would render, in the sense that the DOM would
|
|
228
|
+
* be right, and be unreadable, which is not what 「整屏工作台」 means. So the
|
|
229
|
+
* standalone mount installs a fallback set. It is a THEME, not a renderer:
|
|
230
|
+
* no structure, no text, and the console never loads it.
|
|
231
|
+
*/
|
|
232
|
+
const STANDALONE_TOKENS = `
|
|
233
|
+
:root{color-scheme:light dark;
|
|
234
|
+
--dsw-alias-label-primary:#1c1c1e;--dsw-alias-label-secondary:#4a4a4f;--dsw-alias-label-tertiary:#8a8a8f;
|
|
235
|
+
--dsw-alias-border-l1:#d9d9de;--dsw-alias-fill-l2:#ececf1;--dsw-alias-bg-layer-1:#f6f6f8;
|
|
236
|
+
--dsw-alias-brand-primary:#3b6ef5;--dsw-alias-label-success:#2f9e44;--dsw-alias-label-warning:#b8791b;--dsw-alias-label-error:#d13438;
|
|
237
|
+
--dsw-font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
238
|
+
@media (prefers-color-scheme:dark){:root{
|
|
239
|
+
--dsw-alias-label-primary:#e8e8ea;--dsw-alias-label-secondary:#b6b6bc;--dsw-alias-label-tertiary:#8a8a8f;
|
|
240
|
+
--dsw-alias-border-l1:#3a3a40;--dsw-alias-fill-l2:#2a2a30;--dsw-alias-bg-layer-1:#202024;
|
|
241
|
+
--dsw-alias-brand-primary:#6f9bff;--dsw-alias-label-success:#4ec46a;--dsw-alias-label-warning:#e8a33d;--dsw-alias-label-error:#ff6b6f}}
|
|
242
|
+
`
|
|
243
|
+
|
|
244
|
+
/** Install one `<style>`, once per document, deduped by its own tag id. */
|
|
245
|
+
function injectStyleTag(doc, tagId, css) {
|
|
246
|
+
if (doc === undefined || doc === null) return
|
|
247
|
+
if (doc.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return
|
|
248
|
+
const tag = doc.createElement('style')
|
|
175
249
|
tag.dataset.plugin = '@deepblend/dsh-blender-ui'
|
|
176
250
|
tag.dataset.pluginCss = tagId
|
|
177
|
-
tag.textContent =
|
|
178
|
-
|
|
251
|
+
tag.textContent = css
|
|
252
|
+
doc.head.appendChild(tag)
|
|
179
253
|
}
|
|
180
254
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
255
|
+
/** The workbench stylesheet. @param {Document} [doc] */
|
|
256
|
+
function injectStyles(doc) {
|
|
257
|
+
injectStyleTag(doc ?? (typeof document === 'undefined' ? null : document), '@deepblend/dsh-blender-ui/workbench.css', CSS)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The standalone theme fallback. Only `mountStandalone` calls this. @param {Document} doc */
|
|
261
|
+
function injectStandaloneTokens(doc) {
|
|
262
|
+
injectStyleTag(doc, '@deepblend/dsh-blender-ui/standalone-tokens.css', STANDALONE_TOKENS)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// =========================================================================
|
|
266
|
+
// §D The element vocabulary
|
|
267
|
+
// =========================================================================
|
|
268
|
+
//
|
|
269
|
+
// A node is either a string (text) or `{ tag, props, children }`. Nothing
|
|
270
|
+
// here knows what React is; §I is where a node becomes a React element or a
|
|
271
|
+
// DOM node, and those two are the only places that know about either.
|
|
272
|
+
|
|
273
|
+
/** Build one node. @returns {{ tag: string|Function, props: object, children: any[] }} */
|
|
274
|
+
function el(tag, props, ...children) {
|
|
275
|
+
return { tag, props: props === null || props === undefined ? {} : props, children: flatten(children) }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Drop the empties a conditional produces, and splice nested arrays. @param {any[]} list */
|
|
279
|
+
function flatten(list) {
|
|
280
|
+
const out = []
|
|
281
|
+
for (const item of list) {
|
|
282
|
+
if (item === null || item === undefined || item === false || item === true) continue
|
|
283
|
+
if (Array.isArray(item)) { out.push(...flatten(item)); continue }
|
|
284
|
+
out.push(item)
|
|
285
|
+
}
|
|
286
|
+
return out
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The shared pieces, as ordinary functions returning nodes.
|
|
291
|
+
*
|
|
292
|
+
* They take a props object with `children`, the way a component does, and
|
|
293
|
+
* they are CALLED by the views rather than handed to a renderer as a tag —
|
|
294
|
+
* so a typo is a missing call, not a blank cell. `toReact`/`toDom` refuse a
|
|
295
|
+
* function tag loudly for the same reason.
|
|
296
|
+
*/
|
|
297
|
+
|
|
298
|
+
function Badge(props) {
|
|
299
|
+
return el('span', { className: 'db-badge', 'data-tone': props.tone || 'muted', 'data-badge': props.name || undefined }, props.children)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function Row(props) {
|
|
303
|
+
return el('div', { className: 'db-row' },
|
|
304
|
+
el('span', null, props.label),
|
|
305
|
+
el('span', null, props.value === null || props.value === undefined || props.value === '' ? '—' : String(props.value)))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function Button(props) {
|
|
309
|
+
return el('button', {
|
|
310
|
+
type: 'button',
|
|
311
|
+
className: 'db-btn',
|
|
312
|
+
'data-tone': props.tone,
|
|
313
|
+
'data-action': props.action,
|
|
314
|
+
disabled: props.disabled === true,
|
|
315
|
+
onClick: props.onClick,
|
|
316
|
+
}, props.children)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function ErrorBox(props) {
|
|
320
|
+
if (props.error === null || props.error === undefined) return null
|
|
321
|
+
return el('div', { className: 'db-error', 'data-deepblend-error': props.error.code || 'error' },
|
|
322
|
+
el('div', null, `${props.error.code || 'ERROR'}: ${props.error.message || ''}`),
|
|
323
|
+
props.error.detail ? el('pre', { className: 'db-pre' }, JSON.stringify(props.error.detail, null, 2)) : null)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function ProgressBar(props) {
|
|
327
|
+
const percent = Math.max(0, Math.min(100, Number(props.percent) || 0))
|
|
328
|
+
return el('div', { className: 'db-bar', 'data-progress': String(percent) }, el('i', { style: { width: `${percent}%` } }))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A definition list, used by the settings page and the revision summary. */
|
|
332
|
+
function KeyValues(props) {
|
|
333
|
+
const entries = (props.entries || []).filter(entry => entry !== null && entry !== undefined)
|
|
334
|
+
return el('dl', { className: 'db-kv' }, entries.flatMap((entry, index) => [
|
|
335
|
+
el('dt', { key: `k${index}` }, entry.label),
|
|
336
|
+
el('dd', { key: `v${index}` }, entry.value === null || entry.value === undefined || entry.value === '' ? '—' : String(entry.value)),
|
|
337
|
+
]))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// =========================================================================
|
|
341
|
+
// §E Display helpers
|
|
342
|
+
// =========================================================================
|
|
343
|
+
|
|
344
|
+
/** Shorten a digest for display without losing which digest it is. */
|
|
345
|
+
function shortDigest(value) {
|
|
346
|
+
return typeof value === 'string' && value.length > 12 ? `${value.slice(0, 12)}…` : (value || '—')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A timestamp a human reads at a glance. */
|
|
350
|
+
function formatTime(value) {
|
|
351
|
+
if (value === null || value === undefined || value === '') return '—'
|
|
352
|
+
const date = typeof value === 'number' ? new Date(value) : new Date(String(value))
|
|
353
|
+
if (Number.isNaN(date.getTime())) return String(value)
|
|
354
|
+
return date.toLocaleTimeString()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function statusTone(status) {
|
|
358
|
+
if (status === 'completed') return 'ok'
|
|
359
|
+
if (status === 'failed') return 'bad'
|
|
360
|
+
if (status === 'cancelled') return 'warn'
|
|
361
|
+
if (status === 'queued' || status === 'running' || status === 'stopping' || status === 'recovering') return 'live'
|
|
362
|
+
return 'muted'
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A value that is a finite number, or undefined. @param {unknown} value */
|
|
366
|
+
function numberOrUndefined(value) {
|
|
367
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
368
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
369
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// =========================================================================
|
|
373
|
+
// §F Talking to the Host
|
|
374
|
+
// =========================================================================
|
|
184
375
|
|
|
185
376
|
/**
|
|
186
377
|
* The one place this half notices a deployment older than itself.
|
|
@@ -217,64 +408,57 @@ window.__ModuleLoader__.load({
|
|
|
217
408
|
}
|
|
218
409
|
|
|
219
410
|
/**
|
|
220
|
-
* Read one Host route,
|
|
411
|
+
* Read one Host route once, with the stale-deployment diagnosis applied.
|
|
221
412
|
*
|
|
222
413
|
* A page refresh re-runs exactly this, which is why the panel rebuilds itself
|
|
223
|
-
* from the Host (SPEC §20 M4 「UI 刷新后可从 Host 恢复权威状态」)
|
|
414
|
+
* from the Host (SPEC §20 M4 「UI 刷新后可从 Host 恢复权威状态」) — and why the
|
|
415
|
+
* standalone page inherits that property for free.
|
|
416
|
+
*
|
|
417
|
+
* @param {typeof fetch} fetchImpl
|
|
418
|
+
* @param {string} path
|
|
419
|
+
* @param {string|null} expectedRoute
|
|
420
|
+
* @returns {Promise<{ status: 'ok'|'error'|'stale', payload: any, error: any }>}
|
|
224
421
|
*/
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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) } })
|
|
422
|
+
async function readRoute(fetchImpl, path, expectedRoute) {
|
|
423
|
+
try {
|
|
424
|
+
const response = await fetchImpl(path, { headers: { accept: 'application/json' } })
|
|
425
|
+
const text = await response.text()
|
|
426
|
+
let payload = null
|
|
427
|
+
try {
|
|
428
|
+
payload = text.length === 0 ? null : JSON.parse(text)
|
|
429
|
+
} catch {
|
|
430
|
+
payload = null
|
|
431
|
+
}
|
|
432
|
+
// A route this half declared but the running Host does not serve: either
|
|
433
|
+
// a body with the wrong identity, or a response that is not ours at all.
|
|
434
|
+
if (expectedRoute !== null && (payload === null || payload.route !== expectedRoute)) {
|
|
435
|
+
return {
|
|
436
|
+
status: 'stale',
|
|
437
|
+
payload: null,
|
|
438
|
+
error: staleHostError(expectedRoute, {
|
|
439
|
+
payload,
|
|
440
|
+
status: response.status,
|
|
441
|
+
detail: payload === null
|
|
442
|
+
? `HTTP ${response.status},${text.length} 字节,非 JSON`
|
|
443
|
+
: `route=${JSON.stringify(payload.route)}`,
|
|
444
|
+
}),
|
|
263
445
|
}
|
|
264
446
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
447
|
+
if (payload && payload.ok) return { status: 'ok', payload, error: null }
|
|
448
|
+
return {
|
|
449
|
+
status: 'error',
|
|
450
|
+
payload: null,
|
|
451
|
+
error: (payload && payload.error) || { code: `HTTP_${response.status}`, message: `HTTP ${response.status}` },
|
|
452
|
+
}
|
|
453
|
+
} catch (error) {
|
|
454
|
+
return { status: 'error', payload: null, error: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } }
|
|
455
|
+
}
|
|
272
456
|
}
|
|
273
457
|
|
|
274
458
|
/** POST a JSON body and return the parsed payload. Errors come back as data. */
|
|
275
|
-
async function postJson(path, body) {
|
|
459
|
+
async function postJson(fetchImpl, path, body) {
|
|
276
460
|
try {
|
|
277
|
-
const response = await
|
|
461
|
+
const response = await fetchImpl(path, {
|
|
278
462
|
method: 'POST',
|
|
279
463
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
280
464
|
body: JSON.stringify(body || {}),
|
|
@@ -287,344 +471,567 @@ window.__ModuleLoader__.load({
|
|
|
287
471
|
}
|
|
288
472
|
}
|
|
289
473
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
474
|
+
// =========================================================================
|
|
475
|
+
// §G The store: the workbench's state, its polling and its actions
|
|
476
|
+
// =========================================================================
|
|
477
|
+
//
|
|
478
|
+
// Framework-free on purpose. Both faces subscribe to the same object, so
|
|
479
|
+
// "what the workbench is showing" and "what a click does" are decided once —
|
|
480
|
+
// the console only differs in HOW it draws the snapshot, never in what the
|
|
481
|
+
// snapshot is. Local UI state (which tab, which project, the compare pair,
|
|
482
|
+
// the half-typed patch) lives here too, exactly as SPEC §14.3 draws the line:
|
|
483
|
+
// it decides what is DISPLAYED, never what is TRUE.
|
|
484
|
+
|
|
485
|
+
/** A blank snapshot, so `getState()` always answers the same shape. */
|
|
486
|
+
function emptySnapshot() {
|
|
487
|
+
return {
|
|
488
|
+
status: 'loading',
|
|
489
|
+
error: null,
|
|
490
|
+
hostApiVersion: null,
|
|
491
|
+
projects: [],
|
|
492
|
+
projectsRoot: null,
|
|
493
|
+
selected: null,
|
|
494
|
+
currentRevision: null,
|
|
495
|
+
jobs: [],
|
|
496
|
+
unfinishedJobs: [],
|
|
497
|
+
previews: null,
|
|
498
|
+
artifactBase: '',
|
|
499
|
+
view: 'projects',
|
|
500
|
+
projectId: null,
|
|
501
|
+
activeProjectId: null,
|
|
502
|
+
compareLeft: null,
|
|
503
|
+
compareRight: null,
|
|
504
|
+
compareMode: 'renders',
|
|
505
|
+
diff: null,
|
|
506
|
+
diffError: null,
|
|
507
|
+
previewBusy: false,
|
|
508
|
+
forms: { title: '', goal: '', patch: null, frameStart: '', frameEnd: '', profile: 'preview' },
|
|
509
|
+
busy: { create: false, patch: false, render: false, restore: false },
|
|
510
|
+
notices: { projects: null, scene: null, preview: null, jobs: null, revisions: null },
|
|
511
|
+
}
|
|
302
512
|
}
|
|
303
513
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Build the workbench store.
|
|
516
|
+
*
|
|
517
|
+
* @param {{ fetch?: typeof fetch, pollLiveMs?: number, pollIdleMs?: number }} [options]
|
|
518
|
+
*/
|
|
519
|
+
function createWorkbenchStore(options) {
|
|
520
|
+
const settings = options || {}
|
|
521
|
+
const fetchImpl = settings.fetch ?? ((...args) => fetch(...args))
|
|
522
|
+
const pollLiveMs = settings.pollLiveMs ?? POLL_LIVE_MS
|
|
523
|
+
const pollIdleMs = settings.pollIdleMs ?? POLL_IDLE_MS
|
|
524
|
+
|
|
525
|
+
let data = emptySnapshot()
|
|
526
|
+
let snapshot = { ...data }
|
|
527
|
+
const listeners = new Set()
|
|
528
|
+
let timer = null
|
|
529
|
+
let tick = 0
|
|
530
|
+
let live = false
|
|
531
|
+
|
|
532
|
+
const notify = () => {
|
|
533
|
+
snapshot = { ...data, forms: { ...data.forms }, busy: { ...data.busy }, notices: { ...data.notices } }
|
|
534
|
+
for (const listener of [...listeners]) listener(snapshot)
|
|
535
|
+
}
|
|
536
|
+
/** Change the snapshot and tell everyone. @param {object} patch */
|
|
537
|
+
const set = (patch) => { data = { ...data, ...patch }; notify() }
|
|
538
|
+
/** Change one nested table. @param {'forms'|'busy'|'notices'} table @param {string} key @param {any} value */
|
|
539
|
+
const setIn = (table, key, value) => { data = { ...data, [table]: { ...data[table], [key]: value } }; notify() }
|
|
540
|
+
|
|
541
|
+
/** The project every per-project route is addressed to. */
|
|
542
|
+
const target = () => data.projectId ?? (data.projects[0] ? data.projects[0].projectId : null)
|
|
543
|
+
|
|
544
|
+
/** Read everything the panel shows, in one pass. */
|
|
545
|
+
const load = async () => {
|
|
546
|
+
const projectId = target()
|
|
547
|
+
const statePath = projectId === null ? ROUTES.state : `${ROUTES.state}?projectId=${encodeURIComponent(projectId)}`
|
|
548
|
+
const stateResult = await readRoute(fetchImpl, `${statePath}${statePath.includes('?') ? '&' : '?'}t=${tick}`, 'state')
|
|
549
|
+
if (stateResult.status !== 'ok') {
|
|
550
|
+
set({ status: stateResult.status, error: stateResult.error, hostApiVersion: stateResult.payload ? stateResult.payload.hostApiVersion : null })
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
const payload = stateResult.payload
|
|
554
|
+
const active = projectId !== null ? projectId : (payload.projects && payload.projects[0] ? payload.projects[0].projectId : null)
|
|
555
|
+
const patch = {
|
|
556
|
+
status: 'ok',
|
|
557
|
+
error: null,
|
|
558
|
+
hostApiVersion: payload.hostApiVersion ?? null,
|
|
559
|
+
projects: payload.projects || [],
|
|
560
|
+
projectsRoot: payload.projectsRoot ?? null,
|
|
561
|
+
selected: payload.selected ?? null,
|
|
562
|
+
activeProjectId: active,
|
|
563
|
+
currentRevision: payload.selected ? payload.selected.currentRevision : null,
|
|
564
|
+
jobs: payload.selected ? payload.selected.jobs || [] : [],
|
|
565
|
+
unfinishedJobs: payload.selected ? payload.selected.unfinishedJobs || [] : [],
|
|
566
|
+
}
|
|
567
|
+
live = patch.unfinishedJobs.length > 0
|
|
568
|
+
|
|
569
|
+
if (active !== null) {
|
|
570
|
+
const jobsResult = await readRoute(fetchImpl, `${projectRoute(active, '/jobs')}?t=${tick}`, 'project.jobs')
|
|
571
|
+
if (jobsResult.status === 'ok') {
|
|
572
|
+
patch.jobs = jobsResult.payload.jobs || []
|
|
573
|
+
patch.unfinishedJobs = jobsResult.payload.unfinished || []
|
|
574
|
+
live = patch.unfinishedJobs.length > 0
|
|
575
|
+
} else if (jobsResult.status === 'stale') {
|
|
576
|
+
patch.status = 'stale'
|
|
577
|
+
patch.error = jobsResult.error
|
|
578
|
+
}
|
|
579
|
+
const previewResult = await readRoute(fetchImpl, `${projectRoute(active, '/previews')}?t=${tick}`, 'project.previews')
|
|
580
|
+
if (previewResult.status === 'ok') {
|
|
581
|
+
patch.previews = previewResult.payload.previews ?? null
|
|
582
|
+
patch.artifactBase = previewResult.payload.artifactBase ?? ''
|
|
583
|
+
} else if (previewResult.status === 'stale') {
|
|
584
|
+
patch.status = 'stale'
|
|
585
|
+
patch.error = previewResult.error
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
patch.previews = null
|
|
589
|
+
patch.artifactBase = ''
|
|
590
|
+
}
|
|
591
|
+
set(patch)
|
|
592
|
+
arm()
|
|
593
|
+
}
|
|
314
594
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
595
|
+
/** Poll at the cadence the current state deserves. */
|
|
596
|
+
const arm = () => {
|
|
597
|
+
if (timer !== null) { clearInterval(timer); timer = null }
|
|
598
|
+
if (!running) return
|
|
599
|
+
timer = setInterval(() => { void load() }, live ? pollLiveMs : pollIdleMs)
|
|
600
|
+
}
|
|
321
601
|
|
|
322
|
-
|
|
323
|
-
function shortDigest(value) {
|
|
324
|
-
return typeof value === 'string' && value.length > 12 ? `${value.slice(0, 12)}…` : (value || '—')
|
|
325
|
-
}
|
|
602
|
+
let running = false
|
|
326
603
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
604
|
+
/** A write just happened: re-run every read, including the view-specific ones. */
|
|
605
|
+
const reload = () => { tick += 1; void load() }
|
|
606
|
+
|
|
607
|
+
/** Run one write, report it in this view's notice, then reload. */
|
|
608
|
+
const write = async (view, path, body, describe) => {
|
|
609
|
+
const outcome = await postJson(fetchImpl, path, body)
|
|
610
|
+
setIn('notices', view, outcome.ok ? describe(outcome.payload) : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
611
|
+
reload()
|
|
612
|
+
return outcome
|
|
613
|
+
}
|
|
334
614
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
615
|
+
const actions = {
|
|
616
|
+
/**
|
|
617
|
+
* Switch tabs, and drop the notice the tab being ENTERED was carrying.
|
|
618
|
+
*
|
|
619
|
+
* The notice reports something that happened while a person was looking at
|
|
620
|
+
* that view, and a view you left is not the view it happened in. In the
|
|
621
|
+
* console this used to fall out of the view being a component that
|
|
622
|
+
* unmounted; the store is shared by two faces now, so it has to be SAID
|
|
623
|
+
* rather than inherited — and it is not cosmetic: a stale 「已提交 r0002」
|
|
624
|
+
* still on screen after a tab round-trip reads as the result of the edit
|
|
625
|
+
* you just made. Clicking the tab you are already on is not a re-entry, so
|
|
626
|
+
* it clears nothing.
|
|
627
|
+
*/
|
|
628
|
+
setView: (view) => (view === data.view ? undefined : set({ view, notices: { ...data.notices, [view]: null } })),
|
|
629
|
+
selectProject: (projectId) => set({ projectId, diff: null, diffError: null, compareLeft: null, compareRight: null }),
|
|
630
|
+
reload,
|
|
631
|
+
setForm: (field, value) => setIn('forms', field, value),
|
|
632
|
+
setCompareMode: (compareMode) => set({ compareMode }),
|
|
633
|
+
pickCompare: (side, revision) => set(side === 'left'
|
|
634
|
+
? { compareLeft: revision, diff: null, diffError: null }
|
|
635
|
+
: { compareRight: revision, diff: null, diffError: null }),
|
|
636
|
+
/** Hand off from 版本 to 预览对比 with that revision on the left. */
|
|
637
|
+
compareFrom: (revision) => set({ compareLeft: revision, view: 'preview' }),
|
|
638
|
+
|
|
639
|
+
diff: async (from, to) => {
|
|
640
|
+
set({ diffError: null })
|
|
641
|
+
try {
|
|
642
|
+
const response = await fetchImpl(`${projectRoute(target(), '/diff')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { headers: { accept: 'application/json' } })
|
|
643
|
+
const parsed = await response.json()
|
|
644
|
+
if (parsed && parsed.ok) set({ diff: parsed.diff })
|
|
645
|
+
else set({ diff: null, diffError: (parsed && parsed.error) || { code: `HTTP_${response.status}`, message: 'diff 失败' } })
|
|
646
|
+
} catch (error) {
|
|
647
|
+
set({ diff: null, diffError: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } })
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
createProject: async () => {
|
|
652
|
+
setIn('busy', 'create', true)
|
|
653
|
+
const outcome = await postJson(fetchImpl, ROUTES.projects, {
|
|
654
|
+
title: data.forms.title,
|
|
655
|
+
goal: data.forms.goal.length > 0 ? data.forms.goal : undefined,
|
|
656
|
+
})
|
|
657
|
+
setIn('busy', 'create', false)
|
|
658
|
+
if (outcome.ok) {
|
|
659
|
+
set({ forms: { ...data.forms, title: '', goal: '' }, projectId: outcome.payload.project.projectId })
|
|
660
|
+
setIn('notices', 'projects', { ok: true, message: `已创建 ${outcome.payload.project.projectId}` })
|
|
661
|
+
reload()
|
|
662
|
+
} else {
|
|
663
|
+
setIn('notices', 'projects', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
664
|
+
}
|
|
665
|
+
},
|
|
342
666
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
667
|
+
applyPatch: async () => {
|
|
668
|
+
const text = data.forms.patch
|
|
669
|
+
let patch
|
|
670
|
+
try {
|
|
671
|
+
patch = JSON.parse(text)
|
|
672
|
+
} catch (error) {
|
|
673
|
+
setIn('notices', 'scene', { ok: false, message: `patch 不是合法 JSON:${error.message}` })
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
setIn('busy', 'patch', true)
|
|
677
|
+
const outcome = await postJson(fetchImpl, projectRoute(target(), '/patch'), { patch })
|
|
678
|
+
setIn('busy', 'patch', false)
|
|
679
|
+
if (outcome.ok) {
|
|
680
|
+
setIn('notices', 'scene', { ok: true, message: `已提交 ${outcome.payload.revision.revision}(digest ${shortDigest(outcome.payload.revision.digest)})` })
|
|
681
|
+
setIn('forms', 'patch', null)
|
|
682
|
+
reload()
|
|
683
|
+
} else {
|
|
684
|
+
setIn('notices', 'scene', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
renderPreview: async () => {
|
|
689
|
+
set({ previewBusy: true, previewResult: null })
|
|
690
|
+
const outcome = await postJson(fetchImpl, projectRoute(target(), '/preview'), {})
|
|
691
|
+
set({ previewBusy: false })
|
|
692
|
+
set({
|
|
693
|
+
previewResult: outcome.ok
|
|
694
|
+
? {
|
|
695
|
+
ok: true,
|
|
696
|
+
message: `已渲染 ${outcome.payload.preview.views.length} 个视角 → 合成 ${outcome.payload.preview.revision} 的 contact sheet`
|
|
697
|
+
+ (outcome.payload.preview.sheets && outcome.payload.preview.sheets.previous
|
|
698
|
+
? ';上一张已留作「上一次渲染」,可以直接并排比较'
|
|
699
|
+
: '(这是第一张;再渲染一次就能并排比较前后)')
|
|
700
|
+
+ '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
|
|
701
|
+
}
|
|
702
|
+
: { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` },
|
|
703
|
+
})
|
|
704
|
+
reload()
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
startRender: async (resumeJobId) => {
|
|
708
|
+
setIn('busy', 'render', true)
|
|
709
|
+
const outcome = await postJson(fetchImpl, projectRoute(target(), '/render'), {
|
|
710
|
+
resumeJobId,
|
|
711
|
+
frameStart: data.forms.frameStart === '' ? undefined : Number(data.forms.frameStart),
|
|
712
|
+
frameEnd: data.forms.frameEnd === '' ? undefined : Number(data.forms.frameEnd),
|
|
713
|
+
profile: resumeJobId === undefined ? data.forms.profile : undefined,
|
|
714
|
+
})
|
|
715
|
+
setIn('busy', 'render', false)
|
|
716
|
+
setIn('notices', 'jobs', outcome.ok
|
|
717
|
+
? { kind: 'render', ok: true, message: `任务 ${outcome.payload.job.jobId} 已启动(${outcome.payload.job.frames || '?'} 帧)` }
|
|
718
|
+
: { kind: 'render', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
719
|
+
reload()
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
cancelJob: async (jobId) => {
|
|
723
|
+
setIn('busy', 'render', true)
|
|
724
|
+
const outcome = await postJson(fetchImpl, projectRoute(target(), `/jobs/${encodeURIComponent(jobId)}/cancel`), {})
|
|
725
|
+
setIn('busy', 'render', false)
|
|
726
|
+
setIn('notices', 'jobs', outcome.ok
|
|
727
|
+
? {
|
|
728
|
+
kind: 'cancel',
|
|
729
|
+
ok: outcome.payload.cancelled.processGone !== false,
|
|
730
|
+
message: outcome.payload.cancelled.processGone === false
|
|
731
|
+
? '取消已请求,但进程仍在'
|
|
732
|
+
: `已取消 ${jobId},进程实测已消失`,
|
|
733
|
+
}
|
|
734
|
+
: { kind: 'cancel', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
735
|
+
reload()
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
restoreRevision: async (revision) => {
|
|
739
|
+
setIn('busy', 'restore', true)
|
|
740
|
+
const outcome = await postJson(fetchImpl, projectRoute(target(), '/restore'), { revision })
|
|
741
|
+
setIn('busy', 'restore', false)
|
|
742
|
+
setIn('notices', 'revisions', outcome.ok
|
|
743
|
+
? { ok: true, message: `已恢复 ${revision} 为 ${outcome.payload.revision.revision}` }
|
|
744
|
+
: { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
|
|
745
|
+
reload()
|
|
746
|
+
},
|
|
747
|
+
}
|
|
347
748
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
749
|
+
return {
|
|
750
|
+
getState: () => snapshot,
|
|
751
|
+
subscribe(listener) {
|
|
752
|
+
listeners.add(listener)
|
|
753
|
+
return () => { listeners.delete(listener) }
|
|
754
|
+
},
|
|
755
|
+
/** Begin reading, and keep reading while something is live. */
|
|
756
|
+
start() {
|
|
757
|
+
if (running) return
|
|
758
|
+
running = true
|
|
759
|
+
void load()
|
|
760
|
+
},
|
|
761
|
+
stop() {
|
|
762
|
+
running = false
|
|
763
|
+
if (timer !== null) { clearInterval(timer); timer = null }
|
|
764
|
+
},
|
|
765
|
+
actions,
|
|
766
|
+
/** The console's write helper, kept reachable for the tool cards. */
|
|
767
|
+
readRoute: (path, route) => readRoute(fetchImpl, path, route),
|
|
768
|
+
}
|
|
355
769
|
}
|
|
356
770
|
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
771
|
+
// =========================================================================
|
|
772
|
+
// §H The six tabs — THE implementation
|
|
773
|
+
// =========================================================================
|
|
774
|
+
//
|
|
775
|
+
// Every function below is a pure function of the snapshot (plus the action
|
|
776
|
+
// table), and every one of them returns a node. Nothing in this section
|
|
777
|
+
// touches React, the DOM, or the network.
|
|
360
778
|
|
|
361
779
|
/** 项目: the list, the create form, and the selected project's summary. */
|
|
362
|
-
function ProjectsView(
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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,
|
|
780
|
+
function ProjectsView(ctx) {
|
|
781
|
+
const state = ctx.state
|
|
782
|
+
const actions = ctx.actions
|
|
783
|
+
return el('div', { 'data-view': 'projects' },
|
|
784
|
+
ErrorBox({ error: state.error }),
|
|
785
|
+
el('div', { className: 'db-card' },
|
|
786
|
+
el('h4', null, '项目'),
|
|
787
|
+
state.projects.length === 0
|
|
788
|
+
? el('div', { className: 'db-muted' }, '这个工作区还没有项目。')
|
|
789
|
+
: el('ul', { className: 'db-list' }, state.projects.map(project => el('li', { key: project.projectId, 'data-project': project.projectId },
|
|
790
|
+
el('div', { className: 'db-inline' },
|
|
791
|
+
Button({
|
|
792
|
+
tone: project.projectId === state.activeProjectId ? 'primary' : undefined,
|
|
392
793
|
action: `select-project:${project.projectId}`,
|
|
393
|
-
onClick: () =>
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
794
|
+
onClick: () => actions.selectProject(project.projectId),
|
|
795
|
+
children: project.title || project.projectId,
|
|
796
|
+
}),
|
|
797
|
+
el('span', { className: 'db-muted db-mono' }, project.projectId),
|
|
798
|
+
project.unfinishedJobs > 0 ? Badge({ tone: 'live', name: 'unfinished', children: `${project.unfinishedJobs} 个任务在跑` }) : null,
|
|
799
|
+
el('span', { className: 'db-muted' }, `${project.revisionCount} 个 revision`),
|
|
398
800
|
),
|
|
399
|
-
|
|
400
|
-
project.goal ?
|
|
801
|
+
el('div', { className: 'db-muted db-mono' }, `当前 ${project.currentRevision || '—'} · 更新于 ${formatTime(project.updatedAt)}`),
|
|
802
|
+
project.goal ? el('div', { className: 'db-muted' }, project.goal) : null,
|
|
401
803
|
))),
|
|
402
|
-
|
|
804
|
+
el('div', { className: 'db-muted db-mono', style: { marginTop: '6px' } }, `projectsRoot: ${state.projectsRoot || '—'}`),
|
|
403
805
|
),
|
|
404
806
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
807
|
+
el('div', { className: 'db-card' },
|
|
808
|
+
el('h4', null, '新建项目(写操作经 Host)'),
|
|
809
|
+
el('div', { className: 'db-inline' },
|
|
810
|
+
el('input', {
|
|
409
811
|
className: 'db-input',
|
|
410
812
|
'data-field': 'project-title',
|
|
411
813
|
placeholder: '标题,例如 watch-commercial',
|
|
412
|
-
value: title,
|
|
413
|
-
onChange: event =>
|
|
814
|
+
value: state.forms.title,
|
|
815
|
+
onChange: event => actions.setForm('title', event.target.value),
|
|
414
816
|
}),
|
|
415
|
-
|
|
817
|
+
el('input', {
|
|
416
818
|
className: 'db-input',
|
|
417
819
|
'data-field': 'project-goal',
|
|
418
820
|
placeholder: '目标(可选)',
|
|
419
|
-
value: goal,
|
|
420
|
-
onChange: event =>
|
|
821
|
+
value: state.forms.goal,
|
|
822
|
+
onChange: event => actions.setForm('goal', event.target.value),
|
|
823
|
+
}),
|
|
824
|
+
Button({
|
|
825
|
+
tone: 'primary',
|
|
826
|
+
action: 'create-project',
|
|
827
|
+
disabled: state.busy.create || state.forms.title.trim().length === 0,
|
|
828
|
+
onClick: actions.createProject,
|
|
829
|
+
children: state.busy.create ? '创建中…' : '创建',
|
|
421
830
|
}),
|
|
422
|
-
h(Button, { tone: 'primary', action: 'create-project', disabled: busy || title.trim().length === 0, onClick: create }, busy ? '创建中…' : '创建'),
|
|
423
831
|
),
|
|
424
|
-
|
|
832
|
+
state.notices.projects ? Notice(state.notices.projects, { marginTop: '8px' }) : null,
|
|
425
833
|
),
|
|
426
834
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
{ label: 'projectId', value:
|
|
431
|
-
{ label: '当前 revision', value:
|
|
432
|
-
{ label: 'revision 数', value:
|
|
433
|
-
{ label: 'digest', value: shortDigest(
|
|
434
|
-
{ label: '帧范围', value: `${
|
|
435
|
-
{ label: '活动相机', value:
|
|
835
|
+
state.selected ? el('div', { className: 'db-card' },
|
|
836
|
+
el('h4', null, `当前项目:${state.selected.project.title}`),
|
|
837
|
+
KeyValues({ entries: [
|
|
838
|
+
{ label: 'projectId', value: state.selected.project.projectId },
|
|
839
|
+
{ label: '当前 revision', value: state.selected.currentRevision },
|
|
840
|
+
{ label: 'revision 数', value: state.selected.project.revisionCount },
|
|
841
|
+
{ label: 'digest', value: shortDigest(state.selected.scene.digest) },
|
|
842
|
+
{ label: '帧范围', value: `${state.selected.scene.project.frameStart}–${state.selected.scene.project.frameEnd} @ ${state.selected.scene.project.fps}fps` },
|
|
843
|
+
{ label: '活动相机', value: state.selected.scene.project.activeCamera },
|
|
436
844
|
{ label: '对象 / 材质 / 灯 / 相机', value: [
|
|
437
|
-
|
|
438
|
-
|
|
845
|
+
state.selected.scene.counts.entities, state.selected.scene.counts.materials,
|
|
846
|
+
state.selected.scene.counts.lights, state.selected.scene.counts.cameras,
|
|
439
847
|
].join(' / ') },
|
|
440
848
|
] }),
|
|
441
|
-
|
|
442
|
-
) :
|
|
849
|
+
el('div', { className: 'db-muted', style: { marginTop: '6px' } }, state.selected.qa.summary),
|
|
850
|
+
) : el('div', { className: 'db-card db-muted' }, '尚未选择项目。'),
|
|
443
851
|
)
|
|
444
852
|
}
|
|
445
853
|
|
|
854
|
+
/** A result line, in the two shapes the panel already used. */
|
|
855
|
+
function Notice(result, style) {
|
|
856
|
+
if (result === null || result === undefined) return null
|
|
857
|
+
return el('div', {
|
|
858
|
+
'data-result': result.ok ? 'ok' : 'error',
|
|
859
|
+
'data-result-kind': result.kind || undefined,
|
|
860
|
+
className: result.ok ? 'db-muted' : 'db-error',
|
|
861
|
+
style: style || undefined,
|
|
862
|
+
}, result.message)
|
|
863
|
+
}
|
|
864
|
+
|
|
446
865
|
/** 场景树: the Scene Tree of the revision in view. */
|
|
447
|
-
function SceneView(
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const template =
|
|
454
|
-
baseRevision:
|
|
866
|
+
function SceneView(ctx) {
|
|
867
|
+
const state = ctx.state
|
|
868
|
+
const actions = ctx.actions
|
|
869
|
+
const scene = state.selected ? state.selected.scene : null
|
|
870
|
+
if (scene === null) return el('div', { 'data-view': 'scene', className: 'db-muted' }, '尚未选择项目。')
|
|
871
|
+
|
|
872
|
+
const template = JSON.stringify({
|
|
873
|
+
baseRevision: state.selected.currentRevision,
|
|
455
874
|
operations: [{
|
|
456
875
|
op: 'entity.transform.update',
|
|
457
876
|
entityId: (scene.nodes.entities[0] || {}).id || 'entity-id',
|
|
458
877
|
rotationEuler: [0, 0, 0.12],
|
|
459
878
|
}],
|
|
460
879
|
}, null, 2)
|
|
461
|
-
const text =
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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}` : '默认'}`),
|
|
880
|
+
const text = state.forms.patch === null ? template : state.forms.patch
|
|
881
|
+
|
|
882
|
+
const section = (title, items, render) => el('div', { className: 'db-card', key: title },
|
|
883
|
+
el('h4', null, `${title}(${items.length})`),
|
|
884
|
+
items.length === 0
|
|
885
|
+
? el('div', { className: 'db-muted' }, '空')
|
|
886
|
+
: el('ul', { className: 'db-list' }, items.map(item => el('li', { key: item.id }, render(item)))))
|
|
887
|
+
|
|
888
|
+
return el('div', { 'data-view': 'scene' },
|
|
889
|
+
ErrorBox({ error: state.error }),
|
|
890
|
+
el('div', { className: 'db-card' },
|
|
891
|
+
el('div', { className: 'db-inline' },
|
|
892
|
+
el('strong', null, String(scene.project.title || scene.project.id || '')),
|
|
893
|
+
Badge({ children: scene.revision }),
|
|
894
|
+
el('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(scene.digest)}`),
|
|
895
|
+
Badge({ children: `world ${scene.world ? `${(scene.world.color || []).join(',')} × ${scene.world.strength}` : '默认'}` }),
|
|
497
896
|
),
|
|
498
897
|
),
|
|
499
|
-
|
|
500
|
-
section('实体 entities', scene.nodes.entities, entity =>
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
entity.materialId ?
|
|
504
|
-
entity.locked ?
|
|
505
|
-
entity.tags.length > 0 ?
|
|
506
|
-
entity.transform ?
|
|
507
|
-
section('材质 materials', scene.nodes.materials, material =>
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
material.parameters ?
|
|
511
|
-
section('灯光 lights', scene.nodes.lights, light =>
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
section('相机 cameras', scene.nodes.cameras, camera =>
|
|
516
|
-
|
|
517
|
-
camera.isActive ?
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
section('镜头 shots', scene.nodes.shots, shot =>
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
section('动画轨道 animationTracks', scene.nodes.animationTracks, track =>
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
section('资产 assets', scene.nodes.assets, asset =>
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
898
|
+
el('div', { className: 'db-grid' },
|
|
899
|
+
section('实体 entities', scene.nodes.entities, entity => el('div', null,
|
|
900
|
+
el('span', { className: 'db-mono', 'data-node': `entity:${entity.id}` }, entity.id), ' ',
|
|
901
|
+
el('span', { className: 'db-kind' }, entity.shape || entity.kind),
|
|
902
|
+
entity.materialId ? el('span', { className: 'db-muted' }, `材质 ${entity.materialId}`) : null,
|
|
903
|
+
entity.locked ? Badge({ tone: 'warn', children: 'locked' }) : null,
|
|
904
|
+
entity.tags.length > 0 ? el('span', { className: 'db-muted db-mono' }, ` tags=[${entity.tags.join(' ')}]`) : null,
|
|
905
|
+
entity.transform ? el('div', { className: 'db-muted db-mono' }, `loc ${(entity.transform.location || []).map(value => Number(value).toFixed(3)).join(', ')}`) : null)),
|
|
906
|
+
section('材质 materials', scene.nodes.materials, material => el('div', null,
|
|
907
|
+
el('span', { className: 'db-mono', 'data-node': `material:${material.id}` }, material.id), ' ',
|
|
908
|
+
el('span', { className: 'db-kind' }, material.shader),
|
|
909
|
+
material.parameters ? el('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)),
|
|
910
|
+
section('灯光 lights', scene.nodes.lights, light => el('div', null,
|
|
911
|
+
el('span', { className: 'db-mono', 'data-node': `light:${light.id}` }, light.id), ' ',
|
|
912
|
+
el('span', { className: 'db-kind' }, light.type),
|
|
913
|
+
el('span', { className: 'db-muted' }, `energy ${light.energy}`))),
|
|
914
|
+
section('相机 cameras', scene.nodes.cameras, camera => el('div', null,
|
|
915
|
+
el('span', { className: 'db-mono', 'data-node': `camera:${camera.id}` }, camera.id), ' ',
|
|
916
|
+
camera.isActive ? Badge({ tone: 'ok', children: 'active' }) : null, ' ',
|
|
917
|
+
el('span', { className: 'db-kind' }, camera.role || 'no role'),
|
|
918
|
+
el('span', { className: 'db-muted' }, `lens ${camera.lens}`))),
|
|
919
|
+
section('镜头 shots', scene.nodes.shots, shot => el('div', null,
|
|
920
|
+
el('span', { className: 'db-mono', 'data-node': `shot:${shot.id}` }, shot.id), ' ',
|
|
921
|
+
el('span', { className: 'db-muted' }, `${shot.cameraId} ${(shot.frameRange || []).join('–')}`))),
|
|
922
|
+
section('动画轨道 animationTracks', scene.nodes.animationTracks, track => el('div', null,
|
|
923
|
+
el('span', { className: 'db-mono', 'data-node': `track:${track.id}` }, track.id), ' ',
|
|
924
|
+
el('span', { className: 'db-kind' }, track.targetKind),
|
|
925
|
+
el('span', { className: 'db-muted' }, `${track.targetId} · ${track.property} · ${track.keyframes} 关键帧`))),
|
|
926
|
+
section('资产 assets', scene.nodes.assets, asset => el('div', null,
|
|
927
|
+
el('span', { className: 'db-mono', 'data-node': `asset:${asset.id}` }, asset.id), ' ',
|
|
928
|
+
el('span', { className: 'db-kind' }, asset.type),
|
|
929
|
+
el('span', { className: 'db-muted db-mono' }, String(asset.path || '')))),
|
|
531
930
|
),
|
|
532
931
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
932
|
+
el('div', { className: 'db-card' },
|
|
933
|
+
el('h4', null, 'ScenePatch(写操作经 Host,原子提交为一个 revision)'),
|
|
934
|
+
el('textarea', {
|
|
536
935
|
className: 'db-area',
|
|
537
936
|
'data-field': 'scene-patch',
|
|
538
937
|
value: text,
|
|
539
938
|
spellCheck: false,
|
|
540
|
-
onChange: event =>
|
|
939
|
+
onChange: event => actions.setForm('patch', event.target.value),
|
|
541
940
|
}),
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
941
|
+
el('div', { className: 'db-inline', style: { marginTop: '8px' } },
|
|
942
|
+
Button({
|
|
943
|
+
tone: 'primary',
|
|
944
|
+
action: 'apply-patch',
|
|
945
|
+
disabled: state.busy.patch || state.activeProjectId === null,
|
|
946
|
+
onClick: actions.applyPatch,
|
|
947
|
+
children: state.busy.patch ? '提交中…' : '提交',
|
|
948
|
+
}),
|
|
949
|
+
Button({ action: 'reset-patch', onClick: () => actions.setForm('patch', null), children: '重置模板' }),
|
|
950
|
+
Notice(state.notices.scene, { border: 0, padding: '0 6px', marginBottom: 0 }),
|
|
546
951
|
),
|
|
547
952
|
),
|
|
548
953
|
)
|
|
549
954
|
}
|
|
550
955
|
|
|
956
|
+
/**
|
|
957
|
+
* The newest image to show for a revision.
|
|
958
|
+
*
|
|
959
|
+
* Order of preference: the sheet THIS panel's render just composed, then any
|
|
960
|
+
* contact sheet (a review's), then the newest single view. The artifact is
|
|
961
|
+
* passed through WHOLE with a display label added: an earlier version built a
|
|
962
|
+
* fresh `{ path, kind }` here and threw away the digest and the timestamp —
|
|
963
|
+
* which is how the panel ended up keyed on the path alone and showing a stale
|
|
964
|
+
* render (§13.5B).
|
|
965
|
+
*/
|
|
966
|
+
function sheetOf(entry) {
|
|
967
|
+
if (entry === null) return null
|
|
968
|
+
const sheets = entry.contactSheets || []
|
|
969
|
+
const current = sheets.find(sheet => sheet.slot === 'preview-current')
|
|
970
|
+
if (current && current.path) return { ...current, label: '本次渲染' }
|
|
971
|
+
const sheet = sheets[sheets.length - 1]
|
|
972
|
+
if (sheet && sheet.path) return { ...sheet, label: 'contact sheet' }
|
|
973
|
+
const list = entry.previews || []
|
|
974
|
+
const preview = list[list.length - 1]
|
|
975
|
+
if (preview && preview.path) return { ...preview, label: preview.kind || 'preview' }
|
|
976
|
+
return null
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The pair a render leaves behind: what it just composed, and what came before.
|
|
981
|
+
*
|
|
982
|
+
* "Before" is the previous generation of the SAME revision when there is one.
|
|
983
|
+
* When there is not — which is the case on the first render after a scene
|
|
984
|
+
* change, and therefore the case the comparison exists for — it falls back to
|
|
985
|
+
* the newest render of an older revision. Without that fallback the axis is
|
|
986
|
+
* empty exactly when a person wants it: they changed something, rendered once,
|
|
987
|
+
* and the left pane would say "nothing to compare yet" while the picture they
|
|
988
|
+
* want to compare against is sitting one revision back.
|
|
989
|
+
*
|
|
990
|
+
* The pane labels say which revision each side came from, so a cross-revision
|
|
991
|
+
* pair cannot be mistaken for two renders of one scene.
|
|
992
|
+
*/
|
|
993
|
+
function renderPairOf(entry, allRevisions) {
|
|
994
|
+
if (entry === null) return { current: null, previous: null }
|
|
995
|
+
const sheets = entry.contactSheets || []
|
|
996
|
+
const current = sheets.find(sheet => sheet.slot === 'preview-current') ?? null
|
|
997
|
+
let previous = sheets.find(sheet => sheet.slot === 'preview-previous') ?? null
|
|
998
|
+
let previousRevision = entry.revision
|
|
999
|
+
if (previous === null) {
|
|
1000
|
+
const index = allRevisions.findIndex(candidate => candidate.revision === entry.revision)
|
|
1001
|
+
const older = (index > 0 ? allRevisions.slice(0, index) : []).reverse()
|
|
1002
|
+
.find(candidate => (candidate.contactSheets || []).some(sheet => sheet.slot === 'preview-current'))
|
|
1003
|
+
if (older !== undefined) {
|
|
1004
|
+
previous = (older.contactSheets || []).find(sheet => sheet.slot === 'preview-current')
|
|
1005
|
+
previousRevision = older.revision
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
current: current === null ? null : { ...current, label: '本次渲染', sourceRevision: entry.revision },
|
|
1010
|
+
previous: previous === null ? null : { ...previous, label: '上一次渲染', sourceRevision: previousRevision },
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
551
1014
|
/** 预览对比: two revisions' contact sheets side by side. */
|
|
552
|
-
function PreviewView(
|
|
553
|
-
const
|
|
554
|
-
|
|
1015
|
+
function PreviewView(ctx) {
|
|
1016
|
+
const state = ctx.state
|
|
1017
|
+
const actions = ctx.actions
|
|
1018
|
+
const previews = state.previews
|
|
1019
|
+
if (previews === null) return el('div', { 'data-view': 'preview', className: 'db-muted' }, '尚未选择项目。')
|
|
555
1020
|
const revisions = previews.revisions
|
|
556
1021
|
const pick = wanted => (revisions.some(entry => entry.revision === wanted) ? wanted : (((revisions[revisions.length - 1] || {}).revision) || null))
|
|
557
|
-
const left = pick(
|
|
558
|
-
const right = pick(
|
|
1022
|
+
const left = pick(state.compareLeft || state.currentRevision)
|
|
1023
|
+
const right = pick(state.compareRight || state.currentRevision)
|
|
559
1024
|
const entryOf = id => revisions.find(entry => entry.revision === id) || null
|
|
560
1025
|
|
|
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
1026
|
/** One image (or the reason there is none), with its digest and its time. */
|
|
620
1027
|
const imagePane = (side, title, artifact, missing) => {
|
|
621
1028
|
const when = artifactTime(artifact)
|
|
622
|
-
return
|
|
623
|
-
|
|
1029
|
+
return el('div', { className: 'db-shot', 'data-compare': side, 'data-compare-kind': artifact === null ? 'empty' : 'image' },
|
|
1030
|
+
el('h5', null, title),
|
|
624
1031
|
artifact === null
|
|
625
|
-
?
|
|
1032
|
+
? el('div', { className: 'db-muted' }, missing)
|
|
626
1033
|
: [
|
|
627
|
-
|
|
1034
|
+
el('img', {
|
|
628
1035
|
key: 'img',
|
|
629
1036
|
'data-artifact': artifact.path,
|
|
630
1037
|
'data-artifact-digest': artifact.sha256 || '',
|
|
@@ -632,27 +1039,27 @@ window.__ModuleLoader__.load({
|
|
|
632
1039
|
'data-artifact-revision': artifact.sourceRevision || '',
|
|
633
1040
|
'data-artifact-at': artifact.at || '',
|
|
634
1041
|
alt: `${title} ${artifact.path}`,
|
|
635
|
-
src: artifactUrl(
|
|
1042
|
+
src: artifactUrl(state.artifactBase, artifact),
|
|
636
1043
|
}),
|
|
637
|
-
|
|
1044
|
+
el('div', { className: 'db-muted db-mono', key: 'meta' },
|
|
638
1045
|
`${artifact.sourceRevision ? `${artifact.sourceRevision} ` : ''}${artifact.slot ? artifact.slot : (artifact.kind || 'artifact')} · ${artifact.sha256 ? String(artifact.sha256).slice(0, 10) : '—'}${when === null ? '' : ` · 渲染于 ${when}`}`),
|
|
639
1046
|
].filter(Boolean))
|
|
640
1047
|
}
|
|
641
1048
|
|
|
642
|
-
const revisionMeta = entry =>
|
|
1049
|
+
const revisionMeta = entry => el('div', { className: 'db-muted', key: 'meta' }, `${entry.summary || '(无说明)'} · ${formatTime(entry.createdAt)}`)
|
|
643
1050
|
|
|
644
1051
|
/** The revision axis: two revisions side by side, each one's newest image. */
|
|
645
|
-
const revisionPane = (entry, side) =>
|
|
646
|
-
|
|
1052
|
+
const revisionPane = (entry, side) => el('div', { className: 'db-shot', 'data-compare': side },
|
|
1053
|
+
el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? ' (当前)' : ''}`),
|
|
647
1054
|
entry === null
|
|
648
|
-
?
|
|
1055
|
+
? el('div', { className: 'db-muted' }, '没有这个 revision')
|
|
649
1056
|
: [
|
|
650
1057
|
revisionMeta(entry),
|
|
651
1058
|
(() => {
|
|
652
1059
|
const sheet = sheetOf(entry)
|
|
653
1060
|
return sheet === null
|
|
654
|
-
?
|
|
655
|
-
:
|
|
1061
|
+
? el('div', { className: 'db-muted', key: 'none' }, '这个 revision 还没有预览图(渲染一次预览即可)。')
|
|
1062
|
+
: el('img', {
|
|
656
1063
|
key: 'img',
|
|
657
1064
|
'data-artifact': sheet.path,
|
|
658
1065
|
'data-artifact-digest': sheet.sha256 || '',
|
|
@@ -660,18 +1067,21 @@ window.__ModuleLoader__.load({
|
|
|
660
1067
|
'data-artifact-revision': entry.revision,
|
|
661
1068
|
'data-artifact-at': sheet.at || '',
|
|
662
1069
|
alt: `${entry.revision} ${sheet.label}`,
|
|
663
|
-
src: artifactUrl(
|
|
1070
|
+
src: artifactUrl(state.artifactBase, sheet),
|
|
664
1071
|
})
|
|
665
1072
|
})(),
|
|
666
1073
|
(() => {
|
|
667
1074
|
const sheet = sheetOf(entry)
|
|
668
1075
|
const when = artifactTime(sheet)
|
|
669
|
-
return sheet === null ? null :
|
|
1076
|
+
return sheet === null ? null : el('div', { className: 'db-muted db-mono', key: 'sheet' },
|
|
670
1077
|
`${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${when === null ? '' : ` · 渲染于 ${when}`}`)
|
|
671
1078
|
})(),
|
|
672
|
-
|
|
1079
|
+
el('div', { className: 'db-muted db-mono', key: 'counts' }, `previews ${(entry.previews || []).length} · sheets ${(entry.contactSheets || []).length} · reviews ${(entry.reviews || []).length}`),
|
|
673
1080
|
(entry.reviews || []).length > 0
|
|
674
|
-
?
|
|
1081
|
+
? el('div', { key: 'score' }, Badge({
|
|
1082
|
+
tone: entry.reviews[entry.reviews.length - 1].pass ? 'ok' : 'warn',
|
|
1083
|
+
children: `review ${entry.reviews[entry.reviews.length - 1].score === undefined ? '—' : entry.reviews[entry.reviews.length - 1].score} 分`,
|
|
1084
|
+
}))
|
|
675
1085
|
: null,
|
|
676
1086
|
].filter(Boolean))
|
|
677
1087
|
|
|
@@ -686,7 +1096,7 @@ window.__ModuleLoader__.load({
|
|
|
686
1096
|
// M4 brief describe, kept because it is the one that survives a
|
|
687
1097
|
// scene change.
|
|
688
1098
|
const pair = renderPairOf(entryOf(right), revisions)
|
|
689
|
-
const rendersMode =
|
|
1099
|
+
const rendersMode = state.compareMode !== 'revisions'
|
|
690
1100
|
const renderPairPanes = [
|
|
691
1101
|
imagePane('left', pair.previous === null ? '上一次渲染' : `上一次渲染 · ${pair.previous.sourceRevision}`, pair.previous,
|
|
692
1102
|
'还没有上一次渲染:再点一次「渲染预览」,或者先在某个更早的 revision 上渲一次。'),
|
|
@@ -694,184 +1104,156 @@ window.__ModuleLoader__.load({
|
|
|
694
1104
|
'这个 revision 还没有由面板渲过预览(上方的「渲染预览」会生成第一张)。'),
|
|
695
1105
|
]
|
|
696
1106
|
|
|
697
|
-
return
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1107
|
+
return el('div', { 'data-view': 'preview' },
|
|
1108
|
+
ErrorBox({ error: state.error }),
|
|
1109
|
+
el('div', { className: 'db-tabs' },
|
|
1110
|
+
Button({
|
|
1111
|
+
tone: 'primary',
|
|
1112
|
+
action: 'render-preview',
|
|
1113
|
+
disabled: state.previewBusy || state.activeProjectId === null,
|
|
1114
|
+
onClick: actions.renderPreview,
|
|
1115
|
+
children: state.previewBusy ? '渲染中…' : '渲染预览',
|
|
1116
|
+
}),
|
|
1117
|
+
Notice(state.previewResult, { border: 0, padding: '0 6px', marginBottom: 0 }),
|
|
702
1118
|
),
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1119
|
+
el('div', { className: 'db-tabs' },
|
|
1120
|
+
el('span', { className: 'db-muted' }, '比较:'),
|
|
1121
|
+
el('button', {
|
|
706
1122
|
type: 'button', className: 'db-btn', 'data-compare-mode': 'renders', 'data-active': String(rendersMode),
|
|
707
|
-
onClick: () =>
|
|
1123
|
+
onClick: () => actions.setCompareMode('renders'),
|
|
708
1124
|
}, '上一次 vs 本次渲染'),
|
|
709
|
-
|
|
1125
|
+
el('button', {
|
|
710
1126
|
type: 'button', className: 'db-btn', 'data-compare-mode': 'revisions', 'data-active': String(!rendersMode),
|
|
711
|
-
onClick: () =>
|
|
1127
|
+
onClick: () => actions.setCompareMode('revisions'),
|
|
712
1128
|
}, '两个 revision'),
|
|
713
|
-
|
|
1129
|
+
el('span', { style: { flex: 1 } }),
|
|
714
1130
|
rendersMode
|
|
715
|
-
?
|
|
716
|
-
|
|
717
|
-
|
|
1131
|
+
? el('span', { className: 'db-inline' },
|
|
1132
|
+
el('span', { className: 'db-muted' }, '版本'),
|
|
1133
|
+
el('select', {
|
|
718
1134
|
className: 'db-input', 'data-field': 'compare-revision', style: { width: 'auto' }, value: right || '',
|
|
719
1135
|
onChange: event => {
|
|
720
|
-
|
|
721
|
-
|
|
1136
|
+
actions.pickCompare('left', event.target.value)
|
|
1137
|
+
actions.pickCompare('right', event.target.value)
|
|
722
1138
|
},
|
|
723
|
-
}, revisions.map(entry =>
|
|
724
|
-
:
|
|
725
|
-
|
|
1139
|
+
}, revisions.map(entry => el('option', { key: entry.revision, value: entry.revision }, entry.revision))))
|
|
1140
|
+
: el('span', { className: 'db-inline' },
|
|
1141
|
+
el('select', {
|
|
726
1142
|
className: 'db-input', 'data-field': 'compare-left', style: { width: 'auto' }, value: left || '',
|
|
727
|
-
onChange: event =>
|
|
728
|
-
}, revisions.map(entry =>
|
|
729
|
-
|
|
730
|
-
|
|
1143
|
+
onChange: event => actions.pickCompare('left', event.target.value),
|
|
1144
|
+
}, revisions.map(entry => el('option', { key: entry.revision, value: entry.revision }, entry.revision))),
|
|
1145
|
+
el('span', { className: 'db-muted' }, '↔'),
|
|
1146
|
+
el('select', {
|
|
731
1147
|
className: 'db-input', 'data-field': 'compare-right', style: { width: 'auto' }, value: right || '',
|
|
732
|
-
onChange: event =>
|
|
733
|
-
}, revisions.map(entry =>
|
|
734
|
-
|
|
1148
|
+
onChange: event => actions.pickCompare('right', event.target.value),
|
|
1149
|
+
}, revisions.map(entry => el('option', { key: entry.revision, value: entry.revision }, entry.revision))),
|
|
1150
|
+
state.activeProjectId ? Button({ action: 'diff', onClick: () => actions.diff(left, right), children: '看结构差异' }) : null),
|
|
735
1151
|
),
|
|
736
|
-
|
|
737
|
-
|
|
1152
|
+
el('div', { style: { paddingTop: '2px' } },
|
|
1153
|
+
el('div', { className: 'db-grid' }, rendersMode
|
|
738
1154
|
? renderPairPanes
|
|
739
1155
|
: [revisionPane(entryOf(left), 'left'), revisionPane(entryOf(right), 'right')]),
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
Object.entries(
|
|
1156
|
+
state.diff ? el('div', { className: 'db-card', 'data-diff': state.diff.identical ? 'identical' : 'changed' },
|
|
1157
|
+
el('h4', null, `${state.diff.fromRevision} → ${state.diff.toRevision}:${state.diff.identical ? '结构完全相同' : `${state.diff.totalChanges} 处结构变化`}`),
|
|
1158
|
+
state.diff.identical ? null : el('div', null,
|
|
1159
|
+
Object.entries(state.diff.collections)
|
|
744
1160
|
.filter(([, entry]) => entry.added.length + entry.removed.length + entry.changed.length > 0)
|
|
745
|
-
.map(([kind, entry]) =>
|
|
746
|
-
|
|
747
|
-
entry.added.length > 0 ?
|
|
748
|
-
entry.removed.length > 0 ?
|
|
749
|
-
entry.changed.map(change =>
|
|
1161
|
+
.map(([kind, entry]) => el('div', { key: kind },
|
|
1162
|
+
el('strong', null, kind),
|
|
1163
|
+
entry.added.length > 0 ? el('div', { className: 'db-muted db-mono' }, `+ ${entry.added.join(', ')}`) : null,
|
|
1164
|
+
entry.removed.length > 0 ? el('div', { className: 'db-muted db-mono' }, `- ${entry.removed.join(', ')}`) : null,
|
|
1165
|
+
entry.changed.map(change => el('div', { key: change.id, className: 'db-muted db-mono' },
|
|
750
1166
|
`~ ${change.id}: ${change.fields.map(field => `${field.field} ${JSON.stringify(field.from)} → ${JSON.stringify(field.to)}`).join('; ')}`)))),
|
|
751
|
-
|
|
752
|
-
|
|
1167
|
+
state.diff.project.length > 0 ? el('div', { className: 'db-muted db-mono' }, `project: ${state.diff.project.map(field => `${field.field} ${JSON.stringify(field.from)} → ${JSON.stringify(field.to)}`).join('; ')}`) : null,
|
|
1168
|
+
state.diff.world.length > 0 ? el('div', { className: 'db-muted db-mono' }, `world: ${state.diff.world.map(field => `${field.field} ${JSON.stringify(field.from)} → ${JSON.stringify(field.to)}`).join('; ')}`) : null,
|
|
753
1169
|
),
|
|
754
1170
|
) : null,
|
|
755
|
-
|
|
1171
|
+
state.diffError ? ErrorBox({ error: state.diffError }) : null,
|
|
756
1172
|
),
|
|
757
1173
|
)
|
|
758
1174
|
}
|
|
759
1175
|
|
|
760
1176
|
/** 任务: live progress, cancel, resume, and the approval threshold. */
|
|
761
|
-
function JobsView(
|
|
762
|
-
const
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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, '任务'),
|
|
1177
|
+
function JobsView(ctx) {
|
|
1178
|
+
const state = ctx.state
|
|
1179
|
+
const actions = ctx.actions
|
|
1180
|
+
if (state.activeProjectId === null) return el('div', { 'data-view': 'jobs', className: 'db-muted' }, '尚未选择项目。')
|
|
1181
|
+
const jobs = state.jobs || []
|
|
1182
|
+
|
|
1183
|
+
return el('div', { 'data-view': 'jobs' },
|
|
1184
|
+
ErrorBox({ error: state.error }),
|
|
1185
|
+
el('div', { className: 'db-card' },
|
|
1186
|
+
el('h4', null, '任务'),
|
|
806
1187
|
jobs.length === 0
|
|
807
|
-
?
|
|
808
|
-
:
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
job.approval && job.approval.required ?
|
|
815
|
-
job.deliverable ?
|
|
816
|
-
job.cancelable ?
|
|
817
|
-
job.resumable ?
|
|
1188
|
+
? el('div', { className: 'db-muted' }, '还没有渲染任务。')
|
|
1189
|
+
: el('ul', { className: 'db-list' }, jobs.map(job => el('li', { key: job.jobId, 'data-job': job.jobId },
|
|
1190
|
+
el('div', { className: 'db-inline' },
|
|
1191
|
+
el('span', { className: 'db-mono' }, job.jobId),
|
|
1192
|
+
Badge({ tone: statusTone(job.status), children: job.status }),
|
|
1193
|
+
el('span', { className: 'db-muted' }, job.type),
|
|
1194
|
+
el('span', { className: 'db-muted db-mono' }, `帧 ${job.frameStart}–${job.frameEnd}`),
|
|
1195
|
+
job.approval && job.approval.required ? Badge({ tone: 'warn', name: 'approval', children: `需审批:${job.approval.frames} 帧 > 阈值 ${job.approval.threshold}` }) : null,
|
|
1196
|
+
job.deliverable ? Badge({ tone: 'ok', children: '交付已校验' }) : null,
|
|
1197
|
+
job.cancelable ? Button({ tone: 'danger', action: `cancel:${job.jobId}`, disabled: state.busy.render, onClick: () => actions.cancelJob(job.jobId), children: '取消' }) : null,
|
|
1198
|
+
job.resumable ? Button({ action: `resume:${job.jobId}`, disabled: state.busy.render, onClick: () => actions.startRender(job.jobId), children: '继续渲染' }) : null,
|
|
818
1199
|
),
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
job.errorCode ?
|
|
822
|
-
job.delivery ?
|
|
1200
|
+
ProgressBar({ percent: job.progress.percent }),
|
|
1201
|
+
el('div', { className: 'db-muted', 'data-job-detail': job.jobId }, `${job.detail} · ${job.progress.percent}% · 缺失 ${job.progress.missing} · 损坏 ${job.progress.corrupt}`),
|
|
1202
|
+
job.errorCode ? el('div', { className: 'db-error', style: { marginTop: '4px' } }, `${job.errorCode}: ${job.message || ''}`) : null,
|
|
1203
|
+
job.delivery ? el('div', { className: 'db-muted db-mono' }, `video ${job.delivery.videoPath || ''}`) : null,
|
|
823
1204
|
))),
|
|
824
1205
|
),
|
|
825
1206
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
className: 'db-input', 'data-field': 'render-profile', style: { width: 'auto' }, value: profile,
|
|
836
|
-
onChange: event =>
|
|
1207
|
+
el('div', { className: 'db-card' },
|
|
1208
|
+
el('h4', null, '启动一次交付渲染(写操作经 Host)'),
|
|
1209
|
+
el('div', { className: 'db-inline' },
|
|
1210
|
+
el('label', { className: 'db-muted' }, '帧起'),
|
|
1211
|
+
el('input', { className: 'db-input', 'data-field': 'frame-start', style: { width: '90px' }, value: state.forms.frameStart, onChange: event => actions.setForm('frameStart', event.target.value) }),
|
|
1212
|
+
el('label', { className: 'db-muted' }, '帧止'),
|
|
1213
|
+
el('input', { className: 'db-input', 'data-field': 'frame-end', style: { width: '90px' }, value: state.forms.frameEnd, onChange: event => actions.setForm('frameEnd', event.target.value) }),
|
|
1214
|
+
el('label', { className: 'db-muted' }, 'profile'),
|
|
1215
|
+
el('select', {
|
|
1216
|
+
className: 'db-input', 'data-field': 'render-profile', style: { width: 'auto' }, value: state.forms.profile,
|
|
1217
|
+
onChange: event => actions.setForm('profile', event.target.value),
|
|
837
1218
|
},
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1219
|
+
el('option', { value: 'preview' }, 'preview'),
|
|
1220
|
+
el('option', { value: 'final' }, 'final')),
|
|
1221
|
+
Button({ tone: 'primary', action: 'start-render', disabled: state.busy.render, onClick: () => actions.startRender(undefined), children: state.busy.render ? '提交中…' : '启动' }),
|
|
841
1222
|
),
|
|
842
|
-
|
|
1223
|
+
Notice(state.notices.jobs, { marginTop: '8px' }),
|
|
843
1224
|
),
|
|
844
1225
|
)
|
|
845
1226
|
}
|
|
846
1227
|
|
|
847
1228
|
/** QA: technical validation and the visual review, never merged. */
|
|
848
|
-
function QaView(
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
issue.
|
|
1229
|
+
function QaView(ctx) {
|
|
1230
|
+
const state = ctx.state
|
|
1231
|
+
const qa = state.selected ? state.selected.qa : null
|
|
1232
|
+
if (qa === null || qa === undefined) return el('div', { 'data-view': 'qa', className: 'db-muted' }, '尚未选择项目。')
|
|
1233
|
+
const issueList = (issues, keyPrefix) => el('ul', { className: 'db-list' }, issues.map((issue, index) => el('li', { key: `${keyPrefix}${index}` },
|
|
1234
|
+
el('div', { className: 'db-inline' },
|
|
1235
|
+
Badge({ tone: issue.severity === 'critical' ? 'bad' : issue.severity === 'major' ? 'warn' : 'muted', children: issue.severity || '—' }),
|
|
1236
|
+
el('span', { className: 'db-mono' }, issue.code || '—'),
|
|
1237
|
+
issue.category ? el('span', { className: 'db-muted' }, issue.category) : null,
|
|
1238
|
+
issue.viewId ? el('span', { className: 'db-muted db-mono' }, issue.viewId) : null,
|
|
857
1239
|
),
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
return
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
qa.visual.available ?
|
|
867
|
-
qa.semantic.noticeCount > 0 ?
|
|
1240
|
+
el('div', null, issue.evidence || ''))))
|
|
1241
|
+
|
|
1242
|
+
return el('div', { 'data-view': 'qa' },
|
|
1243
|
+
ErrorBox({ error: state.error }),
|
|
1244
|
+
el('div', { className: 'db-card', 'data-qa-revision': qa.revision },
|
|
1245
|
+
el('div', { className: 'db-inline' },
|
|
1246
|
+
el('strong', null, `QA · ${qa.revision}`),
|
|
1247
|
+
Badge({ tone: qa.technical.ok ? 'ok' : 'bad', children: qa.technical.available ? (qa.technical.ok ? '技术校验通过' : `技术错误 ${qa.technical.errorCount}`) : '无技术校验记录' }),
|
|
1248
|
+
qa.visual.available ? Badge({ tone: qa.visual.pass ? 'ok' : 'warn', children: `视觉评分 ${qa.visual.score}` }) : Badge({ children: '未跑视觉评审' }),
|
|
1249
|
+
qa.semantic.noticeCount > 0 ? Badge({ tone: 'warn', children: `${qa.semantic.noticeCount} 条 notices` }) : null,
|
|
868
1250
|
),
|
|
869
|
-
|
|
1251
|
+
el('div', { className: 'db-muted', style: { marginTop: '4px' } }, qa.summary),
|
|
870
1252
|
),
|
|
871
1253
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1254
|
+
el('div', { className: 'db-card' },
|
|
1255
|
+
el('h4', null, '技术校验(validation.json)'),
|
|
1256
|
+
KeyValues({ entries: [
|
|
875
1257
|
{ label: '引擎', value: qa.technical.engine },
|
|
876
1258
|
{ label: '活动相机', value: qa.technical.activeCamera },
|
|
877
1259
|
{ label: '帧范围', value: qa.technical.frameRange ? qa.technical.frameRange.join('–') : null },
|
|
@@ -880,19 +1262,19 @@ window.__ModuleLoader__.load({
|
|
|
880
1262
|
{ label: '相机', value: qa.technical.counts ? qa.technical.counts.cameraObjects : null },
|
|
881
1263
|
] }),
|
|
882
1264
|
qa.technical.errors.length === 0
|
|
883
|
-
?
|
|
1265
|
+
? el('div', { className: 'db-muted' }, '没有技术错误。')
|
|
884
1266
|
: issueList(qa.technical.errors, 'tech'),
|
|
885
|
-
qa.semantic.notices.length === 0 ? null :
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1267
|
+
qa.semantic.notices.length === 0 ? null : el('div', { style: { marginTop: '8px' } },
|
|
1268
|
+
el('h4', null, '编译器 notices'),
|
|
1269
|
+
el('ul', { className: 'db-list' }, qa.semantic.notices.map((notice, index) => el('li', { key: `n${index}` },
|
|
1270
|
+
el('span', { className: 'db-mono' }, notice.code || ''), ' ', notice.message || '')))),
|
|
889
1271
|
),
|
|
890
1272
|
|
|
891
|
-
|
|
892
|
-
|
|
1273
|
+
el('div', { className: 'db-card' },
|
|
1274
|
+
el('h4', null, '视觉评审(测量 + 模型 finding,两个来源不合并)'),
|
|
893
1275
|
qa.visual.available
|
|
894
|
-
?
|
|
895
|
-
|
|
1276
|
+
? el('div', null,
|
|
1277
|
+
KeyValues({ entries: [
|
|
896
1278
|
{ label: '分数', value: qa.visual.score },
|
|
897
1279
|
{ label: '通过', value: qa.visual.pass ? '是' : '否' },
|
|
898
1280
|
{ label: '轮次', value: qa.visual.iteration },
|
|
@@ -900,252 +1282,322 @@ window.__ModuleLoader__.load({
|
|
|
900
1282
|
{ label: '视角数', value: qa.visual.viewCount },
|
|
901
1283
|
{ label: '审查器', value: qa.visual.reviewerAvailable ? (qa.visual.reviewerModel || '已调用') : (qa.visual.reviewerError || '未调用') },
|
|
902
1284
|
] }),
|
|
903
|
-
qa.visual.measuredIssues.length === 0 ?
|
|
1285
|
+
qa.visual.measuredIssues.length === 0 ? el('div', { className: 'db-muted' }, '测量没有发现问题。') : issueList(qa.visual.measuredIssues, 'm'),
|
|
904
1286
|
qa.visual.findings.length === 0
|
|
905
|
-
?
|
|
1287
|
+
? el('div', { className: 'db-muted' }, qa.visual.reviewerAvailable ? '审查器没有报告 finding。' : '没有第二意见。')
|
|
906
1288
|
: issueList(qa.visual.findings, 'f'))
|
|
907
|
-
:
|
|
1289
|
+
: el('div', { className: 'db-muted' }, '这个 revision 还没有视觉评审。可以用 blender_visual_review 跑一次。'),
|
|
908
1290
|
),
|
|
909
1291
|
)
|
|
910
1292
|
}
|
|
911
1293
|
|
|
912
1294
|
/** 版本: the revision list, restore, and the hand-off to Preview Compare. */
|
|
913
|
-
function RevisionsView(
|
|
914
|
-
const
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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) }, '恢复'),
|
|
1295
|
+
function RevisionsView(ctx) {
|
|
1296
|
+
const state = ctx.state
|
|
1297
|
+
const actions = ctx.actions
|
|
1298
|
+
if (state.activeProjectId === null) return el('div', { 'data-view': 'revisions', className: 'db-muted' }, '尚未选择项目。')
|
|
1299
|
+
const revisions = (state.selected && state.selected.revisions) || []
|
|
1300
|
+
|
|
1301
|
+
return el('div', { 'data-view': 'revisions' },
|
|
1302
|
+
ErrorBox({ error: state.error }),
|
|
1303
|
+
el('div', { className: 'db-card' },
|
|
1304
|
+
el('h4', null, `版本(${revisions.length})`),
|
|
1305
|
+
el('ul', { className: 'db-list' }, revisions.map(entry => el('li', { key: entry.revision, 'data-revision': entry.revision },
|
|
1306
|
+
el('div', { className: 'db-inline' },
|
|
1307
|
+
el('span', { className: 'db-mono' }, entry.revision),
|
|
1308
|
+
entry.isCurrent ? Badge({ tone: 'ok', children: '当前' }) : null,
|
|
1309
|
+
el('span', { className: 'db-muted' }, entry.kind || '—'),
|
|
1310
|
+
el('span', { className: 'db-muted' }, formatTime(entry.createdAt)),
|
|
1311
|
+
el('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(entry.digest)}`),
|
|
1312
|
+
Button({ action: `compare-from:${entry.revision}`, onClick: () => actions.compareFrom(entry.revision), children: '对比' }),
|
|
1313
|
+
entry.isCurrent ? null : Button({ action: `restore:${entry.revision}`, disabled: state.busy.restore, onClick: () => actions.restoreRevision(entry.revision), children: '恢复' }),
|
|
943
1314
|
),
|
|
944
|
-
|
|
945
|
-
|
|
1315
|
+
el('div', { className: 'db-muted' }, entry.summary || '(无说明)'),
|
|
1316
|
+
el('div', { className: 'db-muted db-mono' }, `previews ${(entry.previews || []).length} · validation ${entry.validation ? (entry.validation.ok ? 'ok' : `${entry.validation.errorCount} errors`) : '—'}`),
|
|
946
1317
|
))),
|
|
947
|
-
|
|
1318
|
+
Notice(state.notices.revisions, { marginTop: '8px' }),
|
|
948
1319
|
),
|
|
949
1320
|
)
|
|
950
1321
|
}
|
|
951
1322
|
|
|
1323
|
+
/** The active view, and nothing else — the `.db-body` wrapper is the caller's. */
|
|
1324
|
+
function renderView(ctx) {
|
|
1325
|
+
switch (ctx.state.view) {
|
|
1326
|
+
case 'scene': return SceneView(ctx)
|
|
1327
|
+
case 'preview': return PreviewView(ctx)
|
|
1328
|
+
case 'jobs': return JobsView(ctx)
|
|
1329
|
+
case 'qa': return QaView(ctx)
|
|
1330
|
+
case 'revisions': return RevisionsView(ctx)
|
|
1331
|
+
default: return ProjectsView(ctx)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
952
1335
|
/**
|
|
953
|
-
* The
|
|
1336
|
+
* The whole workbench: header, the six-tab nav, and the active view.
|
|
954
1337
|
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
1338
|
+
* This is the function both faces call. The console hands its result to
|
|
1339
|
+
* `toReact`; the standalone page hands it to `toDom`. Neither of them decides
|
|
1340
|
+
* anything about what the workbench contains.
|
|
1341
|
+
*
|
|
1342
|
+
* @param {object} state - the store's snapshot
|
|
1343
|
+
* @param {object} actions - the store's action table
|
|
958
1344
|
*/
|
|
959
|
-
function
|
|
960
|
-
const
|
|
961
|
-
const
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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} 个任务在跑`)
|
|
1345
|
+
function buildWorkbenchView(state, actions) {
|
|
1346
|
+
const ctx = { state, actions }
|
|
1347
|
+
const stale = state.status === 'stale'
|
|
1348
|
+
const body = stale
|
|
1349
|
+
? el('div', { className: 'db-body' }, ErrorBox({ error: state.error }))
|
|
1350
|
+
: state.status === 'loading'
|
|
1351
|
+
? el('div', { className: 'db-body db-muted' }, '读取 Host 状态…')
|
|
1352
|
+
: el('div', { className: 'db-body' }, renderView(ctx))
|
|
1353
|
+
|
|
1354
|
+
return el('div', { className: 'db-root', 'data-deepblend-panel': PANEL_ID },
|
|
1355
|
+
el('div', { className: 'db-head' },
|
|
1356
|
+
el('span', { className: 'db-title' }, `${PANEL_LABEL} 工作台`),
|
|
1357
|
+
state.selected ? el('span', { className: 'db-muted' }, state.selected.project.title) : null,
|
|
1358
|
+
state.selected ? Badge({ children: state.selected.currentRevision || '—' }) : null,
|
|
1359
|
+
state.unfinishedJobs.length > 0
|
|
1360
|
+
? Badge({ tone: 'live', name: 'unfinished', children: `${state.unfinishedJobs.length} 个任务在跑` })
|
|
1113
1361
|
: null,
|
|
1114
|
-
|
|
1115
|
-
?
|
|
1362
|
+
state.hostApiVersion !== null && state.hostApiVersion !== EXPECTED_HOST_API
|
|
1363
|
+
? Badge({ tone: 'warn', name: 'api', children: `hostApiVersion ${state.hostApiVersion} ≠ ${EXPECTED_HOST_API}` })
|
|
1116
1364
|
: null,
|
|
1117
|
-
|
|
1118
|
-
|
|
1365
|
+
el('span', { style: { flex: 1 } }),
|
|
1366
|
+
Button({ action: 'reload', onClick: actions.reload, children: '刷新' }),
|
|
1119
1367
|
),
|
|
1120
|
-
|
|
1368
|
+
el('div', { className: 'db-nav' }, VIEWS.map(entry => el('button', {
|
|
1121
1369
|
key: entry.id,
|
|
1122
1370
|
type: 'button',
|
|
1123
1371
|
'data-view-tab': entry.id,
|
|
1124
|
-
'data-active': String(view === entry.id),
|
|
1125
|
-
onClick: () => setView(entry.id),
|
|
1372
|
+
'data-active': String(state.view === entry.id),
|
|
1373
|
+
onClick: () => actions.setView(entry.id),
|
|
1126
1374
|
}, entry.label))),
|
|
1127
|
-
error !== null && error !== undefined && !stale ?
|
|
1375
|
+
state.error !== null && state.error !== undefined && !stale ? el('div', { style: { padding: '8px 14px 0' } }, ErrorBox({ error: state.error })) : null,
|
|
1128
1376
|
body,
|
|
1129
1377
|
)
|
|
1130
1378
|
}
|
|
1131
1379
|
|
|
1380
|
+
// =========================================================================
|
|
1381
|
+
// §I The two bindings of a node — generic, and deliberately ignorant
|
|
1382
|
+
// =========================================================================
|
|
1383
|
+
|
|
1384
|
+
/** A node is text, a list, or `{ tag, props, children }`; anything else is a bug. */
|
|
1385
|
+
function assertNode(node) {
|
|
1386
|
+
if (typeof node.tag !== 'string') {
|
|
1387
|
+
throw new Error(`workbench: a node's tag must be a string, got ${typeof node.tag} — a component must be CALLED, not used as a tag`)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Node → React element.
|
|
1393
|
+
*
|
|
1394
|
+
* The console's binding. It knows `createElement` and nothing else: no route,
|
|
1395
|
+
* no tab id, no field name.
|
|
1396
|
+
*
|
|
1397
|
+
* @param {any} node
|
|
1398
|
+
* @param {(type: any, props: any, ...children: any[]) => any} createElement
|
|
1399
|
+
*/
|
|
1400
|
+
function toReact(node, createElement) {
|
|
1401
|
+
if (node === null || node === undefined || node === false || node === true) return null
|
|
1402
|
+
if (typeof node === 'string' || typeof node === 'number') return node
|
|
1403
|
+
if (Array.isArray(node)) return node.map(child => toReact(child, createElement))
|
|
1404
|
+
assertNode(node)
|
|
1405
|
+
return createElement(node.tag, node.props, ...node.children.map(child => toReact(child, createElement)))
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** Props that are listeners, spelled the way this vocabulary spells them. */
|
|
1409
|
+
const EVENT_NAMES = {
|
|
1410
|
+
onClick: 'click', onChange: 'input', onInput: 'input', onKeyDown: 'keydown',
|
|
1411
|
+
onSubmit: 'submit', onBlur: 'blur', onFocus: 'focus',
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Apply one node's props to a DOM element.
|
|
1416
|
+
*
|
|
1417
|
+
* Generic by construction: `className`, `style`, `value`, `on*`, and
|
|
1418
|
+
* everything else as an attribute. Nothing here names a DeepBlend concept.
|
|
1419
|
+
*/
|
|
1420
|
+
function applyProps(element, props) {
|
|
1421
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1422
|
+
if (value === undefined || value === null || value === false) continue
|
|
1423
|
+
if (key === 'children' || key === 'key') continue
|
|
1424
|
+
if (key === 'className') { element.setAttribute('class', String(value)); continue }
|
|
1425
|
+
if (key === 'style') { Object.assign(element.style, value); continue }
|
|
1426
|
+
if (key === 'value') { element.value = String(value); continue }
|
|
1427
|
+
if (key === 'disabled') { element.disabled = value === true; continue }
|
|
1428
|
+
if (key === 'spellCheck') { element.setAttribute('spellcheck', String(value)); continue }
|
|
1429
|
+
if (EVENT_NAMES[key] !== undefined) { element.addEventListener(EVENT_NAMES[key], value); continue }
|
|
1430
|
+
element.setAttribute(key, value === true ? '' : String(value))
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Node → DOM node.
|
|
1436
|
+
*
|
|
1437
|
+
* The standalone page's binding, and the same story as `toReact`: a generic
|
|
1438
|
+
* walk of the tree, with no idea what it is drawing.
|
|
1439
|
+
*
|
|
1440
|
+
* @param {any} node
|
|
1441
|
+
* @param {Document} doc
|
|
1442
|
+
* @returns {Node|null}
|
|
1443
|
+
*/
|
|
1444
|
+
function toDom(node, doc) {
|
|
1445
|
+
if (node === null || node === undefined || node === false || node === true) return null
|
|
1446
|
+
if (typeof node === 'string' || typeof node === 'number') return doc.createTextNode(String(node))
|
|
1447
|
+
if (Array.isArray(node)) {
|
|
1448
|
+
const fragment = doc.createDocumentFragment()
|
|
1449
|
+
for (const child of node) {
|
|
1450
|
+
const built = toDom(child, doc)
|
|
1451
|
+
if (built !== null) fragment.appendChild(built)
|
|
1452
|
+
}
|
|
1453
|
+
return fragment
|
|
1454
|
+
}
|
|
1455
|
+
assertNode(node)
|
|
1456
|
+
const element = doc.createElement(node.tag)
|
|
1457
|
+
applyProps(element, node.props)
|
|
1458
|
+
for (const child of node.children) {
|
|
1459
|
+
const built = toDom(child, doc)
|
|
1460
|
+
if (built !== null) element.appendChild(built)
|
|
1461
|
+
}
|
|
1462
|
+
return element
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// =========================================================================
|
|
1466
|
+
// §J The standalone face (SPEC §20 M6)
|
|
1467
|
+
// =========================================================================
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Mount the whole workbench into a plain DOM element.
|
|
1471
|
+
*
|
|
1472
|
+
* This is the ONLY thing the standalone page adds, and it is the only place
|
|
1473
|
+
* in this file where the two faces differ: the console's `WorkbenchPanel`
|
|
1474
|
+
* (§K) subscribes to the same store and draws the same tree through
|
|
1475
|
+
* `toReact`; this draws it through `toDom`.
|
|
1476
|
+
*
|
|
1477
|
+
* It is deliberately NOT a second panel: no view is named here, no route is
|
|
1478
|
+
* spelled here, and there is no state of its own beyond the store both faces
|
|
1479
|
+
* share. `contract/workbench-page.test.mjs` asserts exactly that — that this
|
|
1480
|
+
* function and the two bindings contain no DeepBlend noun at all.
|
|
1481
|
+
*
|
|
1482
|
+
* @param {HTMLElement} root - the element the page reserves for the workbench
|
|
1483
|
+
* @param {{ fetch?: typeof fetch, pollLiveMs?: number, pollIdleMs?: number }} [options]
|
|
1484
|
+
* @returns {Promise<{ store: object, dispose: () => void }>}
|
|
1485
|
+
*/
|
|
1486
|
+
async function mountStandalone(root, options) {
|
|
1487
|
+
const doc = root.ownerDocument
|
|
1488
|
+
injectStandaloneTokens(doc)
|
|
1489
|
+
injectStyles(doc)
|
|
1490
|
+
root.classList.add('db-standalone-root')
|
|
1491
|
+
|
|
1492
|
+
const store = createWorkbenchStore(options)
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Redraw from the snapshot.
|
|
1496
|
+
*
|
|
1497
|
+
* The tree is rebuilt wholesale, so a text field would lose its caret on
|
|
1498
|
+
* every keystroke. The focus and the selection are therefore carried across
|
|
1499
|
+
* the redraw by the field's own `data-field` marker — the one piece of DOM
|
|
1500
|
+
* bookkeeping this binding needs, and the reason it can rebuild rather than
|
|
1501
|
+
* reconcile.
|
|
1502
|
+
*/
|
|
1503
|
+
const draw = () => {
|
|
1504
|
+
const active = doc.activeElement
|
|
1505
|
+
const focused = active !== null && active !== doc.body && active.dataset ? active.dataset.field ?? null : null
|
|
1506
|
+
const caret = focused === null ? null : active.selectionStart
|
|
1507
|
+
|
|
1508
|
+
const next = toDom(buildWorkbenchView(store.getState(), store.actions), doc)
|
|
1509
|
+
root.replaceChildren(...(next === null ? [] : [next]))
|
|
1510
|
+
|
|
1511
|
+
if (focused !== null) {
|
|
1512
|
+
const restored = root.querySelector(`[data-field="${focused}"]`)
|
|
1513
|
+
if (restored !== null) {
|
|
1514
|
+
restored.focus()
|
|
1515
|
+
if (caret !== null && typeof restored.setSelectionRange === 'function') restored.setSelectionRange(caret, caret)
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const unsubscribe = store.subscribe(draw)
|
|
1521
|
+
store.start()
|
|
1522
|
+
draw()
|
|
1523
|
+
|
|
1524
|
+
return {
|
|
1525
|
+
store,
|
|
1526
|
+
dispose() {
|
|
1527
|
+
unsubscribe()
|
|
1528
|
+
store.stop()
|
|
1529
|
+
root.replaceChildren()
|
|
1530
|
+
},
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// =========================================================================
|
|
1535
|
+
// §K The console face — React, and only React
|
|
1536
|
+
// =========================================================================
|
|
1537
|
+
|
|
1538
|
+
/** One store per panel, started with the mount and stopped with it. */
|
|
1539
|
+
function useWorkbenchStore() {
|
|
1540
|
+
const ref = react().useRef(null)
|
|
1541
|
+
if (ref.current === null) ref.current = createWorkbenchStore({})
|
|
1542
|
+
react().useEffect(() => {
|
|
1543
|
+
ref.current.start()
|
|
1544
|
+
return () => ref.current.stop()
|
|
1545
|
+
}, [])
|
|
1546
|
+
return ref.current
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** Re-render on every store change. */
|
|
1550
|
+
function useSnapshot(store) {
|
|
1551
|
+
const [snapshot, setSnapshot] = react().useState(() => store.getState())
|
|
1552
|
+
react().useEffect(() => store.subscribe(setSnapshot), [store])
|
|
1553
|
+
return snapshot
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** React bindings for the shared pieces: one line each, no second implementation. */
|
|
1557
|
+
const RBadge = props => toReact(Badge(props), h)
|
|
1558
|
+
const RRow = props => toReact(Row(props), h)
|
|
1559
|
+
const RButton = props => toReact(Button(props), h)
|
|
1560
|
+
const RErrorBox = props => toReact(ErrorBox(props), h)
|
|
1561
|
+
const RProgressBar = props => toReact(ProgressBar(props), h)
|
|
1562
|
+
const RKeyValues = props => toReact(KeyValues(props), h)
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* The panel: the shared workbench tree, drawn by React.
|
|
1566
|
+
*
|
|
1567
|
+
* The selected project and the compare pair are LOCAL UI state (SPEC §14.3) —
|
|
1568
|
+
* they live in the store, which both faces share. Everything they point at is
|
|
1569
|
+
* read from the Host on every pass, so the mirror cannot outlive the truth.
|
|
1570
|
+
*/
|
|
1571
|
+
function WorkbenchPanel() {
|
|
1572
|
+
const store = useWorkbenchStore()
|
|
1573
|
+
const snapshot = useSnapshot(store)
|
|
1574
|
+
return toReact(buildWorkbenchView(snapshot, store.actions), h)
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1132
1577
|
/** The settings page: the M0 card, now with a seat. */
|
|
1133
1578
|
function BlenderSettingsPage() {
|
|
1134
|
-
const state =
|
|
1579
|
+
const [state, setState] = react().useState({ status: 'loading', payload: null, error: null })
|
|
1580
|
+
react().useEffect(() => {
|
|
1581
|
+
let live = true
|
|
1582
|
+
readRoute((...args) => fetch(...args), ROUTES.capabilities, 'capabilities').then(result => {
|
|
1583
|
+
if (live) setState(result)
|
|
1584
|
+
})
|
|
1585
|
+
return () => { live = false }
|
|
1586
|
+
}, [])
|
|
1135
1587
|
const payload = state.payload
|
|
1136
1588
|
const card = payload ? payload.card : null
|
|
1137
1589
|
|
|
1138
1590
|
return h('div', { 'data-deepblend-settings': PANEL_ID, style: { padding: '4px 2px' } },
|
|
1139
|
-
state.error !== null && state.error !== undefined ? h(
|
|
1591
|
+
state.error !== null && state.error !== undefined ? h(RErrorBox, { error: state.error }) : null,
|
|
1140
1592
|
state.status === 'loading' ? h('div', { className: 'db-muted' }, '检测 Blender…') : null,
|
|
1141
1593
|
card === null ? null : h('div', null,
|
|
1142
1594
|
h('div', { className: 'db-inline' },
|
|
1143
1595
|
h('strong', null, card.title),
|
|
1144
|
-
h(
|
|
1596
|
+
h(RBadge, { tone: card.status === 'ready' ? 'ok' : 'bad' }, card.statusLabel),
|
|
1145
1597
|
card.probedAt ? h('span', { className: 'db-muted' }, `探测于 ${formatTime(card.probedAt)}`) : null,
|
|
1146
1598
|
),
|
|
1147
1599
|
h('div', { className: 'db-card', style: { marginTop: '8px' } },
|
|
1148
|
-
card.rows.map(row => h(
|
|
1600
|
+
card.rows.map(row => h(RRow, { key: row.label, label: row.label, value: row.value }))),
|
|
1149
1601
|
card.warnings.length === 0 ? null : h('div', { className: 'db-card' },
|
|
1150
1602
|
h('h4', null, '警告'),
|
|
1151
1603
|
h('ul', { className: 'db-list' }, card.warnings.map((entry, index) => h('li', { key: `w${index}` },
|
|
@@ -1184,6 +1636,23 @@ window.__ModuleLoader__.load({
|
|
|
1184
1636
|
return block.error ? `${block.error.name || 'error'}: ${block.error.code || ''}` : ''
|
|
1185
1637
|
}
|
|
1186
1638
|
|
|
1639
|
+
/** Read one route for a card, once or on a cadence. */
|
|
1640
|
+
function useCardRoute(path, route, intervalMs) {
|
|
1641
|
+
const [state, setState] = react().useState({ status: 'loading', payload: null, error: null })
|
|
1642
|
+
react().useEffect(() => {
|
|
1643
|
+
let live = true
|
|
1644
|
+
const load = async () => {
|
|
1645
|
+
const result = await readRoute((...args) => fetch(...args), path, route)
|
|
1646
|
+
if (live) setState(result)
|
|
1647
|
+
}
|
|
1648
|
+
load()
|
|
1649
|
+
if (intervalMs <= 0) return () => { live = false }
|
|
1650
|
+
const timer = setInterval(load, intervalMs)
|
|
1651
|
+
return () => { live = false; clearInterval(timer) }
|
|
1652
|
+
}, [path, intervalMs, route])
|
|
1653
|
+
return state
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1187
1656
|
/**
|
|
1188
1657
|
* A DeepBlend tool card.
|
|
1189
1658
|
*
|
|
@@ -1198,24 +1667,27 @@ window.__ModuleLoader__.load({
|
|
|
1198
1667
|
const projectId = typeof args.projectId === 'string' ? args.projectId : null
|
|
1199
1668
|
const jobId = typeof args.jobId === 'string' ? args.jobId : (typeof args.resumeJobId === 'string' ? args.resumeJobId : null)
|
|
1200
1669
|
const settled = props.block !== null && props.block !== undefined && 'kind' in props.block
|
|
1201
|
-
const [expanded, setExpanded] = react.useState(false)
|
|
1670
|
+
const [expanded, setExpanded] = react().useState(false)
|
|
1202
1671
|
|
|
1203
1672
|
const jobShaped = ['blender_final_render', 'blender_export', 'blender_job_status', 'blender_job_cancel'].includes(toolName)
|
|
1204
1673
|
const reviewShaped = ['blender_visual_review', 'blender_visual_autofix', 'blender_preview_views'].includes(toolName)
|
|
1205
1674
|
const canReadJob = projectId !== null && jobId !== null
|
|
1206
1675
|
const idle = `${ROUTES.state}?t=0`
|
|
1207
1676
|
|
|
1208
|
-
const jobState =
|
|
1677
|
+
const jobState = useCardRoute(
|
|
1209
1678
|
canReadJob ? projectRoute(projectId, `/jobs/${encodeURIComponent(jobId)}`) : idle,
|
|
1210
|
-
|
|
1679
|
+
canReadJob ? 'project.job' : 'state',
|
|
1680
|
+
jobShaped && canReadJob ? POLL_LIVE_MS : 0,
|
|
1211
1681
|
)
|
|
1212
|
-
const qaState =
|
|
1682
|
+
const qaState = useCardRoute(
|
|
1213
1683
|
projectId === null ? idle : projectRoute(projectId, '/qa'),
|
|
1214
|
-
|
|
1684
|
+
projectId === null ? 'state' : 'project.qa',
|
|
1685
|
+
reviewShaped && projectId !== null ? POLL_IDLE_MS : 0,
|
|
1215
1686
|
)
|
|
1216
|
-
const previewState =
|
|
1687
|
+
const previewState = useCardRoute(
|
|
1217
1688
|
projectId === null ? idle : projectRoute(projectId, '/previews'),
|
|
1218
|
-
|
|
1689
|
+
projectId === null ? 'state' : 'project.previews',
|
|
1690
|
+
0,
|
|
1219
1691
|
)
|
|
1220
1692
|
|
|
1221
1693
|
const job = jobState.payload && jobState.payload.job ? jobState.payload.job : null
|
|
@@ -1243,28 +1715,28 @@ window.__ModuleLoader__.load({
|
|
|
1243
1715
|
h('span', { className: 'db-dot', 'data-tone': settled ? (props.block.isError ? 'bad' : 'ok') : 'live' }),
|
|
1244
1716
|
h('strong', { className: 'db-mono' }, toolName),
|
|
1245
1717
|
projectId ? h('span', { className: 'db-muted db-mono' }, projectId) : null,
|
|
1246
|
-
jobId ? h(
|
|
1247
|
-
Array.isArray(args.operations) ? h(
|
|
1248
|
-
job && job.approval && job.approval.required ? h(
|
|
1718
|
+
jobId ? h(RBadge, null, jobId) : null,
|
|
1719
|
+
Array.isArray(args.operations) ? h(RBadge, null, `${args.operations.length} 个操作`) : null,
|
|
1720
|
+
job && job.approval && job.approval.required ? h(RBadge, { tone: 'warn', name: 'approval' }, `需审批:${job.approval.frames} 帧 > ${job.approval.threshold}`) : null,
|
|
1249
1721
|
h('span', { style: { flex: 1 } }),
|
|
1250
1722
|
settled ? h('button', { type: 'button', className: 'db-chip', 'data-action': 'toggle-card', onClick: () => setExpanded(value => !value) }, expanded ? '收起' : '详情') : null,
|
|
1251
1723
|
),
|
|
1252
1724
|
|
|
1253
1725
|
jobShaped && job !== null ? h('div', { 'data-tool-job': job.jobId },
|
|
1254
1726
|
h('div', { className: 'db-inline' },
|
|
1255
|
-
h(
|
|
1727
|
+
h(RBadge, { tone: statusTone(job.status) }, job.status),
|
|
1256
1728
|
h('span', { className: 'db-muted' }, `${job.progress.completed}/${job.progress.expected} 帧 · ${job.progress.percent}%`),
|
|
1257
1729
|
job.estimatedRemainingMs ? h('span', { className: 'db-muted' }, `预计 ${Math.round(job.estimatedRemainingMs / 1000)} s`) : null,
|
|
1258
1730
|
),
|
|
1259
|
-
h(
|
|
1731
|
+
h(RProgressBar, { percent: job.progress.percent }),
|
|
1260
1732
|
h('div', { className: 'db-muted' }, job.detail),
|
|
1261
1733
|
job.delivery ? h('div', { className: 'db-muted db-mono' }, String(job.delivery.videoPath || '')) : null,
|
|
1262
1734
|
) : null,
|
|
1263
1735
|
|
|
1264
1736
|
reviewShaped && qa !== null ? h('div', { 'data-tool-qa': qa.revision },
|
|
1265
1737
|
h('div', { className: 'db-inline' },
|
|
1266
|
-
qa.visual.available ? h(
|
|
1267
|
-
h(
|
|
1738
|
+
qa.visual.available ? h(RBadge, { tone: qa.visual.pass ? 'ok' : 'warn' }, `视觉评分 ${qa.visual.score}`) : h(RBadge, null, '未跑视觉评审'),
|
|
1739
|
+
h(RBadge, { tone: qa.technical.ok ? 'ok' : 'bad' }, qa.technical.available ? (qa.technical.ok ? '技术校验通过' : `${qa.technical.errorCount} 个技术错误`) : '无记录'),
|
|
1268
1740
|
h('span', { className: 'db-muted' }, `测量 ${qa.visual.measuredIssueCount} · 模型 finding ${qa.visual.findingCount}`),
|
|
1269
1741
|
),
|
|
1270
1742
|
qa.visual.measuredIssues.slice(0, 4).map((issue, index) => h('div', { key: `i${index}`, className: 'db-muted db-mono' },
|
|
@@ -1288,7 +1760,7 @@ window.__ModuleLoader__.load({
|
|
|
1288
1760
|
|
|
1289
1761
|
/** The session-header chip: how many renders are live, right now. */
|
|
1290
1762
|
function SessionJobsChip() {
|
|
1291
|
-
const state =
|
|
1763
|
+
const state = useCardRoute(ROUTES.projects, 'projects.list', POLL_LIVE_MS)
|
|
1292
1764
|
if (state.payload === null) return null
|
|
1293
1765
|
const projects = state.payload.projects || []
|
|
1294
1766
|
const unfinished = projects.reduce((sum, project) => sum + (project.unfinishedJobs || 0), 0)
|
|
@@ -1376,6 +1848,35 @@ window.__ModuleLoader__.load({
|
|
|
1376
1848
|
|
|
1377
1849
|
exports.apply = apply
|
|
1378
1850
|
exports.inject = inject
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* The standalone page's entry point, and the pieces a test can reach without
|
|
1854
|
+
* a browser. Exported from THIS bundle on purpose: the page imports this
|
|
1855
|
+
* module out of `window.__DSH_BOOT__`, so "the standalone workbench and the
|
|
1856
|
+
* console workbench are one implementation" is a fact about which file was
|
|
1857
|
+
* loaded, not a promise about two files staying in step.
|
|
1858
|
+
*/
|
|
1859
|
+
exports.mountStandalone = mountStandalone
|
|
1860
|
+
exports.workbench = {
|
|
1861
|
+
PANEL_ID,
|
|
1862
|
+
VIEWS,
|
|
1863
|
+
CSS,
|
|
1864
|
+
ROUTES,
|
|
1865
|
+
projectRoute,
|
|
1866
|
+
artifactUrl,
|
|
1867
|
+
shortDigest,
|
|
1868
|
+
formatTime,
|
|
1869
|
+
statusTone,
|
|
1870
|
+
el,
|
|
1871
|
+
toReact,
|
|
1872
|
+
toDom,
|
|
1873
|
+
buildWorkbenchView,
|
|
1874
|
+
createWorkbenchStore,
|
|
1875
|
+
renderView,
|
|
1876
|
+
mountStandalone,
|
|
1877
|
+
injectStyles,
|
|
1878
|
+
injectStandaloneTokens,
|
|
1879
|
+
}
|
|
1379
1880
|
return module.exports
|
|
1380
1881
|
},
|
|
1381
1882
|
})
|