@deepblend/dsh-blender-ui 0.1.0 → 0.2.1

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