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