diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index d4e415dbc..5eacb8af0 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -124,3 +124,90 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [x] Outbox Worker 本地文件删除、对象不存在幂等成功和失败退避测试通过。 - [ ] 使用真实 OSS 配置验证对象删除成功、权限失败后的退避重试和多实例租约接管。 - [ ] 浏览器真实环境验证登录快照慢响应期间工作区保持不可写,以及删除后立即刷新不复活素材。 + +## 3D 导演台时间轴关键帧删除与快捷键 + +关键帧删除入口、全局快捷键和交互控件守卫已接入,以下场景仍需要在浏览器中由用户确认: + +- [x] `web` TypeScript 检查、导演台专项测试与全量前端测试通过。 +- [x] 关键帧删除覆盖对象 transform、对象骨骼和摄影机三类轨道;未命中时返回同一场景引用,不记历史、不产生修订。 +- [x] 时间轴关键帧为可 Tab 聚焦的真实 button;点击或 Enter/Space 只选择并定位播放头,Delete/Backspace 或顶栏删除按钮才删除,避免普通点击误删。 +- [x] 所选关键帧可切换保持、线性、平滑缓动;缓动同时作用于对象/摄影机 Transform 与骨骼旋转插值,旧数据默认线性。 +- [x] 切换镜头/摄影机或外部删除关键帧后会清除陈旧选择,顶栏缓动和删除控件不会继续修改不可见轨道。 +- [x] 全局快捷键在输入框、按钮、链接、role 型控件、可编辑区域及其子元素内一律不解析;仅在动作真正执行时 `preventDefault`。 +- [x] 鼠标点选场景对象、dock 工具、模式按钮、快速添加和顶栏图标按钮后会释放焦点,全局快捷键立刻可用;键盘激活(Enter/Space)保留焦点,Tab 序列不断。 +- [ ] 在真实画布中记录多个关键帧后逐个删除,确认删除的是目标帧、其余帧顺序不变,且「撤销」能恢复。 +- [ ] 折叠对象轨道后仅通过汇总轨删除关键帧,确认收起状态下仍可删除。 +- [ ] 用键盘 Tab 走到关键帧上按 Enter/Space,确认只选择并把播放头定位到该帧;再按 Delete,确认删除的是该关键帧而不是整个对象。 +- [ ] 对同一段动画依次选择保持、线性、平滑缓动,确认播放结果明显不同,撤销可恢复上一个缓动设置。 +- [ ] 选中摄影机关键帧后切换到使用另一台摄影机的镜头,确认旧选择和顶栏缓动/删除控件立即消失。 +- [ ] 确认浅色/深色主题下关键帧的悬停环与焦点环可见,命中区易于点中。 +- [ ] 逐个验证 W/E/R、Delete/Backspace、H、Escape、Ctrl/Cmd+Z、Ctrl/Cmd+Shift+Z、Ctrl/Cmd+Y 和空格;确认在标题输入框内输入这些字符不会触发快捷键。 +- [ ] 在场景列表点选一个对象后直接按 Delete,确认删除的是该对象;再点 dock 的「旋转」后按 W,确认能切回移动。 +- [ ] 用键盘 Tab 走到场景对象行按 Enter 选中,确认焦点仍停在该行、可继续 Tab 到下一项。 + +## 3D 导演台场景模板与四模式骨架 + +新建场景模板和摆场/姿态/动画/摄影机四个一级模式已接入,以下场景仍需要在浏览器中由用户确认: + +- [x] `web` TypeScript 检查、导演台专项测试与全量前端测试通过。 +- [x] 导演台 P0 Chrome E2E 全部通过;场景 A 已同步为「默认摆场不显示时间轴与 AutoKey,切到动画模式后 AutoKey 默认关闭」。 +- [x] 五个模板结构确定、每次实例 id 独立、对象 XZ 占位两两不相交,判据与 `resolveDirectorPlacement` 一致。 +- [x] 空场景不含任何演员;未知模板 id 回落到空场景。 +- [x] 模板不改动 `DirectorScene` 结构与 `version`;`mode` 只存在于工作台 store,不写入场景。 +- [x] 离开动画模式会停止播放并关闭 Auto Key,并把渲染视图夹回当前模式允许的集合。 +- [x] 动画模式会根据 Transform 关键帧绘制可见演员与摄影机的空间轨迹,包含起终点、方向和当前播放进度;隐藏演员及零长度轨迹不显示。 +- [x] 播放头按镜头 FPS 而非固定 60 次/秒更新,跨过镜头末尾时保留余量;轨迹长度只在对象/摄影机内容变化时重算。 +- [x] 「按运镜生成轨迹」只更新首尾关键帧,保留手工中间帧、关键帧 ID 与 easing,并提示切到动画模式继续编辑。 +- [x] 截图/录制绑定开始时的场景快照;上传完成后重新核验节点与镜头,并只把预览引用合入最新场景,不用旧快照覆盖并发编辑。 +- [ ] 从工具栏和画布右键菜单分别新建镜头,确认都会先弹出模板选择,且右键位置会作为新节点位置。 +- [ ] 逐个使用五个模板新建镜头,确认空场景没有演员,单人/双人/走位演员数量正确,产品镜头含产品主体、背景板和侧面道具且没有演员。 +- [ ] 确认双人对话两名演员面对面、走位三名演员互不重叠、产品镜头道具不穿模。 +- [ ] 打开一个已保存的镜头,确认不弹模板选择、场景内容未被改写。 +- [ ] 在摆场模式确认时间轴、Auto Key、记录关键帧、骨骼与姿势预设、深度/法线视图都不可见。 +- [ ] 在姿态模式选中演员,确认骨骼与姿势入口出现;未选中演员时不出现;确认不会自动产生关键帧。 +- [ ] 在动画模式开启 Auto Key 并开始播放,再切到其他模式,确认播放停止、Auto Key 关闭,且场景没有被继续写入关键帧。 +- [ ] 在动画模式为演员和摄影机分别记录至少两枚位置不同的 Transform 关键帧,确认轨迹、起终点、方向锥和当前进度点可见;隐藏演员后其轨迹同步消失,离开动画模式后全部轨迹消失。 +- [ ] 在摄影机模式确认右栏固定显示镜头/摄影机检查器,对齐视图与运镜可用,深度/法线视图可选。 +- [ ] 在摄影机已有关键帧且播放头不在 0 秒时执行「摄影机对齐当前视图」,确认当前帧立即保持新机位,旧关键帧仍在且撤销可恢复。 +- [ ] 在摄影机已有首尾帧和至少一枚带缓动的手工中间帧时执行「按运镜生成轨迹」,确认只更新首尾位置,中间帧、缓动与撤销历史都保留。 +- [ ] 开始截图或白膜录制后切换活动镜头、删除源节点或继续编辑场景,确认旧输出被拒绝或安全合并,不写进另一项目、不复活节点、不覆盖新编辑。 +- [ ] 确认模式切换不丢失草稿、撤销历史和保存状态,切换后标题与保存指示器仍正确。 +- [ ] 确认浅色/深色主题下模式按钮的选中态、悬停态与焦点环可见,选中态不是主操作色。 +- [ ] 在窄窗口(小于 lg 断点)确认模式切换仍可见可点击。 + +用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。 + +## 3D 导演台取景模式(3D/CAM/五个正交视图) + +新增 `viewMode`(自由/机位/顶前后左右五个正交轴向)取景切换,独立于摆场/姿态/动画/摄影机四个一级模式;以下场景仍需要在浏览器中由用户确认: + +- [x] `web` TypeScript 检查通过;`director-view-modes.test.ts`、`director-modes.test.ts`、`director-template-mode-wiring.test.ts` 专项测试通过。 +- [x] 三台相机对象(自由观察、机位、正交)互相独立持有,切换 `viewMode` 只挪动视口的活动相机指针,源码层面确认不共享可变状态。 +- [x] 正交轴向使用真正的 `THREE.OrthographicCamera`,取景来自场景内容包围盒,而非把透视相机换个角度伪装;取景解算与视口接线均有专项测试覆盖退化场景(空场景、非法数值)。 +- [ ] 逐个切换 3D/CAM/顶视/前视/后视/左视/右视,确认画面确实是对应视角,正交视图没有透视畸变。 +- [ ] 放入大比例背景板和旋转后的长道具,逐个切换正交视图,确认对象完整进入画面,不再因只按对象中心构图而裁切。 +- [ ] 在自由视角环绕后切到机位或任一正交视图,再切回自由视角,确认之前的环绕位置原样恢复,没有被拉回默认机位。 +- [ ] 在机位视图播放/拖动时间轴,确认摄影机运镜正确预览;切到正交视图确认环绕已锁定、拖拽无效。 +- [ ] 确认取景切换器(3D/CAM/TOP/FRONT/BACK/LEFT/RIGHT 七个按钮)可键盘 Tab 到达、Enter/Space 激活,`aria-pressed` 状态正确朗读。 +- [ ] 确认切换取景模式不产生新的撤销历史记录,也不触发保存/未保存提示。 +- [ ] 在机位或正交视图下点击「导出预览图」「录制白膜视频」,确认导出流程仍正常完成(不因当前相机是正交投影而失败)。 +- [ ] 确认浅色/深色主题下取景切换器的选中态、悬停态与焦点环可见。 +- [ ] 在窄窗口(小于 lg 断点)确认取景切换器仍可见可点击,不与其他浮层重叠遮挡。 + +用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。 + +## 3D 导演台首次引导与窄屏属性面板 + +首次引导已接入真实登录用户 scope,并按 Issue #305 改为“添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用”六步;以下场景仍需要浏览器确认: + +- [x] 引导进度按认证用户 ID 隔离,不回落 guest;重启入口在加载期间也可用,重置失败会重新读取原进度。 +- [x] 已跳过或已结束的引导可从标题栏重新开始;最后一步使用“结束引导”中性文案,不宣称用户已经完成实际任务。 +- [x] 小于 `lg` 断点时右侧属性区移动到工作区下方且可滚动,不再直接隐藏姿势与骨骼控制。 +- [x] `director-onboarding.test.ts` 专项测试与 `web` TypeScript 检查通过。 +- [ ] 使用两个真实账号分别推进、跳过和重启引导,确认进度互不串号;切换账号时旧读取结果不会闪现。 +- [ ] 从新账号首次打开导演台,逐步完成添加演员、移动、姿态、两枚 Transform 关键帧、CAM 构图与应用,确认六步文案都指向真实可见入口。 +- [ ] 在引导加载期间连续点击“重新开始引导”,再模拟 IndexedDB 写入失败与恢复,确认界面不会永久隐藏或假装推进。 +- [ ] 在窄窗口检查摆场、姿态、动画、摄影机四种模式:标题栏可横向到达,底部属性面板可滚动,时间轴与引导不互相遮挡。 + +用户确认上述场景后,再将对应条目移动到功能文档的已验证范围。 diff --git a/web/package.json b/web/package.json index 10077a196..23357104e 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "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/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": "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 test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.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 .", diff --git a/web/scripts/director-p0-chrome-e2e.mjs b/web/scripts/director-p0-chrome-e2e.mjs index 4ca7a41cf..d661dc83a 100644 --- a/web/scripts/director-p0-chrome-e2e.mjs +++ b/web/scripts/director-p0-chrome-e2e.mjs @@ -259,33 +259,52 @@ async function connectCdp(cdpPort) { }; /** - * 真实鼠标点击:先量出可见目标的中心点,再派发 Input.dispatchMouseEvent。 + * 真实鼠标点击:等待目标中心稳定且位于最上层,再派发 Input.dispatchMouseEvent。 * 不用 el.click(),因为那是 untrusted 合成事件,拿不到真实 user gesture。 */ const clickPoint = async (locatorExpression, label) => { - const box = await evaluate(`(() => { + const readInteractiveBox = () => + evaluate(`(() => { const el = ${locatorExpression}; - if (!el) return null; - const r = el.getBoundingClientRect(); - if (r.width <= 0 || r.height <= 0) return null; + if (!(el instanceof HTMLElement)) 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 }; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + if (rect.width <= 0 || rect.height <= 0 || style.display === "none" || style.visibility === "hidden" || style.pointerEvents === "none" || Number(style.opacity) <= 0 || el.matches(":disabled") || el.getAttribute("aria-disabled") === "true") return null; + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return null; + const hit = document.elementFromPoint(x, y); + if (!hit || (hit !== el && !el.contains(hit))) return null; + return { x: Math.round(x), y: Math.round(y) }; })()`); + const deadline = Date.now() + 5000; + let previous = null; + let box = null; + while (Date.now() < deadline) { + const next = await readInteractiveBox(); + if (next && previous && next.x === previous.x && next.y === previous.y) { + box = next; + break; + } + previous = next; + await sleep(100); + } if (!box) { - console.log(` (click target not visible: ${label})`); + console.log(` (click target not interactable: ${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 }); + const point = { x: box.x, y: box.y, button: "left" }; + await send("Input.dispatchMouseEvent", { type: "mouseMoved", ...point, buttons: 0 }); + await send("Input.dispatchMouseEvent", { type: "mousePressed", ...point, buttons: 1, clickCount: 1 }); + await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...point, buttons: 0, 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})`); + const clickText = (text, tag = "button") => + clickPoint(`[...document.querySelectorAll(${JSON.stringify(tag)})].find((element) => (element.textContent || "").trim() === ${JSON.stringify(text)} && element.getClientRects().length > 0)`, `${tag}:text-is(${text})`); /** 每个场景都从干净页面开始:诊断缓冲区与 store 都重置。 */ const navigateFresh = async (url) => { @@ -360,26 +379,41 @@ async function smokeWorkbench(cdp, baseUrl) { 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"); + // P1-A 起 AutoKey/时间轴归属动画模式:默认摆场模式下它们必须不存在。 + const layoutGating = await cdp.evaluate(`(() => ({ + mode: document.querySelector('button[data-mode="layout"]')?.getAttribute('aria-pressed') ?? null, + sequencer: document.querySelectorAll('.director-sequencer').length, + autoKey: document.querySelectorAll('button[title="自动关键帧"]').length, + }))()`); + assert(layoutGating.mode === "true", "A6 默认进入摆场模式", `got ${JSON.stringify(layoutGating.mode)}`); + assert(layoutGating.sequencer === 0 && layoutGating.autoKey === 0, "A7 摆场模式不显示时间轴与 AutoKey", JSON.stringify(layoutGating)); + + // 原 A6 的断言意图(AutoKey 默认不开启)在它真正存在的模式里继续守住。 + const switched = await cdp.click('button[data-mode="animate"]'); + if (!switched) throw new Error("A: 动画模式按钮 not clickable"); + const sequencerShown = await cdp.poll(`document.querySelectorAll('.director-sequencer').length === 1`, "sequencer in animate mode", 20000); + assert(sequencerShown, "A8 动画模式显示时间轴"); + 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)}`); + assert(autoKey === "false", "A9 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"); + assert(cubeAppeared, "A10 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"); + assert(cubeGone, "A11 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(shellGone, "A12 workbench closed cleanly before leaving scenario A"); - assert(cdp.problems.length === 0, "A10 no browser problems in scenario A", JSON.stringify(cdp.problems)); + assert(cdp.problems.length === 0, "A13 no browser problems in scenario A", JSON.stringify(cdp.problems)); } /** diff --git a/web/src/components/canvas/director/canvas-director-onboarding.tsx b/web/src/components/canvas/director/canvas-director-onboarding.tsx new file mode 100644 index 000000000..ee33e2971 --- /dev/null +++ b/web/src/components/canvas/director/canvas-director-onboarding.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + advanceDirectorOnboarding, + createDirectorOnboardingGate, + loadDirectorOnboardingProgress, + resetDirectorOnboardingProgress, + resolveDirectorOnboardingView, + type DirectorOnboardingAction, + type DirectorOnboardingProgress, + type DirectorOnboardingStorage, + type DirectorOnboardingView, +} from "@/lib/canvas/director/director-onboarding"; + +/** + * 导演台首次上手引导浮层(P2)。 + * + * 硬约束: + * - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台, + * 任何模态化都会把「照着做」变成「先关掉」。因此用 role="region" 而不是 role="dialog"。 + * - 只用原生 button。三个动作都是真正的按钮,键盘 Tab/Enter/Space 与读屏天然可用。 + * - 只用既有 token(CSS 变量 + Tailwind 布局类),不新增全局 CSS,不写颜色/圆角/字号字面值。 + * - 读不到持久进度(IndexedDB 失败)时不展示:无法确认用户是否已经跳过,就不要再骚扰他。 + */ + +type DirectorOnboardingController = { + /** null 表示不展示:未加载完、已跳过、已完成、或持久层不可用。 */ + view: DirectorOnboardingView | null; + /** 有动作在写盘。用于禁用按钮,避免连点产生并发写。 */ + busy: boolean; + next: () => void; + back: () => void; + dismiss: () => void; + complete: () => void; + /** 重新开始引导。已跳过/已完成的用户只能靠它回到第一步。 */ + reset: () => void; +}; + +/** + * 引导状态接线。 + * + * scope 变化(切账号)会重新加载并丢弃在途结果:generation 守卫保证旧账号的进度 + * 不会落到新账号的界面上。enabled 为 false 或 scope 为空时不读盘、不展示。 + * + * 并发写保护用 gateRef(同步闭包锁)而不是只靠 busy state:同一 tick 内的重复调用 + * (双击、程序化连点)在 busy state 反映到下一次渲染之前就已经发生,只看 state 挡不住。 + * + * 关键边界:gateRef 在换代(scope/enabled 变化)时必须整体替换成全新实例,而不是对 + * 共享的旧实例调用 release()。原因:如果旧一代的写入仍在途,release 共享实例会把 + * 刚为新一代腾出来的锁也一起放开;而新一代锁上之后,旧写入 .finally() 里如果重新读 + * `gateRef.current`(这时已经指向新一代的锁)去 release,会把新一代还在写的锁误放掉—— + * generation 守卫只保护展示状态,保护不了锁。因此 run() 必须在拿锁的同一刻把 + * `gateRef.current` 同步捕获进局部变量 `gate`,tryEnter/release 全部作用在这份捕获上, + * 绝不在 .then/.catch/.finally 里重新读取 `gateRef.current`。 + */ +export function useDirectorOnboarding({ scope, enabled = true, storage }: { scope: string; enabled?: boolean; storage?: DirectorOnboardingStorage }): DirectorOnboardingController { + const [progress, setProgress] = useState(null); + const [busy, setBusy] = useState(false); + const generation = useRef(0); + const gateRef = useRef(createDirectorOnboardingGate()); + const normalizedScope = scope.trim(); + const active = enabled && normalizedScope.length > 0; + + useEffect(() => { + generation.current += 1; + const current = generation.current; + // 换新实例而不是 release 旧实例:旧实例可能仍被上一代在途的写入持有。 + gateRef.current = createDirectorOnboardingGate(); + setProgress(null); + setBusy(false); + if (!active) return; + void loadDirectorOnboardingProgress(normalizedScope, storage) + .then((loaded) => { + if (generation.current === current) setProgress(loaded); + }) + .catch(() => { + // 持久层不可用:保持不展示。引导不值得为它弹错误提示。 + }); + }, [active, normalizedScope, storage]); + + const run = useCallback( + (action: DirectorOnboardingAction) => { + if (!active || !progress) return; + // 同步捕获本次调用时刻的锁实例:即使换代把 gateRef.current 换成新实例, + // 这次调用后续的 tryEnter/release 也只作用在捕获到的这一份上。 + const gate = gateRef.current; + // 同步拦截:拿不到锁说明上一次调用还没落盘,本次直接放弃,不基于同一份旧 progress 重复发起写入。 + if (!gate.tryEnter()) return; + const current = generation.current; + setBusy(true); + void advanceDirectorOnboarding(normalizedScope, progress, action, storage) + .then((next) => { + if (generation.current === current) setProgress(next); + }) + .catch(() => { + // 写盘失败:停在当前步骤,用户可以再点一次。绝不在界面上假装已前进。 + }) + .finally(() => { + // 释放捕获的 gate,不是 gateRef.current —— 换代后 ref 可能已经指向 + // 新一代的锁,重新读取会把新一代还在写的锁误放掉。 + gate.release(); + if (generation.current === current) setBusy(false); + }); + }, + [active, progress, normalizedScope, storage], + ); + + const next = useCallback(() => run("next"), [run]); + const back = useCallback(() => run("back"), [run]); + const dismiss = useCallback(() => run("dismiss"), [run]); + const complete = useCallback(() => run("complete"), [run]); + const reset = useCallback(() => { + if (!active) return; + const gate = gateRef.current; + if (!gate.tryEnter()) return; + // A restart supersedes the pending load for this scope. Advancing the + // generation prevents that older read from restoring a completed or + // dismissed progress record after the reset has landed. + generation.current += 1; + const current = generation.current; + setBusy(true); + void resetDirectorOnboardingProgress(normalizedScope, storage) + .then((next) => { + if (generation.current === current) setProgress(next); + }) + .catch(() => { + if (generation.current !== current) return; + // 重置失败后把同一 scope 的持久进度重新读回来。重置开始时已经让旧读取 + // 失效,若这里不补读,加载期触发重置的用户会一直停在 progress=null。 + return loadDirectorOnboardingProgress(normalizedScope, storage) + .then((loaded) => { + if (generation.current === current) setProgress(loaded); + }) + .catch(() => { + // 持久层仍不可用时继续隐藏;标题栏入口允许用户稍后再次尝试。 + }); + }) + .finally(() => { + gate.release(); + if (generation.current === current) setBusy(false); + }); + }, [active, normalizedScope, storage]); + + return { view: progress ? resolveDirectorOnboardingView(progress) : null, busy, next, back, dismiss, complete, reset }; +} + +/** 焦点环与既有 director 控件一致:token 化的 outline,不引入新的全局样式。 */ +const FOCUS_RING = "focus-visible:[outline:var(--stroke-2)_solid_var(--control-focus-ring)] focus-visible:[outline-offset:1px]"; + +/** + * 引导卡片。 + * + * scope 必须由调用方显式传入已认证用户 id —— 组件不读取 guest fallback 或当前活跃 + * scope,避免绕过「显式用户 scope」这条边界。 + */ +export function CanvasDirectorOnboarding({ scope, open = true, restartSignal = 0, className, storage }: { scope: string; open?: boolean; restartSignal?: number; className?: string; storage?: DirectorOnboardingStorage }) { + const { view, busy, next, back, dismiss, reset } = useDirectorOnboarding({ scope, enabled: open, storage }); + const previousRestartSignal = useRef(restartSignal); + + useEffect(() => { + if (previousRestartSignal.current === restartSignal) return; + previousRestartSignal.current = restartSignal; + reset(); + }, [reset, restartSignal]); + + if (!view) return null; + + const { step, position, total, isFirst, isLast } = view; + + return ( +
+
+

+ {step.title} +

+ {`第 ${position} 步 / 共 ${total} 步`} +
+ + {/* 步骤切换靠 aria-live 播报,不靠焦点转移:焦点必须留在用户正在操作的地方。 */} +

+ {step.detail} +

