From ad0775605dd357897c5a48cae72cc0b8c1cd66d8 Mon Sep 17 00:00:00 2001 From: echoD886 Date: Thu, 27 Aug 2026 10:32:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=203D=20=E5=AF=BC=E6=BC=94=E5=8F=B0?= =?UTF-8?q?=20-=20=E5=AE=8C=E6=88=90=20Issue=20#305=20P0=20=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E6=B2=BB=E7=90=86=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期 * style(canvas): 3D 导演台 - 对齐 CI 格式门禁 --- .github/workflows/quality.yml | 6 + web/package.json | 3 +- .../models/director-repro-triangle.gltf | 54 + web/scripts/director-p0-chrome-e2e.mjs | 724 +++++++++++++ .../director/canvas-director-node-panel.tsx | 47 +- .../director/canvas-director-workbench.tsx | 389 +++++-- .../canvas/director/director-viewport.tsx | 655 ++++++++++-- .../director/use-director-save-coordinator.ts | 185 ++++ web/src/components/layout/app-providers.tsx | 15 +- .../director/director-animation-semantics.ts | 136 +++ .../director/director-diagnostics-recorder.ts | 48 + .../canvas/director/director-diagnostics.ts | 181 ++++ .../director/director-gesture-transaction.ts | 114 ++ .../lib/canvas/director/director-placement.ts | 132 +++ .../lib/canvas/director/director-preview.ts | 51 + .../lib/canvas/director/director-recovery.ts | 125 +++ .../canvas/director/director-repro-fixture.ts | 191 ++++ .../canvas/director/director-repro-runtime.ts | 178 ++++ .../lib/canvas/director/director-resources.ts | 143 +++ .../canvas/director/director-save-wiring.ts | 122 +++ web/src/lib/canvas/director/director-save.ts | 443 ++++++++ .../lib/canvas/director/director-session.ts | 16 + web/src/pages/canvas/project.tsx | 3 +- web/src/pages/canvas/use-canvas-director.ts | 19 +- web/src/pages/dev/director-repro-lab.tsx | 203 ++++ web/src/router.tsx | 22 +- .../canvas/use-director-workbench-store.ts | 2 +- web/src/styles/globals.css | 63 ++ web/test/director-bone-precedence.test.ts | 72 ++ .../director-diagnostics-recorder.test.ts | 151 +++ web/test/director-diagnostics.test.ts | 186 ++++ web/test/director-gesture-transaction.test.ts | 106 ++ web/test/director-node-preview.test.ts | 212 ++++ web/test/director-placement.test.ts | 347 ++++++ web/test/director-recovery.test.ts | 348 ++++++ web/test/director-repro-runtime.test.ts | 448 ++++++++ web/test/director-resources.test.ts | 315 ++++++ web/test/director-save-coordinator.test.ts | 995 ++++++++++++++++++ web/test/director-save-wiring.test.ts | 264 +++++ web/test/director-session-authority.test.ts | 202 ++++ .../director-transaction-lifecycle.test.ts | 232 ++++ web/test/director-transform-semantics.test.ts | 164 +++ 42 files changed, 8124 insertions(+), 188 deletions(-) create mode 100644 web/public/canvas/models/director-repro-triangle.gltf create mode 100644 web/scripts/director-p0-chrome-e2e.mjs create mode 100644 web/src/components/canvas/director/use-director-save-coordinator.ts create mode 100644 web/src/lib/canvas/director/director-animation-semantics.ts create mode 100644 web/src/lib/canvas/director/director-diagnostics-recorder.ts create mode 100644 web/src/lib/canvas/director/director-diagnostics.ts create mode 100644 web/src/lib/canvas/director/director-gesture-transaction.ts create mode 100644 web/src/lib/canvas/director/director-placement.ts create mode 100644 web/src/lib/canvas/director/director-preview.ts create mode 100644 web/src/lib/canvas/director/director-recovery.ts create mode 100644 web/src/lib/canvas/director/director-repro-fixture.ts create mode 100644 web/src/lib/canvas/director/director-repro-runtime.ts create mode 100644 web/src/lib/canvas/director/director-resources.ts create mode 100644 web/src/lib/canvas/director/director-save-wiring.ts create mode 100644 web/src/lib/canvas/director/director-save.ts create mode 100644 web/src/lib/canvas/director/director-session.ts create mode 100644 web/src/pages/dev/director-repro-lab.tsx create mode 100644 web/test/director-bone-precedence.test.ts create mode 100644 web/test/director-diagnostics-recorder.test.ts create mode 100644 web/test/director-diagnostics.test.ts create mode 100644 web/test/director-gesture-transaction.test.ts create mode 100644 web/test/director-node-preview.test.ts create mode 100644 web/test/director-placement.test.ts create mode 100644 web/test/director-recovery.test.ts create mode 100644 web/test/director-repro-runtime.test.ts create mode 100644 web/test/director-resources.test.ts create mode 100644 web/test/director-save-coordinator.test.ts create mode 100644 web/test/director-save-wiring.test.ts create mode 100644 web/test/director-session-authority.test.ts create mode 100644 web/test/director-transaction-lifecycle.test.ts create mode 100644 web/test/director-transform-semantics.test.ts diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 119579738..ca5637d96 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -76,3 +76,9 @@ jobs: run: bun run typecheck - name: Run tests run: bun run test + # ubuntu-latest 预装 google-chrome;脚本的解析顺序已覆盖该路径, + # 因此不引入第三方 setup action,也不需要任何 secret。 + - name: Director P0 Chrome E2E + run: | + google-chrome --version || chromium --version + bun run test:director:e2e diff --git a/web/package.json b/web/package.json index d374c8ba8..10077a196 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,8 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "bun run build:bridge && tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/public/canvas/models/director-repro-triangle.gltf b/web/public/canvas/models/director-repro-triangle.gltf new file mode 100644 index 000000000..ce397b768 --- /dev/null +++ b/web/public/canvas/models/director-repro-triangle.gltf @@ -0,0 +1,54 @@ +{ + "asset": { "version": "2.0", "generator": "director-repro-fixture (hand-authored, offline)" }, + "scene": 0, + "scenes": [{ "name": "ReproTriangleScene", "nodes": [0] }], + "nodes": [{ "name": "ReproTriangle", "mesh": 0 }], + "meshes": [ + { + "name": "ReproTriangleMesh", + "primitives": [{ "attributes": { "POSITION": 0 }, "indices": 1, "material": 0, "mode": 4 }] + } + ], + "materials": [ + { + "name": "ReproTriangleMaterial", + "doubleSided": true, + "pbrMetallicRoughness": { + "baseColorFactor": [0.42, 0.58, 0.72, 1.0], + "metallicFactor": 0.0, + "roughnessFactor": 0.85 + } + } + ], + "accessors": [ + { + "name": "positions", + "bufferView": 0, + "byteOffset": 0, + "componentType": 5126, + "count": 3, + "type": "VEC3", + "min": [0.0, 0.0, 0.0], + "max": [1.0, 1.0, 0.0] + }, + { + "name": "indices", + "bufferView": 1, + "byteOffset": 0, + "componentType": 5123, + "count": 3, + "type": "SCALAR" + } + ], + "bufferViews": [ + { "name": "positionView", "buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962 }, + { "name": "indexView", "buffer": 0, "byteOffset": 36, "byteLength": 6, "target": 34963 } + ], + "buffers": [ + { + "name": "embeddedBuffer", + "byteLength": 44, + "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAABAAIAAAA=" + } + ] +} diff --git a/web/scripts/director-p0-chrome-e2e.mjs b/web/scripts/director-p0-chrome-e2e.mjs new file mode 100644 index 000000000..4ca7a41cf --- /dev/null +++ b/web/scripts/director-p0-chrome-e2e.mjs @@ -0,0 +1,724 @@ +/** + * Issue #305 P0 — 真实 Chrome E2E。 + * + * 无新增依赖:只用 Bun/Node 内置 spawn / fetch / WebSocket / fs。 + * 自己启动 Vite DEV 与 headless Chrome,通过 CDP 驱动 /dev/director-repro。 + * 所有等待都有硬超时;任何断言失败 → exit 1。 + * finally 只终止本脚本记录的 PID,只删除本脚本 mkdtemp 创建的 profile。 + */ +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; + +const CHROME_CANDIDATES = [ + process.env.CHROME_BIN, + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/opt/google/chrome/chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", +].filter(Boolean); + +/** 唯一允许出现的浏览器噪声:精确匹配,其他一律判失败。 */ +const ALLOWED_NOISE = ["Warning: [antd: InputNumber] `addonAfter` is deprecated. Please use `Space.Compact` instead.", "Warning: [antd: InputNumber] `addonBefore` is deprecated. Please use `Space.Compact` instead."]; + +/** + * 网络与资源失败绝不放行:复现台必须是真正的同源本地确定性场景。 + * 出现 4xx/5xx/ERR_* 一律判失败,由根因修复,而不是扩大 allowlist。 + */ + +const results = []; +let failures = 0; + +function pass(name, detail = "") { + results.push({ ok: true, name, detail }); + console.log(`PASS ${name}${detail ? " — " + detail : ""}`); +} + +function fail(name, detail = "") { + results.push({ ok: false, name, detail }); + failures += 1; + console.log(`FAIL ${name}${detail ? " — " + detail : ""}`); +} + +function assert(condition, name, detail = "") { + if (condition) pass(name, detail); + else fail(name, detail); + return Boolean(condition); +} + +function resolveChrome() { + for (const candidate of CHROME_CANDIDATES) { + if (candidate && existsSync(candidate)) return candidate; + } + throw new Error("No Chrome binary found. Set CHROME_BIN or install google-chrome/chromium. Tried:\n " + CHROME_CANDIDATES.join("\n ")); +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 启动 Vite DEV,等待 ready 行或 TCP 可连接;超时即抛。 */ +async function launchVite(port) { + const child = spawn("bunx", ["vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + }); + let log = ""; + child.stdout.on("data", (d) => { + log += d.toString(); + }); + child.stderr.on("data", (d) => { + log += d.toString(); + }); + + const deadline = Date.now() + 120000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Vite exited early (code ${child.exitCode}):\n${log}`); + try { + const res = await fetch(`http://127.0.0.1:${port}/dev/director-repro`); + if (res.ok) return child; + } catch { + // not up yet + } + await sleep(500); + } + throw new Error(`Vite did not become ready within 120s:\n${log}`); +} + +/** 启动 headless Chrome 并等待 CDP /json/version;超时即抛。 */ +async function launchChrome(chromePath, cdpPort, profileDir) { + const args = [ + "--headless=new", + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${profileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--window-size=1440,1000", + // 保留 WebGL:不要 --disable-gpu。SwiftShader 提供确定性软件渲染。 + "--enable-unsafe-swiftshader", + "--use-angle=swiftshader", + ]; + if (process.platform === "linux" || process.env.CI) args.push("--no-sandbox", "--disable-dev-shm-usage"); + args.push("about:blank"); + + const child = spawn(chromePath, args, { stdio: ["ignore", "pipe", "pipe"] }); + let log = ""; + child.stdout.on("data", (d) => { + log += d.toString(); + }); + child.stderr.on("data", (d) => { + log += d.toString(); + }); + + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Chrome exited early (code ${child.exitCode}):\n${log}`); + try { + const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`); + if (res.ok) return child; + } catch { + // not up yet + } + await sleep(400); + } + throw new Error(`Chrome CDP did not become ready within 60s:\n${log}`); +} + +/** + * CDP 客户端:id/pending map + 事件收集。 + * 只放行 ALLOWED_NOISE 精确匹配,其他异常/console error/网络失败全部记入 problems。 + */ +async function connectCdp(cdpPort) { + const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json(); + const target = list.find((t) => t.type === "page"); + if (!target) throw new Error("No CDP page target found"); + + const ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("CDP websocket open timeout")), 20000); + ws.addEventListener( + "open", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + ws.addEventListener( + "error", + (e) => { + clearTimeout(timer); + reject(new Error("CDP websocket error: " + String(e?.message || e))); + }, + { once: true }, + ); + }); + + const pending = new Map(); + let nextId = 0; + const problems = []; + const record = (kind, text) => { + const clean = String(text ?? "").trim(); + if (!clean) return; + if (ALLOWED_NOISE.includes(clean)) return; + problems.push({ kind, text: clean }); + }; + + ws.addEventListener("message", (ev) => { + let msg; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(`CDP error: ${JSON.stringify(msg.error)}`)); + else resolve(msg.result); + return; + } + + const p = msg.params; + switch (msg.method) { + case "Runtime.exceptionThrown": + record("exception", p?.exceptionDetails?.exception?.description || p?.exceptionDetails?.text); + break; + case "Log.entryAdded": + if (p?.entry?.level === "error") record("log.error", p.entry.text); + break; + case "Runtime.consoleAPICalled": + if (p?.type === "error") record("console.error", (p.args || []).map((a) => a.value ?? a.description ?? "").join(" ")); + break; + case "Network.loadingFailed": + record("network.failed", `${p?.type || "?"} ${p?.errorText || "?"}`); + break; + case "Network.responseReceived": + if (typeof p?.response?.status === "number" && p.response.status >= 400) { + record("network.status", `${p.response.status} ${p.response.url}`); + } + break; + default: + break; + } + }); + + const send = (method, params = {}) => { + const id = ++nextId; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`CDP timeout: ${method}`)); + } + }, 30000); + }); + }; + + await send("Runtime.enable"); + await send("Page.enable"); + await send("Log.enable"); + await send("Network.enable"); + + const evaluate = async (expression) => { + const r = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) { + throw new Error("evaluate threw: " + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); + } + return r.result.value; + }; + + const poll = async (expression, label, timeout = 15000, interval = 250) => { + const deadline = Date.now() + timeout; + let last; + while (Date.now() < deadline) { + last = await evaluate(expression); + if (last) return true; + await sleep(interval); + } + console.log(` (poll timed out after ${timeout}ms: ${label}, last=${JSON.stringify(last)})`); + return false; + }; + + /** + * 真实鼠标点击:先量出可见目标的中心点,再派发 Input.dispatchMouseEvent。 + * 不用 el.click(),因为那是 untrusted 合成事件,拿不到真实 user gesture。 + */ + const clickPoint = async (locatorExpression, label) => { + const box = await evaluate(`(() => { + const el = ${locatorExpression}; + if (!el) return null; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return null; + el.scrollIntoView({ block: "center", inline: "center" }); + const after = el.getBoundingClientRect(); + return { x: after.left + after.width / 2, y: after.top + after.height / 2 }; + })()`); + if (!box) { + console.log(` (click target not visible: ${label})`); + return false; + } + const point = { x: Math.round(box.x), y: Math.round(box.y), button: "left", buttons: 1 }; + await send("Input.dispatchMouseEvent", { type: "mouseMoved", ...point }); + await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point, clickCount: 1 }); + await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, clickCount: 1 }); + return true; + }; + + const click = (selector) => clickPoint(`document.querySelector(${JSON.stringify(selector)})`, selector); + + const clickText = (text, tag = "button") => clickPoint(`[...document.querySelectorAll(${JSON.stringify(tag)})].find((b) => (b.textContent || "").includes(${JSON.stringify(text)}) && b.getClientRects().length > 0)`, `${tag}:contains(${text})`); + + /** 每个场景都从干净页面开始:诊断缓冲区与 store 都重置。 */ + const navigateFresh = async (url) => { + // 必须在导航前清空:导航后再清会吞掉 bootstrap 阶段的真实异常。 + problems.length = 0; + await send("Page.navigate", { url }); + const ok = await poll(`!!document.querySelector('[data-testid="inject-local-model"]')`, "lab mounted", 60000); + if (!ok) throw new Error("Repro lab did not mount within 60s"); + return true; + }; + + return { send, evaluate, poll, click, clickText, navigateFresh, problems, close: () => ws.close() }; +} + +/** + * 只终止本脚本记录的子进程:先 SIGTERM,有界等待后才对同一 PID SIGKILL。 + * + * 被信号杀死的 Node 子进程 exitCode 仍为 null,只有 signalCode 有值, + * 因此「已停止」必须同时看两者,否则会误判成还在运行并一路等到硬截止。 + */ +async function stopExact(child, name) { + if (!child) return; + const stopped = () => child.exitCode !== null || child.signalCode !== null; + if (stopped()) return; + + try { + child.kill("SIGTERM"); + } catch { + /* already gone */ + } + const deadline = Date.now() + 8000; + while (Date.now() < deadline && !stopped()) await sleep(200); + + if (!stopped()) { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + const hard = Date.now() + 4000; + while (Date.now() < hard && !stopped()) await sleep(200); + } + + if (!stopped()) { + throw new Error(`Failed to stop ${name} (pid=${child.pid}) after SIGTERM and SIGKILL`); + } + console.log(` stopped ${name} (pid=${child.pid}, exit=${child.exitCode}, signal=${child.signalCode})`); +} + +/** 场景 A:复现台 + 工作台基础集成链路(AutoKey 默认、新增、Undo)。 */ +async function smokeWorkbench(cdp, baseUrl) { + console.log("\n=== A. workbench smoke ==="); + await cdp.navigateFresh(`${baseUrl}/dev/director-repro`); + + const initial = await cdp.evaluate(`(() => { + const headings = [...document.querySelectorAll('h2')].map((h) => h.textContent || ""); + return { + snapshot: headings.some((t) => t.includes('环境快照')), + matrix15: headings.some((t) => t.includes('P0 手工复现矩阵(15)')), + injectLocal: !!document.querySelector('[data-testid="inject-local-model"]'), + injectMissing: !!document.querySelector('[data-testid="inject-missing-model"]'), + shellCount: document.querySelectorAll('.director-viewport-shell').length, + }; + })()`); + assert(initial.snapshot, "A1 环境快照 rendered"); + assert(initial.matrix15, "A2 P0 手工复现矩阵(15)rendered"); + assert(initial.injectLocal && initial.injectMissing, "A3 both inject buttons present"); + assert(initial.shellCount === 0, "A4 workbench closed initially", `shellCount=${initial.shellCount}`); + + const opened = await cdp.click('[data-testid="toggle-workbench"]'); + if (!opened) throw new Error("A: toggle-workbench not clickable"); + const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000); + assert(hasCanvas, "A5 real canvas present in viewport shell"); + + const autoKey = await cdp.evaluate(`document.querySelector('button[title="自动关键帧"]')?.getAttribute('aria-pressed') ?? null`); + assert(autoKey === "false", "A6 AutoKey defaults to aria-pressed=false", `got ${JSON.stringify(autoKey)}`); + + const addedCube = await cdp.click('[aria-label="添加立方体"]'); + if (!addedCube) throw new Error("A: 添加立方体 button not clickable"); + const cubeAppeared = await cdp.poll(`!!document.querySelector('[aria-label="删除立方体"]')`, "cube row", 20000); + assert(cubeAppeared, "A7 added cube appears in object list"); + + const undone = await cdp.click('[aria-label="撤销"]'); + if (!undone) throw new Error("A: 撤销 button not clickable"); + const cubeGone = await cdp.poll(`!document.querySelector('[aria-label="删除立方体"]')`, "cube removed by undo", 20000); + assert(cubeGone, "A8 Undo removes the added cube"); + + // 场景结束前必须真实关闭:下一个场景要重新导航,不能靠忽略 beforeunload 绕过未保存态。 + const closed = await cdp.click('[aria-label="关闭导演台"]'); + if (!closed) throw new Error("A: 关闭导演台 button not clickable"); + const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 30000); + assert(shellGone, "A9 workbench closed cleanly before leaving scenario A"); + + assert(cdp.problems.length === 0, "A10 no browser problems in scenario A", JSON.stringify(cdp.problems)); +} + +/** + * 场景 B:本地 triangle glTF。 + * 判据是集成层面的稳定窗口:5s 内不出现任何失败态且 canvas 持续可用。 + * 名称出现只证明对象在场景里,不等于 loader ready,因此不作为 ready 断言。 + */ +async function localModel(cdp, baseUrl) { + console.log("\n=== B. local triangle model ==="); + await cdp.navigateFresh(`${baseUrl}/dev/director-repro`); + + const injected = await cdp.click('[data-testid="inject-local-model"]'); + if (!injected) throw new Error("B: inject-local-model not clickable"); + const counted = await cdp.poll(`(document.querySelector('[data-testid="object-count"]')?.textContent || "").includes('4')`, "object count 4", 15000); + assert(counted, "B1 local model injected into scene"); + + const opened = await cdp.click('[data-testid="toggle-workbench"]'); + if (!opened) throw new Error("B: toggle-workbench not clickable"); + + const rowReady = await cdp.poll(`!!document.querySelector('[aria-label="删除本地模型 repro triangle"]')`, "model row", 30000); + assert(rowReady, "B2 local model row present in object list"); + const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000); + assert(hasCanvas, "B3 real canvas present"); + + await sleep(5000); + + const stable = await cdp.evaluate(`(() => { + const t = document.body.innerText || ""; + const c = document.querySelector('.director-viewport-shell canvas'); + return { + modelFailed: t.includes('个 3D 模型加载失败'), + retryLoad: [...document.querySelectorAll('.director-viewport-notice button')].some((b) => (b.textContent || "").includes('重试加载') && b.getClientRects().length > 0), + renderFailed: t.includes('3D 视口渲染失败'), + contextLost: t.includes('3D 显示上下文已丢失'), + canvasUsable: !!c && c.clientWidth > 0 && c.clientHeight > 0, + }; + })()`); + assert(!stable.modelFailed, "B4 no model-load-failed notice", JSON.stringify(stable)); + assert(!stable.retryLoad, "B5 no retry-load affordance"); + assert(!stable.renderFailed, "B6 no viewport render failure"); + assert(!stable.contextLost, "B7 no WebGL context-lost notice"); + assert(stable.canvasUsable, "B8 5s 稳定窗口内无失败且 canvas 持续可用"); + + assert(cdp.problems.length === 0, "B9 no browser problems in scenario B", JSON.stringify(cdp.problems)); +} + +/** + * 场景 C:缺失模型失败 → 用户重试 → 第二轮失败。 + * 通过「提示消失再重新出现」证明重试真的重跑了加载,而不是命中陈旧提示。 + */ +async function missingRetry(cdp, baseUrl) { + console.log("\n=== C. missing model failure and retry ==="); + await cdp.navigateFresh(`${baseUrl}/dev/director-repro`); + + const injected = await cdp.click('[data-testid="inject-missing-model"]'); + if (!injected) throw new Error("C: inject-missing-model not clickable"); + const counted = await cdp.poll(`(document.querySelector('[data-testid="object-count"]')?.textContent || "").includes('4')`, "object count 4", 15000); + assert(counted, "C1 missing model injected into scene"); + + const opened = await cdp.click('[data-testid="toggle-workbench"]'); + if (!opened) throw new Error("C: toggle-workbench not clickable"); + + const failed = await cdp.poll(`/个 3D 模型加载失败/.test(document.body.innerText || "")`, "load-failed notice", 40000); + assert(failed, "C2 model-load-failed notice appears"); + const retryVisible = await cdp.poll(`[...document.querySelectorAll('button')].some((b) => (b.textContent || "").includes('重试加载'))`, "retry affordance", 20000); + assert(retryVisible, "C3 actionable 重试加载 affordance present"); + + const clickedRetry = await cdp.clickText("重试加载"); + if (!clickedRetry) throw new Error("C: 重试加载 button not clickable"); + + const noticeCleared = await cdp.poll(`!/个 3D 模型加载失败/.test(document.body.innerText || "")`, "notice cleared after retry", 20000); + assert(noticeCleared, "C4 notice clears when retry restarts the load"); + const failedAgain = await cdp.poll(`/个 3D 模型加载失败/.test(document.body.innerText || "")`, "second-round failure", 40000); + assert(failedAgain, "C5 second-round failure after retry"); + + const closed = await cdp.click('[aria-label="关闭导演台"]'); + if (!closed) throw new Error("C: 关闭导演台 button not clickable"); + const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 20000); + assert(shellGone, "C6 workbench closed cleanly"); + + const refreshed = await cdp.click('[data-testid="refresh-events"]'); + if (!refreshed) throw new Error("C: refresh-events not clickable"); + + const hasRetryCode = await cdp.poll(`(document.body.innerText || "").includes('DIRECTOR_MODEL_LOAD_RETRY')`, "retry diagnostic", 20000); + assert(hasRetryCode, "C7 DIRECTOR_MODEL_LOAD_RETRY recorded"); + const twoFailures = await cdp.poll(`((document.body.innerText || "").match(/DIRECTOR_MODEL_LOAD_FAILED/g) || []).length >= 2`, "two load failures", 20000); + const failCount = await cdp.evaluate(`((document.body.innerText || "").match(/DIRECTOR_MODEL_LOAD_FAILED/g) || []).length`); + assert(twoFailures, "C8 DIRECTOR_MODEL_LOAD_FAILED recorded at least twice", `count=${failCount}`); + + assert(cdp.problems.length === 0, "C9 no browser problems in scenario C", JSON.stringify(cdp.problems)); +} + +/** + * 场景 D:加载中删除。 + * 用真实网络节流把 GLB 拉长到仍在飞行中,再立刻删除对象; + * 恢复网络后必须没有晚到回流,也不能为已删除对象记任何失败诊断。 + */ +async function deleteWhileLoading(cdp, baseUrl) { + console.log("\n=== D. delete while loading (throttled) ==="); + await cdp.navigateFresh(`${baseUrl}/dev/director-repro`); + + try { + await cdp.send("Network.emulateNetworkConditions", { + offline: false, + latency: 3000, + downloadThroughput: 20000, + uploadThroughput: 20000, + connectionType: "cellular3g", + }); + + const injected = await cdp.click('[data-testid="inject-local-model"]'); + if (!injected) throw new Error("D: inject-local-model not clickable"); + const opened = await cdp.click('[data-testid="toggle-workbench"]'); + if (!opened) throw new Error("D: toggle-workbench not clickable"); + + const rowReady = await cdp.poll(`!!document.querySelector('[aria-label="删除本地模型 repro triangle"]')`, "model row", 30000); + assert(rowReady, "D1 model row present while load still in flight"); + + const deleted = await cdp.click('[aria-label="删除本地模型 repro triangle"]'); + if (!deleted) throw new Error("D: delete button not clickable"); + const gone = await cdp.poll(`!(document.body.innerText || "").includes('本地模型 repro triangle')`, "name removed", 20000); + assert(gone, "D2 object removed while its load was in flight"); + } finally { + await cdp.send("Network.emulateNetworkConditions", { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + } + + await sleep(5000); + + const settled = await cdp.evaluate(`(() => { + const t = document.body.innerText || ""; + const c = document.querySelector('.director-viewport-shell canvas'); + return { + nameBack: t.includes('本地模型 repro triangle'), + modelFailed: t.includes('个 3D 模型加载失败'), + retryLoad: [...document.querySelectorAll('.director-viewport-notice button')].some((b) => (b.textContent || "").includes('重试加载') && b.getClientRects().length > 0), + renderFailed: t.includes('3D 视口渲染失败'), + contextLost: t.includes('3D 显示上下文已丢失'), + canvasUsable: !!c && c.clientWidth > 0 && c.clientHeight > 0, + }; + })()`); + assert(!settled.nameBack, "D3 no late-arriving reflow of the deleted object", JSON.stringify(settled)); + assert(!settled.modelFailed && !settled.retryLoad, "D4 no failure/retry surfaced for deleted object"); + assert(!settled.renderFailed && !settled.contextLost, "D5 viewport stayed healthy"); + assert(settled.canvasUsable, "D6 canvas still usable after in-flight delete"); + + const closed = await cdp.click('[aria-label="关闭导演台"]'); + if (!closed) throw new Error("D: 关闭导演台 button not clickable"); + const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 20000); + assert(shellGone, "D7 workbench closed cleanly"); + + const refreshed = await cdp.click('[data-testid="refresh-events"]'); + if (!refreshed) throw new Error("D: refresh-events not clickable"); + await sleep(600); + + const localDiag = await cdp.evaluate(`(() => [...document.querySelectorAll('.ant-table-tbody tr.ant-table-row')] + .map((r) => [...r.querySelectorAll('td')].map((td) => td.innerText).join(" ")) + .filter((row) => row.includes('repro-model-local') && (row.includes('DIRECTOR_MODEL_LOAD_FAILED') || row.includes('DIRECTOR_MODEL_ADOPT_FAILED'))))()`); + assert(localDiag.length === 0, "D8 no LOAD_FAILED/ADOPT_FAILED for the deleted object", JSON.stringify(localDiag)); + + assert(cdp.problems.length === 0, "D9 no browser problems in scenario D", JSON.stringify(cdp.problems)); +} + +/** + * 场景 E:真实 WebGL 上下文丢失与恢复。 + * 用 WEBGL_lose_context 扩展驱动真实 GPU 事件,不伪造 DOM 状态。 + */ +async function webglLossRestore(cdp, baseUrl) { + console.log("\n=== E. WebGL context lost and restored ==="); + await cdp.navigateFresh(`${baseUrl}/dev/director-repro`); + + const opened = await cdp.click('[data-testid="toggle-workbench"]'); + if (!opened) throw new Error("E: toggle-workbench not clickable"); + const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000); + assert(hasCanvas, "E1 real canvas present before context loss"); + + // 真实就绪门:capture context 已登记且未 lost,说明监听器已安装。 + // 早于此触发 loseContext 会让事件落在监听器安装之前。 + const rendererReady = await cdp.poll(`document.querySelector('.director-viewport-shell')?.getAttribute('data-renderer-ready') === 'true'`, "renderer ready", 30000); + assert(rendererReady, "E2 capture renderer registered before context loss"); + + const prepared = await cdp.evaluate(`(() => { + const canvas = document.querySelector('.director-viewport-shell canvas'); + if (!canvas) return false; + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl) return false; + const ext = gl.getExtension('WEBGL_lose_context'); + if (!ext || typeof ext.loseContext !== 'function' || typeof ext.restoreContext !== 'function') return false; + window.__directorE2ELoseContext = ext; + return true; + })()`); + if (!prepared) throw new Error("E: WEBGL_lose_context extension unavailable — cannot drive a real context loss"); + assert(prepared, "E3 WEBGL_lose_context extension acquired from live context"); + + await cdp.evaluate(`(() => { window.__directorE2ELoseContext.loseContext(); return true; })()`); + const lostNotice = await cdp.poll(`(document.body.innerText || "").includes('3D 显示上下文已丢失')`, "context-lost notice", 30000); + assert(lostNotice, "E4 context-lost notice surfaced in DOM"); + + await cdp.evaluate(`(() => { window.__directorE2ELoseContext.restoreContext(); return true; })()`); + const restored = await cdp.poll(`!(document.body.innerText || "").includes('3D 显示上下文已丢失')`, "context-lost notice cleared", 30000); + assert(restored, "E5 context-lost notice cleared after restore"); + const canvasUsable = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0 && c.clientHeight > 0; })()`, "canvas usable after restore", 20000); + assert(canvasUsable, "E6 canvas remains usable after restore"); + + const closed = await cdp.click('[aria-label="关闭导演台"]'); + if (!closed) throw new Error("E: 关闭导演台 button not clickable"); + const shellGone = await cdp.poll(`document.querySelectorAll('.director-viewport-shell').length === 0`, "workbench closed", 20000); + assert(shellGone, "E7 workbench closed cleanly"); + + const refreshed = await cdp.click('[data-testid="refresh-events"]'); + if (!refreshed) throw new Error("E: refresh-events not clickable"); + const hasLost = await cdp.poll(`(document.body.innerText || "").includes('DIRECTOR_VIEWPORT_CONTEXT_LOST')`, "lost diagnostic", 20000); + assert(hasLost, "E8 DIRECTOR_VIEWPORT_CONTEXT_LOST recorded"); + const hasRestored = await cdp.poll(`(document.body.innerText || "").includes('DIRECTOR_VIEWPORT_CONTEXT_RESTORED')`, "restored diagnostic", 20000); + assert(hasRestored, "E9 DIRECTOR_VIEWPORT_CONTEXT_RESTORED recorded"); + + assert(cdp.problems.length === 0, "E10 no browser problems in scenario E", JSON.stringify(cdp.problems)); +} + +/** + * 场景 F:强制保存失败 → 头部错误态与重试入口 → 关闭走确认保护。 + * 选择「留在导演台」后 workbench 必须仍然存在(onClose 不得被调用)。 + */ +async function saveFailureCloseGuard(cdp, baseUrl) { + console.log("\n=== F. save failure and close guard ==="); + await cdp.navigateFresh(`${baseUrl}/dev/director-repro`); + + const toggledFailure = await cdp.click('[data-testid="force-save-failure"]'); + if (!toggledFailure) throw new Error("F: force-save-failure switch not clickable"); + const failureOn = await cdp.poll(`document.querySelector('[data-testid="force-save-failure"]')?.getAttribute('aria-checked') === 'true'`, "failure switch on", 15000); + assert(failureOn, "F1 forced save failure enabled"); + + const opened = await cdp.click('[data-testid="toggle-workbench"]'); + if (!opened) throw new Error("F: toggle-workbench not clickable"); + const hasCanvas = await cdp.poll(`(() => { const c = document.querySelector('.director-viewport-shell canvas'); return !!c && c.clientWidth > 0; })()`, "canvas", 40000); + assert(hasCanvas, "F2 workbench open with real canvas"); + + // canonical 改动:dock 新增立方体会走 commit → coordinator.edit → flush(被强制失败)。 + const addedCube = await cdp.click('[aria-label="添加立方体"]'); + if (!addedCube) throw new Error("F: 添加立方体 button not clickable"); + + const errorState = await cdp.poll(`(document.body.innerText || "").includes('保存失败')`, "save failure header", 40000); + assert(errorState, "F3 header surfaces 保存失败 after forced flush failure"); + const retryVisible = await cdp.poll(`[...document.querySelectorAll('button')].some((b) => (b.textContent || "").includes('重试保存'))`, "retry save affordance", 20000); + assert(retryVisible, "F4 actionable 重试保存 affordance present"); + + const closeClicked = await cdp.click('[aria-label="关闭导演台"]'); + if (!closeClicked) throw new Error("F: 关闭导演台 button not clickable"); + + const modalShown = await cdp.poll(`!!document.querySelector('.ant-modal-confirm') && (document.body.innerText || "").includes('留在导演台')`, "close confirm modal", 40000); + assert(modalShown, "F5 close is guarded by a confirm dialog, not silent exit"); + + const stayClicked = await cdp.clickText("留在导演台"); + if (!stayClicked) throw new Error("F: 留在导演台 button not clickable"); + const modalGone = await cdp.poll(`!document.querySelector('.ant-modal-confirm')`, "modal dismissed", 20000); + assert(modalGone, "F6 confirm dialog dismissed after choosing 留在导演台"); + + await sleep(1000); + const stillOpen = await cdp.evaluate(`document.querySelectorAll('.director-viewport-shell').length`); + assert(stillOpen === 1, "F7 workbench remains open after choosing 留在导演台", `shellCount=${stillOpen}`); + + assert(cdp.problems.length === 0, "F8 no browser problems in scenario F", JSON.stringify(cdp.problems)); +} + +async function main() { + const chromePath = resolveChrome(); + console.log(`Chrome binary: ${chromePath}`); + + const vitePort = await freePort(); + const cdpPort = await freePort(); + const baseUrl = `http://127.0.0.1:${vitePort}`; + const profileDir = mkdtempSync(join(tmpdir(), "director-p0-e2e-")); + + let vite = null; + let chrome = null; + let cdp = null; + + try { + console.log(`Starting Vite on ${baseUrl} ...`); + vite = await launchVite(vitePort); + console.log(` vite pid=${vite.pid}`); + + console.log(`Starting Chrome with CDP on 127.0.0.1:${cdpPort} ...`); + chrome = await launchChrome(chromePath, cdpPort, profileDir); + console.log(` chrome pid=${chrome.pid}, profile=${profileDir}`); + + cdp = await connectCdp(cdpPort); + console.log(" CDP connected (Runtime, Page, Log, Network enabled)"); + + for (const scenario of [smokeWorkbench, localModel, missingRetry, deleteWhileLoading, webglLossRestore, saveFailureCloseGuard]) { + try { + await scenario(cdp, baseUrl); + } catch (error) { + fail(`${scenario.name} threw`, String(error?.message || error)); + } + } + } finally { + try { + cdp?.close(); + } catch { + /* socket already closed */ + } + // 三个清理步骤互不阻塞:任一失败都记为断言失败(最终 exit 1),但不吞掉其余清理。 + try { + await stopExact(chrome, "chrome"); + } catch (error) { + fail("cleanup: stop chrome", String(error?.message || error)); + } + try { + await stopExact(vite, "vite"); + } catch (error) { + fail("cleanup: stop vite", String(error?.message || error)); + } + try { + rmSync(profileDir, { recursive: true, force: true }); + console.log(` removed profile ${profileDir}`); + } catch (error) { + fail("cleanup: remove profile", `${profileDir}: ${String(error?.message || error)}`); + } + } + + const passed = results.filter((r) => r.ok).length; + console.log("\n================ SUMMARY ================"); + console.log(`assertions: ${passed} passed, ${failures} failed, ${results.length} total`); + for (const r of results.filter((x) => !x.ok)) console.log(` FAILED: ${r.name}${r.detail ? " — " + r.detail : ""}`); + console.log("========================================="); + + if (failures > 0) { + console.error(`\nDirector P0 Chrome E2E FAILED (${failures} assertion(s)).`); + process.exit(1); + } + console.log("\nDirector P0 Chrome E2E PASSED."); +} + +main().catch((error) => { + console.error("\nDirector P0 Chrome E2E crashed:", error?.stack || error); + process.exit(1); +}); diff --git a/web/src/components/canvas/director/canvas-director-node-panel.tsx b/web/src/components/canvas/director/canvas-director-node-panel.tsx index 1ea1a4e63..68ed4742a 100644 --- a/web/src/components/canvas/director/canvas-director-node-panel.tsx +++ b/web/src/components/canvas/director/canvas-director-node-panel.tsx @@ -1,14 +1,21 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Box, Camera, Clapperboard, Lightbulb, LockKeyhole, Move3d } from "lucide-react"; -import { canvasThemes } from "@/lib/canvas-theme"; +import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; +import { gateDirectorPreviewFailure, resolveDirectorActiveShot, resolveDirectorPreviewSource, type DirectorNodeContentReader } from "@/lib/canvas/director/director-preview"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; import type { DirectorScene } from "@/types/director"; -export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, professional = true }: { node: CanvasNodeData; scene: DirectorScene | null; previewUrl?: string; onOpen: () => void; professional?: boolean }) { +export function CanvasDirectorNodePanel({ node, scene, readNodeContent, onOpen, professional = true }: { node: CanvasNodeData; scene: DirectorScene | null; readNodeContent: DirectorNodeContentReader; onOpen: () => void; professional?: boolean }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const shot = scene?.shots?.find((item) => item.id === node.metadata?.directorShotId) || scene?.shots?.[0]; + const shot = resolveDirectorActiveShot(scene, node.metadata?.directorShotId); + // 记录「失败的那个 URL」而非布尔量:同一个坏 URL 不再反复渲染,换成另一个 URL 时自动重试。 + const [failedUrl, setFailedUrl] = useState(null); + const preview = gateDirectorPreviewFailure( + resolveDirectorPreviewSource({ scene, shot, previewNodeId: node.metadata?.directorPreviewNodeId, readNodeContent }), + failedUrl, + ); return (
@@ -27,14 +34,16 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe type="button" data-canvas-no-zoom className="group relative min-h-0 flex-1 cursor-pointer overflow-hidden rounded-lg border text-left focus-visible:outline-none focus-visible:ring-2 disabled:cursor-default" - style={{ background: scene?.background || theme.canvas.background, borderColor: theme.node.stroke }} + style={{ background: theme.node.fill, borderColor: theme.node.stroke }} title={professional ? "打开 3D 导演台" : "切换到专业模式后编辑导演台"} disabled={!professional} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} > - {previewUrl ? {`${node.title} : } + {preview.kind === "image" + ? {`${node.metadata?.workflowTitle setFailedUrl(preview.url)} /> + : } {professional ? <>进入导演台 : <>专业模式可编辑} @@ -47,21 +56,19 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe ); } -function SceneSchematic({ scene }: { scene: DirectorScene | null }) { +/** + * 诚实空态/准备态:不绘制地面、地平线、机位或任何伪 3D 物体。 + * 颜色全部走画布主题 token;层级靠字号/字重区分,不靠降低对比度。 + */ +function DirectorPreviewState({ kind, theme }: { kind: "loading" | "empty"; theme: CanvasTheme }) { + const loading = kind === "loading"; return ( -
-
-
-
- {(scene?.objects || []).slice(0, 6).map((object, index) => { - const left = 48 + object.transform.position[0] * 12; - const bottom = 25 + object.transform.position[2] * 7 + Math.max(0, object.transform.position[1]) * 4; - const height = object.primitive === "character" ? 42 : 22 + Math.min(18, object.transform.scale[1] * 8); - return ; - })} - - - {scene ? "场景预览" : "正在准备场景"} +
+ + + + {loading ? "正在准备场景" : "尚未生成预览"} + {loading ? "场景数据加载完成后显示" : "进入导演台并回写构图后显示"}
); } diff --git a/web/src/components/canvas/director/canvas-director-workbench.tsx b/web/src/components/canvas/director/canvas-director-workbench.tsx index 4aa627374..c82ca5e3e 100644 --- a/web/src/components/canvas/director/canvas-director-workbench.tsx +++ b/web/src/components/canvas/director/canvas-director-workbench.tsx @@ -1,6 +1,6 @@ import { App, Button, ColorPicker, Dropdown, Input, InputNumber, Select, Slider, Switch } from "antd"; import type { MenuProps } from "antd"; -import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react"; +import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, RotateCcw, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react"; import { nanoid } from "nanoid"; import { Euler, Quaternion } from "three"; @@ -11,7 +11,14 @@ import { DirectorViewportDock } from "@/components/canvas/director/director-view import { DirectorSequencer } from "@/components/canvas/director/director-sequencer"; import { canvasThemes } from "@/lib/canvas-theme"; import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler"; -import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorPoseLabel, touchDirectorScene, upsertDirectorBoneKeyframe, upsertDirectorKeyframe } from "@/lib/canvas/director/director-scene"; +import { resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "@/lib/canvas/director/director-animation-semantics"; +import { createDirectorTransaction, installDirectorTerminalListeners, type DirectorTransaction } from "@/lib/canvas/director/director-gesture-transaction"; +import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder"; +import { resolveDirectorPlacement, resolveDirectorPlacementAnchor } from "@/lib/canvas/director/director-placement"; +import { shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session"; +import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorPoseLabel, interpolateDirectorTransform, touchDirectorScene, upsertDirectorBoneKeyframe } from "@/lib/canvas/director/director-scene"; +import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring"; +import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator"; import { uploadMediaFile } from "@/services/file-storage"; import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store"; import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store"; @@ -19,8 +26,8 @@ import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRig, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director"; -export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise; onDeleteImageNode: (nodeId: string) => void }) { - const { message } = App.useApp(); +export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise; onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise }) { + const { message, modal } = App.useApp(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const viewportRef = useRef(null); const modelInputRef = useRef(null); @@ -54,20 +61,132 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh const addAsset = useAssetStore((state) => state.addAsset); const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]); + const draftRef = useRef(null); + const stagedRef = useRef(null); + const initializedSceneIdRef = useRef(null); + const onChangeRef = useRef(onChange); + const onFlushRef = useRef(onFlush); + useEffect(() => { onChangeRef.current = onChange; onFlushRef.current = onFlush; }, [onChange, onFlush]); + + const closingRef = useRef(false); + const recoveryPromptedRef = useRef(null); + const [retrying, setRetrying] = useState(false); + + // 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。 + const saveController = useDirectorSaveCoordinator({ + sceneId: scene?.id ?? null, + initialScene: scene, + persistScene: (next) => onChangeRef.current(next), + flushPersistence: () => onFlushRef.current?.(), + }); + const saveControllerRef = useRef(saveController); + saveControllerRef.current = saveController; + const saveIndicator = describeDirectorSaveStatus(saveController.progress); + + const retrySave = async () => { + setRetrying(true); + try { + if (await saveController.retry()) { + recordDirectorDiagnostic("DIRECTOR_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true }); + message.success("已保存到项目"); + return; + } + // 只有真的存在合法本地候选才敢说草稿已保留。 + const draftStored = Boolean(saveController.restoreCandidate()); + recordDirectorDiagnostic("DIRECTOR_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true }); + if (!draftStored) recordDirectorDiagnostic("DIRECTOR_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision }); + if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试"); + else message.error("远端和本地都未保存,请不要关闭导演台并继续重试"); + } finally { + setRetrying(false); + } + }; + + const writeDraft = useCallback((next: DirectorScene | null) => { + draftRef.current = next; + setDraft(next); + }, []); + + /** + * 仅镜像当前 draft 到项目 directorScenes,不产生 canonical 提交。 + * 用于取消预览、idle pagehide、卸载兜底 —— 这些都不是新的用户改动。 + */ + const mirrorDraft = useCallback(() => { + const current = draftRef.current; + if (!current || initializedSceneIdRef.current !== current.id) return; + onChangeRef.current(current); + }, []); + + /** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */ + const commitDraft = useCallback(() => { + const current = draftRef.current; + if (!current || initializedSceneIdRef.current !== current.id) return; + saveControllerRef.current?.commitScene(current); + onChangeRef.current(current); + }, []); + + const writeAndPublish = useCallback((next: DirectorScene) => { + writeDraft(next); + saveControllerRef.current?.commitScene(next); + onChangeRef.current(next); + }, [writeDraft]); + + // 会话初始化只认 scene id:同 id 的父级镜像回流不得重建会话。 useEffect(() => { if (!open || !scene) return; + if (!shouldReinitializeDirectorSession({ initializedSceneId: initializedSceneIdRef.current, nextSceneId: scene.id })) return; const next = structuredClone(scene); next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 })); - setDraft(next); + stagedRef.current?.end("cancel"); + initializedSceneIdRef.current = scene.id; + writeDraft(next); setHistory([]); setFuture([]); resetWorkbench(); - }, [open, resetWorkbench, scene]); + }, [open, resetWorkbench, scene, writeDraft]); + + // 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。 + useEffect(() => { + if (!open || !scene) return; + if (recoveryPromptedRef.current === scene.id) return; + recoveryPromptedRef.current = scene.id; + + const controller = saveControllerRef.current; + const candidate = controller?.restoreCandidate() ?? null; + if (!controller || !candidate) return; + if (!shouldOfferDirectorDraftRecovery({ candidate, authoritativeScene: scene })) return; + + modal.confirm({ + title: "发现未保存的本地草稿", + content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`, + okText: "恢复草稿", + cancelText: "放弃草稿", + closable: false, + mask: { closable: false }, + keyboard: false, + onOk: () => { + if (!controller.restoreDraft(candidate)) { + message.error("草稿恢复失败,已保留当前场景"); + return; + } + writeDraft(candidate.scene); + message.success("已恢复本地草稿并写回项目"); + }, + onCancel: () => { + if (controller.discardDraft()) message.success("已放弃本地草稿"); + else message.error("草稿删除失败,下次打开可能仍会提示"); + }, + }); + }, [message, modal, open, scene, writeDraft]); const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null; const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null; const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null; const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null; + // 写入关键帧的目的时间用吸附值;取值/显示/手势起点一律用 raw playhead, + // 否则处在两个帧格之间时 AutoKey OFF 的增量会从错误起点计算而产生漂移。 + const snappedPlayhead = snapDirectorTime(playhead, activeShot?.fps || 24); + const selectedObjectRendered = selectedObject ? interpolateDirectorTransform(selectedObject.transform, selectedObject.keyframes, playhead) : null; useEffect(() => { if (!playing || !activeShot) return; @@ -85,30 +204,142 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh }, [activeShot, playing, setPlayhead]); const commit = useCallback((updater: (current: DirectorScene) => DirectorScene) => { - setDraft((current) => { - if (!current) return current; - const next = touchDirectorScene(updater(current)); - setHistory((items) => [...items.slice(-49), structuredClone(current)]); - setFuture([]); - return next; - }); - }, []); + // 普通提交前先终结暂存手势,避免新动作消费旧 base。 + stagedRef.current?.end("commit"); + const current = draftRef.current; + if (!current) return; + setHistory((items) => [...items.slice(-49), structuredClone(current)]); + setFuture([]); + writeAndPublish(touchDirectorScene(updater(current))); + }, [writeAndPublish]); - const replaceWithoutHistory = useCallback((updater: (current: DirectorScene) => DirectorScene) => setDraft((current) => (current ? touchDirectorScene(updater(current)) : current)), []); + /** 暂存型手势(数值滑杆):实时预览写草稿但不产生历史,也不镜像到项目。 */ + const stagedTransaction = useMemo(() => createDirectorTransaction({ + read: () => draftRef.current, + // 取消:恢复快照且绝不发布被取消的值。 + restore: (snapshot) => writeDraft(snapshot), + commit: (from) => { + setHistory((items) => [...items.slice(-49), from]); + setFuture([]); + // 手势成功终态是真实 canonical 提交。 + commitDraft(); + }, + setActive: () => undefined, + }), [commitDraft, writeDraft]); + stagedRef.current = stagedTransaction; + + const stageGesture = useCallback((updater: (current: DirectorScene) => DirectorScene) => { + const current = draftRef.current; + if (!current) return; + stagedTransaction.begin(); + writeDraft(touchDirectorScene(updater(current))); + }, [stagedTransaction, writeDraft]); + + /** 无历史但持久的变化(标题、rig/motionClips 等)同样要镜像。 */ + const replaceWithoutHistory = useCallback((updater: (current: DirectorScene) => DirectorScene) => { + const current = draftRef.current; + if (current) writeAndPublish(touchDirectorScene(updater(current))); + }, [writeAndPublish]); + + // 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。 + useEffect(() => installDirectorTerminalListeners(stagedTransaction, { + window, + document, + isHidden: () => document.visibilityState === "hidden", + }), [stagedTransaction]); + + // 切换选择/骨骼、关闭或卸载前必须先终止旧手势,不能让新选择消费旧 base。 + useEffect(() => () => stagedTransaction.end("cancel"), [open, selectedBone, selectedObjectId, stagedTransaction]); + + // 离开页面:active 预览由 end("commit") 完成真实提交,idle 只镜像;落盘统一交给 controller。 + useEffect(() => { + const onPageHide = () => { + if (stagedTransaction.active()) stagedTransaction.end("commit"); + else mirrorDraft(); + // 只调用 handlePageHide:dirty 时它自己会 persist + flush,组件再叠一次就是重复落盘。 + void saveControllerRef.current?.handlePageHide(); + }; + // 异步 flush 不可能阻塞卸载:这里只同步声明「仍有未确认改动」,让浏览器自己弹保护。 + const onBeforeUnload = (event: BeforeUnloadEvent) => { + const controller = saveControllerRef.current; + if (!controller || !shouldBlockDirectorUnload(controller.progress)) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("pagehide", onPageHide); + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [mirrorDraft, stagedTransaction]); + + // 卸载兜底:只把最新 draft 镜像回项目,不制造新的 canonical revision。 + useEffect(() => () => { + stagedRef.current?.end("cancel"); + mirrorDraft(); + }, [mirrorDraft]); const undo = () => { const previous = history.at(-1); if (!previous || !draft) return; setHistory((items) => items.slice(0, -1)); setFuture((items) => [structuredClone(draft), ...items].slice(0, 50)); - setDraft(previous); + writeAndPublish(previous); }; const redo = () => { const next = future[0]; if (!next || !draft) return; setFuture((items) => items.slice(1)); setHistory((items) => [...items, structuredClone(draft)].slice(-50)); - setDraft(next); + writeAndPublish(next); + }; + + /** + * 关闭统一入口:取消未结束的预览、镜像当前 draft,再按 prepareClose 决策是否真的退出。 + * 这里只镜像不提交:取消预览不是新的 canonical 变化。 + */ + const closeWorkbench = () => { + if (closingRef.current) return; + closingRef.current = true; + stagedTransaction.end("cancel"); + mirrorDraft(); + void (async () => { + let decision; + try { + decision = resolveDirectorCloseOutcome(await saveController.prepareClose()); + } catch { + message.error("关闭前的保存检查失败,已留在导演台"); + closingRef.current = false; + return; + } + + if (decision.kind === "close") { + onClose(); + return; + } + if (decision.kind === "blocked") { + recordDirectorDiagnostic("DIRECTOR_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored }); + message.error(decision.message); + closingRef.current = false; + return; + } + + // 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。 + modal.confirm({ + title: "远端保存失败", + content: decision.message, + okText: "仍然离开", + cancelText: "留在导演台", + closable: false, + mask: { closable: false }, + keyboard: false, + onOk: () => onClose(), + onCancel: () => { + closingRef.current = false; + }, + }); + })(); }; const updateObject = (id: string, patch: Partial) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) })); @@ -139,24 +370,32 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh })); }; - const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => { - const object = createDirectorObject(primitive, name); - commit((current) => ({ ...current, objects: [...current.objects, object] })); + /** + * 所有「新增到场景」的唯一入口。 + * 在 commit 内读取一次 placement intent:因此模型上传等异步路径拿到的是 + * 「点击添加完成那一刻」的意图,而不是发起上传时捕获的过时坐标。 + * 锚点只提供 XZ,Y 严格保留构造器给定值,再交给 resolveDirectorPlacement 做碰撞避让。 + */ + const addObject = (object: DirectorObject) => { + commit((current) => { + const anchored = resolveDirectorPlacementAnchor({ + intent: viewportRef.current?.readPlacementIntent() ?? null, + fallback: object.transform.position, + }); + const position = resolveDirectorPlacement({ object: { ...object, transform: { ...object.transform, position: anchored } }, existing: current.objects }); + return { ...current, objects: [...current.objects, { ...object, transform: { ...object.transform, position } }] }; + }); setSelectedObjectId(object.id); }; + const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => addObject(createDirectorObject(primitive, name)); + const addActor = () => { const actorCount = draft?.objects.filter((item) => item.kind === "actor").length || 0; - const actor = createDirectorActor(`演员 ${actorCount + 1}`, [actorCount * 0.8, 0, 0], DIRECTOR_ACTOR_COLORS[actorCount % DIRECTOR_ACTOR_COLORS.length]); - commit((current) => ({ ...current, objects: [...current.objects, actor] })); - setSelectedObjectId(actor.id); + addObject(createDirectorActor(`演员 ${actorCount + 1}`, [0, 0, 0], DIRECTOR_ACTOR_COLORS[actorCount % DIRECTOR_ACTOR_COLORS.length])); }; - const addModelAsset = (asset: ModelAsset) => { - const object = createDirectorModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType }); - commit((current) => ({ ...current, objects: [...current.objects, object] })); - setSelectedObjectId(object.id); - }; + const addModelAsset = (asset: ModelAsset) => addObject(createDirectorModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType })); const uploadModel = async (file?: File) => { if (!file || !/\.(glb|gltf)$/i.test(file.name)) return; @@ -169,9 +408,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh const addBillboard = (node: CanvasNodeData) => { if (!node.metadata?.content) return; - const object = createDirectorBillboard(node.title, node.metadata.content, node.metadata.storageKey, node.id); - commit((current) => ({ ...current, objects: [...current.objects, object] })); - setSelectedObjectId(object.id); + addObject(createDirectorBillboard(node.title, node.metadata.content, node.metadata.storageKey, node.id)); }; const addCamera = () => { @@ -212,41 +449,57 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh const addObjectKeyframe = () => { if (!selectedObject) return; - updateObject(selectedObject.id, { keyframes: upsertDirectorKeyframe(selectedObject.keyframes, playhead, selectedObject.transform) }); + // 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。 + const record = resolveDirectorKeyframeRecord({ base: selectedObject.transform, keyframes: selectedObject.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }); + updateObject(selectedObject.id, { keyframes: record.keyframes }); }; const addCameraKeyframe = () => { if (!activeCamera) return; - commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: upsertDirectorKeyframe(item.keyframes, playhead, item.transform) } : item) })); + commit((current) => ({ + ...current, + cameras: current.cameras.map((item) => item.id === activeCamera.id + ? { ...item, keyframes: resolveDirectorKeyframeRecord({ base: item.transform, keyframes: item.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }).keyframes } + : item), + })); }; const recordSelectedKeyframe = () => { if (selectedObject && selectedBone) { const rotation = selectedObject.boneOverrides?.[selectedBone as DirectorHumanoidBone] || [0, 0, 0, 1] as DirectorQuat; - updateObject(selectedObject.id, { boneTracks: upsertDirectorBoneKeyframe(selectedObject.boneTracks || [], selectedBone as DirectorHumanoidBone, playhead, rotation) }); + updateObject(selectedObject.id, { boneTracks: upsertDirectorBoneKeyframe(selectedObject.boneTracks || [], selectedBone as DirectorHumanoidBone, snappedPlayhead, rotation) }); return; } if (selectedObject) addObjectKeyframe(); else addCameraKeyframe(); }; - const handleObjectTransform = useCallback((id: string, transform: DirectorTransform) => { + /** 对象 transform 编辑的唯一入口:gizmo 与检查器共用同一套静态/动画语义。 */ + const handleObjectTransform = useCallback((id: string, from: DirectorTransform, to: DirectorTransform) => { commit((current) => ({ ...current, - objects: current.objects.map((item) => item.id === id ? { ...item, transform, keyframes: autoKey ? upsertDirectorKeyframe(item.keyframes, playhead, transform) : item.keyframes } : item), + objects: current.objects.map((item) => { + if (item.id !== id) return item; + const edit = resolveDirectorObjectTransformEdit({ base: item.transform, keyframes: item.keyframes, rendered: from, edited: to, autoKey, time: snappedPlayhead }); + return { ...item, transform: edit.transform, keyframes: edit.keyframes }; + }), })); - }, [autoKey, commit, playhead]); + }, [autoKey, commit, snappedPlayhead]); - const handleBoneTransform = useCallback((id: string, bone: string, rotation: DirectorQuat) => { - commit((current) => ({ + /** 骨骼写入语义:静态覆盖 + autoKey 时在吸附播放头补关键帧。gizmo 与数值编辑器共用。 */ + const writeBoneRotation = useCallback((id: string, bone: string, rotation: DirectorQuat, mode: "stage" | "commit") => { + const write = mode === "stage" ? stageGesture : commit; + write((current) => ({ ...current, objects: current.objects.map((item) => item.id === id ? { ...item, boneOverrides: { ...item.boneOverrides, [bone]: rotation }, - boneTracks: autoKey ? upsertDirectorBoneKeyframe(item.boneTracks || [], bone as DirectorHumanoidBone, playhead, rotation) : item.boneTracks, + boneTracks: autoKey ? upsertDirectorBoneKeyframe(item.boneTracks || [], bone as DirectorHumanoidBone, snappedPlayhead, rotation) : item.boneTracks, } : item), })); - }, [autoKey, commit, playhead]); + }, [autoKey, commit, snappedPlayhead, stageGesture]); + + const handleBoneTransform = useCallback((id: string, bone: string, rotation: DirectorQuat) => writeBoneRotation(id, bone, rotation, "commit"), [writeBoneRotation]); const handleActorRigReady = useCallback((id: string, rig: DirectorRig, animations: AnimationClip[]) => { replaceWithoutHistory((current) => ({ @@ -277,14 +530,16 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh }; const applyToCanvas = async () => { - if (!draft || !activeShot || !viewportRef.current) return; + stagedTransaction.end("commit"); + const current = draftRef.current; + if (!current || !activeShot || !viewportRef.current) return; setSaving(true); try { const beauty = await viewportRef.current.capture("beauty"); - const prompt = compileDirectorPrompt(draft, activeShot); - const next = touchDirectorScene(draft); - setDraft(next); - onChange(next); + const prompt = compileDirectorPrompt(current, activeShot); + // 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。 + const next = touchDirectorScene(current); + writeAndPublish(next); await onApply({ scene: next, shot: activeShot, prompt, beauty }); message.success("导演台构图已回写画布"); } catch (error) { @@ -295,7 +550,9 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh }; const exportClayVideo = async () => { - if (!draft || !activeShot || !viewportRef.current || recording) return; + stagedTransaction.end("commit"); + const current = draftRef.current; + if (!current || !activeShot || !viewportRef.current || recording) return; setRecording(true); const wasPlaying = playing; const previousPlayhead = playhead; @@ -304,8 +561,8 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh try { await new Promise((resolve) => requestAnimationFrame(() => resolve())); const clayVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps); - const next = touchDirectorScene(draft); - onChange(next); + const next = touchDirectorScene(draftRef.current || current); + writeAndPublish(next); await onApply({ scene: next, shot: activeShot, prompt: compileDirectorPrompt(next, activeShot), beauty: await viewportRef.current.capture("beauty"), clayVideo, clayVideoMimeType: clayVideo.type }); message.success("白膜视频已回写画布"); } catch (error) { @@ -322,12 +579,22 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh return (
- + replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} /> -
+
+ + {saveIndicator.label} + + {saveIndicator.retryable ? : null} +
+
({ label: clip.name, value: clip.id }))]} onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })} />{activeMotionClip ?
updateActiveMotion({ playbackRate: playbackRate || 1 })} /> updateActiveMotion({ loop })} />
: null} :
模型加载后会显示可用动作 Clip
} {mappedBones.length ?