@deepblend/dsh-blender-ui 0.1.0 → 0.2.0

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