+ + {/* 进度点是纯装饰,真实进度由上面的「第 N 步 / 共 M 步」文本承担。 */} +
+ {Array.from({ length: total }, (_, index) => ( + + ))} +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/components/canvas/director/canvas-director-template-modal.tsx b/web/src/components/canvas/director/canvas-director-template-modal.tsx new file mode 100644 index 000000000..60ed75498 --- /dev/null +++ b/web/src/components/canvas/director/canvas-director-template-modal.tsx @@ -0,0 +1,34 @@ +import { Modal } from "antd"; + +import { DIRECTOR_TEMPLATES, type DirectorTemplateId } from "@/lib/canvas/director/director-templates"; + +/** + * 新建导演台镜头前的模板选择。 + * + * 为什么必须显式选:过去新建场景无条件塞一个默认演员, + * 产品镜头和空场景用户第一件事就是把它删掉。这里让开局内容由用户决定。 + * 没有「默认」按钮 —— 空场景本身就是那个选项,且它真的没有演员。 + */ +export function CanvasDirectorTemplateModal({ open, onClose, onSelect }: { open: boolean; onClose: () => void; onSelect: (templateId: DirectorTemplateId) => void }) { + return ( + +
+ {DIRECTOR_TEMPLATES.map((template) => ( + + ))} +
+
+ ); +} diff --git a/web/src/components/canvas/director/canvas-director-workbench.tsx b/web/src/components/canvas/director/canvas-director-workbench.tsx index c82ca5e3e..5c1948808 100644 --- a/web/src/components/canvas/director/canvas-director-workbench.tsx +++ b/web/src/components/canvas/director/canvas-director-workbench.tsx @@ -6,17 +6,20 @@ import { nanoid } from "nanoid"; import { Euler, Quaternion } from "three"; import type { AnimationClip } from "three"; +import { CanvasDirectorOnboarding } from "@/components/canvas/director/canvas-director-onboarding"; import { DirectorViewport, type DirectorViewportHandle } from "@/components/canvas/director/director-viewport"; import { DirectorViewportDock } from "@/components/canvas/director/director-viewport-dock"; import { DirectorSequencer } from "@/components/canvas/director/director-sequencer"; import { canvasThemes } from "@/lib/canvas-theme"; import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler"; -import { resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "@/lib/canvas/director/director-animation-semantics"; +import { advanceDirectorPlayhead, resolveDirectorCameraAlignment, resolveDirectorCameraMoveKeyframes, 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 { DIRECTOR_MODES, directorModeCapabilities, type DirectorModeCapabilities } from "@/lib/canvas/director/director-modes"; 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 { isDirectorOutputSnapshotCurrent, shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session"; +import { blocksDirectorShortcut, releaseDirectorFocusAfterPointer, resolveDirectorShortcut, type DirectorShortcutAction } from "@/lib/canvas/director/director-shortcuts"; +import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorFocalLengthToFov, directorPoseLabel, interpolateDirectorTransform, removeDirectorSceneKeyframe, setDirectorSceneKeyframeEasing, 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"; @@ -24,9 +27,9 @@ import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store"; import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store"; 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"; +import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director"; -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 }) { +export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingScope, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onboardingScope: string; 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); @@ -36,6 +39,11 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh const [future, setFuture] = useState([]); const [saving, setSaving] = useState(false); const [recording, setRecording] = useState(false); + const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0); + const mode = useDirectorWorkbenchStore((state) => state.mode); + const viewMode = useDirectorWorkbenchStore((state) => state.viewMode); + const setViewMode = useDirectorWorkbenchStore((state) => state.setViewMode); + const setMode = useDirectorWorkbenchStore((state) => state.setMode); const selectedObjectId = useDirectorWorkbenchStore((state) => state.selectedObjectId); const selectedLightId = useDirectorWorkbenchStore((state) => state.selectedLightId); const transformMode = useDirectorWorkbenchStore((state) => state.transformMode); @@ -61,6 +69,10 @@ 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 capabilities = directorModeCapabilities(mode); + const renderModeOptions = useMemo(() => DIRECTOR_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value)), [capabilities.renderModes]); + const draftRef = useRef(null); const stagedRef = useRef(null); const initializedSceneIdRef = useRef(null); @@ -192,11 +204,16 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh if (!playing || !activeShot) return; let frame = 0; let last = performance.now(); + let pending = 0; + const frameInterval = 1 / Math.max(1, Math.min(120, activeShot.fps || 24)); const tick = (now: number) => { - const delta = (now - last) / 1000; + pending += Math.max(0, (now - last) / 1000); last = now; - const next = useDirectorWorkbenchStore.getState().playhead + delta; - setPlayhead(next >= activeShot.duration ? 0 : next); + if (pending >= frameInterval) { + const elapsed = Math.floor(pending / frameInterval) * frameInterval; + pending -= elapsed; + setPlayhead(advanceDirectorPlayhead(useDirectorWorkbenchStore.getState().playhead, elapsed, activeShot.duration)); + } frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); @@ -474,6 +491,92 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh else addCameraKeyframe(); }; + /** + * 时间轴删除关键帧的唯一入口。 + * + * 未命中(对象/摄影机/关键帧已不存在)时 removeDirectorSceneKeyframe 返回同一引用, + * 此时不进 commit:不记历史、不产生修订、不触发保存。 + */ + const deleteKeyframe = useCallback((target: DirectorKeyframeDeleteTarget) => { + const current = draftRef.current; + if (!current || removeDirectorSceneKeyframe(current, target) === current) return; + commit((scene) => removeDirectorSceneKeyframe(scene, target)); + }, [commit]); + + const setKeyframeEasing = useCallback((target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing) => { + const current = draftRef.current; + if (!current || setDirectorSceneKeyframeEasing(current, target, easing) === current) return; + commit((scene) => setDirectorSceneKeyframeEasing(scene, target, easing)); + }, [commit]); + + /** + * 快捷键执行器。放在 ref 里:监听只在 open 变化时注册一次, + * 但每次渲染都能拿到最新的选择、历史和 draft,避免闭包读到过期状态。 + * + * 返回值表示「动作真的执行了」,只有执行了才 preventDefault: + * 没有选中对象时的 Delete 仍然交还给浏览器。 + */ + const runShortcut = (action: DirectorShortcutAction): boolean => { + switch (action.kind) { + case "transform-mode": + setTransformMode(action.mode); + return true; + case "delete-selected": + if (selectedObject) { + removeObject(selectedObject.id); + return true; + } + if (selectedLight) { + removeLight(selectedLight.id); + return true; + } + return false; + case "undo": + if (!history.length) return false; + undo(); + return true; + case "redo": + if (!future.length) return false; + redo(); + return true; + case "toggle-visibility": + if (!selectedObject) return false; + updateObject(selectedObject.id, { visible: !selectedObject.visible }); + return true; + case "deselect": + if (!selectedObjectId && !selectedLightId && !selectedBone) return false; + setSelectedObjectId(null); + setSelectedLightId(null); + setSelectedBone(null); + return true; + case "toggle-play": + setPlaying(!playing); + return true; + } + }; + const runShortcutRef = useRef(runShortcut); + runShortcutRef.current = runShortcut; + + // 导演台是全屏浮层,快捷键挂在 window;焦点落在任何交互控件内时交还给该控件, + // 关键帧按钮再额外拦截自己的 Enter/Space/Delete/Backspace。 + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + const action = resolveDirectorShortcut({ + key: event.key, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + isInteractiveTarget: blocksDirectorShortcut(event.target), + }); + if (!action) return; + if (runShortcutRef.current(action)) event.preventDefault(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open]); + /** 对象 transform 编辑的唯一入口:gizmo 与检查器共用同一套静态/动画语义。 */ const handleObjectTransform = useCallback((id: string, from: DirectorTransform, to: DirectorTransform) => { commit((current) => ({ @@ -515,17 +618,18 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh const applyCameraMove = () => { if (!activeCamera || !activeShot) return; - const start = activeCamera.transform; - const end = cameraMoveTransform(start, activeShot.cameraMove); - commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: [{ id: nanoid(), time: 0, transform: start }, { id: nanoid(), time: activeShot.duration, transform: end }] } : item) })); - message.success("已生成相机运动关键帧"); + const cameraId = activeCamera.id; + const move = activeShot.cameraMove; + const duration = activeShot.duration; + commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === cameraId ? { ...item, keyframes: resolveDirectorCameraMoveKeyframes(item.keyframes, item.transform, cameraMoveTransform(item.transform, move), duration) } : item) })); + message.success("已更新运镜首尾关键帧,可在动画模式继续编辑"); }; const alignCameraToView = () => { if (!activeCamera) return; const transform = viewportRef.current?.readCameraTransform(); if (!transform) return; - commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, transform } : item) })); + commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? resolveDirectorCameraAlignment(item, transform, snappedPlayhead) : item) })); message.success("摄影机已对齐当前视图"); }; @@ -533,9 +637,11 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh stagedTransaction.end("commit"); const current = draftRef.current; if (!current || !activeShot || !viewportRef.current) return; + const expected = { scene: current, shotId: activeShot.id }; setSaving(true); try { const beauty = await viewportRef.current.capture("beauty"); + if (!isDirectorOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("输出期间场景或镜头已变化,请重试"); const prompt = compileDirectorPrompt(current, activeShot); // 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。 const next = touchDirectorScene(current); @@ -553,6 +659,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh stagedTransaction.end("commit"); const current = draftRef.current; if (!current || !activeShot || !viewportRef.current || recording) return; + const expected = { scene: current, shotId: activeShot.id }; setRecording(true); const wasPlaying = playing; const previousPlayhead = playhead; @@ -561,9 +668,12 @@ 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); + if (!isDirectorOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("录制期间场景或镜头已变化,请重试"); 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 }); + const beauty = await viewportRef.current.capture("beauty"); + if (!isDirectorOutputSnapshotCurrent(draftRef.current, { scene: next, shotId: expected.shotId })) throw new Error("输出期间场景或镜头已变化,请重试"); + await onApply({ scene: next, shot: activeShot, prompt: compileDirectorPrompt(next, activeShot), beauty, clayVideo, clayVideoMimeType: clayVideo.type }); message.success("白膜视频已回写画布"); } catch (error) { message.error(error instanceof Error ? error.message : "白膜视频导出失败"); @@ -578,12 +688,33 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh return (
-
+
replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} /> + + {/* 一级模式切换:小屏也必须可达,因此不加 max-lg:hidden。 */} +
} loading={retrying || saveIndicator.busy} onClick={() => void retrySave()}>重试保存 : null}
-
@@ -626,22 +758,25 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
- +
{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s
- addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} /> + + addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} />
-
- setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> + {/* 时间轴只属于动画模式:其他模式下它不渲染,Auto Key 与录制入口一并消失。 */} + {capabilities.timeline ? setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> : null} ); } -function ObjectInspector({ object, rendered, playhead, selectedBone, onSelectBone, onUpdate, onTransformEdit, onBoneRotationStage, onBoneRotationCommit, onAddKeyframe, onDelete }: { object: DirectorObject; rendered: DirectorTransform; playhead: number; selectedBone: string | null; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial) => void; onTransformEdit: (transform: DirectorTransform) => void; onBoneRotationStage: (rotation: DirectorQuat) => void; onBoneRotationCommit: () => void; onAddKeyframe: () => void; onDelete: () => void }) { +function ObjectInspector({ object, rendered, playhead, selectedBone, capabilities, onSelectBone, onUpdate, onTransformEdit, onBoneRotationStage, onBoneRotationCommit, onAddKeyframe, onDelete }: { object: DirectorObject; rendered: DirectorTransform; playhead: number; selectedBone: string | null; capabilities: DirectorModeCapabilities; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial) => void; onTransformEdit: (transform: DirectorTransform) => void; onBoneRotationStage: (rotation: DirectorQuat) => void; onBoneRotationCommit: () => void; onAddKeyframe: () => void; onDelete: () => void }) { const motionClips = object.motionClips || []; const activeMotionClip = motionClips.find((clip) => clip.id === object.activeMotionClipId); const mappedBones = Object.keys(object.rig?.boneMap || {}) as DirectorHumanoidBone[]; @@ -649,25 +784,43 @@ function ObjectInspector({ object, rendered, playhead, selectedBone, onSelectBon const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || [0, 0, 0, 1] as DirectorQuat : null; const updateActiveMotion = (patch: Partial[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip) }); const applyPose = (pose: DirectorPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} }); + const resetSelectedBone = () => { + if (!selectedBoneId) return; + const boneOverrides = { ...(object.boneOverrides || {}) }; + delete boneOverrides[selectedBoneId]; + onUpdate({ boneOverrides }); + }; return onUpdate({ name })} onDelete={onDelete}> {object.kind === "actor" || object.primitive === "character" ?
{DIRECTOR_ACTOR_COLORS.map((color) =>
: onUpdate({ color })} />} - {object.kind === "actor" || object.primitive === "character" || motionClips.length ? <> + {/* + 骨骼与姿势入口:只在姿态/动画模式出现,且只对演员出现。 + 规格要求「仅在演员选择时展示现有骨骼/姿势入口」—— + 带动画的普通模型不是演员,不应拿到姿势预设与骨骼控制。 + */} + {capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? <>
姿势预设{directorPoseLabel(object.pose || "stand")}
{poseOptions.map((option) => )}
+
角色绑定{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}
- {motionClips.length ? <> ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} /> : null} - {selectedBoneId && selectedBoneRotation ? : null} + {selectedBoneId && selectedBoneRotation ? <> : null} + {/* 演员还没加载出模型时给一句解释,避免「动作片段」区域凭空消失。 */} + {motionClips.length ? null :
模型加载后会显示可用动作 Clip
} : null} + {/* 动作片段是动画内容而非骨骼入口:任何带 Clip 的对象都能调,不限演员。 */} + {motionClips.length ? <> onUpdate({ type })} /> onUpdate({ transform: { ...light.transform, position } })} /> onUpdate({ color })} /> onUpdate({ intensity: value || 0 })} /> onUpdate({ castShadow })} />
; } -function ShotInspector({ shot, camera, cameras, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView, onExportClay, recording }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; onUpdateShot: (patch: Partial) => void; onUpdateCamera: (patch: Partial) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void; onExportClay: () => void; recording: boolean }) { +function ShotInspector({ shot, camera, cameras, capabilities, onUpdateShot, onUpdateCamera, onAddCameraKeyframe, onApplyCameraMove, onAlignCameraToView, onExportClay, recording }: { shot: DirectorShot; camera: DirectorCamera | null; cameras: DirectorScene["cameras"]; capabilities: DirectorModeCapabilities; onUpdateShot: (patch: Partial) => void; onUpdateCamera: (patch: Partial) => void; onAddCameraKeyframe: () => void; onApplyCameraMove: () => void; onAlignCameraToView: () => void; onExportClay: () => void; recording: boolean }) { return onUpdateShot({ name })}> onUpdateShot({ shotSize })} /> onUpdateShot({ cameraMove })} /> onUpdateShot({ duration: value || 5 })} /> onUpdateShot({ prompt: event.target.value })} /> - {camera ? <> onUpdateCamera({ transform: { ...camera.transform, position } })} /> onUpdateCamera({ target })} /> onUpdateCamera({ focalLength: focalLength || 35, fov: focalLengthToFov(focalLength || 35) })} />
onUpdateCamera({ aperture: aperture || 2.8 })} /> onUpdateCamera({ focusDistance: focusDistance || 5 })} />
: null} + {camera ? <> onUpdateCamera({ transform: { ...camera.transform, position } })} /> onUpdateCamera({ target })} /> onUpdateCamera({ focalLength: focalLength || 35, fov: directorFocalLengthToFov(focalLength || 35) })} />
onUpdateCamera({ aperture: aperture || 2.8 })} /> onUpdateCamera({ focusDistance: focusDistance || 5 })} />
{capabilities.keyframes ? : null} : null}
; } @@ -737,20 +890,25 @@ function Vec3Field({ label, value, step = 0.1, onChange }: { label: string; valu function Field({ label, children }: { label: string; children: ReactNode }) { return ; } function PanelTitle({ title, action }: { title: string; action?: ReactNode }) { return
{title}{action}
; } +/** + * 场景列表行。选择按钮点完必须释放焦点: + *「点选对象 -> 按 Delete」是 delete-selected 快捷键的主流程, + * 焦点留在按钮上会让守卫把 Delete 吃掉。 + */ function SceneRow({ active, icon, label, onClick, onDelete }: { active?: boolean; icon: ReactElement; label: string; onClick: () => void; onDelete?: () => void }) { return
- - {onDelete ? : null} + {onDelete ? : null}
; } function AddMenuButton({ label, items }: { label: string; items: MenuProps["items"] }) { return ; } -function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return ; } -function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return ; } +function QuickAdd({ label, icon, onClick }: { label: string; icon: ReactElement; onClick: () => void }) { return ; } +function IconButton({ label, disabled, children, onClick }: { label: string; disabled?: boolean; children: ReactNode; onClick: () => void }) { return ; } const poseOptions: Array<{ label: string; value: DirectorPose }> = [ { label: "站立", value: "stand" }, { label: "T 型", value: "t_pose" }, { label: "行走", value: "walk" }, { label: "跑步", value: "run" }, { label: "坐姿", value: "sit" }, { label: "蹲下", value: "squat" }, { label: "单膝跪", value: "kneel_single" }, { label: "双膝跪", value: "kneel_double" }, @@ -760,6 +918,14 @@ const poseOptions: Array<{ label: string; value: DirectorPose }> = [ ]; const shotSizeOptions = [{ label: "大远景", value: "extreme_wide" }, { label: "远景", value: "wide" }, { label: "全身景", value: "full" }, { label: "中景", value: "medium" }, { label: "近景", value: "close_up" }, { label: "大特写", value: "extreme_close_up" }]; const cameraMoveOptions = [{ label: "固定", value: "static" }, { label: "推进", value: "push_in" }, { label: "拉远", value: "pull_out" }, { label: "左摇", value: "pan_left" }, { label: "右摇", value: "pan_right" }, { label: "上摇", value: "tilt_up" }, { label: "下摇", value: "tilt_down" }, { label: "左环绕", value: "orbit_left" }, { label: "右环绕", value: "orbit_right" }, { label: "手持", value: "handheld" }]; +/** 渲染视图全集。实际可选项由当前模式的 capabilities.renderModes 过滤。 */ +const DIRECTOR_RENDER_MODE_LABELS: Array<{ label: string; value: DirectorRenderMode }> = [ + { label: "预览", value: "beauty" }, + { label: "彩色白膜", value: "clay" }, + { label: "骨骼", value: "pose" }, + { label: "深度", value: "depth" }, + { label: "法线", value: "normal" }, +]; function cameraMoveTransform(transform: DirectorTransform, move: DirectorCameraMove): DirectorTransform { const [x, y, z] = transform.position; @@ -767,5 +933,3 @@ function cameraMoveTransform(transform: DirectorTransform, move: DirectorCameraM const offset = offsets[move]; return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] }; } - -function focalLengthToFov(focalLength: number) { return (2 * Math.atan(36 / (2 * focalLength)) * 180) / Math.PI; } diff --git a/web/src/components/canvas/director/director-sequencer.tsx b/web/src/components/canvas/director/director-sequencer.tsx index 446608dfe..5eb478ba6 100644 --- a/web/src/components/canvas/director/director-sequencer.tsx +++ b/web/src/components/canvas/director/director-sequencer.tsx @@ -1,8 +1,9 @@ -import { ChevronDown, ChevronRight, ChevronUp, KeyRound, Magnet, Pause, Play, Plus, Rows3, ZoomIn, ZoomOut } from "lucide-react"; -import { useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { ChevronDown, ChevronRight, ChevronUp, KeyRound, Magnet, Pause, Play, Plus, Rows3, Trash2, ZoomIn, ZoomOut } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { directorBoneLabel } from "@/lib/canvas/director/director-scene"; -import type { DirectorCamera, DirectorObject, DirectorScene, DirectorShot } from "@/types/director"; +import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts"; +import type { DirectorCamera, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorObject, DirectorScene, DirectorShot } from "@/types/director"; type DirectorSequencerProps = { scene: DirectorScene; @@ -25,21 +26,37 @@ type DirectorSequencerProps = { onSelectBone: (bone: string | null) => void; onRecordKeyframe: () => void; onAddShot: () => void; + /** 删除某条可见轨道上的关键帧;覆盖 transform / camera / bone 三类。 */ + onDeleteKeyframe: (target: DirectorKeyframeDeleteTarget) => void; + /** 更新关键帧到下一帧区间的缓动。 */ + onSetKeyframeEasing: (target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing) => void; onSelectShot: (id: string) => void; }; -type TrackKey = { id: string; time: number; color?: string }; +/** + * 轨道上的一个关键帧标记。 + * 带 target 才可删除;镜头总轨等只读轨道不给 target。 + */ +type TrackKey = { id: string; time: number; color?: string; label?: string; easing?: DirectorKeyframeEasing; target?: DirectorKeyframeDeleteTarget }; -export function DirectorSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, visible, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onVisibilityChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onSelectShot }: DirectorSequencerProps) { +export function DirectorSequencer({ scene, shot, camera, objects, selectedObjectId, selectedBone, playhead, playing, autoKey, height, visible, onPlayToggle, onPlayheadChange, onAutoKeyChange, onHeightChange, onVisibilityChange, onSelectObject, onSelectBone, onRecordKeyframe, onAddShot, onDeleteKeyframe, onSetKeyframeEasing, onSelectShot }: DirectorSequencerProps) { const [expanded, setExpanded] = useState>({}); const [snapEnabled, setSnapEnabled] = useState(true); const [showDetails, setShowDetails] = useState(true); const [timelineScale, setTimelineScale] = useState(1); + const [selectedKey, setSelectedKey] = useState(null); const duration = Math.max(0.5, shot.duration); const fps = shot.fps || 24; const rootRef = useRef(null); const ticks = useMemo(() => Array.from({ length: Math.ceil(duration) + 1 }, (_, index) => index), [duration]); const actorObjects = objects.filter((object) => object.kind === "actor" || object.primitive === "character"); + const activeSelectedKey = selectedKey?.target && directorKeyframeTargetExists(selectedKey.target, camera, objects) ? selectedKey : null; + + // 选择只对当前可见轨道有效。切换镜头/摄影机或外部删帧后立即废弃旧 target, + // 避免顶部缓动与删除控件继续修改已经不可见的轨道。 + useEffect(() => { + setSelectedKey((current) => current?.target && !directorKeyframeTargetExists(current.target, camera, objects) ? null : current); + }, [camera, objects]); const setTimeFromPointer = (event: React.PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); @@ -60,7 +77,26 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject window.addEventListener("pointerup", stop, { once: true }); }; - const cameraKeys = camera?.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#78a9ff" })) || []; + // 摄影机关键帧只在摄影机行提供删除入口;Camera Cut 是概览轨,保持只读。 + const cameraKeys: TrackKey[] = camera?.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#78a9ff", label: `${camera.name} 镜头`, easing: key.easing, target: { track: "camera", cameraId: camera.id, keyframeId: key.id } })) || []; + + const selectTrackKey = (key: TrackKey) => { + if (!key.target) return; + setSelectedKey(key); + onPlayheadChange(key.time); + if (key.target.track === "camera") { + onSelectObject(null); + onSelectBone(null); + } else { + onSelectObject(key.target.objectId); + onSelectBone(key.target.track === "object-bone" ? key.target.bone : null); + } + }; + + const deleteTrackKey = (target: DirectorKeyframeDeleteTarget) => { + onDeleteKeyframe(target); + if (selectedKey?.target && directorKeyframeTargetId(selectedKey.target) === directorKeyframeTargetId(target)) setSelectedKey(null); + }; if (!visible) { return
@@ -84,6 +120,24 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject + {activeSelectedKey?.target ? <> + + + : null} @@ -102,42 +156,44 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject - undefined}> + undefined}> - undefined}> + undefined}> - {camera ? { onSelectObject(null); onSelectBone(null); }}> + {camera ? { onSelectObject(null); onSelectBone(null); }}> - + : null} {actorObjects.map((object) => { const isExpanded = expanded[object.id] ?? object.id === selectedObjectId; const activeClip = object.motionClips?.find((clip) => clip.id === object.activeMotionClipId); - const boneTrackKeys = object.boneTracks?.flatMap((track) => track.keyframes.map((key) => ({ id: `${track.bone}-${key.id}`, time: key.time, color: "#f0b36a" }))) || []; - const transformKeys = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#61d2ad" })); + // 骨骼帧 id 在不同轨道间可能重复,React key 用「骨骼-帧」组合;删除目标仍指向原始帧 id。 + const boneTrackKeys: TrackKey[] = object.boneTracks?.flatMap((track) => track.keyframes.map((key) => ({ id: `bone-${track.bone}-${key.id}`, time: key.time, color: "#f0b36a", label: `${object.name} ${directorBoneLabel(track.bone)}`, easing: key.easing, target: { track: "object-bone" as const, objectId: object.id, bone: track.bone, keyframeId: key.id } }))) || []; + const transformKeys: TrackKey[] = object.keyframes.map((key) => ({ id: `transform-${key.id}`, time: key.time, color: "#61d2ad", label: `${object.name} Transform`, easing: key.easing, target: { track: "object-transform" as const, objectId: object.id, keyframeId: key.id } })); return
- : } selected={selectedObjectId === object.id && !selectedBone} keys={transformKeys} onClick={() => { onSelectObject(object.id); onSelectBone(null); setExpanded((current) => ({ ...current, [object.id]: !isExpanded })); }}> - + : } selected={selectedObjectId === object.id && !selectedBone} onClick={() => { onSelectObject(object.id); onSelectBone(null); setExpanded((current) => ({ ...current, [object.id]: !isExpanded })); }}> + {/* 折叠时这是唯一的关键帧入口,必须可删除。 */} + {isExpanded && showDetails ? <> - onSelectObject(object.id)}> + onSelectObject(object.id)}> - onSelectObject(object.id)}> - + { onSelectObject(object.id); onSelectBone(null); }}> + {object.boneTracks?.map((track) => { - const keys = track.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#f0b36a" })); - return { onSelectObject(object.id); onSelectBone(track.bone); }}>; + const keys: TrackKey[] = track.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#f0b36a", label: `${object.name} ${directorBoneLabel(track.bone)}`, easing: key.easing, target: { track: "object-bone", objectId: object.id, bone: track.bone, keyframeId: key.id } })); + return { onSelectObject(object.id); onSelectBone(track.bone); }}>; })} : null}
; })} {objects.filter((object) => !actorObjects.some((actor) => actor.id === object.id)).map((object) => { - const keys = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#b8c0ca" })); - return { onSelectObject(object.id); onSelectBone(null); }}>; + const keys: TrackKey[] = object.keyframes.map((key) => ({ id: key.id, time: key.time, color: "#b8c0ca", label: `${object.name} Transform`, easing: key.easing, target: { track: "object-transform", objectId: object.id, keyframeId: key.id } })); + return { onSelectObject(object.id); onSelectBone(null); }}>; })} @@ -145,15 +201,75 @@ export function DirectorSequencer({ scene, shot, camera, objects, selectedObject ); } -function SequencerRow({ label, icon, selected, indent, children, onClick }: { label: string; icon: ReactNode; selected: boolean; indent?: boolean; keys?: TrackKey[]; children: ReactNode; onClick: () => void }) { +/** + * 轨道行。标签按钮是选择控件,点完必须释放焦点: + *「点选轨道 -> 按 Delete」与场景列表同源,焦点留在按钮上会让守卫吃掉 Delete。 + * 注意 children 里的 TrackKeys 不走这条规则 —— 关键帧按钮自己拥有那些键。 + */ +function SequencerRow({ label, icon, selected, indent, children, onClick }: { label: string; icon: ReactNode; selected: boolean; indent?: boolean; children: ReactNode; onClick: () => void }) { return
- +
{children}
; } -function TrackKeys({ duration, keys }: { duration: number; keys: TrackKey[] }) { - return
{keys.map((key) => )}
; +/** + * 关键帧渲染为真实 button:可 Tab 聚焦、可 Enter/Space/Delete 触发、也可点击。 + * 之前是惰性 span,关键帧一旦记录就无法删除。 + * + * 只有同时具备 onDeleteKey 和 key.target 才可交互; + * 概览轨(Camera Cut)保持只读 span,避免同一帧出现两个语义相同的删除入口。 + */ +function TrackKeys({ duration, keys, selectedTarget, onSelectKey, onDeleteKey }: { duration: number; keys: TrackKey[]; selectedTarget?: DirectorKeyframeDeleteTarget; onSelectKey?: (key: TrackKey) => void; onDeleteKey?: (target: DirectorKeyframeDeleteTarget) => void }) { + return ( +
+ {keys.map((key) => { + const target = onDeleteKey && key.target; + if (!target) { + return ; + } + return
+ ); +} + +function directorKeyframeTargetExists(target: DirectorKeyframeDeleteTarget, camera: DirectorCamera | null, objects: DirectorObject[]) { + if (target.track === "camera") return camera?.id === target.cameraId && camera.keyframes.some((keyframe) => keyframe.id === target.keyframeId); + const object = objects.find((item) => item.id === target.objectId); + if (!object) return false; + if (target.track === "object-transform") return object.keyframes.some((keyframe) => keyframe.id === target.keyframeId); + return object.boneTracks?.some((track) => track.bone === target.bone && track.keyframes.some((keyframe) => keyframe.id === target.keyframeId)) ?? false; +} + +function directorKeyframeTargetId(target: DirectorKeyframeDeleteTarget) { + if (target.track === "camera") return `camera:${target.cameraId}:${target.keyframeId}`; + if (target.track === "object-transform") return `object:${target.objectId}:transform:${target.keyframeId}`; + return `object:${target.objectId}:bone:${target.bone}:${target.keyframeId}`; } function TrackBar({ duration, color, label, start = 0, clipDuration }: { duration: number; color: string; label: string; start?: number; clipDuration?: number }) { diff --git a/web/src/components/canvas/director/director-view-toolbar.tsx b/web/src/components/canvas/director/director-view-toolbar.tsx new file mode 100644 index 000000000..b91101616 --- /dev/null +++ b/web/src/components/canvas/director/director-view-toolbar.tsx @@ -0,0 +1,55 @@ +import { Tooltip } from "antd"; + +import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts"; +import { DIRECTOR_VIEW_MODES, type DirectorViewMode } from "@/lib/canvas/director/director-view-modes"; + +type DirectorViewToolbarProps = { + viewMode: DirectorViewMode; + onViewModeChange: (mode: DirectorViewMode) => void; +}; + +/** + * 取景模式切换(3D / CAM)。 + * + * 独立于底部 dock:dock 装的是「改内容」的工具(变换、添加、渲染视图), + * 这里只切换「从哪只眼睛看」,不产生任何场景改动,也不进 undo/history。 + * + * 用文字标签而不是图标:3D 与 CAM 是两个含义相反的取景状态,图标化只会更难认。 + * 因此不复用 .director-viewport-dock-button —— 那条规则写死了正方形尺寸且未分层, + * Tailwind 工具类改不动它。这里用同一批 --director-* token 自行排布,不动 globals.css。 + */ +export function DirectorViewToolbar({ viewMode, onViewModeChange }: DirectorViewToolbarProps) { + return ( +
+ {DIRECTOR_VIEW_MODES.map((item) => { + const active = viewMode === item.mode; + return ( + + + + ); + })} +
+ ); +} diff --git a/web/src/components/canvas/director/director-viewport-dock.tsx b/web/src/components/canvas/director/director-viewport-dock.tsx index b3fe09782..cdbaf338a 100644 --- a/web/src/components/canvas/director/director-viewport-dock.tsx +++ b/web/src/components/canvas/director/director-viewport-dock.tsx @@ -1,12 +1,15 @@ import { Tooltip } from "antd"; -import { Bone, Box, Camera, Crosshair, Lightbulb, Move3D, Palette, Rotate3D, Scaling, UserRound } from "lucide-react"; +import { Bone, Box, Camera, Compass, Crosshair, Layers, Lightbulb, Move3D, Palette, Rotate3D, Scaling, UserRound } from "lucide-react"; import type { ReactNode } from "react"; +import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts"; import type { DirectorRenderMode } from "@/types/director"; type DirectorViewportDockProps = { transformMode: "translate" | "rotate" | "scale"; renderMode: DirectorRenderMode; + /** 当前模式允许的渲染视图。dock 只展示这些,避免成为绕过模式门控的第二条路径。 */ + renderModes: DirectorRenderMode[]; onTransformModeChange: (mode: DirectorViewportDockProps["transformMode"]) => void; onRenderModeChange: (mode: DirectorRenderMode) => void; onAddActor: () => void; @@ -16,7 +19,16 @@ type DirectorViewportDockProps = { onAlignCamera: () => void; }; -export function DirectorViewportDock({ transformMode, renderMode, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: DirectorViewportDockProps) { +/** 渲染视图按钮的展示顺序与图标。实际可见项由 renderModes 过滤。 */ +const RENDER_VIEW_BUTTONS: Array<{ mode: DirectorRenderMode; label: string; icon: ReactNode }> = [ + { mode: "beauty", label: "构图预览", icon: }, + { mode: "clay", label: "彩色白膜", icon: }, + { mode: "pose", label: "骨骼视图", icon: }, + { mode: "depth", label: "深度视图", icon: }, + { mode: "normal", label: "法线视图", icon: }, +]; + +export function DirectorViewportDock({ transformMode, renderMode, renderModes, onTransformModeChange, onRenderModeChange, onAddActor, onAddBox, onAddLight, onAddCamera, onAlignCamera }: DirectorViewportDockProps) { return ( ); } +/** + * Dock 按钮。 + * + * 鼠标点完后释放焦点:这个 dock 承载 W/E/R 变换工具,焦点留在按钮上会让 + * 交互控件守卫吃掉这三个键。规则集中在 releaseDirectorFocusAfterPointer。 + */ function DockButton({ label, active, children, onClick }: { label: string; active?: boolean; children: ReactNode; onClick: () => void }) { return ( - + ); } diff --git a/web/src/components/canvas/director/director-viewport.tsx b/web/src/components/canvas/director/director-viewport.tsx index 4dae4d47c..aaa18b072 100644 --- a/web/src/components/canvas/director/director-viewport.tsx +++ b/web/src/components/canvas/director/director-viewport.tsx @@ -1,20 +1,22 @@ import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber"; -import { Grid, OrbitControls, TransformControls } from "@react-three/drei"; +import { Grid, Line, OrbitControls, TransformControls } from "@react-three/drei"; import { Component, forwardRef, memo, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState, type ComponentRef, type ReactNode } from "react"; import { AnimationClip, AnimationMixer, Box3, Bone, Camera, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Plane, Quaternion, Raycaster, Scene, SkeletonHelper, Texture, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three"; import type { Material } from "three"; import { GLTFLoader, SkeletonUtils } from "three-stdlib"; -import { directorCameraSyncKey, resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics"; +import { resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics"; import { createDirectorTransaction, installDirectorTerminalListeners } from "@/lib/canvas/director/director-gesture-transaction"; import { emptyDirectorPlacementIntent, finiteDirectorGroundPoint, type DirectorGroundPoint, type DirectorPlacementIntent } from "@/lib/canvas/director/director-placement"; import { directorDiagnosticObjectKind } from "@/lib/canvas/director/director-diagnostics"; import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder"; import { directorCaptureInitial, directorCaptureUsable, directorLoadIdentity, directorLoadInitial, installDirectorContextListeners, reduceDirectorCapture, reduceDirectorLoad, releaseDirectorCapture, resolveDirectorDisplay, restoreDirectorCapture, upsertDirectorFailedLoad, type DirectorFailedLoads, type DirectorLoadSignal } from "@/lib/canvas/director/director-recovery"; import { disposeDirectorAdoptionFailure, disposeDirectorHelper, disposeDirectorMaterials, disposeDirectorModelResources, disposeDirectorObject3D, resolveDirectorLoadOwnership } from "@/lib/canvas/director/director-resources"; -import { DIRECTOR_DEFAULT_ACTOR_URL, directorPoseBoneDeltas, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene"; +import { DIRECTOR_DEFAULT_ACTOR_URL, directorPoseBoneDeltas, directorTransformPathLength, finiteDirectorTransformKeyframes, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene"; +import { DIRECTOR_DEFAULT_VIEW_MODE, directorViewFramingKey, resolveDirectorEffectiveViewport, resolveDirectorOrthographicFraming, resolveDirectorOrthographicFrustum, resolveDirectorViewFraming, type DirectorOrthographicFraming, type DirectorViewFraming, type DirectorViewMode } from "@/lib/canvas/director/director-view-modes"; +import { DirectorViewToolbar } from "@/components/canvas/director/director-view-toolbar"; import { resolveMediaUrl } from "@/services/file-storage"; -import type { DirectorCamera, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director"; +import type { DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director"; export type DirectorOrbitControls = ComponentRef; @@ -34,6 +36,12 @@ type DirectorViewportProps = { renderMode: DirectorRenderMode; playhead: number; playing: boolean; + /** 动画模式下显示演员与摄影机 Transform 关键帧形成的空间路径。 */ + showMotionPaths?: boolean; + /** 取景模式。省略即自由视角,保持接线前的行为不变。 */ + viewMode?: DirectorViewMode; + /** 提供该回调即在视口内渲染 3D/CAM 切换器;不提供则不显示,视口仍按 viewMode 取景。 */ + onViewModeChange?: (mode: DirectorViewMode) => void; onSelectObject: (id: string | null) => void; onSelectBone: (bone: string | null) => void; onObjectTransform: (id: string, from: DirectorTransform, to: DirectorTransform) => void; @@ -41,7 +49,7 @@ type DirectorViewportProps = { onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void; }; -type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: PerspectiveCamera; suspendDisplayMaterialOverride: () => () => void }; +type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: Camera; suspendDisplayMaterialOverride: () => () => void }; // 稳定空值:identity 不匹配时返回同一引用,避免下游 effect 依赖每次 render 都变化。 const emptyAnimations: AnimationClip[] = []; @@ -53,8 +61,14 @@ const emptyRestRotations: Partial> = const directorCanvasGl = { antialias: true, preserveDrawingBuffer: true, alpha: false } as const; const directorCanvasCamera = { position: [4.8, 2.7, 6.8] as [number, number, number], fov: 50, near: 0.05, far: 500 } as const; const directorCanvasDpr: [number, number] = [1, 1.5]; +// 自由视角固定环绕焦点:free 是独立观察相机,不跟随 shot 摄影机的 target 走, +// 否则切换镜头/摄影机会连带把用户正在环绕的焦点也悄悄挪走。 +const DIRECTOR_FREE_ORBIT_TARGET: DirectorVec3 = [0, 1, 0]; export const DirectorViewport = forwardRef(function DirectorViewport(props, ref) { + // onViewModeChange 只服务 DOM 层的切换器,绝不进 Canvas 子树:它的身份每次父级 + // render 都可能变化,穿透到 memo 化的 Canvas 会触发 configure 重建 renderer。 + const { onViewModeChange, ...sceneProps } = props; const captureContext = useRef(null); // 地面点连同 owner canvas 一起记录:owner 不是当前 renderer 的 canvas 就是陈旧值。 const groundRef = useRef<{ owner: HTMLCanvasElement; point: DirectorGroundPoint } | null>(null); @@ -145,7 +159,7 @@ export const DirectorViewport = forwardRef + {/* 取景切换是纯视口状态:放在 DOM 层,不随 Canvas 重建而丢失。 */} + {onViewModeChange ? : null} {capture.contextLost ? ( & { onCaptureContext: (context: CaptureContext) => void; onRelease: () => void; onContextLost: () => void; @@ -270,13 +287,28 @@ function DirectorViewportNotice({ title, description, actionLabel, onAction, var ); } -function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void; onRelease: () => void; onContextLost: () => void; onContextRestored: () => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void; onGroundPoint: (owner: HTMLCanvasElement, point: DirectorGroundPoint | null) => void; onOrbitControls: (controls: DirectorOrbitControls | null) => void }) { - const { gl, camera, scene: threeScene, invalidate } = useThree(); +function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorCanvasSurfaceProps) { + const { gl, camera, scene: threeScene, invalidate, set, size } = useThree(); const orbitRef = useRef(null); const [transforming, setTransforming] = useState(false); const displayClayRestoreRef = useRef<(() => void) | null>(null); - const shot = scene.shots.find((item) => item.id === scene.activeShotId) || scene.shots[0]; - const activeCamera = scene.cameras.find((item) => item.id === shot?.cameraId) || scene.cameras[0]; + // 三台相机各司其职、互不共享:free 只由 OrbitControls 驱动,CAM/正交只在各自模式下 + // 由取景数据接管。切换 viewMode 只挪动「谁是活动相机」这个指针,任何一台的内部状态 + // 都不会因为切换而被读写——这是「切换不丢失/不污染任一相机状态」的唯一来源。 + const [freeCamera] = useState(() => { + const instance = new PerspectiveCamera(directorCanvasCamera.fov, 1, directorCanvasCamera.near, directorCanvasCamera.far); + instance.position.set(...directorCanvasCamera.position); + return instance; + }); + const [camCamera] = useState(() => new PerspectiveCamera(directorCanvasCamera.fov, 1, directorCanvasCamera.near, directorCanvasCamera.far)); + const [orthoCamera] = useState(() => new OrthographicCamera(-1, 1, 1, -1, 0.05, 500)); + // CAM 与正交轴向的取景解算都是纯函数:mode 不匹配时各自返回 null,互不冲突。 + // 活动相机不直接看 viewMode:CAM 取景失败必须回落 free,否则会露出从未写入的 camCamera。 + const camFraming = resolveDirectorViewFraming({ scene, mode: viewMode, playhead }); + const orthoFraming = resolveDirectorOrthographicFraming({ scene, mode: viewMode }); + const effectiveViewport = resolveDirectorEffectiveViewport({ mode: viewMode, framing: camFraming }); + const actorMotionPaths = useMemo(() => showMotionPaths ? scene.objects.filter((object) => object.visible && (object.kind === "actor" || object.primitive === "character") && directorTransformPathLength(object.keyframes) > 0.001) : [], [scene.objects, showMotionPaths]); + const cameraMotionPaths = useMemo(() => showMotionPaths ? scene.cameras.filter((item) => directorTransformPathLength(item.keyframes) > 0.001) : [], [scene.cameras, showMotionPaths]); const suspendDisplayMaterialOverride = useCallback(() => { const suspended = Boolean(displayClayRestoreRef.current); displayClayRestoreRef.current?.(); @@ -286,7 +318,7 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform }; }, [threeScene]); - const readCaptureContext = useCallback((): CaptureContext => ({ gl, camera: camera as PerspectiveCamera, scene: threeScene, suspendDisplayMaterialOverride }), [camera, gl, suspendDisplayMaterialOverride, threeScene]); + const readCaptureContext = useCallback((): CaptureContext => ({ gl, camera: camera as Camera, scene: threeScene, suspendDisplayMaterialOverride }), [camera, gl, suspendDisplayMaterialOverride, threeScene]); useEffect(() => { onCaptureContext(readCaptureContext()); @@ -367,9 +399,32 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform }; }, [invalidate, renderMode, scene.objects, threeScene]); + // 透视相机(free/CAM)的宽高比随容器尺寸变化;正交相机的半范围在自己的同步 effect 里 + // 按水平/竖直跨度与 aspect 同时拟合,这里只负责两台透视相机的 aspect + 投影矩阵。 + useEffect(() => { + const aspect = size.width / Math.max(size.height, 1); + freeCamera.aspect = aspect; + freeCamera.updateProjectionMatrix(); + camCamera.aspect = aspect; + camCamera.updateProjectionMatrix(); + invalidate(); + }, [camCamera, freeCamera, invalidate, size]); + + // 唯一决定「视口活动相机是谁」的入口:只挪动 state.camera 指针,从不读写另外两台 + // 相机对象的内部状态——这是「切换模式互不污染」的保证来源。 + // CAM 无合法取景时指针指向 freeCamera,取景恢复后再指回 camCamera。 + useEffect(() => { + const next = effectiveViewport.camera === "orthographic" ? orthoCamera : effectiveViewport.camera === "camera" ? camCamera : freeCamera; + set({ camera: next }); + invalidate(); + }, [camCamera, effectiveViewport.camera, freeCamera, invalidate, orthoCamera, set]); + return ( <> - + {/* CAM 与正交轴向的取景各自独立同步到专属相机对象,互不干扰;free 完全交给 + 下面的 OrbitControls,这里不对它做任何写入。 */} + + {scene.lights.map((light) => )} {scene.gridVisible ? : null} @@ -377,6 +432,8 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform + {actorMotionPaths.map((object) => )} + {cameraMotionPaths.map((item) => )} {scene.objects.filter((item) => item.visible).map((object) => ( ))} - + {/* 只有有效 free 回落允许环绕:drei 只在 enabled 时调 controls.update(),CAM/正交下这是 + 真正的锁定,不会有 controls 每帧把相机拽回自己 target 的回写竞争。 + camera 显式绑定 freeCamera:即使 state.camera 当前指向别的相机,也绝不会 + 被这份环绕状态误伤。CAM 取景失败时 orbit 打开,用已有的自由视角,不改场景。 */} + ); } -function CameraSync({ camera, playhead, playing }: { camera?: DirectorCamera; playhead: number; playing: boolean }) { - const threeCamera = useThree((state) => state.camera as PerspectiveCamera); +/** 起点、终点、路径点、方向与当前进度共用一条 Transform 关键帧路径。 */ +function DirectorTransformPath({ keyframes, playhead, color }: { keyframes: DirectorObject["keyframes"]; playhead: number; color: string }) { + const sorted = useMemo(() => finiteDirectorTransformKeyframes(keyframes).toSorted((left, right) => left.time - right.time), [keyframes]); + const points = useMemo(() => sorted.map((keyframe) => keyframe.transform.position), [sorted]); + const current = interpolateDirectorTransform(sorted[0].transform, sorted, playhead).position; + const previous = points[points.length - 2]; + const end = points[points.length - 1]; + const direction = useMemo(() => { + const vector = new Vector3(...end).sub(new Vector3(...previous)); + if (vector.lengthSq() < 1e-8) return [0, 0, 0, 1] as DirectorQuat; + return new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), vector.normalize()).toArray() as DirectorQuat; + }, [end, previous]); + + return + + {points.map((point, index) => + + + )} + + + + + + + + + ; +} + +/** + * CAM 取景:把 shot 摄影机的解算结果写进专属的机位相机对象。 + * + * 这台相机独立持有,不是共享的视口默认相机:离开 CAM 模式后不需要任何快照/还原—— + * 没有人会在其他模式下碰它,回到 CAM 时上一次写入的状态原样还在,物理上不可能被污染。 + * + * 只写这台相机对象,绝不触碰 DirectorScene:换一只眼睛看场景不是内容改动, + * 因此不产生任何 undo/history 记录,也不会触发保存。 + */ +function DirectorShotCameraSync({ camera, framing }: { camera: PerspectiveCamera; framing: DirectorViewFraming | null }) { const invalidate = useThree((state) => state.invalidate); - // 只有相机真正被关键帧/播放驱动时才写入,暂停时用户 Orbit 不会被拉回。 - const syncKey = directorCameraSyncKey({ camera, playhead, playing }); + const framingKey = directorViewFramingKey(framing); useEffect(() => { - if (!camera || !syncKey) return; - const transform = interpolateDirectorTransform(camera.transform, camera.keyframes, playhead); - threeCamera.position.set(...transform.position); - threeCamera.rotation.set(...transform.rotation); - threeCamera.fov = camera.fov; - threeCamera.near = camera.near; - threeCamera.far = camera.far; - threeCamera.lookAt(...camera.target); - threeCamera.updateProjectionMatrix(); + if (!framing) return; + // up 必须先写:lookAt 用当前 camera.up 解基向量,顺序颠倒会丢掉荷兰角。 + camera.up.set(...framing.up); + camera.position.set(...framing.position); + camera.fov = framing.fov; + camera.near = framing.near; + camera.far = framing.far; + camera.lookAt(...framing.target); + camera.updateProjectionMatrix(); invalidate(); - // camera/playhead 只通过 syncKey 参与依赖,避免草稿对象身份变化触发无谓回写。 + // framing 只通过 framingKey 参与依赖:草稿对象身份变化不触发无谓回写。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [invalidate, syncKey, threeCamera]); + }, [framingKey, invalidate, camera]); + return null; +} + +/** + * 正交轴向取景:把包围盒解算结果写进专属的正交相机对象,语义与 DirectorShotCameraSync + * 完全对称——独立持有、无需快照还原、绝不碰 DirectorScene。 + * + * 水平/竖直跨度由领域层给出;视口只传入运行时 aspect,半范围换算走 + * resolveDirectorOrthographicFrustum,同时装下两条轴,避免宽内容被裁切。 + */ +function DirectorOrthoCameraSync({ camera, framing, aspect }: { camera: OrthographicCamera; framing: DirectorOrthographicFraming | null; aspect: number }) { + const invalidate = useThree((state) => state.invalidate); + const framingKey = framing ? [...framing.position, ...framing.target, ...framing.up, framing.horizontalSpan, framing.verticalSpan, framing.near, framing.far].map((value) => value.toFixed(4)).join("|") : ""; + useEffect(() => { + if (!framing) return; + const frustum = resolveDirectorOrthographicFrustum({ horizontalSpan: framing.horizontalSpan, verticalSpan: framing.verticalSpan, aspect }); + camera.up.set(...framing.up); + camera.position.set(...framing.position); + camera.left = frustum.left; + camera.right = frustum.right; + camera.top = frustum.top; + camera.bottom = frustum.bottom; + camera.near = framing.near; + camera.far = framing.far; + camera.lookAt(...framing.target); + camera.updateProjectionMatrix(); + invalidate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [framingKey, aspect, invalidate, camera]); return null; } diff --git a/web/src/lib/canvas/director/director-animation-semantics.ts b/web/src/lib/canvas/director/director-animation-semantics.ts index efae81a24..0e1287ade 100644 --- a/web/src/lib/canvas/director/director-animation-semantics.ts +++ b/web/src/lib/canvas/director/director-animation-semantics.ts @@ -1,6 +1,6 @@ import { Euler, Quaternion } from "three"; -import type { DirectorBoneKeyframe, DirectorKeyframe, DirectorQuat, DirectorTransform, DirectorVec3 } from "../../../types/director"; +import type { DirectorBoneKeyframe, DirectorCamera, DirectorKeyframe, DirectorQuat, DirectorTransform, DirectorVec3 } from "../../../types/director"; import { interpolateDirectorBoneRotation, interpolateDirectorTransform, upsertDirectorKeyframe } from "./director-scene"; // 缩放为 0 时无法用比例表达增量,改用绝对偏移;阈值同时兼顾数值噪声。 @@ -121,6 +121,31 @@ export function resolveDirectorKeyframeRecord(input: { base: DirectorTransform; return { time: input.snappedTime, transform: rendered, keyframes: upsertDirectorKeyframe(input.keyframes, input.snappedTime, rendered) }; } +/** + * 把自由观察相机显式写回实际摄影机。 + * + * 没有动画轨道时更新基础 transform;已有轨道时写当前播放头关键帧,否则旧关键帧会 + * 继续接管 CAM 取景,让界面提示“已对齐”但画面立即跳回旧位置。 + */ +export function resolveDirectorCameraAlignment(camera: DirectorCamera, transform: DirectorTransform, time: number): DirectorCamera { + if (!camera.keyframes.length) return { ...camera, transform }; + return { ...camera, keyframes: upsertDirectorKeyframe(camera.keyframes, time, transform) }; +} + +/** 生成运镜只更新首尾帧;保留用户手工添加的中间帧、帧 id 与 easing。 */ +export function resolveDirectorCameraMoveKeyframes(keyframes: DirectorKeyframe[], start: DirectorTransform, end: DirectorTransform, duration: number) { + const endTime = Number.isFinite(duration) && duration > 0 ? duration : 0; + return upsertDirectorKeyframe(upsertDirectorKeyframe(keyframes, 0, start), endTime, end); +} + +/** 播放头按镜头时长循环;非法输入回落 0,长帧也保留越界余量。 */ +export function advanceDirectorPlayhead(playhead: number, elapsed: number, duration: number) { + if (!Number.isFinite(duration) || duration <= 0) return 0; + const current = Number.isFinite(playhead) ? playhead : 0; + const delta = Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0; + return (((current + delta) % duration) + duration) % duration; +} + /** * 暂停且相机没有被关键帧驱动时不写相机,避免与用户 Orbit 操作互相抢夺。 * key 必须包含该 playhead 上真正解算出的 transform,否则「同一 playhead 改关键帧内容」 diff --git a/web/src/lib/canvas/director/director-modes.ts b/web/src/lib/canvas/director/director-modes.ts new file mode 100644 index 000000000..adb1959bc --- /dev/null +++ b/web/src/lib/canvas/director/director-modes.ts @@ -0,0 +1,73 @@ +import type { DirectorRenderMode } from "@/types/director"; + +/** + * 导演台一级模式。 + * + * mode 是工作台 UI 状态,绝不写入 DirectorScene —— 场景是内容,模式是当前怎么看内容。 + * 这里只做纯粹的「模式 -> 能力」映射与切换清理,组件层照着 capabilities 决定显示什么。 + */ + +export type DirectorMode = "layout" | "pose" | "animate" | "camera"; + +export type DirectorModeCapabilities = { + /** 时间轴是否显示。只有动画模式显示。 */ + timeline: boolean; + /** 是否允许记录/删除关键帧与 Auto Key。只有动画模式允许。 */ + keyframes: boolean; + /** 是否展示骨骼与姿势入口。姿态与动画模式展示。 */ + bones: boolean; + /** 是否突出 shot/camera 检查器与运镜入口。摄影机模式突出。 */ + cameraTools: boolean; + /** 该模式允许的渲染视图。摆场不给深度/法线这类高级视图。 */ + renderModes: DirectorRenderMode[]; +}; + +const CAPABILITIES: Record = { + // 摆场:先把人和物放对位置,隐藏骨骼、关键帧、Auto Key、深度/法线。 + layout: { timeline: false, keyframes: false, bones: false, cameraTools: false, renderModes: ["beauty", "clay"] }, + // 姿态:调骨骼与姿势,但不默认制造关键帧。 + pose: { timeline: false, keyframes: false, bones: true, cameraTools: false, renderModes: ["beauty", "clay", "pose"] }, + // 动画:唯一显示时间轴、允许 Auto Key 与录制关键帧的模式。 + animate: { timeline: true, keyframes: true, bones: true, cameraTools: false, renderModes: ["beauty", "clay", "pose"] }, + // 摄影机:突出机位、对齐视图与运镜;深度/法线在这里才有意义。 + camera: { timeline: false, keyframes: false, bones: false, cameraTools: true, renderModes: ["beauty", "clay", "depth", "normal"] }, +}; + +export const DIRECTOR_MODES: Array<{ mode: DirectorMode; label: string; hint: string }> = [ + { mode: "layout", label: "摆场", hint: "摆放演员、道具与灯光" }, + { mode: "pose", label: "姿态", hint: "调整选中演员的骨骼与姿势" }, + { mode: "animate", label: "动画", hint: "时间轴、关键帧与 Auto Key" }, + { mode: "camera", label: "摄影机", hint: "机位、对齐视图与运镜" }, +]; + +export const DIRECTOR_DEFAULT_MODE: DirectorMode = "layout"; + +export function directorModeCapabilities(mode: DirectorMode): DirectorModeCapabilities { + return CAPABILITIES[mode] ?? CAPABILITIES[DIRECTOR_DEFAULT_MODE]; +} + +export type DirectorModeTransition = { + mode: DirectorMode; + playing: boolean; + autoKey: boolean; + renderMode: DirectorRenderMode; +}; + +/** + * 切换模式时必须一起收敛的运行时状态。 + * + * 关键安全边界:离开动画模式一定停播 + 关 Auto Key。 + * 否则播放循环仍在推进 playhead,而 Auto Key 会在用户以为只是「换了个模式」时 + * 继续往场景里写关键帧 —— 那是后台偷偷改内容。 + * + * renderMode 同时被夹回目标模式允许的集合:不能带着深度视图退回摆场。 + */ +export function resolveDirectorModeTransition(input: { mode: DirectorMode; playing: boolean; autoKey: boolean; renderMode: DirectorRenderMode }): DirectorModeTransition { + const capabilities = directorModeCapabilities(input.mode); + return { + mode: input.mode, + playing: capabilities.timeline ? input.playing : false, + autoKey: capabilities.keyframes ? input.autoKey : false, + renderMode: capabilities.renderModes.includes(input.renderMode) ? input.renderMode : "beauty", + }; +} diff --git a/web/src/lib/canvas/director/director-onboarding.ts b/web/src/lib/canvas/director/director-onboarding.ts new file mode 100644 index 000000000..6da9b28b8 --- /dev/null +++ b/web/src/lib/canvas/director/director-onboarding.ts @@ -0,0 +1,246 @@ +import { localForageStorageForScope } from "@/lib/localforage-storage"; +import type { DirectorMode } from "@/lib/canvas/director/director-modes"; + +/** + * 导演台首次上手引导(P2)。 + * + * 为什么存在:导演台把模板、四个一级模式、对象操作、保存/导出堆在同一个工作台里, + * 第一次进来的人没有任何线索该先做什么。引导给出一条确定的最短路径。 + * + * 硬约束: + * - 纯状态机 + 持久化,绝不触碰 DirectorScene,也不写 undo/redo 历史: + * 引导是「用户看到什么提示」,不是场景内容。 + * - 进度按用户隔离。所有导出 API 必须显式收到非空 scope,禁止内部兜底成 guest —— + * 默认 scope 会让 A 账号的引导状态泄漏给 B 账号。 + * - 只用 localforage(走 localForageStorageForScope),不用 localStorage: + * 与项目里其他用户态持久化保持同一条通道。 + * - 缺失、损坏、版本不符的持久数据一律回落到初始进度;只有存储层本身失败才向上抛。 + */ + +export type DirectorOnboardingStepId = "actor" | "move" | "pose" | "path" | "camera" | "apply"; + +/** active 才展示。dismissed(用户跳过)与 completed(走完)都不再展示,且不可被 next/back 唤醒。 */ +export type DirectorOnboardingStatus = "active" | "dismissed" | "completed"; + +/** + * 进度 schema 版本。 + * 步骤集合发生语义变化(增删步骤、改变顺序)时必须 +1: + * 旧进度里的 stepId 在新步骤表里可能不存在,继续沿用会把用户卡在不存在的步骤上。 + */ +export const DIRECTOR_ONBOARDING_VERSION = 2; + +/** localforage 键名。真实键由 localForageStorageForScope 追加 `:user:`。 */ +export const DIRECTOR_ONBOARDING_KEY = "director-onboarding-v2"; + +export type DirectorOnboardingStep = { + id: DirectorOnboardingStepId; + title: string; + /** 一句话说清「在哪儿点什么」,不解释概念。 */ + detail: string; + /** + * 该步骤应该在哪个一级模式里完成。 + * 模式巡览步骤本身没有单一归属,故为 undefined —— 由 UI 决定是否代为切换模式。 + */ + mode?: DirectorMode; +}; + +/** 顺序与 Issue #305 的首次任务一致:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */ +export const DIRECTOR_ONBOARDING_STEPS: DirectorOnboardingStep[] = [ + { id: "actor", title: "添加并选中演员", detail: "在左侧「快速添加」点演员;模板已有演员时,直接在场景树或视口选中它。", mode: "layout" }, + { id: "move", title: "把演员移动到表演位置", detail: "保持摆场模式,在底部 dock 选移动工具(W),拖动轴手柄;右栏可输入精确坐标。", mode: "layout" }, + { id: "pose", title: "调整演员姿态", detail: "切到姿态模式,在右侧选择姿势预设;模型有骨骼时还可选择手臂、腿和躯干微调。", mode: "pose" }, + { id: "path", title: "记录一段走位", detail: "切到动画模式,在起点和终点分别移动播放头、摆放演员并记录 Transform 关键帧,再播放预览。", mode: "animate" }, + { id: "camera", title: "用 CAM 检查构图", detail: "切到摄影机模式,再点视口工具栏 CAM;调整焦距、焦点和机位,确认安全框内的画面。", mode: "camera" }, + { id: "apply", title: "应用到镜头", detail: "确认场景、姿态、走位和 CAM 构图后,点顶栏「应用到镜头」生成静帧并回写画布。", mode: "camera" }, +]; + +export type DirectorOnboardingProgress = { + version: number; + status: DirectorOnboardingStatus; + stepId: DirectorOnboardingStepId; +}; + +export const directorOnboardingInitial: DirectorOnboardingProgress = { + version: DIRECTOR_ONBOARDING_VERSION, + status: "active", + stepId: DIRECTOR_ONBOARDING_STEPS[0].id, +}; + +export type DirectorOnboardingAction = "next" | "back" | "dismiss" | "complete" | "reset"; + +/** + * 引导状态机。 + * + * 关键不变量: + * - 只有 reset 能让已 dismissed/completed 的引导重新活过来。next/back/dismiss/complete + * 在非 active 状态下是恒等变换,否则「跳过引导」会被后续任何一次误触重新拉起来。 + * - 在最后一步 next 等于完成:完成必须是终态,而不是停在最后一步继续可 next。 + * - 首步 back 保持不动,不越界,也不隐式关闭引导。 + * - 当前 stepId 不在步骤表里(旧数据、被外部改写)时回到初始进度,而不是把 -1 当下标继续算。 + */ +export function reduceDirectorOnboarding(progress: DirectorOnboardingProgress, action: DirectorOnboardingAction): DirectorOnboardingProgress { + if (action === "reset") return directorOnboardingInitial; + if (progress.status !== "active") return progress; + if (action === "dismiss") return { ...progress, status: "dismissed" }; + if (action === "complete") return { ...progress, status: "completed" }; + + const index = DIRECTOR_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId); + if (index < 0) return directorOnboardingInitial; + + if (action === "back") { + if (index === 0) return progress; + return { ...progress, stepId: DIRECTOR_ONBOARDING_STEPS[index - 1].id }; + } + + const next = DIRECTOR_ONBOARDING_STEPS[index + 1]; + if (!next) return { ...progress, status: "completed" }; + return { ...progress, stepId: next.id }; +} + +export type DirectorOnboardingView = { + step: DirectorOnboardingStep; + /** 1 起数,直接用于「第 N 步 / 共 M 步」。 */ + position: number; + total: number; + isFirst: boolean; + isLast: boolean; +}; + +/** + * 展示态。返回 null 表示不该显示引导 —— 组件层只看这一个判据, + * 不自己再拼 status 与步骤下标,避免出现第二套可见性规则。 + */ +export function resolveDirectorOnboardingView(progress: DirectorOnboardingProgress): DirectorOnboardingView | null { + if (progress.status !== "active") return null; + const index = DIRECTOR_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId); + if (index < 0) return null; + return { + step: DIRECTOR_ONBOARDING_STEPS[index], + position: index + 1, + total: DIRECTOR_ONBOARDING_STEPS.length, + isFirst: index === 0, + isLast: index === DIRECTOR_ONBOARDING_STEPS.length - 1, + }; +} + +function isStepId(value: unknown): value is DirectorOnboardingStepId { + return typeof value === "string" && DIRECTOR_ONBOARDING_STEPS.some((step) => step.id === value); +} + +function isStatus(value: unknown): value is DirectorOnboardingStatus { + return value === "active" || value === "dismissed" || value === "completed"; +} + +/** + * 解析持久进度。 + * + * 引导是提示,不是内容:任何无法确信的持久值都当成「没有进度」,宁可再引导一次, + * 也不要把用户卡在一个无法解释的状态里。覆盖:非 JSON、非对象、数组、版本不符、 + * status/stepId 不在枚举内。 + */ +export function parseDirectorOnboardingProgress(raw: string | null | undefined): DirectorOnboardingProgress { + if (!raw) return directorOnboardingInitial; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return directorOnboardingInitial; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return directorOnboardingInitial; + const candidate = parsed as Partial; + if (candidate.version !== DIRECTOR_ONBOARDING_VERSION) return directorOnboardingInitial; + if (!isStatus(candidate.status) || !isStepId(candidate.stepId)) return directorOnboardingInitial; + return { version: DIRECTOR_ONBOARDING_VERSION, status: candidate.status, stepId: candidate.stepId }; +} + +/** 只要求读写能力:既是测试注入内存实现的接缝,也避免依赖 zustand StateStorage 的全量接口。 */ +export type DirectorOnboardingStorage = { + getItem: (name: string) => string | null | Promise; + setItem: (name: string, value: string) => void | Promise; +}; + +/** + * scope 归一化 + 守卫。前后空白裁掉后再校验/使用,这样 " user-a " 与 "user-a" + * 落到同一把生产键上,不会分裂成两份互不可见的进度。 + * + * 必须在每个导出的持久化函数入口无条件调用 —— 包括调用方已经注入自定义 storage 的分支。 + * 之前的实现写成 `storage ?? localForageStorageForScope(requireScope(scope))`, + * `??` 短路导致「传了 storage」时 requireScope 根本不会执行,空 scope 就此绕过校验。 + */ +function requireScope(scope: string) { + const normalized = scope.trim(); + if (!normalized) throw new Error("缺少导演台引导的用户 scope"); + return normalized; +} + +/** + * 读取进度。 + * 数据脏 -> 初始进度;存储层(IndexedDB)失败 -> 向上抛,由调用方决定是否隐藏引导, + * 不在这里 catch 成默认值假装读到了。 + */ +export async function loadDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise { + const normalizedScope = requireScope(scope); + const target = storage ?? localForageStorageForScope(normalizedScope); + return parseDirectorOnboardingProgress(await target.getItem(DIRECTOR_ONBOARDING_KEY)); +} + +export async function saveDirectorOnboardingProgress(scope: string, progress: DirectorOnboardingProgress, storage?: DirectorOnboardingStorage): Promise { + const normalizedScope = requireScope(scope); + const target = storage ?? localForageStorageForScope(normalizedScope); + await target.setItem(DIRECTOR_ONBOARDING_KEY, JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: progress.status, stepId: progress.stepId })); +} + +/** + * 推进并持久化。返回新进度;写失败向上抛,调用方拿到的进度与已落盘的进度保持一致, + * 不存在「界面前进了但磁盘没动」这种静默分叉。 + * + * scope 校验必须在「无变化动作提前返回」之前完成:否则对已 dismissed/completed 账号 + * 调用 next 之类的恒等动作会在校验之前就 return progress,空 scope 也能悄悄跑过去。 + * 无变化的动作仍然不写盘。 + */ +export async function advanceDirectorOnboarding(scope: string, progress: DirectorOnboardingProgress, action: DirectorOnboardingAction, storage?: DirectorOnboardingStorage): Promise { + const normalizedScope = requireScope(scope); + const next = reduceDirectorOnboarding(progress, action); + if (next === progress) return progress; + await saveDirectorOnboardingProgress(normalizedScope, next, storage); + return next; +} + +/** + * 独立的重启 API。不依赖调用方持有当前内存进度 —— 未来工作台设置里的 + * 「重新开始引导」按钮不必先加载一次旧进度才能重置,直接落盘初始状态即可, + * 对已 dismissed/completed 的账号同样生效。 + */ +export async function resetDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise { + const normalizedScope = requireScope(scope); + await saveDirectorOnboardingProgress(normalizedScope, directorOnboardingInitial, storage); + return directorOnboardingInitial; +} + +export type DirectorOnboardingGate = { + /** 尝试进入互斥区;已有一次未完成的调用时返回 false,调用方必须放弃本次动作。 */ + tryEnter: () => boolean; + /** 释放互斥区。scope/enabled 变化(换账号)时也要调用,否则旧账号的占用会永久卡死新账号。 */ + release: () => void; +}; + +/** + * 创建一次性写锁:保证同一 tick 内的重复调用(双击、程序化连点)只有一个能真正发起写入。 + * + * 用同步闭包变量而不是 React state 做门禁:state 更新要等到下一次渲染才对外可见, + * 两次点击完全可能发生在同一次渲染之间,仅靠 busy state 挡不住这种同步重入。 + * release 是幂等的:未锁定时调用不抛错,方便在 effect 里无条件清理。 + */ +export function createDirectorOnboardingGate(): DirectorOnboardingGate { + let locked = false; + return { + tryEnter: () => { + if (locked) return false; + locked = true; + return true; + }, + release: () => { + locked = false; + }, + }; +} diff --git a/web/src/lib/canvas/director/director-scene.ts b/web/src/lib/canvas/director/director-scene.ts index 58a19c4ca..1053906ba 100644 --- a/web/src/lib/canvas/director/director-scene.ts +++ b/web/src/lib/canvas/director/director-scene.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { Color, Euler, Quaternion } from "three"; -import type { DirectorBoneKeyframe, DirectorBoneTrack, DirectorCamera, DirectorHumanoidBone, DirectorKeyframe, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director"; +import type { DirectorBoneKeyframe, DirectorBoneTrack, DirectorCamera, DirectorHumanoidBone, DirectorKeyframe, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director"; export const DIRECTOR_DEFAULT_ACTOR_URL = "https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/models/gltf/Xbot.glb"; export const DIRECTOR_ACTOR_COLORS = ["#f1f3f5", "#202329", "#2f7de1", "#d84949", "#dfae3f", "#34a276"] as const; @@ -72,6 +72,11 @@ export function createDirectorCamera(name = "主摄影机"): DirectorCamera { return { id: nanoid(), name, transform: directorIdentityTransform([4.8, 2.7, 6.8]), target: [0, 1, 0], focalLength: 35, fov: 50, aperture: 2.8, focusDistance: 5, near: 0.05, far: 500, keyframes: [] }; } +/** 35mm 全画幅水平视角换算。摄影机检查器与场景模板共用,避免两处各写一份光学。 */ +export function directorFocalLengthToFov(focalLength: number) { + return (2 * Math.atan(36 / (2 * Math.max(1, focalLength))) * 180) / Math.PI; +} + export function createDirectorLight(type: DirectorLight["type"], name: string, position: DirectorVec3, intensity = 1): DirectorLight { return { id: nanoid(), name, type, transform: directorIdentityTransform(position), color: "#ffffff", intensity, angle: Math.PI / 4, penumbra: 0.35, castShadow: type !== "ambient" }; } @@ -80,8 +85,11 @@ export function touchDirectorScene(scene: DirectorScene): DirectorScene { return { ...scene, updatedAt: new Date().toISOString() }; } +/** 关键帧命中容差:upsert 与 remove 必须同判据,否则会出现「记录能覆盖但删不掉」。 */ +export const DIRECTOR_KEYFRAME_EPSILON = 0.001; + export function upsertDirectorKeyframe(keyframes: DirectorKeyframe[], time: number, transform: DirectorTransform) { - const current = keyframes.find((item) => Math.abs(item.time - time) < 0.001); + const current = keyframes.find((item) => Math.abs(item.time - time) < DIRECTOR_KEYFRAME_EPSILON); const next = current ? keyframes.map((item) => (item.id === current.id ? { ...item, transform } : item)) : [...keyframes, { id: nanoid(), time, transform }]; return next.toSorted((a, b) => a.time - b.time); } @@ -93,17 +101,128 @@ export function upsertDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: Di } function upsertBoneKeyframe(keyframes: DirectorBoneKeyframe[], time: number, rotation: DirectorQuat) { - const current = keyframes.find((item) => Math.abs(item.time - time) < 0.001); + const current = keyframes.find((item) => Math.abs(item.time - time) < DIRECTOR_KEYFRAME_EPSILON); const next = current ? keyframes.map((item) => item.id === current.id ? { ...item, rotation } : item) : [...keyframes, { id: nanoid(), time, rotation }]; return next.toSorted((a, b) => a.time - b.time); } +/** 按 id 删除对象 transform 关键帧;id 不存在时返回原数组引用。 */ +export function removeDirectorKeyframe(keyframes: DirectorKeyframe[], keyframeId: string): DirectorKeyframe[] { + if (!keyframes.some((item) => item.id === keyframeId)) return keyframes; + return keyframes.filter((item) => item.id !== keyframeId); +} + +/** + * 按 id 删除某骨骼轨道上的关键帧。 + * 轨道被删空后整条移除,否则时间轴会留下永远为空的子轨道。 + */ +export function removeDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: DirectorHumanoidBone, keyframeId: string): DirectorBoneTrack[] { + const track = tracks.find((item) => item.bone === bone); + if (!track?.keyframes.some((item) => item.id === keyframeId)) return tracks; + + const nextKeyframes = track.keyframes.filter((item) => item.id !== keyframeId); + if (!nextKeyframes.length) return tracks.filter((item) => item.bone !== bone); + return tracks.map((item) => (item.bone === bone ? { ...item, keyframes: nextKeyframes } : item)); +} + +/** + * 时间轴删除关键帧的唯一入口:按轨道类型分派到对应领域函数。 + * + * 未命中(对象/摄影机/骨骼/关键帧任一不存在)时返回同一个 scene 引用, + * 调用方据此跳过历史与保存,避免「点了没删掉却多一次修订」。 + */ +export function removeDirectorSceneKeyframe(scene: DirectorScene, target: DirectorKeyframeDeleteTarget): DirectorScene { + if (target.track === "camera") { + const camera = scene.cameras.find((item) => item.id === target.cameraId); + if (!camera) return scene; + const keyframes = removeDirectorKeyframe(camera.keyframes, target.keyframeId); + if (keyframes === camera.keyframes) return scene; + return { ...scene, cameras: scene.cameras.map((item) => (item.id === camera.id ? { ...item, keyframes } : item)) }; + } + + const object = scene.objects.find((item) => item.id === target.objectId); + if (!object) return scene; + + if (target.track === "object-transform") { + const keyframes = removeDirectorKeyframe(object.keyframes, target.keyframeId); + if (keyframes === object.keyframes) return scene; + return { ...scene, objects: scene.objects.map((item) => (item.id === object.id ? { ...item, keyframes } : item)) }; + } + + const tracks = object.boneTracks || []; + const boneTracks = removeDirectorBoneKeyframe(tracks, target.bone, target.keyframeId); + if (boneTracks === tracks) return scene; + return { ...scene, objects: scene.objects.map((item) => (item.id === object.id ? { ...item, boneTracks } : item)) }; +} + +/** + * 更新一枚关键帧后续区间的缓动。未命中时返回原 scene 引用,调用方据此跳过历史与保存。 + */ +export function setDirectorSceneKeyframeEasing(scene: DirectorScene, target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing): DirectorScene { + const update = (keyframes: T[]) => { + if (!keyframes.some((item) => item.id === target.keyframeId)) return keyframes; + return keyframes.map((item) => item.id === target.keyframeId ? { ...item, easing } : item); + }; + + if (target.track === "camera") { + const camera = scene.cameras.find((item) => item.id === target.cameraId); + if (!camera) return scene; + const keyframes = update(camera.keyframes); + if (keyframes === camera.keyframes) return scene; + return { ...scene, cameras: scene.cameras.map((item) => item.id === camera.id ? { ...item, keyframes } : item) }; + } + + const object = scene.objects.find((item) => item.id === target.objectId); + if (!object) return scene; + if (target.track === "object-transform") { + const keyframes = update(object.keyframes); + if (keyframes === object.keyframes) return scene; + return { ...scene, objects: scene.objects.map((item) => item.id === object.id ? { ...item, keyframes } : item) }; + } + + const tracks = object.boneTracks || []; + const track = tracks.find((item) => item.bone === target.bone); + if (!track) return scene; + const keyframes = update(track.keyframes); + if (keyframes === track.keyframes) return scene; + const boneTracks = tracks.map((item) => item.bone === target.bone ? { ...item, keyframes } : item); + return { ...scene, objects: scene.objects.map((item) => item.id === object.id ? { ...item, boneTracks } : item) }; +} + +/** 缓动属于前一枚关键帧到下一枚关键帧的区间。旧数据未声明时保持线性。 */ +export function resolveDirectorKeyframeProgress(progress: number, easing: DirectorKeyframeEasing = "linear") { + const clamped = Math.max(0, Math.min(1, progress)); + if (easing === "step") return 0; + if (easing === "smooth") return clamped * clamped * (3 - 2 * clamped); + return clamped; +} + +/** 轨迹渲染只接受有限时间与位置;坏数据不得进入 Three 几何体。 */ +export function finiteDirectorTransformKeyframes(keyframes: DirectorKeyframe[]) { + return keyframes.filter((keyframe) => [keyframe.time, ...keyframe.transform.position].every(Number.isFinite)); +} + +/** 按时间顺序累计 Transform 关键帧路径长度;非法时间或坐标段忽略,不污染界面统计。 */ +export function directorTransformPathLength(keyframes: DirectorKeyframe[]) { + const sorted = keyframes.toSorted((left, right) => left.time - right.time); + let length = 0; + for (let index = 1; index < sorted.length; index += 1) { + const previousTime = sorted[index - 1].time; + const currentTime = sorted[index].time; + const previous = sorted[index - 1].transform.position; + const current = sorted[index].transform.position; + if (![previousTime, currentTime, ...previous, ...current].every(Number.isFinite)) continue; + length += Math.hypot(current[0] - previous[0], current[1] - previous[1], current[2] - previous[2]); + } + return length; +} + export function interpolateDirectorTransform(base: DirectorTransform, keyframes: DirectorKeyframe[], time: number): DirectorTransform { if (!keyframes.length) return base; const previous = [...keyframes].reverse().find((item) => item.time <= time) || keyframes[0]; const next = keyframes.find((item) => item.time >= time) || keyframes[keyframes.length - 1]; if (previous.id === next.id) return previous.transform; - const progress = Math.max(0, Math.min(1, (time - previous.time) / Math.max(next.time - previous.time, 0.001))); + const progress = resolveDirectorKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, DIRECTOR_KEYFRAME_EPSILON), previous.easing); const rotation = new Quaternion().setFromEuler(new Euler(...previous.transform.rotation)).slerp(new Quaternion().setFromEuler(new Euler(...next.transform.rotation)), progress); return { position: lerpVec3(previous.transform.position, next.transform.position, progress), @@ -117,7 +236,7 @@ export function interpolateDirectorBoneRotation(base: DirectorQuat, keyframes: D const previous = [...keyframes].reverse().find((item) => item.time <= time) || keyframes[0]; const next = keyframes.find((item) => item.time >= time) || keyframes[keyframes.length - 1]; if (previous.id === next.id) return previous.rotation; - const progress = Math.max(0, Math.min(1, (time - previous.time) / Math.max(next.time - previous.time, 0.001))); + const progress = resolveDirectorKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, DIRECTOR_KEYFRAME_EPSILON), previous.easing); return new Quaternion(...previous.rotation).slerp(new Quaternion(...next.rotation), progress).toArray() as DirectorQuat; } diff --git a/web/src/lib/canvas/director/director-session.ts b/web/src/lib/canvas/director/director-session.ts index f0747b365..d720bfb01 100644 --- a/web/src/lib/canvas/director/director-session.ts +++ b/web/src/lib/canvas/director/director-session.ts @@ -14,3 +14,22 @@ export function shouldReinitializeDirectorSession(input: { initializedSceneId: s export function upsertDirectorSceneById(scenes: DirectorScene[], scene: DirectorScene): DirectorScene[] { return scenes.some((item) => item.id === scene.id) ? scenes.map((item) => (item.id === scene.id ? scene : item)) : [...scenes, scene]; } + +/** + * 输出上传可能跨越数秒。只把生成出的预览引用合并到最新场景,绝不拿输出开始时的 + * scene 快照覆盖期间发生的标题、对象、关键帧或镜头编辑。 + */ +export function mergeDirectorOutputPreview(scene: DirectorScene, input: { sceneId: string; shotId: string; previewNodeId: string }) { + if (scene.id !== input.sceneId || !scene.shots.some((shot) => shot.id === input.shotId)) return null; + return { + ...scene, + shots: scene.shots.map((shot) => (shot.id === input.shotId ? { ...shot, previewNodeId: input.previewNodeId, depthNodeId: undefined, normalNodeId: undefined } : shot)), + }; +} + +/** 长截图/录制结束前,场景引用与活动镜头必须仍是操作开始时的快照。 */ +export function isDirectorOutputSnapshotCurrent(scene: DirectorScene | null, expected: { scene: DirectorScene; shotId: string }) { + if (scene !== expected.scene) return false; + const activeShot = scene.shots.find((shot) => shot.id === scene.activeShotId) || scene.shots[0]; + return activeShot?.id === expected.shotId; +} diff --git a/web/src/lib/canvas/director/director-shortcuts.ts b/web/src/lib/canvas/director/director-shortcuts.ts new file mode 100644 index 000000000..4faa1bb15 --- /dev/null +++ b/web/src/lib/canvas/director/director-shortcuts.ts @@ -0,0 +1,163 @@ +/** + * 导演台键盘快捷键解析。 + * + * 纯函数:只把「按键事件形状」映射为语义动作,不碰 DOM、不改场景。 + * 组件层负责注册监听与执行动作,因此这里可以完整单测。 + * + * 安全边界:焦点落在任何交互控件(输入框、按钮、链接、role 型控件、 + * 可编辑区域)或其内部时,绝不把按键解析为全局快捷键。 + * 否则用户改名时会误删对象、按 Space 激活按钮时会误切换播放。 + */ + +/** + * 语义动作。只保留导演台里已经存在真实执行路径的动作: + * 变换模式(DirectorViewportDock 的 W/E/R)、删除选中对象或灯光、 + * 撤销/重做(顶栏按钮)、切换选中对象显隐(检查器「可见」开关)、 + * 取消选择、播放/暂停(时间轴 transport)。 + * + * 聚焦、复制、重命名没有对应实现,不在这里造无处可去的动作。 + */ +export type DirectorShortcutAction = { kind: "transform-mode"; mode: "translate" | "rotate" | "scale" } | { kind: "delete-selected" } | { kind: "undo" } | { kind: "redo" } | { kind: "toggle-visibility" } | { kind: "deselect" } | { kind: "toggle-play" }; + +/** 只取事件的必要形状,便于测试构造,也避免耦合真实 KeyboardEvent。 */ +export type DirectorKeyEvent = { + key: string; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + /** 事件目标是否落在交互控件内,由调用方判定后传入。 */ + isInteractiveTarget?: boolean; +}; + +const TRANSFORM_KEYS: Record = { + w: "translate", + e: "rotate", + r: "scale", +}; + +/** + * 解析按键为动作;不匹配返回 null。 + * + * 约定: + * - 交互控件语境一律不解析,交还给控件本身; + * - 带修饰键的组合优先(Undo/Redo),避免 Ctrl+R 被当作 scale; + * - 单字母快捷键不区分大小写,Shift+W 仍是 translate; + * - Redo 同时接受 Ctrl/Cmd+Shift+Z 与 Ctrl/Cmd+Y。 + */ +export function resolveDirectorShortcut(event: DirectorKeyEvent): DirectorShortcutAction | null { + if (event.isInteractiveTarget) return null; + + const key = String(event.key || ""); + const lower = key.toLowerCase(); + const accel = Boolean(event.ctrlKey || event.metaKey); + + if (accel) { + // Alt 组合留给浏览器/系统,不抢。 + if (event.altKey) return null; + if (lower === "z") return event.shiftKey ? { kind: "redo" } : { kind: "undo" }; + if (lower === "y") return { kind: "redo" }; + return null; + } + + if (event.altKey) return null; + + if (lower in TRANSFORM_KEYS) return { kind: "transform-mode", mode: TRANSFORM_KEYS[lower] }; + if (key === "Delete" || key === "Backspace") return { kind: "delete-selected" }; + if (lower === "h") return { kind: "toggle-visibility" }; + if (key === "Escape") return { kind: "deselect" }; + if (key === " " || key === "Spacebar") return { kind: "toggle-play" }; + + return null; +} + +/** + * 交互控件的唯一真源。 + * DIRECTOR_INTERACTIVE_SELECTOR(真实 DOM 的 closest 用)与 + * isDirectorInteractiveNode(单节点判定)都从这里派生,避免两处清单漂移。 + */ +const INTERACTIVE_TAGS = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A", "OPTION", "LABEL", "SUMMARY"] as const; + +/** + * role 型控件:AntD 大量用 role 而非原生标签表达按钮、开关、菜单项。 + * Space/Delete/Backspace 对这些控件都有自己的含义,必须让它们优先。 + */ +const INTERACTIVE_ROLES = [ + "textbox", + "combobox", + "spinbutton", + "searchbox", + "button", + "switch", + "tab", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "option", + "slider", + "checkbox", + "radio", + "link", + "listbox", + "menu", + "radiogroup", + "treeitem", +] as const; + +/** 供真实 DOM 的 Element.closest 使用:命中控件自身或任何交互祖先。 */ +export const DIRECTOR_INTERACTIVE_SELECTOR = [...INTERACTIVE_TAGS.map((tag) => tag.toLowerCase()), ...INTERACTIVE_ROLES.map((role) => `[role="${role}"]`), '[contenteditable="true"]', '[contenteditable=""]'].join(","); + +/** + * 判断单个节点自身是否是交互控件。不看祖先。 + * 祖先由 blocksDirectorShortcut 通过 closest 处理。 + */ +export function isDirectorInteractiveNode(target: unknown): boolean { + if (!target || typeof target !== "object") return false; + const node = target as { tagName?: unknown; isContentEditable?: unknown; getAttribute?: (name: string) => string | null }; + if (node.isContentEditable === true) return true; + + const tagName = typeof node.tagName === "string" ? node.tagName.toUpperCase() : ""; + if ((INTERACTIVE_TAGS as readonly string[]).includes(tagName)) return true; + + if (typeof node.getAttribute === "function") { + const role = node.getAttribute("role"); + if (role && (INTERACTIVE_ROLES as readonly string[]).includes(role)) return true; + const editable = node.getAttribute("contenteditable"); + if (editable === "" || editable === "true") return true; + } + return false; +} + +/** + * 事件目标是否应当阻止全局快捷键。 + * + * 用 closest 而不是只看 target 自身:点击按钮里的 / 时 + * event.target 是子元素,只判自身会漏掉整个控件。 + * closest 同时覆盖 SVG 元素(SVGElement 也实现 Element.closest)。 + * + * 没有 closest 的目标(window、document、测试替身)退化为单节点判定。 + */ +export function blocksDirectorShortcut(target: unknown): boolean { + if (!target || typeof target !== "object") return false; + const node = target as { closest?: (selector: string) => unknown }; + if (typeof node.closest === "function") return Boolean(node.closest(DIRECTOR_INTERACTIVE_SELECTOR)); + return isDirectorInteractiveNode(target); +} + +/** + * 指针激活后释放焦点,使全局快捷键立刻可用。 + * + * 为什么需要:blocksDirectorShortcut 会吃掉聚焦控件上的所有全局快捷键(这是刻意的, + * Space/Delete/Backspace 属于控件自己)。但鼠标点完一个工具按钮或场景行之后, + * 焦点仍留在按钮上,于是「点选对象 -> 按 Delete」这类主流程会静默失效。 + * + * detail === 0 表示键盘激活(Enter/Space 合成的 click),此时必须保留焦点, + * 否则 Tab 序列会断、键盘用户会丢失位置。判据与 plugins 页一致。 + * + * 导演台的按钮分散在 workbench、dock、sequencer 三处,各自没有公共基组件, + * 因此这条规则集中在这里,避免每个 onClick 各写一遍而慢慢走偏。 + */ +export function releaseDirectorFocusAfterPointer(event: { detail: number; currentTarget: { blur?: () => void } }): void { + if (event.detail === 0) return; + event.currentTarget.blur?.(); +} diff --git a/web/src/lib/canvas/director/director-templates.ts b/web/src/lib/canvas/director/director-templates.ts new file mode 100644 index 000000000..d76c2b5a1 --- /dev/null +++ b/web/src/lib/canvas/director/director-templates.ts @@ -0,0 +1,144 @@ +import { nanoid } from "nanoid"; + +import { createDirectorActor, createDirectorCamera, createDirectorLight, createDirectorObject, directorFocalLengthToFov, DIRECTOR_ACTOR_COLORS } from "@/lib/canvas/director/director-scene"; +import type { DirectorCamera, DirectorLight, DirectorObject, DirectorScene, DirectorShot } from "@/types/director"; + +/** + * 新建场景模板。 + * + * 为什么存在:createDirectorScene 无条件塞一个默认演员,产品/空场景根本不需要它, + * 而双人、走位这类常见开局每次都要手工摆一遍。模板把「开局布局」变成显式选择。 + * + * 硬约束: + * - 纯函数 + 确定性布局。同一模板每次生成的结构完全一致,只有 id 与时间戳不同。 + * - 每次调用都产生独立 id(工厂内部 nanoid),两个实例不会共享对象身份。 + * - 不改 DirectorScene schema/version:模板只是预填内容,不引入新字段。 + * - 对象之间 XZ 占位不重叠,判据与 resolveDirectorPlacement 完全一致。 + */ + +export type DirectorTemplateId = "empty" | "monologue" | "dialogue" | "blocking" | "product"; + +export type DirectorTemplate = { + id: DirectorTemplateId; + name: string; + description: string; + /** 列表里给用户的预期提示,不参与生成。 */ + summary: string; +}; + +/** 展示顺序即用户看到的顺序:空场景在最前,其余按上手复杂度递增。 */ +export const DIRECTOR_TEMPLATES: DirectorTemplate[] = [ + { id: "empty", name: "空场景", description: "只有摄影机和三点布光,没有任何演员或道具。", summary: "摄影机 + 布光" }, + { id: "monologue", name: "单人对白", description: "一名演员居中,中近景机位,适合独白与特写。", summary: "1 演员 · 中近景" }, + { id: "dialogue", name: "双人对话", description: "两名演员面对面分立左右,过肩机位起手。", summary: "2 演员 · 对话机位" }, + { id: "blocking", name: "人物走位", description: "三名演员分散站位,广角机位便于安排走动路线。", summary: "3 演员 · 广角" }, + { id: "product", name: "产品/道具镜头", description: "产品置于中心,背景板与侧面道具就位,长焦特写。", summary: "产品 + 背景板 · 长焦" }, +]; + +type TemplateBlueprint = { + objects: DirectorObject[]; + camera: DirectorCamera; + shot: Omit; +}; + +/** + * 三点布光:主光、轮廓光、环境光。 + * 五个模板共用同一套光位,避免模板之间打光风格漂移。 + */ +function threePointRig(): DirectorLight[] { + return [createDirectorLight("directional", "主光", [4, 6, 4], 2.4), createDirectorLight("directional", "轮廓光", [-4, 3, -2], 1.1), createDirectorLight("ambient", "环境光", [0, 0, 0], 0.65)]; +} + +/** 按模板意图摆机位。focalLength 与 fov 一起写,保证两者自洽。 */ +function templateCamera(input: { name: string; position: DirectorCamera["transform"]["position"]; target: DirectorCamera["target"]; focalLength: number; focusDistance: number; aperture: number }): DirectorCamera { + const base = createDirectorCamera(input.name); + return { + ...base, + transform: { ...base.transform, position: input.position }, + target: input.target, + focalLength: input.focalLength, + fov: directorFocalLengthToFov(input.focalLength), + aperture: input.aperture, + focusDistance: input.focusDistance, + }; +} + +/** 演员朝向:绕 Y 轴。左侧演员朝右为 +PI/2,右侧演员朝左为 -PI/2。 */ +function facing(actor: DirectorObject, yaw: number): DirectorObject { + return { ...actor, transform: { ...actor.transform, rotation: [0, yaw, 0] } }; +} + +function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint { + if (id === "monologue") { + return { + objects: [createDirectorActor("演员 1", [0, 0, 0], DIRECTOR_ACTOR_COLORS[0])], + camera: templateCamera({ name: "主摄影机", position: [1.5, 1.65, 3.1], target: [0, 1.15, 0], focalLength: 50, focusDistance: 3.4, aperture: 2.2 }), + shot: { name: "镜头 1", duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" }, + }; + } + + if (id === "dialogue") { + // dx = 1.6 >= (0.8 + 0.8) / 2 + 0.25 = 1.05,两名演员必然不重叠。 + return { + objects: [facing(createDirectorActor("演员 1", [-0.8, 0, 0.3], DIRECTOR_ACTOR_COLORS[0]), Math.PI / 2), facing(createDirectorActor("演员 2", [0.8, 0, -0.3], DIRECTOR_ACTOR_COLORS[2]), -Math.PI / 2)], + camera: templateCamera({ name: "主摄影机", position: [2.9, 1.7, 3.4], target: [0, 1.1, 0], focalLength: 40, focusDistance: 4.2, aperture: 2.8 }), + shot: { name: "镜头 1", duration: 6, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" }, + }; + } + + if (id === "blocking") { + // 三名演员两两 dx >= 1.8,全部大于 1.05 阈值。 + return { + objects: [createDirectorActor("演员 1", [-1.8, 0, 0.6], DIRECTOR_ACTOR_COLORS[0]), createDirectorActor("演员 2", [0, 0, -0.4], DIRECTOR_ACTOR_COLORS[2]), createDirectorActor("演员 3", [1.8, 0, 0.8], DIRECTOR_ACTOR_COLORS[3])], + camera: templateCamera({ name: "主摄影机", position: [0.6, 3.1, 7.4], target: [0, 1, 0], focalLength: 28, focusDistance: 7.6, aperture: 4 }), + shot: { name: "镜头 1", duration: 8, fps: 24, shotSize: "wide", cameraMove: "static", prompt: "" }, + }; + } + + if (id === "product") { + // 产品镜头没有演员。背景板靠 dz=1.35 拉开,侧面道具靠 dx=1.15 拉开。 + const product = createDirectorObject("box", "产品主体", [0, 0.5, 0], "#b8c0ca"); + const backdrop = createDirectorObject("plane", "背景板", [0, 1.4, -1.35], "#e8ebef"); + const prop = createDirectorObject("cylinder", "侧面道具", [1.15, 0.4, 0.5], "#8795a5"); + return { + objects: [ + { ...product, transform: { ...product.transform, scale: [0.9, 0.9, 0.9] } }, + { ...backdrop, transform: { ...backdrop.transform, scale: [3.2, 2, 1] } }, + { ...prop, transform: { ...prop.transform, scale: [0.5, 0.8, 0.5] } }, + ], + camera: templateCamera({ name: "主摄影机", position: [1.1, 1.05, 2.35], target: [0, 0.55, 0], focalLength: 85, focusDistance: 2.6, aperture: 2.8 }), + shot: { name: "镜头 1", duration: 4, fps: 24, shotSize: "close_up", cameraMove: "push_in", prompt: "" }, + }; + } + + return { + objects: [], + camera: templateCamera({ name: "主摄影机", position: [4.8, 2.7, 6.8], target: [0, 1, 0], focalLength: 35, focusDistance: 5, aperture: 2.8 }), + shot: { name: "镜头 1", duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" }, + }; +} + +/** + * 按模板生成全新场景。 + * 未知 id 回落到空场景 —— 宁可少给内容,也不要偷偷塞回默认演员。 + */ +export function createDirectorSceneFromTemplate(templateId: DirectorTemplateId, title = "未命名场景"): DirectorScene { + const blueprint = buildBlueprint(templateId); + const now = new Date().toISOString(); + const shotId = nanoid(); + return { + id: nanoid(), + version: 1, + title, + background: "#d8dde3", + environmentIntensity: 0.7, + gridVisible: true, + objects: blueprint.objects, + cameras: [blueprint.camera], + lights: threePointRig(), + shots: [{ ...blueprint.shot, id: shotId, cameraId: blueprint.camera.id }], + activeShotId: shotId, + createdAt: now, + updatedAt: now, + }; +} diff --git a/web/src/lib/canvas/director/director-view-modes.ts b/web/src/lib/canvas/director/director-view-modes.ts new file mode 100644 index 000000000..2acd13ce2 --- /dev/null +++ b/web/src/lib/canvas/director/director-view-modes.ts @@ -0,0 +1,362 @@ +import { Euler, Vector3 } from "three"; + +import { interpolateDirectorTransform } from "@/lib/canvas/director/director-scene"; +import type { DirectorCamera, DirectorScene, DirectorShot, DirectorTransform, DirectorVec3 } from "@/types/director"; + +/** + * 视口取景模式(3D / CAM / 五个正交轴向:俯视、正视、背视、左视、右视)。 + * + * 与一级模式(director-modes.ts 的摆场/姿态/动画/摄影机)是两个正交的量: + * 一级模式决定「能改什么」,取景模式只决定「从哪只眼睛看」。 + * + * viewMode 是纯视口状态,绝不写入 DirectorScene,也绝不产生 undo/history 记录 —— + * 换一只眼睛看场景不是对内容的修改。本模块只做纯函数,不持有任何 three 对象。 + * + * 五个正交轴向和 CAM 一样锁环绕,但取景来自场景内容的包围盒而不是某台摄影机: + * resolveDirectorOrthographicFraming 解出带 margin 的投影跨度, + * resolveDirectorOrthographicFrustum 再按视口宽高比收成安全的正交半范围。 + */ + +export type DirectorViewMode = "free" | "camera" | "top" | "front" | "back" | "left" | "right"; + +export type DirectorViewModeCapabilities = { + /** 是否允许 OrbitControls 自由环绕。只有 free 允许;CAM 与五个正交轴向都要锁定构图,拖拽会破坏取景。 */ + orbit: boolean; + /** 是否由 shot 的摄影机接管取景(视口相机 == 当前镜头)。只有 CAM。 */ + framed: boolean; + /** 投影方式。free/camera 是透视;五个正交轴向必须是正交投影,否则物体比例会随远近畸变。 */ + projection: "perspective" | "orthographic"; +}; + +const CAPABILITIES: Record = { + // 3D:编辑用的自由视角,可环绕、可缩放,摄影机只被关键帧驱动时才回写。 + free: { orbit: true, framed: false, projection: "perspective" }, + // CAM:所见即成片取景,视口 == 当前 shot 的摄影机,禁止环绕。 + camera: { orbit: false, framed: true, projection: "perspective" }, + // 五个正交轴向:取景来自场景包围盒而非某台摄影机,同样锁环绕,投影必须正交。 + top: { orbit: false, framed: false, projection: "orthographic" }, + front: { orbit: false, framed: false, projection: "orthographic" }, + back: { orbit: false, framed: false, projection: "orthographic" }, + left: { orbit: false, framed: false, projection: "orthographic" }, + right: { orbit: false, framed: false, projection: "orthographic" }, +}; + +export const DIRECTOR_VIEW_MODES: Array<{ mode: DirectorViewMode; label: string; hint: string }> = [ + { mode: "free", label: "3D", hint: "自由视角:拖拽环绕、滚轮缩放" }, + { mode: "camera", label: "CAM", hint: "机位视图:按当前镜头的摄影机取景,环绕已锁定" }, + { mode: "top", label: "TOP", hint: "俯视:从正上方向下看,正交投影,环绕已锁定" }, + { mode: "front", label: "FRONT", hint: "正视:沿 -Z 方向看向场景,正交投影,环绕已锁定" }, + { mode: "back", label: "BACK", hint: "背视:沿 +Z 方向看向场景,正交投影,环绕已锁定" }, + { mode: "left", label: "LEFT", hint: "左视:沿 +X 方向看向场景,正交投影,环绕已锁定" }, + { mode: "right", label: "RIGHT", hint: "右视:沿 -X 方向看向场景,正交投影,环绕已锁定" }, +]; + +export const DIRECTOR_DEFAULT_VIEW_MODE: DirectorViewMode = "free"; + +/** 未知模式回落默认能力,绝不返回 undefined —— 调用方直接读 .orbit / .framed。 */ +export function directorViewModeCapabilities(mode: DirectorViewMode): DirectorViewModeCapabilities { + return CAPABILITIES[mode] ?? CAPABILITIES[DIRECTOR_DEFAULT_VIEW_MODE]; +} + +/** + * 当前 shot / 摄影机的唯一解析入口。 + * + * activeShotId 指向不存在的 shot 时回落到第一个 shot;shot 的 cameraId 指向不存在的 + * 摄影机时回落到第一台。空场景返回 null 而不是抛异常 —— 视口在任何场景下都必须能渲染。 + */ +export function resolveDirectorActiveShot(scene: DirectorScene): DirectorShot | null { + return scene.shots.find((item) => item.id === scene.activeShotId) || scene.shots[0] || null; +} + +export function resolveDirectorActiveCamera(scene: DirectorScene): DirectorCamera | null { + const shot = resolveDirectorActiveShot(scene); + return scene.cameras.find((item) => item.id === shot?.cameraId) || scene.cameras[0] || null; +} + +/** 取景数据:CAM 模式下视口相机需要被写入的完整状态。 */ +export type DirectorViewFraming = { + cameraId: string; + position: DirectorVec3; + target: DirectorVec3; + /** 世界系 up。保留 roll,且保证与视线不共线。 */ + up: DirectorVec3; + fov: number; + near: number; + far: number; +}; + +// 叉积长度低于此值即视为共线:lookAt 会解出非法基向量(NaN 矩阵),视口直接黑屏。 +const DIRECTOR_VIEW_COLLINEAR_EPSILON = 1e-6; + +// 退化时的备选 up:世界 up 优先,其次两个水平轴 —— 正俯视/正仰视镜头落在这里。 +const DIRECTOR_VIEW_UP_FALLBACKS: DirectorVec3[] = [ + [0, 1, 0], + [0, 0, -1], + [1, 0, 0], +]; + +/** + * 解出带 roll 的 up 向量。 + * + * 摄影机 rotation 的 z 分量就是荷兰角。裸 lookAt 会丢掉它 —— three 用固定的 camera.up + * 重算基向量,画面永远水平。这里把世界 up 按摄影机自身欧拉角旋转,roll 才留得住。 + * + * 再处理退化:正俯视/正仰视时旋转后的 up 与视线共线,叉积为 0,lookAt 解出 NaN 矩阵。 + * 此时按 世界up -> -Z -> +X 取第一个不共线的备选,绝不返回让相机失效的值。 + */ +export function resolveDirectorViewUp(rotation: DirectorVec3, viewDirection: DirectorVec3): DirectorVec3 { + const view = new Vector3(...viewDirection); + if (view.lengthSq() <= DIRECTOR_VIEW_COLLINEAR_EPSILON) return [0, 1, 0]; + const rolled = new Vector3(0, 1, 0).applyEuler(new Euler(...rotation)); + const candidates = rolled.lengthSq() > DIRECTOR_VIEW_COLLINEAR_EPSILON ? [rolled.toArray() as DirectorVec3, ...DIRECTOR_VIEW_UP_FALLBACKS] : DIRECTOR_VIEW_UP_FALLBACKS; + return candidates.find((candidate) => new Vector3(...candidate).cross(view).length() > DIRECTOR_VIEW_COLLINEAR_EPSILON) ?? [0, 1, 0]; +} + +/** + * Three 透视投影是否可写进 PerspectiveCamera。 + * + * 只检查有限还不够:fov=0 / 180、near<=0、far<=near 都能通过 isFinite, + * 但 makePerspective 会得到 Inf/NaN 矩阵,视口黑屏。fov 必须落在 (0, 180)。 + */ +export function directorUsablePerspectiveProjection(input: { fov: number; near: number; far: number }): boolean { + const { fov, near, far } = input; + if (![fov, near, far].every(Number.isFinite)) return false; + if (fov <= 0 || fov >= 180) return false; + if (near <= 0) return false; + return far > near; +} + +export type DirectorEffectiveViewport = { + /** 视口应激活的专用相机。CAM 取景失败时必须是 free,不能停在从未写入的 camCamera。 */ + camera: "free" | "camera" | "orthographic"; + /** 是否允许环绕 free 相机。与 camera 同步:只有 free 为 true。 */ + orbit: boolean; +}; + +/** + * 视口活动相机与交互的唯一决策。 + * + * CAM 取景为 null(空场景、非法光学参数)时回落到已有的 free 相机和自由环绕, + * 绝不改 DirectorScene。取景重新合法后确定性回到 CAM(orbit 锁死)。 + * 正交轴向不走这条回落:它们始终有安全默认包围盒。 + */ +export function resolveDirectorEffectiveViewport(input: { mode: DirectorViewMode; framing: DirectorViewFraming | null }): DirectorEffectiveViewport { + if (directorViewModeCapabilities(input.mode).projection === "orthographic") return { camera: "orthographic", orbit: false }; + if (input.mode === "camera" && input.framing) return { camera: "camera", orbit: false }; + return { camera: "free", orbit: true }; +} + +/** + * 解出 CAM 模式下视口相机应有的取景。 + * + * 返回 null 的情形都必须走 free 回落:3D 模式、空场景、位置/旋转非法、 + * 透视投影不可用(fov/near/far)。绝不把非法值写进 three 相机。 + * + * position 取 playhead 上插值后的值,所以 CAM 模式下拖时间轴就是在预览成片运镜。 + */ +export function resolveDirectorViewFraming(input: { scene: DirectorScene; mode: DirectorViewMode; playhead: number }): DirectorViewFraming | null { + if (!directorViewModeCapabilities(input.mode).framed) return null; + const camera = resolveDirectorActiveCamera(input.scene); + if (!camera) return null; + const time = Number.isFinite(input.playhead) ? input.playhead : 0; + const transform: DirectorTransform = interpolateDirectorTransform(camera.transform, camera.keyframes, time); + if (![...transform.position, ...transform.rotation, ...camera.target].every(Number.isFinite)) return null; + if (!directorUsablePerspectiveProjection({ fov: camera.fov, near: camera.near, far: camera.far })) return null; + const position = transform.position; + const raw = new Vector3(camera.target[0] - position[0], camera.target[1] - position[1], camera.target[2] - position[2]); + const degenerate = raw.lengthSq() <= DIRECTOR_VIEW_COLLINEAR_EPSILON; + // 位置与焦点重合时视线为零向量,lookAt 无解:沿摄影机自身 -Z 造一个 1m 外的焦点。 + const view = degenerate ? new Vector3(0, 0, -1).applyEuler(new Euler(...transform.rotation)) : raw; + const target: DirectorVec3 = degenerate ? [position[0] + view.x, position[1] + view.y, position[2] + view.z] : camera.target; + return { cameraId: camera.id, position, target, up: resolveDirectorViewUp(transform.rotation, view.toArray() as DirectorVec3), fov: camera.fov, near: camera.near, far: camera.far }; +} + +/** + * 取景同步键。 + * + * 相机回写的 effect 只依赖这个字符串:草稿对象每次 render 都是新身份,但取景数值没变 + * 就不该重写相机。数值截到 4 位小数,避免浮点噪声引起无意义的重同步。 + */ +export function directorViewFramingKey(framing: DirectorViewFraming | null): string { + if (!framing) return ""; + return [...framing.position, ...framing.target, ...framing.up, framing.fov, framing.near, framing.far].map((value) => value.toFixed(4)).join("|") + `|${framing.cameraId}`; +} + +/** + * 五个正交轴向的视线方向与 up。这五个轴上视线与 up 恒定正交(都是不同的主轴向量), + * 不会出现 CAM 取景那种「roll 导致 up 与视线共线」的退化情形,因此不需要 + * resolveDirectorViewUp 那套回落链。 + * + * 方向约定(世界系,Y-up): + * - top:看向 -Y(从上往下看),up 取 -Z。 + * - front:看向 -Z,up 取 +Y。 + * - back:看向 +Z,up 取 +Y —— front 的正对面。 + * - left:看向 +X(从场景左侧看向右侧),up 取 +Y。 + * - right:看向 -X,up 取 +Y —— left 的正对面。 + */ +type DirectorOrthographicAxis = "top" | "front" | "back" | "left" | "right"; + +/** + * 正交画面上的世界轴。horizontal/vertical 是投影后的屏幕轴,不是视线轴: + * - top:水平 X / 竖直 Z + * - front / back:水平 X / 竖直 Y + * - left / right:水平 Z / 竖直 Y + */ +const DIRECTOR_ORTHOGRAPHIC_AXES: Record = { + top: { view: [0, -1, 0], up: [0, 0, -1], horizontal: 0, vertical: 2 }, + front: { view: [0, 0, -1], up: [0, 1, 0], horizontal: 0, vertical: 1 }, + back: { view: [0, 0, 1], up: [0, 1, 0], horizontal: 0, vertical: 1 }, + left: { view: [1, 0, 0], up: [0, 1, 0], horizontal: 2, vertical: 1 }, + right: { view: [-1, 0, 0], up: [0, 1, 0], horizontal: 2, vertical: 1 }, +}; + +/** 穷举类型守卫:合法的正交轴向集合在类型层面就是穷举的,调用方无需再查能力表判断。 */ +function isDirectorOrthographicAxis(mode: DirectorViewMode): mode is DirectorOrthographicAxis { + return mode === "top" || mode === "front" || mode === "back" || mode === "left" || mode === "right"; +} + +function directorFinitePoint(point: DirectorVec3): boolean { + return point.every(Number.isFinite); +} + +/** + * 取景点云:可见对象的旋转后缩放包围盒角点 + 全部摄影机/灯光的位置。 + * + * 只收对象中心会把大比例背景板、长道具等裁掉。资源尚未加载时无法读取真实 geometry, + * transform.scale 是当前场景合同里稳定可用的保守代理;旋转后的八个角点还能覆盖斜放对象。 + */ +function collectDirectorFramingPoints(scene: DirectorScene): DirectorVec3[] { + const points: DirectorVec3[] = []; + for (const object of scene.objects) { + const { position, rotation, scale } = object.transform; + if (!object.visible || !directorFinitePoint(position)) continue; + points.push(position); + if (![...rotation, ...scale].every(Number.isFinite)) continue; + const half = scale.map((value) => Math.abs(value) / 2) as DirectorVec3; + const euler = new Euler(...rotation); + for (const x of [-half[0], half[0]]) { + for (const y of [-half[1], half[1]]) { + for (const z of [-half[2], half[2]]) { + const corner = new Vector3(x, y, z).applyEuler(euler).add(new Vector3(...position)); + points.push(corner.toArray() as DirectorVec3); + } + } + } + } + for (const camera of scene.cameras) if (directorFinitePoint(camera.transform.position)) points.push(camera.transform.position); + for (const light of scene.lights) if (directorFinitePoint(light.transform.position)) points.push(light.transform.position); + return points; +} + +type DirectorFramingBounds = { center: DirectorVec3; extent: DirectorVec3 }; + +// 空场景 / 全部点位非法时的安全默认包围盒,贴近场景模板的默认布景尺度(见 director-templates.ts 三点布光半径)。 +const DIRECTOR_ORTHOGRAPHIC_DEFAULT_BOUNDS: DirectorFramingBounds = { center: [0, 0, 0], extent: [6, 6, 6] }; +// 单轴范围下限:单点或共面内容不能把该轴压成 0,否则正交相机等于无限缩放。 +const DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT = 2; +// 构图留白:内容贴着画面边缘看不清楚,按包围盒放大这个比例再取景。 +const DIRECTOR_ORTHOGRAPHIC_MARGIN = 1.2; +// 相机与目标的距离相对包围盒半径的倍数:太近会让正交相机的机位穿进内容内部。 +const DIRECTOR_ORTHOGRAPHIC_DISTANCE_FACTOR = 2; +const DIRECTOR_ORTHOGRAPHIC_NEAR = 0.1; +// far 相对距离额外预留的深度倍数,保证视线方向上的内容不被裁切。 +const DIRECTOR_ORTHOGRAPHIC_FAR_MARGIN = 4; +// 视锥换算的跨度下限:0 / 负 / 非有限值都不能当除数或半范围。 +const DIRECTOR_ORTHOGRAPHIC_MIN_SPAN = 1e-3; +// 非法宽高比(0 / 负 / NaN / Infinity)回落正方形,避免半范围变成 NaN 或 Infinity。 +const DIRECTOR_ORTHOGRAPHIC_DEFAULT_ASPECT = 1; + +/** 点云的轴对齐包围盒中心与三轴范围。点云为空时回落安全默认值,每轴范围不低于最小下限。 */ +function resolveDirectorFramingBounds(points: DirectorVec3[]): DirectorFramingBounds { + if (points.length === 0) return DIRECTOR_ORTHOGRAPHIC_DEFAULT_BOUNDS; + const min: DirectorVec3 = [Infinity, Infinity, Infinity]; + const max: DirectorVec3 = [-Infinity, -Infinity, -Infinity]; + for (const point of points) { + for (let axis = 0; axis < 3; axis += 1) { + if (point[axis] < min[axis]) min[axis] = point[axis]; + if (point[axis] > max[axis]) max[axis] = point[axis]; + } + } + const center = [0, 1, 2].map((axis) => (min[axis] + max[axis]) / 2) as DirectorVec3; + const extent = [0, 1, 2].map((axis) => Math.max(max[axis] - min[axis], DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT)) as DirectorVec3; + return { center, extent }; +} + +/** 正交轴向取景数据:视口相机需要被写入的完整状态。 */ +export type DirectorOrthographicFraming = { + position: DirectorVec3; + target: DirectorVec3; + /** 世界系 up,恒定为主轴向量,绝不与视线共线。 */ + up: DirectorVec3; + /** 投影后的水平跨度(已含构图留白)。不得单独按高度×宽高比展开,否则宽内容会被裁切。 */ + horizontalSpan: number; + /** 投影后的竖直跨度(已含构图留白)。 */ + verticalSpan: number; + near: number; + far: number; +}; + +/** 正交相机的左右上下半范围。始终对称,且 width/height 等于安全后的宽高比。 */ +export type DirectorOrthographicFrustum = { + halfWidth: number; + halfHeight: number; + left: number; + right: number; + top: number; + bottom: number; +}; + +function directorSafePositive(value: number, fallback: number): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +/** + * 把投影跨度与视口宽高比收成正交半范围。 + * + * 必须同时装下水平与竖直跨度:只按高度×aspect 算宽度时,宽于画面的内容会被裁掉。 + * aspect 为 0 / 负 / NaN / ±Infinity 时回落 1;跨度非法时回落最小正跨度。 + * 这是纯函数,不读 scene,也不写 three 相机。 + */ +export function resolveDirectorOrthographicFrustum(input: { horizontalSpan: number; verticalSpan: number; aspect: number }): DirectorOrthographicFrustum { + const horizontalSpan = directorSafePositive(input.horizontalSpan, DIRECTOR_ORTHOGRAPHIC_MIN_SPAN); + const verticalSpan = directorSafePositive(input.verticalSpan, DIRECTOR_ORTHOGRAPHIC_MIN_SPAN); + const aspect = directorSafePositive(input.aspect, DIRECTOR_ORTHOGRAPHIC_DEFAULT_ASPECT); + const neededHalfWidth = horizontalSpan / 2; + const neededHalfHeight = verticalSpan / 2; + const halfWidth = Math.max(neededHalfWidth, neededHalfHeight * aspect); + const halfHeight = Math.max(neededHalfHeight, neededHalfWidth / aspect); + return { + halfWidth, + halfHeight, + left: -halfWidth, + right: halfWidth, + top: halfHeight, + bottom: -halfHeight, + }; +} + +/** + * 解出五个正交轴向的取景。free/camera 不是正交轴向,返回 null。 + * + * target 与两条投影跨度来自可见对象的变换包围盒 + 全部摄影机/灯光的位置包围盒;空场景或全部 + * 位置非法(NaN/Infinity)时回落安全默认值,绝不返回会让正交相机退化成无限缩放的 0 值。 + * position 由 target 沿该轴向的视线方向反推,因此永远落在包围盒之外,不会穿模。 + */ +export function resolveDirectorOrthographicFraming(input: { scene: DirectorScene; mode: DirectorViewMode }): DirectorOrthographicFraming | null { + if (!isDirectorOrthographicAxis(input.mode)) return null; + const axis = DIRECTOR_ORTHOGRAPHIC_AXES[input.mode]; + const bounds = resolveDirectorFramingBounds(collectDirectorFramingPoints(input.scene)); + const radius = Math.max(...bounds.extent) / 2; + const distance = Math.max(radius * DIRECTOR_ORTHOGRAPHIC_DISTANCE_FACTOR, DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT); + const target = bounds.center; + const position: DirectorVec3 = [target[0] - axis.view[0] * distance, target[1] - axis.view[1] * distance, target[2] - axis.view[2] * distance]; + return { + position, + target, + up: axis.up, + horizontalSpan: bounds.extent[axis.horizontal] * DIRECTOR_ORTHOGRAPHIC_MARGIN, + verticalSpan: bounds.extent[axis.vertical] * DIRECTOR_ORTHOGRAPHIC_MARGIN, + near: DIRECTOR_ORTHOGRAPHIC_NEAR, + far: distance + radius * DIRECTOR_ORTHOGRAPHIC_FAR_MARGIN, + }; +} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index c7bd52b42..3b348bc7f 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -47,6 +47,7 @@ import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dial import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal"; import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal"; import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal"; +import { CanvasDirectorTemplateModal } from "@/components/canvas/director/canvas-director-template-modal"; import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay"; import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal"; import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; @@ -190,6 +191,7 @@ function InfiniteCanvasPage() { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine); const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); + const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || ""); const [nodes, setNodes] = useState([]); const [connections, setConnections] = useState([]); const [chatSessions, setChatSessions] = useState([]); @@ -219,6 +221,8 @@ function InfiniteCanvasPage() { const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState(null); const [drawingNodeId, setDrawingNodeId] = useState(null); const [stylePickerOpen, setStylePickerOpen] = useState(false); + // 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。 + const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null); const [projectAssetOpen, setProjectAssetOpen] = useState(false); const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all"); const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all"); @@ -2018,6 +2022,12 @@ function InfiniteCanvasPage() { setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> + setDirectorTemplateRequest(null)} + onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)} + /> +
createNode(CanvasNodeType.Drawing)} onAddExtensionNode={(type) => createNode(type)} onAddWorkflow={() => createNode(CanvasNodeType.Config)} - onOpenDirector={() => createDirectorShot()} + onOpenDirector={() => setDirectorTemplateRequest({})} onUndo={undoCanvas} onRedo={redoCanvas} onUpload={() => handleUploadRequest()} @@ -2404,7 +2414,7 @@ function InfiniteCanvasPage() { onAddNode={(type, position) => createNode(type, position)} onAddFolder={createFolder} onChooseStyle={() => setStylePickerOpen(true)} - onOpenDirector={createDirectorShot} + onOpenDirector={(position) => setDirectorTemplateRequest({ position })} onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)} onOpenAssets={openCanvasAssetLibrary} onOpenProjectCharacters={(position) => openProjectAssets("character", position)} @@ -2599,6 +2609,7 @@ function InfiniteCanvasPage() { onApply={applyDirectorOutput} onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))} onFlush={() => flushCanvasStorePersistence()} + onboardingScope={directorOnboardingScope} /> ) : null} diff --git a/web/src/pages/canvas/use-canvas-director.ts b/web/src/pages/canvas/use-canvas-director.ts index 5ff5d7ca7..5e1d62ee5 100644 --- a/web/src/pages/canvas/use-canvas-director.ts +++ b/web/src/pages/canvas/use-canvas-director.ts @@ -1,12 +1,12 @@ -import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import { nanoid } from "nanoid"; import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; -import { createDirectorScene } from "@/lib/canvas/director/director-scene"; -import { upsertDirectorSceneById } from "@/lib/canvas/director/director-session"; +import { createDirectorSceneFromTemplate, type DirectorTemplateId } from "@/lib/canvas/director/director-templates"; +import { mergeDirectorOutputPreview, upsertDirectorSceneById } from "@/lib/canvas/director/director-session"; import { uploadImage } from "@/services/image-storage"; import { uploadMediaFile } from "@/services/file-storage"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; @@ -53,11 +53,24 @@ export function useCanvasDirector({ updateProject, }: UseCanvasDirectorOptions) { const { message } = App.useApp(); + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; - const createDirectorShot = useCallback((position?: Position) => { + useEffect(() => { + projectIdRef.current = projectId; + return () => { + if (projectIdRef.current === projectId) projectIdRef.current = null; + }; + }, [projectId]); + + /** + * 新建镜头。templateId 由调用方(模板选择弹窗)显式给出 —— 这里不设默认模板, + * 否则又会回到「无条件塞一个默认演员」的老问题。 + */ + const createDirectorShot = useCallback((templateId: DirectorTemplateId, position?: Position) => { const shots = nodesRef.current.filter((node) => node.metadata?.workflowKind === "shot"); const shotIndex = Math.max(0, ...shots.map((node) => node.metadata?.shotIndex || 0)) + 1; - let scene = createDirectorScene(`镜头 ${shotIndex}`); + let scene = createDirectorSceneFromTemplate(templateId, `镜头 ${shotIndex}`); const shot = scene.shots[0]; scene = { ...scene, shots: [{ ...shot, name: `镜头 ${shotIndex}` }] }; const node = createCanvasNode(CanvasNodeType.Video, position || getCanvasCenter(), { @@ -87,7 +100,9 @@ export function useCanvasDirector({ if (!node || node.metadata?.workflowKind !== "shot") return; let scene = currentDirectorScenes(projectId, directorScenes).find((item) => item.id === node.metadata?.directorSceneId); if (!scene) { - scene = createDirectorScene(node.metadata?.workflowTitle || node.title || "镜头场景"); + // 孤儿节点修复路径:节点存在但场景丢了。这不是「新建」,不弹模板选择, + // 用空场景兜底 —— 绝不在用户没选过的情况下塞演员进去。 + scene = createDirectorSceneFromTemplate("empty", node.metadata?.workflowTitle || node.title || "镜头场景"); const shot = scene.shots[0]; scene = { ...scene, shots: [{ ...shot, name: node.metadata?.workflowTitle || node.title || shot.name, prompt: node.metadata?.workflowDescription || "" }] }; const directorSceneId = scene.id; @@ -104,13 +119,24 @@ export function useCanvasDirector({ }, [directorScenes, projectId, updateProject]); const applyDirectorOutput = useCallback(async (output: DirectorSceneOutput) => { - const sourceNode = nodesRef.current.find((item) => item.id === directorNodeId); - if (!sourceNode) throw new Error("镜头节点不存在"); + const outputProjectId = projectId; + if (projectIdRef.current !== outputProjectId) throw new Error("画布项目已切换,请重试"); + const sourceNodeAtStart = nodesRef.current.find((item) => item.id === directorNodeId); + if (!sourceNodeAtStart || sourceNodeAtStart.metadata?.directorSceneId !== output.scene.id) throw new Error("镜头节点不存在或场景已切换"); + const sourceNodeId = sourceNodeAtStart.id; const [image, videoUpload] = await Promise.all([ uploadImage(output.beauty), output.clayVideo ? uploadMediaFile(output.clayVideo, "director-clay") : Promise.resolve(null), ]); + // 上传期间项目、节点和镜头都可能变化。以当前权威状态重新核验并合并, + // 不允许旧输出写入另一项目,也不允许旧 scene 快照覆盖并发编辑。 + const outputProject = useCanvasStore.getState().projects.find((item) => item.id === outputProjectId); + const sourceNode = nodesRef.current.find((item) => item.id === sourceNodeId); + const latestScene = outputProject?.directorScenes.find((item) => item.id === output.scene.id); + if (projectIdRef.current !== outputProjectId || !outputProject || !sourceNode || sourceNode.metadata?.directorSceneId !== output.scene.id || !latestScene || !latestScene.shots.some((shot) => shot.id === output.shot.id)) throw new Error("输出期间项目或镜头已切换、删除,请重试"); const previewId = sourceNode.metadata?.directorPreviewNodeId || `image-director-${Date.now()}`; + const mergedScene = mergeDirectorOutputPreview(latestScene, { sceneId: output.scene.id, shotId: output.shot.id, previewNodeId: previewId }); + if (!mergedScene) throw new Error("输出期间镜头已切换或删除,请重试"); const previewSize = fitNodeSize(image.width, image.height); const nextNodes = [...nodesRef.current]; const previewIndex = nextNodes.findIndex((item) => item.id === previewId); @@ -175,8 +201,8 @@ export function useCanvasDirector({ connectionsRef.current = nextConnections; setNodes(finalizedNodes); setConnections(nextConnections); - saveDirectorScene({ ...output.scene, shots: output.scene.shots.map((shot) => shot.id === output.shot.id ? { ...shot, previewNodeId: previewId, depthNodeId: undefined, normalNodeId: undefined } : shot) }); - }, [connectionsRef, directorNodeId, nodesRef, saveDirectorScene, setConnections, setNodes]); + saveDirectorScene(mergedScene); + }, [connectionsRef, directorNodeId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]); return { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene }; } diff --git a/web/src/pages/dev/director-repro-lab.tsx b/web/src/pages/dev/director-repro-lab.tsx index 5655afdb2..ff5f90339 100644 --- a/web/src/pages/dev/director-repro-lab.tsx +++ b/web/src/pages/dev/director-repro-lab.tsx @@ -96,7 +96,9 @@ export default function DirectorReproLab() { - {workbenchOpen ? setWorkbenchOpen(false)} onChange={onChange} onApply={onApply} onDeleteImageNode={() => undefined} onFlush={onFlush} /> : null} + {workbenchOpen ? ( + setWorkbenchOpen(false)} onChange={onChange} onApply={onApply} onDeleteImageNode={() => undefined} onFlush={onFlush} /> + ) : null}
); } diff --git a/web/src/stores/canvas/use-director-workbench-store.ts b/web/src/stores/canvas/use-director-workbench-store.ts index ed07fc2c2..aaeabd511 100644 --- a/web/src/stores/canvas/use-director-workbench-store.ts +++ b/web/src/stores/canvas/use-director-workbench-store.ts @@ -1,11 +1,16 @@ import { create } from "zustand"; +import { DIRECTOR_DEFAULT_MODE, directorModeCapabilities, resolveDirectorModeTransition, type DirectorMode } from "@/lib/canvas/director/director-modes"; +import { DIRECTOR_DEFAULT_VIEW_MODE, type DirectorViewMode } from "@/lib/canvas/director/director-view-modes"; import type { DirectorRenderMode } from "@/types/director"; type DirectorWorkbenchStore = { selectedObjectId: string | null; selectedBone: string | null; selectedLightId: string | null; + mode: DirectorMode; + /** 取景模式(3D/CAM/五个正交轴向)。纯视口状态,不影响一级模式的能力矩阵。 */ + viewMode: DirectorViewMode; transformMode: "translate" | "rotate" | "scale"; renderMode: DirectorRenderMode; playhead: number; @@ -16,7 +21,12 @@ type DirectorWorkbenchStore = { setSelectedObjectId: (id: string | null) => void; setSelectedBone: (bone: string | null) => void; setSelectedLightId: (id: string | null) => void; + /** 切换一级模式。离开动画模式时一并停播、关 Auto Key、夹回合法渲染视图。 */ + setMode: (mode: DirectorMode) => void; + /** 切换取景模式。UI-only:不写入 DirectorScene,不产生 undo/history。 */ + setViewMode: (mode: DirectorViewMode) => void; setTransformMode: (mode: DirectorWorkbenchStore["transformMode"]) => void; + /** 切换渲染视图。当前模式不允许的视图一律忽略,不做静默降级。 */ setRenderMode: (mode: DirectorRenderMode) => void; setPlayhead: (time: number) => void; setPlaying: (playing: boolean) => void; @@ -26,15 +36,32 @@ type DirectorWorkbenchStore = { reset: () => void; }; -const initialState = { selectedObjectId: null, selectedBone: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false, autoKey: false, sequencerHeight: 300, sequencerVisible: true }; +const initialState = { + mode: DIRECTOR_DEFAULT_MODE, + viewMode: DIRECTOR_DEFAULT_VIEW_MODE, + selectedObjectId: null, + selectedBone: null, + selectedLightId: null, + transformMode: "translate" as const, + renderMode: "beauty" as const, + playhead: 0, + playing: false, + autoKey: false, + sequencerHeight: 300, + sequencerVisible: true, +}; export const useDirectorWorkbenchStore = create((set) => ({ ...initialState, setSelectedObjectId: (selectedObjectId) => set({ selectedObjectId, selectedBone: null, selectedLightId: null }), setSelectedBone: (selectedBone) => set({ selectedBone }), setSelectedLightId: (selectedLightId) => set({ selectedLightId, selectedObjectId: null, selectedBone: null }), + setMode: (mode) => set((state) => resolveDirectorModeTransition({ mode, playing: state.playing, autoKey: state.autoKey, renderMode: state.renderMode })), + setViewMode: (viewMode) => set({ viewMode }), setTransformMode: (transformMode) => set({ transformMode }), - setRenderMode: (renderMode) => set({ renderMode }), + // 夹在 store 层而不是只在 UI 层过滤:dock 与顶栏是两条路径, + // 只挡其中一条迟早会漏(本轮就漏过一次:dock 的「骨骼视图」在摆场模式仍可点)。 + setRenderMode: (renderMode) => set((state) => (directorModeCapabilities(state.mode).renderModes.includes(renderMode) ? { renderMode } : {})), setPlayhead: (playhead) => set({ playhead }), setPlaying: (playing) => set({ playing }), setAutoKey: (autoKey) => set({ autoKey }), diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 4ef9023d5..4907a564a 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -9654,7 +9654,8 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f .director-viewport-notice { position: absolute; - z-index: var(--z-toolbar); + /* 可恢复故障必须压过右上角的新手引导,否则真实点击会被引导卡片截获。 */ + z-index: var(--z-toast); display: grid; gap: var(--space-2); padding: var(--space-4); @@ -9765,6 +9766,94 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f background: var(--director-sequencer-border); } +/* 一级模式切换。位于工作台顶栏(跟随明暗主题),因此用主题感知的语义 token, + 不能用 --director-dock-* 那套常暗色,否则亮色主题下会看不见。 + 选中态用 control-selected 角色,而不是主操作色:它表达「当前视图」而非主要命令。 */ +.director-mode-switch { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-half); +} + +.director-mode-switch-button { + height: var(--space-6); + padding: 0 var(--space-2); + border: var(--stroke-1) solid transparent; + border-radius: var(--r-sm); + color: var(--foreground); + font-size: var(--fs-tiny); + opacity: 0.62; + transition: background var(--motion-dur-fast-calc) var(--motion-ease-out), opacity var(--motion-dur-fast-calc) var(--motion-ease-out); +} + +.director-mode-switch-button:hover { + background: var(--surface-card-hover); + opacity: 1; +} + +.director-mode-switch-button.is-active { + border-color: var(--control-selected-border); + background: var(--control-selected-bg); + color: var(--control-selected-fg); + font-weight: 500; + opacity: 1; +} + +.director-mode-switch-button:focus-visible { + outline: var(--stroke-2) solid var(--control-focus-ring); + outline-offset: 1px; +} + +/* 模板选择卡片。位于 Modal 内(跟随明暗主题),同样只用主题感知语义 token。 */ +.director-template-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); +} + +.director-template-card { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + padding: var(--space-3); + border: var(--stroke-1) solid var(--card-border); + border-radius: var(--r-md); + background: var(--card-surface); + color: var(--foreground); + text-align: left; + transition: background var(--motion-dur-fast-calc) var(--motion-ease-out); +} + +.director-template-card:hover { + border-color: var(--control-selected-border); + background: var(--card-surface-hover); +} + +.director-template-card:focus-visible { + outline: var(--stroke-2) solid var(--control-focus-ring); + outline-offset: 1px; +} + +.director-template-card-name { + font-size: var(--fs-body); + font-weight: 500; +} + +.director-template-card-summary { + color: var(--control-selected-fg); + font-size: var(--fs-micro); + opacity: 0.72; +} + +.director-template-card-description { + font-size: var(--fs-tiny); + line-height: 1.5; + opacity: 0.6; +} + .director-actor-colors { display: flex; min-width: 0; @@ -9993,6 +10082,36 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f transform: translate(-50%, -50%) rotate(45deg); } +/* 可删除关键帧:8px 菱形本体太小,用透明外框把命中区扩到 18px,保留视觉尺寸。 + hover / focus 都走 outline,不覆写 box-shadow —— 基础规则的 hairline 保持原样。 + focus-visible 与 hover 同特异性,靠源码顺序让焦点环压过悬停环。 */ +.director-sequencer-key.is-actionable { + padding: 0; + border: 0; + outline-offset: 2px; + cursor: pointer; + transition: outline-color var(--motion-dur-fast-calc) var(--motion-ease-out); +} + +.director-sequencer-key.is-actionable::after { + position: absolute; + inset: -5px; + content: ""; +} + +.director-sequencer-key.is-actionable:hover { + outline: var(--stroke-2) solid color-mix(in srgb, var(--control-focus-ring) 55%, transparent); +} + +.director-sequencer-key.is-actionable:focus-visible { + outline: var(--stroke-2) solid var(--control-focus-ring); +} + +.director-sequencer-key.is-actionable.is-selected { + outline: var(--stroke-2) solid var(--control-focus-ring); + outline-offset: 2px; +} + .director-sequencer-clip { position: absolute; top: 5px; diff --git a/web/src/types/director.ts b/web/src/types/director.ts index 5bb21e45d..664753bce 100644 --- a/web/src/types/director.ts +++ b/web/src/types/director.ts @@ -13,12 +13,13 @@ export type DirectorPose = "neutral" | "stand" | "t_pose" | "walk" | "run" | "si export type DirectorCameraMove = "static" | "push_in" | "pull_out" | "pan_left" | "pan_right" | "tilt_up" | "tilt_down" | "orbit_left" | "orbit_right" | "handheld"; export type DirectorShotSize = "extreme_wide" | "wide" | "full" | "medium" | "close_up" | "extreme_close_up"; export type DirectorRenderMode = "beauty" | "clay" | "depth" | "normal" | "pose"; +export type DirectorKeyframeEasing = "step" | "linear" | "smooth"; export type DirectorKeyframe = { id: string; time: number; transform: DirectorTransform; - easing?: "step" | "linear" | "smooth"; + easing?: DirectorKeyframeEasing; }; export type DirectorFingerBone = @@ -31,7 +32,7 @@ export type DirectorBoneKeyframe = { id: string; time: number; rotation: DirectorQuat; - easing?: "step" | "linear" | "smooth"; + easing?: DirectorKeyframeEasing; }; export type DirectorBoneTrack = { @@ -39,6 +40,15 @@ export type DirectorBoneTrack = { keyframes: DirectorBoneKeyframe[]; }; +/** + * 时间轴上一个可删除关键帧的定位信息。 + * 三类覆盖当前时间轴真正可见的关键帧轨道:对象 transform、对象骨骼、摄影机。 + */ +export type DirectorKeyframeDeleteTarget = + | { track: "object-transform"; objectId: string; keyframeId: string } + | { track: "object-bone"; objectId: string; bone: DirectorHumanoidBone; keyframeId: string } + | { track: "camera"; cameraId: string; keyframeId: string }; + export type DirectorRig = { status: "unmapped" | "ready"; boneMap: Partial>; diff --git a/web/test/director-keyframe-delete-wiring.test.ts b/web/test/director-keyframe-delete-wiring.test.ts new file mode 100644 index 000000000..3babb2d34 --- /dev/null +++ b/web/test/director-keyframe-delete-wiring.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * 生产接线回归:领域函数正确不代表用户能删到关键帧。 + * 这里锁住「时间轴入口 -> workbench -> 领域函数」这条链路真的接上了。 + */ +const dock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-viewport-dock.tsx"), "utf8"); +const workbench = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-workbench.tsx"), "utf8"); +const sequencer = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-sequencer.tsx"), "utf8"); +const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8"); + +function slice(source: string, from: string, to: string) { + const start = source.indexOf(from); + expect(start).toBeGreaterThanOrEqual(0); + const end = source.indexOf(to, start + from.length); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("workbench 接入时间轴关键帧删除", () => { + test("DirectorSequencer 拿到的是 deleteKeyframe,而不是空实现", () => { + const element = slice(workbench, ""); + expect(element).toContain("onDeleteKeyframe={deleteKeyframe}"); + expect(element).not.toContain("onDeleteKeyframe={() =>"); + }); + + test("缓动更新通过 workbench commit 接入,未命中不制造历史", () => { + const element = slice(workbench, ""); + expect(element).toContain("onSetKeyframeEasing={setKeyframeEasing}"); + const handler = slice(workbench, "const setKeyframeEasing = useCallback", "* 快捷键执行器"); + expect(handler).toContain("setDirectorSceneKeyframeEasing(current, target, easing) === current"); + expect(handler).toContain("commit((scene) => setDirectorSceneKeyframeEasing(scene, target, easing));"); + }); + + test("deleteKeyframe 未命中时不进 commit:不记历史、不产生修订", () => { + const handler = slice(workbench, "const deleteKeyframe = useCallback", "* 快捷键执行器"); + expect(handler).toContain("removeDirectorSceneKeyframe(current, target) === current) return;"); + expect(handler).toContain("commit((scene) => removeDirectorSceneKeyframe(scene, target));"); + }); + + test("删除走 commit,因此进历史、可撤销、并触发 canonical 保存", () => { + // commit 自身的语义:入历史 + writeAndPublish(coordinator 保存 + 镜像项目)。 + const commit = slice(workbench, "const commit = useCallback", "/** 暂存型手势"); + expect(commit).toContain("setHistory((items) => [...items.slice(-49), structuredClone(current)]);"); + expect(commit).toContain("writeAndPublish(touchDirectorScene(updater(current)));"); + }); +}); + +describe("workbench 快捷键接线", () => { + const effect = slice(workbench, "// 导演台是全屏浮层,快捷键挂在 window", "/** 对象 transform 编辑的唯一入口"); + + test("监听挂在 window keydown,并随 open 装卸", () => { + expect(effect).toContain("if (!open) return;"); + expect(effect).toContain('window.addEventListener("keydown", onKeyDown);'); + expect(effect).toContain('window.removeEventListener("keydown", onKeyDown);'); + }); + + test("交互控件语境由 blocksDirectorShortcut 判定后交给解析器", () => { + expect(effect).toContain("isInteractiveTarget: blocksDirectorShortcut(event.target),"); + // 旧的 text-only 判定不得残留:它放过 BUTTON/A/role 型控件。 + expect(workbench).not.toContain("isDirectorTextEntryTarget"); + }); + + test("只有动作真的执行了才 preventDefault", () => { + expect(effect).toContain("if (runShortcutRef.current(action)) event.preventDefault();"); + // 不得无条件吞掉按键。 + expect(effect).not.toContain("event.preventDefault();\n if (!action)"); + }); + + test("每个动作都落到已存在的真实执行路径", () => { + const runner = slice(workbench, "const runShortcut = (action: DirectorShortcutAction)", "const runShortcutRef"); + expect(runner).toContain("setTransformMode(action.mode);"); + expect(runner).toContain("removeObject(selectedObject.id);"); + expect(runner).toContain("removeLight(selectedLight.id);"); + expect(runner).toContain("updateObject(selectedObject.id, { visible: !selectedObject.visible });"); + expect(runner).toContain("setPlaying(!playing);"); + expect(runner).toContain("undo();"); + expect(runner).toContain("redo();"); + }); + + test("没有可操作对象/历史时返回 false,把按键交还浏览器", () => { + const runner = slice(workbench, "const runShortcut = (action: DirectorShortcutAction)", "const runShortcutRef"); + expect(runner).toContain("if (!history.length) return false;"); + expect(runner).toContain("if (!future.length) return false;"); + expect(runner).toContain("if (!selectedObject) return false;"); + expect(runner).toContain("if (!selectedObjectId && !selectedLightId && !selectedBone) return false;"); + }); + + test("dock 与场景列表点选后释放焦点:否则点完就再按不动 W/E/R/Delete", () => { + // 全局快捷键是本轮新增的,交互控件守卫会吃掉聚焦按钮上的按键。 + // dock 承载 W/E/R 变换工具;场景列表承载「点选对象 -> 按 Delete」主流程。 + const dockButton = slice(dock, "function DockButton(", "function DockDivider("); + expect(dockButton).toContain("releaseDirectorFocusAfterPointer(event)"); + const sceneRow = slice(workbench, "function SceneRow(", "function AddMenuButton("); + expect(sceneRow).toContain("releaseDirectorFocusAfterPointer(event)"); + }); + + test("焦点释放规则集中在共享 helper,各按钮不自写 blur", () => { + // 判据(keyboard detail === 0 保留焦点)必须只有一处,否则迟早走偏。 + const dockButton = slice(dock, "function DockButton(", "function DockDivider("); + expect(dockButton).not.toContain("event.currentTarget.blur()"); + expect(workbench).not.toContain("if (event.detail !== 0) event.currentTarget.blur();"); + }); + + test("时间轴轨道行点选后也释放焦点:与场景列表同源的 select-then-Delete 流程", () => { + const row = slice(sequencer, "function SequencerRow(", "* 关键帧渲染为真实 button"); + expect(row).toContain("releaseDirectorFocusAfterPointer(event)"); + }); + + test("关键帧按钮绝不释放焦点:它自己拥有 Enter/Space/Delete/Backspace", () => { + // 关键帧 blur 掉焦点会让键盘连续删除失效,且与它的 stopPropagation 设计冲突。 + const trackKeys = slice(sequencer, "function TrackKeys(", "function TrackBar("); + expect(trackKeys).not.toContain("releaseDirectorFocusAfterPointer"); + }); +}); + +describe("时间轴关键帧入口可见、可选择、可键盘删除", () => { + const trackKeys = slice(sequencer, "function TrackKeys(", "function TrackBar("); + + test("可编辑关键帧是真实 button,不是惰性 span", () => { + expect(trackKeys).toContain('type="button"'); + expect(trackKeys).toContain("director-sequencer-key is-actionable"); + expect(trackKeys).toContain('aria-label={`选择 ${key.label ?? "关键帧"} ${key.time.toFixed(2)}s 的关键帧`}'); + expect(trackKeys).toContain("aria-pressed="); + }); + + test("普通点击与 Enter/Space 只选择定位,Delete/Backspace 才删除,且都不冒泡", () => { + expect(trackKeys).toContain('if (!["Enter", " ", "Delete", "Backspace"].includes(event.key)) return;'); + expect(trackKeys).toContain("event.preventDefault();"); + expect(trackKeys).toContain("event.stopPropagation();"); + expect(trackKeys).toContain('if (event.key === "Delete" || event.key === "Backspace") onDeleteKey(target);'); + expect(trackKeys).toContain("else onSelectKey?.(key);"); + expect(trackKeys).toContain("onSelectKey?.(key);"); + }); + + test("没有 target 的轨道保持只读,不渲染删除按钮", () => { + expect(trackKeys).toContain("const target = onDeleteKey && key.target;"); + expect(trackKeys).toContain("if (!target) {"); + }); + + test("命中区扩大且有 focus-visible 焦点环", () => { + expect(styles).toContain(".director-sequencer-key.is-actionable"); + expect(styles).toContain(".director-sequencer-key.is-actionable::after"); + expect(styles).toContain(".director-sequencer-key.is-actionable:focus-visible"); + expect(styles).toContain(".director-sequencer-key.is-actionable.is-selected"); + }); + + test("hover/focus 只用语义 token,不新增硬编码颜色", () => { + const block = slice(styles, "/* 可删除关键帧:", ".director-sequencer-clip {"); + expect(block).toContain("outline: var(--stroke-2) solid var(--control-focus-ring);"); + expect(block).toContain("color-mix(in srgb, var(--control-focus-ring) 55%, transparent)"); + // rgba / 十六进制字面值一律不允许出现在新增块里。 + expect(block).not.toMatch(/rgba?\(/); + expect(block).not.toMatch(/#[0-9a-fA-F]{3,8}/); + }); +}); + +describe("三类轨道都有删除入口,概览轨保持只读", () => { + test("摄影机行可删除,Camera Cut 概览轨只读", () => { + const cameraCut = slice(sequencer, 'label="Camera Cut"', ""); + expect(cameraCut).toContain(""); + expect(cameraCut).not.toContain("onDeleteKey"); + + const cameraRow = slice(sequencer, "{camera ? : null}"); + expect(cameraRow).toContain("onDeleteKey={deleteTrackKey}"); + }); + + test("摄影机关键帧带 camera 删除目标", () => { + expect(sequencer).toContain('target: { track: "camera", cameraId: camera.id, keyframeId: key.id }'); + }); + + test("对象 transform 与骨骼帧各自带正确目标", () => { + expect(sequencer).toContain('target: { track: "object-transform" as const, objectId: object.id, keyframeId: key.id }'); + expect(sequencer).toContain('target: { track: "object-bone" as const, objectId: object.id, bone: track.bone, keyframeId: key.id }'); + }); + + test("折叠状态下的对象汇总轨也可删除,否则收起后就删不掉", () => { + const summaryRow = slice(sequencer, ""); + expect(summaryRow).toContain("keys={[...transformKeys, ...boneTrackKeys]}"); + expect(summaryRow).toContain("onDeleteKey={deleteTrackKey}"); + }); + + test("展开后的 Transform 子轨与骨骼子轨都可删除", () => { + const transformRow = slice(sequencer, 'label="Transform" icon="◇"', ""); + expect(transformRow).toContain("onDeleteKey={deleteTrackKey}"); + const boneRow = slice(sequencer, "label={directorBoneLabel(track.bone)}", ""); + expect(boneRow).toContain("onDeleteKey={deleteTrackKey}"); + }); + + test("非演员对象(立方体/模型/立牌)的 transform 轨同样可删除", () => { + const plainRow = slice(sequencer, 'icon="□"', ""); + expect(plainRow).toContain("onDeleteKey={deleteTrackKey}"); + expect(sequencer).toContain('target: { track: "object-transform", objectId: object.id, keyframeId: key.id }'); + }); + + test("所选关键帧提供 step/linear/smooth 缓动切换与显式删除按钮", () => { + expect(sequencer).toContain('aria-label="关键帧缓动"'); + expect(sequencer).toContain(''); + expect(sequencer).toContain(''); + expect(sequencer).toContain(''); + expect(sequencer).toContain('aria-label="删除所选关键帧"'); + }); + + test("切换摄影机或外部删帧后清除陈旧选择,顶部控件不能误改不可见轨道", () => { + expect(sequencer).toContain("directorKeyframeTargetExists(selectedKey.target, camera, objects)"); + expect(sequencer).toContain("setSelectedKey((current) => current?.target && !directorKeyframeTargetExists(current.target, camera, objects) ? null : current);"); + expect(sequencer).toContain("camera?.id === target.cameraId && camera.keyframes.some"); + expect(sequencer).toContain("object.boneTracks?.some"); + }); +}); diff --git a/web/test/director-keyframe-editing.test.ts b/web/test/director-keyframe-editing.test.ts new file mode 100644 index 000000000..a629eefed --- /dev/null +++ b/web/test/director-keyframe-editing.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; + +import { + DIRECTOR_KEYFRAME_EPSILON, + createDirectorActor, + createDirectorCamera, + createDirectorScene, + interpolateDirectorTransform, + removeDirectorBoneKeyframe, + removeDirectorKeyframe, + removeDirectorSceneKeyframe, + resolveDirectorKeyframeProgress, + setDirectorSceneKeyframeEasing, + upsertDirectorBoneKeyframe, + upsertDirectorKeyframe, +} from "../src/lib/canvas/director/director-scene"; +import type { DirectorKeyframe, DirectorQuat, DirectorScene, DirectorTransform } from "../src/types/director"; + +const transformAt = (x: number): DirectorTransform => ({ position: [x, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }); + +function seededKeyframes(): DirectorKeyframe[] { + let keys: DirectorKeyframe[] = []; + keys = upsertDirectorKeyframe(keys, 0, transformAt(0)); + keys = upsertDirectorKeyframe(keys, 1, transformAt(10)); + keys = upsertDirectorKeyframe(keys, 2, transformAt(20)); + return keys; +} + +describe("删除 transform 关键帧", () => { + test("按 id 删除只移除目标帧,其余保持有序", () => { + const keys = seededKeyframes(); + const next = removeDirectorKeyframe(keys, keys[1].id); + + expect(next).toHaveLength(2); + expect(next.map((k) => k.time)).toEqual([0, 2]); + // 纯函数:不得改写入参。 + expect(keys).toHaveLength(3); + }); + + test("id 不存在时返回原引用,避免无意义重渲染", () => { + const keys = seededKeyframes(); + expect(removeDirectorKeyframe(keys, "missing")).toBe(keys); + }); + + test("删空后得到空数组,插值回落到 base", () => { + let keys = upsertDirectorKeyframe([], 1, transformAt(5)); + keys = removeDirectorKeyframe(keys, keys[0].id); + expect(keys).toEqual([]); + expect(interpolateDirectorTransform(transformAt(99), keys, 1).position[0]).toBe(99); + }); + + test("upsert 使用同一容差,记录下的帧一定能按 id 删除", () => { + // 非整帧时间点最容易出现「记录能覆盖但删不掉」,这里锁住判据一致。 + let keys = upsertDirectorKeyframe([], 0.75, transformAt(3)); + keys = upsertDirectorKeyframe(keys, 0.75 + DIRECTOR_KEYFRAME_EPSILON / 2, transformAt(4)); + expect(keys).toHaveLength(1); + expect(removeDirectorKeyframe(keys, keys[0].id)).toEqual([]); + }); +}); + +describe("关键帧缓动", () => { + test("linear / step / smooth 产生不同且确定的区间进度", () => { + expect(resolveDirectorKeyframeProgress(0.25, "linear")).toBe(0.25); + expect(resolveDirectorKeyframeProgress(0.25, "step")).toBe(0); + expect(resolveDirectorKeyframeProgress(0.25, "smooth")).toBeCloseTo(0.15625, 8); + expect(resolveDirectorKeyframeProgress(-1, "linear")).toBe(0); + expect(resolveDirectorKeyframeProgress(2, "linear")).toBe(1); + }); + + test("插值读取前一枚关键帧的 easing,旧数据默认线性", () => { + const base = seededKeyframes().slice(0, 2); + expect(interpolateDirectorTransform(transformAt(0), base, 0.25).position[0]).toBeCloseTo(2.5, 8); + expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 0.25).position[0]).toBe(0); + expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "smooth" }, base[1]], 0.25).position[0]).toBeCloseTo(1.5625, 8); + expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 1).position[0]).toBe(10); + }); +}); + +describe("删除骨骼关键帧", () => { + const rotation: DirectorQuat = [0, 0, 0, 1]; + + test("删除后轨道保留其余帧", () => { + let tracks = upsertDirectorBoneKeyframe([], "hips", 0, rotation); + tracks = upsertDirectorBoneKeyframe(tracks, "hips", 1, rotation); + const target = tracks[0].keyframes[0].id; + + const next = removeDirectorBoneKeyframe(tracks, "hips", target); + expect(next).toHaveLength(1); + expect(next[0].keyframes).toHaveLength(1); + expect(next[0].keyframes[0].time).toBe(1); + }); + + test("轨道被删空后整条移除,不留空子轨道", () => { + const tracks = upsertDirectorBoneKeyframe([], "head", 0, rotation); + const next = removeDirectorBoneKeyframe(tracks, "head", tracks[0].keyframes[0].id); + expect(next).toEqual([]); + }); + + test("骨骼或 id 不存在时返回原引用", () => { + const tracks = upsertDirectorBoneKeyframe([], "head", 0, rotation); + expect(removeDirectorBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id)).toBe(tracks); + expect(removeDirectorBoneKeyframe(tracks, "head", "missing")).toBe(tracks); + }); + + test("只影响目标骨骼,其他轨道不动", () => { + let tracks = upsertDirectorBoneKeyframe([], "hips", 0, rotation); + tracks = upsertDirectorBoneKeyframe(tracks, "head", 0, rotation); + const next = removeDirectorBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id); + expect(next.map((t) => t.bone)).toEqual(["head"]); + }); +}); + +describe("removeDirectorSceneKeyframe:时间轴删除的唯一分派入口", () => { + const rotation: DirectorQuat = [0, 0, 0, 1]; + + /** 一个带对象 transform 帧、骨骼帧和摄影机帧的完整场景。 */ + function seededScene() { + const base = createDirectorScene("删除测试"); + const actor = { + ...createDirectorActor("演员 A"), + keyframes: upsertDirectorKeyframe(upsertDirectorKeyframe([], 0, transformAt(0)), 1, transformAt(10)), + boneTracks: upsertDirectorBoneKeyframe(upsertDirectorBoneKeyframe([], "hips", 0, rotation), "head", 0.5, rotation), + }; + const camera = { ...createDirectorCamera("摄影机 A"), keyframes: upsertDirectorKeyframe([], 2, transformAt(5)) }; + const scene: DirectorScene = { ...base, objects: [actor], cameras: [camera] }; + return { scene, actor, camera }; + } + + test("删除对象 transform 帧,只动目标对象", () => { + const { scene, actor } = seededScene(); + const next = removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id }); + + expect(next).not.toBe(scene); + expect(next.objects[0].keyframes.map((key) => key.time)).toEqual([1]); + // 骨骼轨道和摄影机不受影响,保持原引用。 + expect(next.objects[0].boneTracks).toBe(actor.boneTracks); + expect(next.cameras).toBe(scene.cameras); + }); + + test("删除骨骼帧,删空的子轨道整条移除", () => { + const { scene, actor } = seededScene(); + const hips = actor.boneTracks.find((track) => track.bone === "hips"); + const next = removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: hips!.keyframes[0].id }); + + expect(next.objects[0].boneTracks?.map((track) => track.bone)).toEqual(["head"]); + expect(next.objects[0].keyframes).toBe(actor.keyframes); + }); + + test("删除摄影机帧,只动目标摄影机", () => { + const { scene, camera } = seededScene(); + const next = removeDirectorSceneKeyframe(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id }); + + expect(next.cameras[0].keyframes).toEqual([]); + expect(next.objects).toBe(scene.objects); + }); + + test("未命中一律返回同一 scene 引用:调用方据此跳过历史与保存", () => { + const { scene, actor, camera } = seededScene(); + // 对象存在但帧 id 不存在。 + expect(removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: "missing" })).toBe(scene); + // 对象本身不存在。 + expect(removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: "missing", keyframeId: actor.keyframes[0].id })).toBe(scene); + // 骨骼轨道不存在。 + expect(removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "leftHand", keyframeId: "missing" })).toBe(scene); + // 摄影机不存在。 + expect(removeDirectorSceneKeyframe(scene, { track: "camera", cameraId: "missing", keyframeId: camera.keyframes[0].id })).toBe(scene); + }); + + test("对象没有 boneTracks 时删除骨骼帧不虚构空数组", () => { + const base = createDirectorScene("无骨骼"); + const object = { ...createDirectorActor("演员 B"), boneTracks: undefined }; + const scene: DirectorScene = { ...base, objects: [object] }; + expect(removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: object.id, bone: "hips", keyframeId: "any" })).toBe(scene); + }); + + test("不改写入参:原 scene 与原数组保持完整", () => { + const { scene, actor } = seededScene(); + removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id }); + expect(scene.objects[0].keyframes).toHaveLength(2); + }); +}); + +describe("setDirectorSceneKeyframeEasing:按轨道更新且保持纯函数", () => { + test("对象、骨骼和摄影机三类目标都能独立更新", () => { + const base = createDirectorScene("缓动测试"); + const actor = { + ...createDirectorActor("演员"), + keyframes: upsertDirectorKeyframe([], 0, transformAt(0)), + boneTracks: upsertDirectorBoneKeyframe([], "hips", 0, [0, 0, 0, 1]), + }; + const camera = { ...createDirectorCamera("摄影机"), keyframes: upsertDirectorKeyframe([], 0, transformAt(1)) }; + const scene: DirectorScene = { ...base, objects: [actor], cameras: [camera] }; + + const objectNext = setDirectorSceneKeyframeEasing(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id }, "smooth"); + expect(objectNext.objects[0].keyframes[0].easing).toBe("smooth"); + expect(objectNext.cameras).toBe(scene.cameras); + + const boneNext = setDirectorSceneKeyframeEasing(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: actor.boneTracks[0].keyframes[0].id }, "step"); + expect(boneNext.objects[0].boneTracks?.[0].keyframes[0].easing).toBe("step"); + + const cameraNext = setDirectorSceneKeyframeEasing(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id }, "linear"); + expect(cameraNext.cameras[0].keyframes[0].easing).toBe("linear"); + expect(scene.objects[0].keyframes[0].easing).toBeUndefined(); + }); + + test("目标不存在时返回原 scene 引用", () => { + const scene = createDirectorScene("missing"); + expect(setDirectorSceneKeyframeEasing(scene, { track: "camera", cameraId: "missing", keyframeId: "missing" }, "smooth")).toBe(scene); + }); +}); diff --git a/web/test/director-modes.test.ts b/web/test/director-modes.test.ts new file mode 100644 index 000000000..e62f6bb8f --- /dev/null +++ b/web/test/director-modes.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, test } from "bun:test"; + +import { DIRECTOR_DEFAULT_MODE, DIRECTOR_MODES, directorModeCapabilities, resolveDirectorModeTransition, type DirectorMode } from "../src/lib/canvas/director/director-modes"; +import { createDirectorScene } from "../src/lib/canvas/director/director-scene"; +import { createDirectorSceneFromTemplate } from "../src/lib/canvas/director/director-templates"; +import { useDirectorWorkbenchStore } from "../src/stores/canvas/use-director-workbench-store"; + +const ALL_MODES: DirectorMode[] = ["layout", "pose", "animate", "camera"]; + +describe("四模式骨架", () => { + test("恰好四个一级模式,顺序为摆场/姿态/动画/摄影机", () => { + expect(DIRECTOR_MODES.map((item) => item.mode)).toEqual(ALL_MODES); + expect(DIRECTOR_MODES.map((item) => item.label)).toEqual(["摆场", "姿态", "动画", "摄影机"]); + }); + + test("默认模式是摆场", () => { + expect(DIRECTOR_DEFAULT_MODE).toBe("layout"); + expect(useDirectorWorkbenchStore.getState().mode).toBe("layout"); + }); + + test("每个模式都有可读标签与提示,保证可发现", () => { + for (const item of DIRECTOR_MODES) { + expect(item.label.length).toBeGreaterThan(0); + expect(item.hint.length).toBeGreaterThan(0); + } + }); +}); + +describe("能力矩阵", () => { + test("只有动画模式显示时间轴、允许关键帧与 Auto Key", () => { + for (const mode of ALL_MODES) { + const capabilities = directorModeCapabilities(mode); + expect(capabilities.timeline).toBe(mode === "animate"); + expect(capabilities.keyframes).toBe(mode === "animate"); + } + }); + + test("摆场隐藏骨骼;姿态与动画显示骨骼", () => { + expect(directorModeCapabilities("layout").bones).toBe(false); + expect(directorModeCapabilities("camera").bones).toBe(false); + expect(directorModeCapabilities("pose").bones).toBe(true); + expect(directorModeCapabilities("animate").bones).toBe(true); + }); + + test("只有摄影机模式突出 shot/camera 工具", () => { + for (const mode of ALL_MODES) { + expect(directorModeCapabilities(mode).cameraTools).toBe(mode === "camera"); + } + }); + + test("摆场不提供深度/法线等高级视图;摄影机模式才提供", () => { + expect(directorModeCapabilities("layout").renderModes).toEqual(["beauty", "clay"]); + expect(directorModeCapabilities("layout").renderModes).not.toContain("depth"); + expect(directorModeCapabilities("layout").renderModes).not.toContain("pose"); + expect(directorModeCapabilities("camera").renderModes).toContain("depth"); + expect(directorModeCapabilities("camera").renderModes).toContain("normal"); + }); + + test("姿态模式提供骨骼视图", () => { + expect(directorModeCapabilities("pose").renderModes).toContain("pose"); + }); + + test("未知模式回落到默认模式的能力,不返回 undefined", () => { + expect(directorModeCapabilities("nope" as DirectorMode)).toEqual(directorModeCapabilities("layout")); + }); +}); + +describe("切换清理:离开动画不得残留后台动画写入", () => { + test("离开动画模式一定停播并关掉 Auto Key", () => { + for (const mode of ["layout", "pose", "camera"] as DirectorMode[]) { + const next = resolveDirectorModeTransition({ mode, playing: true, autoKey: true, renderMode: "beauty" }); + expect(next.playing).toBe(false); + expect(next.autoKey).toBe(false); + } + }); + + test("留在动画模式时保留播放与 Auto Key", () => { + const next = resolveDirectorModeTransition({ mode: "animate", playing: true, autoKey: true, renderMode: "beauty" }); + expect(next.playing).toBe(true); + expect(next.autoKey).toBe(true); + }); + + test("renderMode 被夹回目标模式允许的集合", () => { + // 摄影机模式的深度视图不能带进摆场。 + expect(resolveDirectorModeTransition({ mode: "layout", playing: false, autoKey: false, renderMode: "depth" }).renderMode).toBe("beauty"); + // 姿态模式的骨骼视图同样不属于摆场。 + expect(resolveDirectorModeTransition({ mode: "layout", playing: false, autoKey: false, renderMode: "pose" }).renderMode).toBe("beauty"); + // 合法组合保持不变。 + expect(resolveDirectorModeTransition({ mode: "camera", playing: false, autoKey: false, renderMode: "normal" }).renderMode).toBe("normal"); + expect(resolveDirectorModeTransition({ mode: "layout", playing: false, autoKey: false, renderMode: "clay" }).renderMode).toBe("clay"); + }); +}); + +describe("store 的 setMode", () => { + beforeEach(() => { + useDirectorWorkbenchStore.getState().reset(); + }); + + test("切到动画再切回摆场:playing 与 autoKey 都被清掉", () => { + const store = useDirectorWorkbenchStore; + store.getState().setMode("animate"); + store.getState().setPlaying(true); + store.getState().setAutoKey(true); + expect(store.getState().playing).toBe(true); + expect(store.getState().autoKey).toBe(true); + + store.getState().setMode("layout"); + expect(store.getState().mode).toBe("layout"); + expect(store.getState().playing).toBe(false); + expect(store.getState().autoKey).toBe(false); + }); + + test("切模式不影响选择状态:draft 归属由 workbench 自己管", () => { + const store = useDirectorWorkbenchStore; + store.getState().setSelectedObjectId("obj-1"); + store.getState().setMode("camera"); + expect(store.getState().selectedObjectId).toBe("obj-1"); + store.getState().setMode("pose"); + expect(store.getState().selectedObjectId).toBe("obj-1"); + }); + + test("切模式不重置 playhead:时间位置不因为换视图而丢", () => { + const store = useDirectorWorkbenchStore; + store.getState().setMode("animate"); + store.getState().setPlayhead(2.5); + store.getState().setMode("pose"); + expect(store.getState().playhead).toBe(2.5); + }); + + test("reset 回到默认摆场", () => { + const store = useDirectorWorkbenchStore; + store.getState().setMode("animate"); + store.getState().reset(); + expect(store.getState().mode).toBe("layout"); + }); + + test("摆场模式拒绝 pose/depth/normal,只接受 beauty/clay", () => { + const store = useDirectorWorkbenchStore; + expect(store.getState().mode).toBe("layout"); + + store.getState().setRenderMode("clay"); + expect(store.getState().renderMode).toBe("clay"); + + // 这三个不属于摆场:必须被拒绝,且不得把已有选择冲掉。 + for (const rejected of ["pose", "depth", "normal"] as const) { + store.getState().setRenderMode(rejected); + expect(store.getState().renderMode).toBe("clay"); + } + }); + + test("dock 的「骨骼视图」在摆场模式点了也无效:store 层夹住了所有路径", () => { + const store = useDirectorWorkbenchStore; + // dock 按钮等价于直接调 setRenderMode("pose")。 + store.getState().setRenderMode("pose"); + expect(store.getState().renderMode).toBe("beauty"); + }); + + test("摄影机模式才接受 depth/normal", () => { + const store = useDirectorWorkbenchStore; + store.getState().setMode("camera"); + store.getState().setRenderMode("depth"); + expect(store.getState().renderMode).toBe("depth"); + store.getState().setRenderMode("normal"); + expect(store.getState().renderMode).toBe("normal"); + }); + + test("姿态模式接受骨骼视图,但仍拒绝深度视图", () => { + const store = useDirectorWorkbenchStore; + store.getState().setMode("pose"); + store.getState().setRenderMode("pose"); + expect(store.getState().renderMode).toBe("pose"); + store.getState().setRenderMode("depth"); + expect(store.getState().renderMode).toBe("pose"); + }); + + test("带着深度视图离开摄影机模式会被夹回 beauty,不残留非法组合", () => { + const store = useDirectorWorkbenchStore; + store.getState().setMode("camera"); + store.getState().setRenderMode("depth"); + expect(store.getState().renderMode).toBe("depth"); + + store.getState().setMode("layout"); + expect(store.getState().renderMode).toBe("beauty"); + }); +}); + +describe("mode 不进 DirectorScene schema", () => { + test("兼容 factory 与模板生成的场景都没有 mode 字段", () => { + expect(Object.keys(createDirectorScene("s"))).not.toContain("mode"); + expect(Object.keys(createDirectorSceneFromTemplate("dialogue"))).not.toContain("mode"); + }); + + test("切模式不产生任何场景对象:mode 纯属工作台 UI 状态", () => { + const store = useDirectorWorkbenchStore; + const before = createDirectorSceneFromTemplate("monologue"); + const snapshot = JSON.stringify(before); + store.getState().setMode("animate"); + store.getState().setMode("layout"); + expect(JSON.stringify(before)).toBe(snapshot); + }); +}); diff --git a/web/test/director-onboarding.test.ts b/web/test/director-onboarding.test.ts new file mode 100644 index 000000000..448029c5f --- /dev/null +++ b/web/test/director-onboarding.test.ts @@ -0,0 +1,581 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import localforage from "localforage"; + +import { + DIRECTOR_ONBOARDING_KEY, + DIRECTOR_ONBOARDING_STEPS, + DIRECTOR_ONBOARDING_VERSION, + advanceDirectorOnboarding, + createDirectorOnboardingGate, + directorOnboardingInitial, + loadDirectorOnboardingProgress, + parseDirectorOnboardingProgress, + reduceDirectorOnboarding, + resetDirectorOnboardingProgress, + resolveDirectorOnboardingView, + saveDirectorOnboardingProgress, + type DirectorOnboardingAction, + type DirectorOnboardingProgress, + type DirectorOnboardingStorage, +} from "../src/lib/canvas/director/director-onboarding"; + +const ACTIVE: DirectorOnboardingProgress = directorOnboardingInitial; + +function progressAt(stepId: DirectorOnboardingProgress["stepId"], status: DirectorOnboardingProgress["status"] = "active"): DirectorOnboardingProgress { + return { version: DIRECTOR_ONBOARDING_VERSION, status, stepId }; +} + +/** + * 剥掉源码里的注释再做契约断言:文档注释里出于说明目的提到的反例词 + * (比如「不是 role="dialog"」这句话本身)不该让断言把「提到」误判成「使用」。 + */ +function stripComments(source: string) { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** 单 scope 的内存存储接缝。生产路径由 localForageStorageForScope 加 `:user:`,这里只验状态流转。 */ +function memoryStorage(initial?: string) { + const writes: string[] = []; + let value = initial ?? null; + const storage: DirectorOnboardingStorage = { + getItem: async () => value, + setItem: async (_name, next) => { + value = next; + writes.push(next); + }, + }; + return { + storage, + writes, + current: () => value, + }; +} + +/** + * 真实持久路径的探针:接住 localforage 的默认实例,并记录任何 localStorage 触碰。 + * 引导进度必须只走 IndexedDB —— localStorage 只允许存小型 UI 偏好。 + */ +function localforageHarness(options?: { failReads?: boolean }) { + const originalGetItem = localforage.getItem.bind(localforage); + const originalSetItem = localforage.setItem.bind(localforage); + const originalWindow = (globalThis as { window?: unknown }).window; + const durable = new Map(); + const localStorageCalls: string[] = []; + + const localStorageProbe = { + getItem: (key: string) => { + localStorageCalls.push(`get:${key}`); + return null; + }, + setItem: (key: string) => { + localStorageCalls.push(`set:${key}`); + }, + removeItem: (key: string) => { + localStorageCalls.push(`remove:${key}`); + }, + }; + Object.defineProperty(globalThis, "window", { configurable: true, value: { localStorage: localStorageProbe } }); + localforage.getItem = (async (key: string) => { + if (options?.failReads) throw new Error("indexeddb unavailable"); + return durable.get(key) ?? null; + }) as typeof localforage.getItem; + localforage.setItem = (async (key: string, value: string) => { + durable.set(key, value); + return value; + }) as typeof localforage.setItem; + + return { + durable, + localStorageCalls, + restore() { + localforage.getItem = originalGetItem; + localforage.setItem = originalSetItem; + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); + }, + }; +} + +describe("用户隔离", () => { + test("两个账号的进度写进各自的 scope 键,互不可见", async () => { + const harness = localforageHarness(); + try { + await saveDirectorOnboardingProgress("user-a", progressAt("pose")); + await saveDirectorOnboardingProgress("user-b", progressAt("apply", "completed")); + + expect([...harness.durable.keys()].sort()).toEqual([`${DIRECTOR_ONBOARDING_KEY}:user:user-a`, `${DIRECTOR_ONBOARDING_KEY}:user:user-b`]); + expect(await loadDirectorOnboardingProgress("user-a")).toEqual(progressAt("pose")); + expect(await loadDirectorOnboardingProgress("user-b")).toEqual(progressAt("apply", "completed")); + // 没写过的账号拿到初始进度,而不是继承别人的已完成状态。 + expect(await loadDirectorOnboardingProgress("user-c")).toEqual(ACTIVE); + } finally { + harness.restore(); + } + }); + + test("进度只走 localforage,完全不碰 localStorage", async () => { + const harness = localforageHarness(); + try { + await saveDirectorOnboardingProgress("user-a", progressAt("move")); + await loadDirectorOnboardingProgress("user-a"); + expect(harness.localStorageCalls).toEqual([]); + } finally { + harness.restore(); + } + }); + + test("空 scope 一律硬失败,绝不回落到默认账号", async () => { + await expect(loadDirectorOnboardingProgress("")).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(loadDirectorOnboardingProgress(" ")).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(saveDirectorOnboardingProgress("", ACTIVE)).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(advanceDirectorOnboarding("", ACTIVE, "next")).rejects.toThrow("缺少导演台引导的用户 scope"); + }); + + test("存储层失败向上抛,不伪装成初始进度", async () => { + const harness = localforageHarness({ failReads: true }); + try { + await expect(loadDirectorOnboardingProgress("user-a")).rejects.toThrow("indexeddb unavailable"); + } finally { + harness.restore(); + } + }); +}); + +describe("版本失效与脏数据", () => { + test("版本不符的旧进度整条丢弃,回到第一步", () => { + const stale = JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION - 1, status: "completed", stepId: "apply" }); + expect(parseDirectorOnboardingProgress(stale)).toEqual(ACTIVE); + }); + + test("缺失、非 JSON、非对象、字段越界的持久值都回落到初始进度", () => { + const dirty = [ + null, + undefined, + "", + "{", + "not json", + "[]", + '"active"', + "42", + "null", + JSON.stringify({ status: "active", stepId: "actor" }), + JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: "paused", stepId: "actor" }), + JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "lighting" }), + JSON.stringify({ version: "2", status: "active", stepId: "actor" }), + JSON.stringify([{ version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "actor" }]), + ]; + for (const raw of dirty) expect(parseDirectorOnboardingProgress(raw)).toEqual(ACTIVE); + }); + + test("脏持久值经由真实读路径也只会得到初始进度", async () => { + const harness = localforageHarness(); + try { + harness.durable.set(`${DIRECTOR_ONBOARDING_KEY}:user:user-a`, "{ broken"); + expect(await loadDirectorOnboardingProgress("user-a")).toEqual(ACTIVE); + } finally { + harness.restore(); + } + }); + + test("落盘时只写受控字段,不回写未知字段", async () => { + const store = memoryStorage(); + await saveDirectorOnboardingProgress("user-a", { ...progressAt("move"), extra: "x" } as DirectorOnboardingProgress, store.storage); + expect(JSON.parse(store.writes[0])).toEqual({ version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "move" }); + }); + + test("状态机遇到不存在的步骤时回到初始进度,而不是越界", () => { + const bogus = { version: DIRECTOR_ONBOARDING_VERSION, status: "active", stepId: "lighting" } as unknown as DirectorOnboardingProgress; + expect(reduceDirectorOnboarding(bogus, "next")).toEqual(ACTIVE); + expect(reduceDirectorOnboarding(bogus, "back")).toEqual(ACTIVE); + expect(resolveDirectorOnboardingView(bogus)).toBeNull(); + }); +}); + +describe("步骤流转", () => { + test("next 依次走完六步,最后一步 next 即结束引导", () => { + let progress = ACTIVE; + const visited = [progress.stepId]; + for (let i = 0; i < DIRECTOR_ONBOARDING_STEPS.length - 1; i += 1) { + progress = reduceDirectorOnboarding(progress, "next"); + visited.push(progress.stepId); + } + expect(visited).toEqual(DIRECTOR_ONBOARDING_STEPS.map((step) => step.id)); + expect(progress.status).toBe("active"); + + const done = reduceDirectorOnboarding(progress, "next"); + expect(done.status).toBe("completed"); + // 完成是终态:停在最后一步,且不再前进。 + expect(done.stepId).toBe(DIRECTOR_ONBOARDING_STEPS[DIRECTOR_ONBOARDING_STEPS.length - 1].id); + expect(reduceDirectorOnboarding(done, "next")).toBe(done); + }); + + test("back 逐步回退,首步 back 原地不动且不关闭引导", () => { + const second = reduceDirectorOnboarding(ACTIVE, "next"); + expect(reduceDirectorOnboarding(second, "back").stepId).toBe("actor"); + expect(reduceDirectorOnboarding(ACTIVE, "back")).toBe(ACTIVE); + expect(reduceDirectorOnboarding(ACTIVE, "back").status).toBe("active"); + }); + + test("dismiss 与 complete 都是终态,next/back/dismiss/complete 无法唤醒", () => { + for (const status of ["dismissed", "completed"] as const) { + const frozen = progressAt("move", status); + for (const action of ["next", "back", "dismiss", "complete"] as const) { + expect(reduceDirectorOnboarding(frozen, action)).toBe(frozen); + } + expect(resolveDirectorOnboardingView(frozen)).toBeNull(); + } + }); + + test("reset 是唯一能让已跳过/已完成引导复活的动作", () => { + expect(reduceDirectorOnboarding(progressAt("apply", "dismissed"), "reset")).toEqual(ACTIVE); + expect(reduceDirectorOnboarding(progressAt("apply", "completed"), "reset")).toEqual(ACTIVE); + expect(reduceDirectorOnboarding(progressAt("pose"), "reset")).toEqual(ACTIVE); + }); + + test("dismiss 保留当前步骤,reset 之后才回到第一步", () => { + const dismissed = reduceDirectorOnboarding(progressAt("pose"), "dismiss"); + expect(dismissed).toEqual(progressAt("pose", "dismissed")); + expect(reduceDirectorOnboarding(dismissed, "reset").stepId).toBe("actor"); + }); + + test("展示态给出 1 起数的位置与首尾标记", () => { + const first = resolveDirectorOnboardingView(ACTIVE); + expect(first).toEqual({ step: DIRECTOR_ONBOARDING_STEPS[0], position: 1, total: DIRECTOR_ONBOARDING_STEPS.length, isFirst: true, isLast: false }); + + const last = resolveDirectorOnboardingView(progressAt("apply")); + expect(last?.position).toBe(DIRECTOR_ONBOARDING_STEPS.length); + expect(last?.isLast).toBe(true); + expect(last?.isFirst).toBe(false); + }); +}); + +describe("推进即落盘", () => { + test("每次有效推进都写盘,返回值与磁盘一致", async () => { + const store = memoryStorage(); + const second = await advanceDirectorOnboarding("user-a", ACTIVE, "next", store.storage); + expect(second.stepId).toBe("move"); + expect(parseDirectorOnboardingProgress(store.current())).toEqual(second); + + const dismissed = await advanceDirectorOnboarding("user-a", second, "dismiss", store.storage); + expect(dismissed.status).toBe("dismissed"); + expect(parseDirectorOnboardingProgress(store.current())).toEqual(dismissed); + expect(store.writes).toHaveLength(2); + }); + + test("无变化的动作不写盘", async () => { + const store = memoryStorage(); + expect(await advanceDirectorOnboarding("user-a", ACTIVE, "back", store.storage)).toBe(ACTIVE); + const frozen = progressAt("move", "completed"); + expect(await advanceDirectorOnboarding("user-a", frozen, "next", store.storage)).toBe(frozen); + expect(store.writes).toEqual([]); + }); + + test("写盘失败向上抛,调用方保留旧进度", async () => { + const failing: DirectorOnboardingStorage = { + getItem: async () => null, + setItem: async () => { + throw new Error("indexeddb write failed"); + }, + }; + await expect(advanceDirectorOnboarding("user-a", ACTIVE, "next", failing)).rejects.toThrow("indexeddb write failed"); + }); + + test("重启后从磁盘恢复到同一步骤", async () => { + const harness = localforageHarness(); + try { + const second = await advanceDirectorOnboarding("user-a", ACTIVE, "next"); + const third = await advanceDirectorOnboarding("user-a", second, "next"); + expect(await loadDirectorOnboardingProgress("user-a")).toEqual(third); + expect(resolveDirectorOnboardingView(third)?.step.id).toBe("pose"); + } finally { + harness.restore(); + } + }); +}); + +describe("scope 归一化与并发写锁", () => { + test("回归:注入 storage 时空白 scope 依旧硬失败,不因为跳过默认 localforage 分支而漏检", async () => { + const store = memoryStorage(); + await expect(loadDirectorOnboardingProgress(" ", store.storage)).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(saveDirectorOnboardingProgress("", ACTIVE, store.storage)).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(advanceDirectorOnboarding(" ", ACTIVE, "next", store.storage)).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(resetDirectorOnboardingProgress("\t", store.storage)).rejects.toThrow("缺少导演台引导的用户 scope"); + expect(store.writes).toEqual([]); + }); + + test("advance 对无变化的动作也无条件校验 scope,不因提前返回而漏检", async () => { + await expect(advanceDirectorOnboarding("", ACTIVE, "back")).rejects.toThrow("缺少导演台引导的用户 scope"); + await expect(advanceDirectorOnboarding(" ", progressAt("apply", "completed"), "next")).rejects.toThrow("缺少导演台引导的用户 scope"); + }); + + test("scope 前后空白归一化,与已裁剪的 scope 共用同一把生产键,互不分裂", async () => { + const harness = localforageHarness(); + try { + await saveDirectorOnboardingProgress(" user-a ", progressAt("pose")); + expect([...harness.durable.keys()]).toEqual([`${DIRECTOR_ONBOARDING_KEY}:user:user-a`]); + expect(await loadDirectorOnboardingProgress("user-a")).toEqual(progressAt("pose")); + expect(await loadDirectorOnboardingProgress(" user-a")).toEqual(progressAt("pose")); + expect(await loadDirectorOnboardingProgress("user-a\t")).toEqual(progressAt("pose")); + } finally { + harness.restore(); + } + }); + + test("resetDirectorOnboardingProgress 让已跳过/已完成的账号回到激活的第一步,并落盘", async () => { + const store = memoryStorage(JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: "completed", stepId: "apply" })); + const result = await resetDirectorOnboardingProgress("user-a", store.storage); + expect(result).toEqual(ACTIVE); + expect(parseDirectorOnboardingProgress(store.current())).toEqual(ACTIVE); + }); + + test("resetDirectorOnboardingProgress 经真实持久层也能复活已跳过的引导", async () => { + const harness = localforageHarness(); + try { + await saveDirectorOnboardingProgress("user-a", progressAt("apply", "dismissed")); + expect(await resetDirectorOnboardingProgress("user-a")).toEqual(ACTIVE); + expect(await loadDirectorOnboardingProgress("user-a")).toEqual(ACTIVE); + } finally { + harness.restore(); + } + }); + + test("写锁:锁定期间的重复进入被拒绝,release 后可再次进入,release 本身幂等", () => { + const gate = createDirectorOnboardingGate(); + expect(gate.tryEnter()).toBe(true); + expect(gate.tryEnter()).toBe(false); + expect(gate.tryEnter()).toBe(false); + gate.release(); + expect(gate.tryEnter()).toBe(true); + gate.release(); + expect(() => gate.release()).not.toThrow(); + expect(gate.tryEnter()).toBe(true); + }); + + test("同一 tick 内的重复动作只产生一次写入(复刻 hook 用写锁挡重复调用的方式)", async () => { + const store = memoryStorage(); + const gate = createDirectorOnboardingGate(); + const progress = ACTIVE; + const fire = (action: DirectorOnboardingAction) => { + if (!gate.tryEnter()) return null; + return advanceDirectorOnboarding("user-a", progress, action, store.storage).finally(() => gate.release()); + }; + const first = fire("next"); + const second = fire("next"); + expect(second).toBeNull(); + const resolved = await first; + expect(resolved?.stepId).toBe("move"); + expect(store.writes).toHaveLength(1); + // 锁已经 release,后续调用不再被挡。 + expect(gate.tryEnter()).toBe(true); + }); + + test("跨代隔离:release 旧一代捕获的锁,绝不能误放新一代仍在写入的锁", () => { + // 精确复刻 hook 的接线方式:换代时把 ref 换成全新的 gate 实例(不 release 旧实例), + // 每次调用在拿锁的同一刻把 ref 当前指向的实例捕获进局部变量,release 只作用在这份捕获上, + // 绝不在写入收尾时重新读一次 ref(那时 ref 可能已经转向了新一代的锁)。 + let gateRef = createDirectorOnboardingGate(); + + function fire() { + const gate = gateRef; // 同步捕获调用时刻的实例,而不是稍后再读 gateRef + const entered = gate.tryEnter(); + return { entered, release: () => gate.release() }; + } + + // 第一代发起一次写入,锁定 gen1 的 gate。 + const gen1 = fire(); + expect(gen1.entered).toBe(true); + + // scope 切换:effect 把 ref 换成全新实例,不 release 旧实例 —— gen1 的写入仍在途、仍锁着自己那把。 + gateRef = createDirectorOnboardingGate(); + + // 第二代应该立刻拿到全新的锁,不受 gen1 未完成写入的影响。 + const gen2 = fire(); + expect(gen2.entered).toBe(true); + + // gen2 写入还没收尾时,gen2 的重复调用必须被自己的锁挡住。 + expect(fire().entered).toBe(false); + + // gen1 的写入这时才收尾:它释放的是自己捕获的 gen1 实例,对 gen2 的锁没有任何影响。 + gen1.release(); + expect(fire().entered).toBe(false); // gen2 依旧锁着,没有被 gen1 的 release 误放。 + + // gen2 的写入收尾,释放自己的锁,之后才能再次进入。 + gen2.release(); + expect(fire().entered).toBe(true); + }); +}); + +describe("步骤内容契约", () => { + test("六个步骤覆盖添加演员、移动、调姿、轨迹、CAM 与应用,顺序固定", () => { + expect(DIRECTOR_ONBOARDING_STEPS.map((step) => step.id)).toEqual(["actor", "move", "pose", "path", "camera", "apply"]); + }); + + test("每个步骤都有标题与可执行说明,id 不重复", () => { + for (const step of DIRECTOR_ONBOARDING_STEPS) { + expect(step.title.length).toBeGreaterThan(0); + expect(step.detail.length).toBeGreaterThan(0); + } + expect(new Set(DIRECTOR_ONBOARDING_STEPS.map((step) => step.id)).size).toBe(DIRECTOR_ONBOARDING_STEPS.length); + }); + + test("每一步都给出对应一级模式,覆盖摆场、姿态、动画和摄影机", () => { + const byId = Object.fromEntries(DIRECTOR_ONBOARDING_STEPS.map((step) => [step.id, step])); + expect(byId.actor.mode).toBe("layout"); + expect(byId.move.mode).toBe("layout"); + expect(byId.pose.mode).toBe("pose"); + expect(byId.path.mode).toBe("animate"); + expect(byId.camera.mode).toBe("camera"); + expect(byId.apply.mode).toBe("camera"); + for (const step of DIRECTOR_ONBOARDING_STEPS) { + if (step.mode) expect(["layout", "pose", "animate", "camera"]).toContain(step.mode); + } + }); + + test("步骤文案指向真实入口,不把浏览步骤称为任务完成", () => { + const content = DIRECTOR_ONBOARDING_STEPS.map((step) => `${step.title} ${step.detail}`).join("\n"); + for (const label of ["快速添加", "移动工具", "姿态模式", "Transform 关键帧", "CAM", "应用到镜头"]) expect(content).toContain(label); + }); +}); + +/** + * 组件契约用源码断言:web 没有 jsdom/testing-library(见 package.json), + * 这里与 create-library-button.test.ts 同一手法 —— 守住无障碍与 token 边界, + * 而不是假装渲染过组件。 + */ +describe("引导浮层契约", () => { + const componentPath = resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-onboarding.tsx"); + const source = readFileSync(componentPath, "utf8"); + // 契约断言只看真正的代码:文档注释里为了解释「为什么不这样做」而提到的反例词 + // (role="dialog"、getActiveUserScope、DirectorScene)不该让断言把「提到」误判成「使用」。 + const code = stripComments(source); + + test("非阻塞浮层:region 语义,不是 dialog,也没有遮罩与焦点陷阱", () => { + expect(code).toContain(" { + const buttons = code.match(/ { + expect(code).toContain("第 ${position} 步 / 共 ${total} 步"); + expect(code).toContain('aria-live="polite"'); + expect(code).toContain("aria-hidden"); + }); + + test("首步禁用上一步,末步把主按钮换成中性的结束文案", () => { + expect(code).toContain("disabled={busy || isFirst}"); + expect(code).toContain('{isLast ? "结束引导" : "下一步"}'); + }); + + test("只用既有 token,不写颜色/圆角/字号字面值", () => { + expect(code).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); + expect(code).not.toMatch(/\b(rgba?|oklch|hsla?)\(/); + expect(code).not.toMatch(/:\s*"\d+px"/); + expect(code).not.toMatch(/(text|rounded|z|bg|text)-\[\d/); + for (const token of ["var(--space-3)", "var(--r-lg)", "var(--surface)", "var(--cn-text)", "var(--cn-muted)", "var(--fs-caption)", "var(--elevation-overlay)", "var(--border-semantic)"]) { + expect(code).toContain(token); + } + }); + + test("scope 由调用方显式传入,组件不读当前活跃 scope,也不自己碰 localStorage", () => { + expect(code).toContain("scope: string"); + expect(code).not.toContain("getActiveUserScope"); + expect(code).not.toMatch(/import\s*{[^}]*getActiveUserScope/); + expect(code).not.toContain("localStorage"); + expect(code).not.toContain("localforage"); + }); + + test("状态与持久化全部走 director-onboarding 模块,组件不复制状态机", () => { + expect(code).toContain('from "@/lib/canvas/director/director-onboarding"'); + expect(code).toContain("loadDirectorOnboardingProgress"); + expect(code).toContain("advanceDirectorOnboarding"); + expect(code).toContain("resetDirectorOnboardingProgress"); + expect(code).toContain("resolveDirectorOnboardingView"); + expect(code).not.toContain("DIRECTOR_ONBOARDING_STEPS"); + expect(code).not.toContain('"dismissed"'); + expect(code).not.toContain('"completed"'); + }); + + test("并发写保护走同步 gate:换代新建实例而不是 release 共享实例,释放的是调用时捕获的那把锁", () => { + expect(code).toContain("createDirectorOnboardingGate"); + // 换代必须新建实例,绝不能对共享的旧实例调用 release —— 那会把旧一代还没收尾的写入 + // 和新一代刚建好的锁混在一起判定。 + expect(code).toContain("gateRef.current = createDirectorOnboardingGate()"); + expect(code).not.toContain("gateRef.current.release()"); + // run() 必须在拿锁的同一刻把 gateRef.current 同步捕获进局部变量, + // tryEnter/release 都作用在这份捕获上,绝不在写入收尾时重新读取 gateRef.current。 + expect(code).toMatch(/const\s+gate\s*=\s*gateRef\.current;/); + expect(code).toContain("gate.tryEnter()"); + expect(code).toContain("gate.release()"); + }); + + test("外部重启信号在加载期间也能直接重置,并让旧读取结果失效", () => { + expect(code).toContain("restartSignal?: number"); + expect(code).toContain("const previousRestartSignal = useRef(restartSignal)"); + expect(code).toContain("previousRestartSignal.current === restartSignal"); + expect(code).toContain("resetDirectorOnboardingProgress(normalizedScope, storage)"); + expect(code).toContain("return loadDirectorOnboardingProgress(normalizedScope, storage)"); + expect(code).not.toContain('run("reset")'); + + const generationAdvance = code.indexOf("generation.current += 1", code.indexOf("const reset = useCallback")); + const resetWrite = code.indexOf("resetDirectorOnboardingProgress(normalizedScope, storage)"); + expect(generationAdvance).toBeGreaterThan(-1); + expect(resetWrite).toBeGreaterThan(generationAdvance); + }); + + test("模块不写场景内容,也不引入历史/撤销", () => { + const modelSource = readFileSync(resolve(import.meta.dir, "../src/lib/canvas/director/director-onboarding.ts"), "utf8"); + const modelCode = stripComments(modelSource); + expect(modelCode).not.toContain("DirectorScene"); + expect(modelCode).not.toContain("director-scene"); + expect(modelCode).not.toMatch(/\bhistory\b/i); + expect(modelCode).not.toContain("window.localStorage"); + expect(modelCode).toContain("localForageStorageForScope"); + }); +}); + +describe("引导产品接线", () => { + const projectCode = stripComments(readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8")); + const workbenchCode = stripComments(readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-workbench.tsx"), "utf8")); + + test("页面只把已认证用户 id 传给引导,不使用 guest fallback 或未定义变量", () => { + expect(projectCode).toContain('const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "")'); + expect(projectCode).toContain("onboardingScope={directorOnboardingScope}"); + expect(projectCode).not.toContain("trimmedUserId"); + expect(projectCode).not.toContain("onboardingScope={canvasStorageScope}"); + }); + + test("工作台渲染非模态引导,并提供可发现的重新开始入口", () => { + expect(workbenchCode).toContain('import { CanvasDirectorOnboarding } from "@/components/canvas/director/canvas-director-onboarding"'); + expect(workbenchCode).toContain("onboardingScope: string"); + expect(workbenchCode).toContain('label="重新开始引导"'); + expect(workbenchCode).toContain("setOnboardingRestartSignal((value) => value + 1)"); + expect(workbenchCode).toContain(" { + const reproCode = stripComments(readFileSync(resolve(import.meta.dir, "../src/pages/dev/director-repro-lab.tsx"), "utf8")); + expect(reproCode).toContain('onboardingScope="director-repro-lab"'); + expect(reproCode).not.toContain('onboardingScope=""'); + }); +}); diff --git a/web/test/director-session-authority.test.ts b/web/test/director-session-authority.test.ts index 5754f76d7..b35bbf122 100644 --- a/web/test/director-session-authority.test.ts +++ b/web/test/director-session-authority.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createDirectorTransaction } from "../src/lib/canvas/director/director-gesture-transaction"; -import { shouldReinitializeDirectorSession, upsertDirectorSceneById } from "../src/lib/canvas/director/director-session"; +import { isDirectorOutputSnapshotCurrent, mergeDirectorOutputPreview, shouldReinitializeDirectorSession, upsertDirectorSceneById } from "../src/lib/canvas/director/director-session"; import { createDirectorScene } from "../src/lib/canvas/director/director-scene"; import type { DirectorScene } from "../src/types/director"; @@ -76,6 +76,44 @@ describe("upsertDirectorSceneById:连续保存不丢 scene(A6 回归)", () }); }); +describe("异步输出只合并预览引用,不覆盖最新场景", () => { + test("保留上传期间发生的场景与镜头编辑", () => { + const latest = scene("s1", "上传期间改过的标题"); + const shotId = latest.shots[0].id; + latest.shots[0] = { ...latest.shots[0], name: "上传期间改过的镜头" }; + const merged = mergeDirectorOutputPreview(latest, { sceneId: latest.id, shotId, previewNodeId: "preview-1" }); + expect(merged?.title).toBe("上传期间改过的标题"); + expect(merged?.shots[0].name).toBe("上传期间改过的镜头"); + expect(merged?.shots[0].previewNodeId).toBe("preview-1"); + expect(merged?.shots[0].depthNodeId).toBeUndefined(); + expect(merged?.shots[0].normalNodeId).toBeUndefined(); + }); + + test("场景或镜头已经切换/删除时拒绝合并", () => { + const latest = scene("s1"); + expect(mergeDirectorOutputPreview(latest, { sceneId: "other", shotId: latest.shots[0].id, previewNodeId: "preview" })).toBeNull(); + expect(mergeDirectorOutputPreview(latest, { sceneId: latest.id, shotId: "missing", previewNodeId: "preview" })).toBeNull(); + }); +}); + +describe("截图与录制绑定开始时的场景快照", () => { + test("同一场景快照引用与同一活动镜头才允许继续", () => { + const current = scene("s1"); + const expected = { scene: current, shotId: current.shots[0].id }; + expect(isDirectorOutputSnapshotCurrent(current, expected)).toBe(true); + expect(isDirectorOutputSnapshotCurrent({ ...current }, expected)).toBe(false); + expect(isDirectorOutputSnapshotCurrent(null, expected)).toBe(false); + }); + + test("活动镜头切换时拒绝继续,即使原镜头仍存在", () => { + const current = scene("s1"); + const originalShot = current.shots[0]; + const otherShot = { ...originalShot, id: "shot-2", name: "镜头 2" }; + const withOtherActive = { ...current, shots: [originalShot, otherShot], activeShotId: otherShot.id }; + expect(isDirectorOutputSnapshotCurrent(withOtherActive, { scene: withOtherActive, shotId: originalShot.id })).toBe(false); + }); +}); + describe("预览与持久变更的发布边界(A2/A3/A4 回归)", () => { /** 最小 session controller:与 workbench 的接线同构,但不依赖 React 挂载。 */ function harness(initial: DirectorScene) { diff --git a/web/test/director-shortcuts.test.ts b/web/test/director-shortcuts.test.ts new file mode 100644 index 000000000..e584cca3e --- /dev/null +++ b/web/test/director-shortcuts.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test"; + +import { DIRECTOR_INTERACTIVE_SELECTOR, blocksDirectorShortcut, isDirectorInteractiveNode, releaseDirectorFocusAfterPointer, resolveDirectorShortcut, type DirectorKeyEvent } from "../src/lib/canvas/director/director-shortcuts"; + +const press = (overrides: Partial & { key: string }): DirectorKeyEvent => ({ ...overrides }); + +describe("变换模式快捷键", () => { + test("W/E/R 映射到平移/旋转/缩放", () => { + expect(resolveDirectorShortcut(press({ key: "w" }))).toEqual({ kind: "transform-mode", mode: "translate" }); + expect(resolveDirectorShortcut(press({ key: "e" }))).toEqual({ kind: "transform-mode", mode: "rotate" }); + expect(resolveDirectorShortcut(press({ key: "r" }))).toEqual({ kind: "transform-mode", mode: "scale" }); + }); + + test("大写与 Shift 组合仍然生效", () => { + expect(resolveDirectorShortcut(press({ key: "W" }))).toEqual({ kind: "transform-mode", mode: "translate" }); + expect(resolveDirectorShortcut(press({ key: "E", shiftKey: true }))).toEqual({ kind: "transform-mode", mode: "rotate" }); + }); + + test("带 Ctrl/Cmd 时不再当作变换模式", () => { + // Ctrl+R 是刷新,绝不能被解析成 scale。 + expect(resolveDirectorShortcut(press({ key: "r", ctrlKey: true }))).toBeNull(); + expect(resolveDirectorShortcut(press({ key: "w", metaKey: true }))).toBeNull(); + }); +}); + +describe("对象操作快捷键", () => { + test("Delete 与 Backspace 都删除选中对象", () => { + expect(resolveDirectorShortcut(press({ key: "Delete" }))).toEqual({ kind: "delete-selected" }); + expect(resolveDirectorShortcut(press({ key: "Backspace" }))).toEqual({ kind: "delete-selected" }); + }); + + test("H 切换显隐、Escape 取消选择", () => { + expect(resolveDirectorShortcut(press({ key: "h" }))).toEqual({ kind: "toggle-visibility" }); + expect(resolveDirectorShortcut(press({ key: "Escape" }))).toEqual({ kind: "deselect" }); + }); + + test("没有执行路径的键不解析:F 聚焦、F2 重命名、Ctrl+D 复制", () => { + expect(resolveDirectorShortcut(press({ key: "f" }))).toBeNull(); + expect(resolveDirectorShortcut(press({ key: "F2" }))).toBeNull(); + expect(resolveDirectorShortcut(press({ key: "d", ctrlKey: true }))).toBeNull(); + expect(resolveDirectorShortcut(press({ key: "d", metaKey: true }))).toBeNull(); + }); + + test("空格切换播放", () => { + expect(resolveDirectorShortcut(press({ key: " " }))).toEqual({ kind: "toggle-play" }); + expect(resolveDirectorShortcut(press({ key: "Spacebar" }))).toEqual({ kind: "toggle-play" }); + }); +}); + +describe("撤销/重做", () => { + test("Ctrl/Cmd+Z 撤销", () => { + expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true }))).toEqual({ kind: "undo" }); + expect(resolveDirectorShortcut(press({ key: "z", metaKey: true }))).toEqual({ kind: "undo" }); + }); + + test("Ctrl/Cmd+Shift+Z 与 Ctrl/Cmd+Y 都是重做", () => { + expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true, shiftKey: true }))).toEqual({ kind: "redo" }); + expect(resolveDirectorShortcut(press({ key: "Z", metaKey: true, shiftKey: true }))).toEqual({ kind: "redo" }); + expect(resolveDirectorShortcut(press({ key: "y", ctrlKey: true }))).toEqual({ kind: "redo" }); + }); + + test("裸 Z/Y 不触发撤销重做", () => { + for (const key of ["z", "y"]) expect(resolveDirectorShortcut(press({ key }))).toBeNull(); + }); +}); + +describe("修饰键与未知键边界", () => { + test("Alt 组合一律不解析,留给系统", () => { + expect(resolveDirectorShortcut(press({ key: "w", altKey: true }))).toBeNull(); + expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true, altKey: true }))).toBeNull(); + }); + + test("未映射的键返回 null", () => { + for (const key of ["q", "1", "ArrowUp", "Tab", "", "F5"]) { + expect(resolveDirectorShortcut(press({ key }))).toBeNull(); + } + }); +}); + +describe("交互控件语境不劫持按键", () => { + test("isInteractiveTarget 为真时所有快捷键都不解析", () => { + for (const key of ["w", "Delete", "Backspace", "h", "Escape", " "]) { + expect(resolveDirectorShortcut(press({ key, isInteractiveTarget: true }))).toBeNull(); + } + expect(resolveDirectorShortcut(press({ key: "z", ctrlKey: true, isInteractiveTarget: true }))).toBeNull(); + }); + + test("文本输入控件判为交互语境", () => { + expect(isDirectorInteractiveNode({ tagName: "INPUT" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "textarea" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "SELECT" })).toBe(true); + expect(isDirectorInteractiveNode({ isContentEditable: true })).toBe(true); + }); + + test("BUTTON 与 A 判为交互语境:Space/Delete 属于控件自己", () => { + expect(isDirectorInteractiveNode({ tagName: "BUTTON" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "button" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "A" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "OPTION" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "LABEL" })).toBe(true); + expect(isDirectorInteractiveNode({ tagName: "SUMMARY" })).toBe(true); + }); + + test("常见 role 型控件判为交互语境", () => { + const roles = ["textbox", "combobox", "spinbutton", "searchbox", "button", "switch", "tab", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "slider", "checkbox", "radio", "link", "listbox", "menu", "radiogroup", "treeitem"]; + for (const role of roles) { + expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "role" ? role : null) })).toBe(true); + } + }); + + test("contenteditable 属性形式也判为交互语境", () => { + for (const value of ["", "true"]) { + expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "contenteditable" ? value : null) })).toBe(true); + } + expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "contenteditable" ? "false" : null) })).toBe(false); + }); + + test("普通元素、未知 role 与非法目标不判为交互语境", () => { + expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: () => null })).toBe(false); + expect(isDirectorInteractiveNode({ tagName: "SPAN" })).toBe(false); + expect(isDirectorInteractiveNode({ tagName: "DIV", getAttribute: () => "presentation" })).toBe(false); + expect(isDirectorInteractiveNode(null)).toBe(false); + expect(isDirectorInteractiveNode(undefined)).toBe(false); + expect(isDirectorInteractiveNode("input")).toBe(false); + }); +}); + +describe("blocksDirectorShortcut:交互祖先同样阻止全局快捷键", () => { + test("有 closest 时用选择器向上找,命中祖先即阻止", () => { + // 按钮里的 /:event.target 是子元素,只判自身会漏掉整个控件。 + const queried: string[] = []; + const nestedChild = { + tagName: "svg", + closest: (selector: string) => { + queried.push(selector); + return { tagName: "BUTTON" }; + }, + }; + expect(blocksDirectorShortcut(nestedChild)).toBe(true); + expect(queried).toEqual([DIRECTOR_INTERACTIVE_SELECTOR]); + }); + + test("closest 没命中交互祖先时放行", () => { + const plainChild = { tagName: "SPAN", closest: () => null }; + expect(blocksDirectorShortcut(plainChild)).toBe(false); + }); + + test("选择器覆盖标签、role 与 contenteditable 三类", () => { + for (const fragment of ["button", "a", "input", "textarea", "select", "option", "label", "summary"]) { + expect(DIRECTOR_INTERACTIVE_SELECTOR.split(",")).toContain(fragment); + } + for (const role of ["button", "switch", "tab", "menuitem", "option", "slider", "checkbox", "radio"]) { + expect(DIRECTOR_INTERACTIVE_SELECTOR).toContain(`[role="${role}"]`); + } + expect(DIRECTOR_INTERACTIVE_SELECTOR).toContain('[contenteditable="true"]'); + }); + + test("没有 closest 的目标退化为单节点判定", () => { + expect(blocksDirectorShortcut({ tagName: "BUTTON" })).toBe(true); + expect(blocksDirectorShortcut({ tagName: "DIV", getAttribute: () => null })).toBe(false); + expect(blocksDirectorShortcut(null)).toBe(false); + expect(blocksDirectorShortcut("button")).toBe(false); + }); + + test("普通 DIV 容器仍允许全局快捷键:视口空白处按 W/Delete 必须生效", () => { + const viewportBackdrop = { tagName: "DIV", closest: () => null }; + expect(blocksDirectorShortcut(viewportBackdrop)).toBe(false); + expect(resolveDirectorShortcut(press({ key: "w", isInteractiveTarget: blocksDirectorShortcut(viewportBackdrop) }))).toEqual({ kind: "transform-mode", mode: "translate" }); + expect(resolveDirectorShortcut(press({ key: "Delete", isInteractiveTarget: blocksDirectorShortcut(viewportBackdrop) }))).toEqual({ kind: "delete-selected" }); + }); +}); + +describe("releaseDirectorFocusAfterPointer:点完工具按钮后快捷键必须立刻可用", () => { + const fakeButton = () => { + let blurred = 0; + return { + blur: () => { + blurred += 1; + }, + blurCount: () => blurred, + }; + }; + + test("鼠标激活(detail > 0)释放焦点", () => { + const target = fakeButton(); + releaseDirectorFocusAfterPointer({ detail: 1, currentTarget: target }); + expect(target.blurCount()).toBe(1); + }); + + test("键盘激活(detail === 0)保留焦点,Tab 序列不断", () => { + const target = fakeButton(); + releaseDirectorFocusAfterPointer({ detail: 0, currentTarget: target }); + expect(target.blurCount()).toBe(0); + }); + + test("双击等多次点击仍然释放焦点", () => { + const target = fakeButton(); + releaseDirectorFocusAfterPointer({ detail: 2, currentTarget: target }); + expect(target.blurCount()).toBe(1); + }); + + test("目标没有 blur 方法时不抛异常", () => { + expect(() => releaseDirectorFocusAfterPointer({ detail: 1, currentTarget: {} })).not.toThrow(); + }); + + test("释放焦点后守卫不再拦截:这正是修复的行为", () => { + // 焦点在按钮上 -> 拦截;释放到视口容器 -> 放行。 + const button = { tagName: "BUTTON", closest: () => ({ tagName: "BUTTON" }) }; + expect(resolveDirectorShortcut(press({ key: "Delete", isInteractiveTarget: blocksDirectorShortcut(button) }))).toBeNull(); + + const bodyAfterBlur = { tagName: "BODY", closest: () => null }; + expect(resolveDirectorShortcut(press({ key: "Delete", isInteractiveTarget: blocksDirectorShortcut(bodyAfterBlur) }))).toEqual({ kind: "delete-selected" }); + }); +}); diff --git a/web/test/director-template-mode-wiring.test.ts b/web/test/director-template-mode-wiring.test.ts new file mode 100644 index 000000000..5fedb43bc --- /dev/null +++ b/web/test/director-template-mode-wiring.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * 生产接线回归:模板与模式的领域函数正确,不代表真实入口用上了。 + * 这里锁住「新建必须选模板」「已有场景不弹模板」「时间轴只在动画模式」三条链路。 + */ +const workbench = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-workbench.tsx"), "utf8"); +const dock = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-viewport-dock.tsx"), "utf8"); +const viewport = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/director-viewport.tsx"), "utf8"); +const hook = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-director.ts"), "utf8"); +const project = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8"); +const modal = readFileSync(resolve(import.meta.dir, "../src/components/canvas/director/canvas-director-template-modal.tsx"), "utf8"); +const store = readFileSync(resolve(import.meta.dir, "../src/stores/canvas/use-director-workbench-store.ts"), "utf8"); +const styles = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8"); + +function slice(source: string, from: string, to: string) { + const start = source.indexOf(from); + expect(start).toBeGreaterThanOrEqual(0); + const end = source.indexOf(to, start + from.length); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("新建场景必须显式选模板", () => { + test("createDirectorShot 第一个参数是 templateId,没有默认值", () => { + expect(hook).toContain("const createDirectorShot = useCallback((templateId: DirectorTemplateId, position?: Position) => {"); + // 有默认模板等于又回到「无条件塞演员」。 + expect(hook).not.toContain("templateId: DirectorTemplateId = "); + }); + + test("新建走模板工厂,不再走带默认演员的 createDirectorScene", () => { + expect(hook).toContain("createDirectorSceneFromTemplate(templateId, `镜头 ${shotIndex}`)"); + expect(hook).not.toContain("createDirectorScene("); + }); + + test("两个新建入口都先开模板选择,而不是直接建场景", () => { + expect(project).toContain("onOpenDirector={() => setDirectorTemplateRequest({})}"); + expect(project).toContain("onOpenDirector={(position) => setDirectorTemplateRequest({ position })}"); + expect(project).not.toContain("onOpenDirector={() => createDirectorShot()}"); + expect(project).not.toContain("onOpenDirector={createDirectorShot}"); + }); + + test("选中模板后带着 position 建场景", () => { + const element = slice(project, ""); + expect(element).toContain("open={Boolean(directorTemplateRequest)}"); + expect(element).toContain("onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)}"); + }); + + test("模板弹窗把 5 个模板全列出来,没有「默认」快捷项", () => { + expect(modal).toContain("DIRECTOR_TEMPLATES.map"); + expect(modal).toContain("onSelect(template.id)"); + expect(modal).not.toContain("默认模板"); + }); +}); + +describe("已有场景不触发模板选择", () => { + test("openDirectorWorkbench 命中已存在场景时不建新场景、不弹模板", () => { + const opener = slice(hook, "const openDirectorWorkbench = useCallback", "/** 每次保存都基于 store"); + // 只有找不到场景(孤儿节点修复)才补建。 + expect(opener).toContain("if (!scene) {"); + expect(opener).toContain('createDirectorSceneFromTemplate("empty"'); + expect(opener).not.toContain("setDirectorTemplateRequest"); + }); + + test("孤儿修复用空场景兜底:用户没选过就不许塞演员", () => { + const opener = slice(hook, "const openDirectorWorkbench = useCallback", "/** 每次保存都基于 store"); + expect(opener).not.toContain('createDirectorSceneFromTemplate("monologue"'); + expect(opener).not.toContain("createDirectorActor"); + }); + + test("workbench 自身不含模板选择逻辑:打开已保存场景不改写内容", () => { + expect(workbench).not.toContain("DIRECTOR_TEMPLATES"); + expect(workbench).not.toContain("createDirectorSceneFromTemplate"); + }); +}); + +describe("模式接线", () => { + test("workbench 从 store 读 mode,并按 capabilities 派生显示", () => { + expect(workbench).toContain("const mode = useDirectorWorkbenchStore((state) => state.mode);"); + expect(workbench).toContain("const capabilities = directorModeCapabilities(mode);"); + }); + + test("时间轴只在 capabilities.timeline 为真时渲染", () => { + expect(workbench).toContain("{capabilities.timeline ? { + expect(workbench).toContain("showMotionPaths={capabilities.timeline}"); + expect(viewport).toContain('object.visible && (object.kind === "actor" || object.primitive === "character")'); + expect(viewport).toContain("directorTransformPathLength(object.keyframes) > 0.001"); + expect(viewport).toContain(" { + expect(workbench).toContain('{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? <>'); + // motionClips 不得再作为放行条件:带动画的普通模型不是演员。 + expect(workbench).not.toContain('object.primitive === "character" || motionClips.length) ? <>'); + }); + + test("姿态模式提供全身与当前骨骼重置,不删除动画轨道", () => { + const inspector = slice(workbench, "function ObjectInspector(", "function LightInspector("); + expect(inspector).toContain('onClick={() => applyPose("stand")}>重置姿态'); + expect(inspector).toContain("delete boneOverrides[selectedBoneId]"); + expect(inspector).toContain(">重置当前骨骼"); + expect(inspector).not.toContain("boneTracks: []"); + }); + + test("动作片段与骨骼入口解耦:任何带 Clip 的对象都能调播放速度/循环", () => { + expect(workbench).toContain('{motionClips.length ? <>'); + }); + + test("关键帧入口由 keyframes 把关", () => { + expect(workbench).toContain("{capabilities.keyframes ? <>"); + }); + + test("渲染视图下拉按当前模式过滤,而不是写死五项", () => { + expect(workbench).toContain("DIRECTOR_RENDER_MODE_LABELS.filter((option) => capabilities.renderModes.includes(option.value))"); + expect(workbench).toContain("options={renderModeOptions}"); + }); + + test("dock 不是绕过模式门控的第二条路径:渲染视图按钮同样按 renderModes 过滤", () => { + expect(dock).toContain("renderModes: DirectorRenderMode[];"); + expect(dock).toContain("RENDER_VIEW_BUTTONS.filter((item) => renderModes.includes(item.mode))"); + // 写死的按钮会绕过门控。 + expect(dock).not.toContain('onClick={() => onRenderModeChange("pose")}'); + expect(workbench).toContain("renderModes={capabilities.renderModes}"); + }); + + test("store 层夹住 renderMode:任何路径都无法设置当前模式不允许的视图", () => { + expect(store).toContain("setRenderMode: (renderMode) => set((state) => (directorModeCapabilities(state.mode).renderModes.includes(renderMode) ? { renderMode } : {})),"); + }); + + test("摄影机模式固定显示 shot/camera 检查器", () => { + expect(workbench).toContain("selectedObject && !capabilities.cameraTools ?"); + expect(workbench).toContain("selectedLight && !capabilities.cameraTools ?"); + }); + + test("运镜生成只更新首尾帧并提示到动画模式继续编辑,不清空手工关键帧", () => { + expect(workbench).toContain("resolveDirectorCameraMoveKeyframes(item.keyframes"); + expect(workbench).toContain("已更新运镜首尾关键帧,可在动画模式继续编辑"); + expect(workbench).not.toContain("keyframes: [{ id: nanoid(), time: 0, transform: start }"); + }); + + test("小屏把属性检查器放到下方而不是隐藏,姿态与骨骼入口仍可达", () => { + expect(workbench).toContain("max-lg:col-span-2 max-lg:max-h-[40vh] max-lg:border-l-0 max-lg:border-t"); + expect(workbench).not.toContain("border-l max-lg:hidden"); + }); + + test("store 的 setMode 走 resolveDirectorModeTransition,清理不靠组件自觉", () => { + expect(store).toContain("setMode: (mode) => set((state) => resolveDirectorModeTransition({ mode, playing: state.playing, autoKey: state.autoKey, renderMode: state.renderMode })),"); + }); + + test("mode 不写进 DirectorScene:类型文件里没有 mode 字段", () => { + const types = readFileSync(resolve(import.meta.dir, "../src/types/director.ts"), "utf8"); + const sceneType = slice(types, "export type DirectorScene = {", "};"); + expect(sceneType).not.toContain("mode"); + }); + + test("切模式不重建会话:初始化 effect 的依赖里没有 mode", () => { + const initEffect = slice(workbench, "// 会话初始化只认 scene id", "// 打开会话时检查合法本地恢复候选"); + // 依赖里出现 mode 就意味着换视图会 resetWorkbench + 清空 history。 + expect(initEffect).toContain("}, [open, resetWorkbench, scene, writeDraft]);"); + expect(initEffect).not.toContain("mode"); + }); + + test("draft/history/save 的生命周期 effect 一律不依赖 mode", () => { + // 逐个锁住依赖数组:任一处混入 mode,切模式就会掉草稿或掉历史。 + expect(workbench).toContain("}, [message, modal, open, scene, writeDraft]);"); + expect(workbench).toContain("}, [mirrorDraft, stagedTransaction]);"); + expect(workbench).toContain("}, [mirrorDraft]);"); + // 快捷键监听只随 open 装卸,不随 mode 反复重挂。 + expect(workbench).toContain("}, [open]);"); + }); +}); + +describe("异步导演台输出使用最新权威状态", () => { + test("上传后重新核验节点与场景,并把预览引用合入最新 scene", () => { + expect(hook).toContain("const sourceNodeAtStart = nodesRef.current.find"); + expect(hook).toContain("const outputProjectId = projectId;"); + expect(hook).toContain("projectIdRef.current !== outputProjectId"); + expect(hook).toContain("const outputProject = useCanvasStore.getState().projects.find((item) => item.id === outputProjectId);"); + expect(hook).toContain("const sourceNode = nodesRef.current.find((item) => item.id === sourceNodeId);"); + expect(hook).toContain("const latestScene = outputProject?.directorScenes.find"); + expect(hook).toContain("mergeDirectorOutputPreview(latestScene"); + expect(hook).toContain("saveDirectorScene(mergedScene);"); + expect(hook).not.toContain("saveDirectorScene({ ...output.scene"); + }); +}); + +describe("模式控件可发现、可键盘、小屏可达", () => { + const nav = slice(workbench, '