From 8bc93a415b08a0a7720a3ad8f1486f19c8213681 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:06:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(canvas):=20=E9=A2=84=E6=BC=94=E5=8F=B0?= =?UTF-8?q?=20-=20=E9=87=8D=E6=9E=84=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E4=BA=A4=E4=BA=92=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PREVIS_REDESIGN_EXECUTION_PLAN.md | 677 ++++ PREVIS_REFACTOR_PLAN.md | 288 ++ PREVIS_REQUIREMENTS_STATUS.md | 198 ++ backend/builtin/影策短剧制作工作流/SKILL.md | 6 + ...gent_director.go => cloud_agent_previs.go} | 171 +- .../app/cloud_agent_previs_mutation.go | 1766 ++++++++++ .../app/cloud_agent_previs_mutation_test.go | 543 +++ ...tor_test.go => cloud_agent_previs_test.go} | 4 +- .../internal/app/cloud_agent_runtime_tools.go | 18 +- backend/internal/app/cloud_agent_test.go | 2 +- .../cloud_agent_tool_schema_budget_test.go | 11 +- backend/internal/app/cloud_agent_tools.go | 21 +- .../internal/app/cloud_agent_tools_read.go | 16 +- backend/internal/app/creation_canvas.go | 2 +- backend/internal/canvas/canvas_share.go | 6 +- backend/internal/repository/cloud_agent.go | 27 +- tools/refactor_director_to_previs.py | 72 + web/package.json | 2 +- ...iangle.gltf => previs-repro-triangle.gltf} | 0 ...hrome-e2e.mjs => previs-p0-chrome-e2e.mjs} | 200 +- .../canvas/canvas-cloud-agent-events.ts | 9 +- .../components/canvas/canvas-context-menu.tsx | 10 +- .../components/canvas/canvas-node-content.tsx | 2 +- web/src/components/canvas/canvas-node.tsx | 10 +- web/src/components/canvas/canvas-toolbar.tsx | 6 +- .../canvas/canvas-workspace-panel.css | 2 +- .../director/canvas-director-node-panel.tsx | 78 - .../director/canvas-director-workbench.tsx | 929 ----- .../canvas/director/director-view-toolbar.tsx | 55 - .../canvas/director/director-viewport.tsx | 1008 ------ .../canvas/previs/canvas-previs-node-card.css | 22 + .../previs/canvas-previs-node-panel.tsx | 86 + .../canvas-previs-onboarding.tsx} | 54 +- .../canvas-previs-template-modal.tsx} | 18 +- .../canvas/previs/canvas-previs-workbench.css | 1494 ++++++++ .../canvas/previs/canvas-previs-workbench.tsx | 1567 +++++++++ .../canvas/previs/previs-canvas-dock.tsx | 175 + .../previs-inspectors.tsx} | 238 +- .../canvas/previs/previs-mini-viewport.tsx | 96 + .../previs-sequencer.tsx} | 110 +- .../canvas/previs/previs-view-toolbar.tsx | 56 + .../previs-viewport-capture.ts} | 10 +- .../previs-viewport-dock.tsx} | 28 +- .../previs-viewport-rig.ts} | 115 +- .../canvas/previs/previs-viewport.tsx | 1596 +++++++++ .../use-previs-save-coordinator.ts} | 44 +- web/src/components/layout/app-providers.tsx | 4 +- web/src/lib/canvas/canvas-node-copy.ts | 6 +- web/src/lib/canvas/canvas-video-merge.ts | 24 + .../lib/canvas/director/director-preview.ts | 51 - .../previs-animation-semantics.ts} | 98 +- .../previs-clay-materials.ts} | 15 +- .../previs-diagnostics-recorder.ts} | 16 +- .../previs-diagnostics.ts} | 126 +- .../previs-gesture-transaction.ts} | 26 +- .../previs-modes.ts} | 32 +- .../previs-onboarding.ts} | 114 +- web/src/lib/canvas/previs/previs-path.ts | 115 + .../previs-placement.ts} | 46 +- web/src/lib/canvas/previs/previs-preview.ts | 86 + .../previs-prompt-compiler.ts} | 36 +- .../previs-recovery.ts} | 48 +- .../previs-repro-fixture.ts} | 54 +- .../previs-repro-runtime.ts} | 22 +- .../previs-resources.ts} | 28 +- .../previs-save-wiring.ts} | 32 +- .../previs-save.ts} | 76 +- .../previs-scene.ts} | 166 +- .../previs-session.ts} | 10 +- .../previs-shortcuts.ts} | 34 +- .../previs-templates.ts} | 52 +- .../previs-view-modes.ts} | 176 +- .../definitions/add-node-menu-tools.tsx | 4 +- .../canvas/tool-registry/tool-definition.ts | 4 +- web/src/lib/dev-repro.ts | 4 +- web/src/main.tsx | 4 +- .../canvas/canvas-project-context-menu.tsx | 4 +- .../canvas/canvas-project-world-layers.tsx | 4 +- web/src/pages/canvas/project.tsx | 81 +- ...anvas-director.ts => use-canvas-previs.ts} | 139 +- .../pages/canvas/use-canvas-render-model.ts | 18 +- ...tor-repro-lab.tsx => previs-repro-lab.tsx} | 60 +- web/src/pages/projects/detail/canvases.tsx | 2 +- web/src/router.tsx | 4 +- web/src/stores/canvas/use-canvas-store.ts | 10 +- ...store.ts => use-previs-workbench-store.ts} | 34 +- web/src/styles/admin-ui-1.css | 2798 +++++++++++++++ web/src/styles/admin-ui-2.css | 2803 +++++++++++++++ web/src/styles/admin-ui-3.css | 2801 +++++++++++++++ web/src/styles/admin-ui-4.css | 2925 ++++++++++++++++ web/src/styles/globals-1.css | 2988 ++++++++++++++++ web/src/styles/globals-2.css | 3031 +++++++++++++++++ web/src/styles/globals-3.css | 2970 ++++++++++++++++ web/src/styles/globals-4.css | 3021 ++++++++++++++++ web/src/styles/globals-5.css | 3024 ++++++++++++++++ web/src/styles/globals-6.css | 1974 +++++++++++ web/src/styles/globals-7.css | 1839 ++++++++++ web/src/styles/globals.css | 404 +-- web/src/types/canvas.ts | 16 +- web/src/types/director.ts | 157 - web/src/types/previs.ts | 199 ++ web/test/application-bootstrap.test.mjs | 20 +- web/test/director-bone-precedence.test.ts | 36 +- web/test/director-clay-materials.test.ts | 8 +- .../director-diagnostics-recorder.test.ts | 130 +- web/test/director-diagnostics.test.ts | 96 +- web/test/director-gesture-transaction.test.ts | 60 +- .../director-keyframe-delete-wiring.test.ts | 60 +- web/test/director-keyframe-editing.test.ts | 162 +- web/test/director-modes.test.ts | 90 +- web/test/director-node-preview.test.ts | 76 +- web/test/director-onboarding.test.ts | 272 +- web/test/director-placement.test.ts | 216 +- web/test/director-recovery.test.ts | 190 +- web/test/director-repro-runtime.test.ts | 108 +- web/test/director-resources.test.ts | 56 +- web/test/director-save-coordinator.test.ts | 30 +- web/test/director-save-wiring.test.ts | 108 +- web/test/director-session-authority.test.ts | 68 +- web/test/director-shortcuts.test.ts | 140 +- .../director-template-mode-wiring.test.ts | 106 +- web/test/director-templates.test.ts | 68 +- .../director-transaction-lifecycle.test.ts | 22 +- web/test/director-transform-semantics.test.ts | 134 +- web/test/director-view-modes.test.ts | 402 +-- web/test/helpers/module-group-source.ts | 2 - web/test/previs-path.test.ts | 37 + web/test/previs-preview-request.test.ts | 23 + 128 files changed, 42372 insertions(+), 5011 deletions(-) create mode 100644 PREVIS_REDESIGN_EXECUTION_PLAN.md create mode 100644 PREVIS_REFACTOR_PLAN.md create mode 100644 PREVIS_REQUIREMENTS_STATUS.md rename backend/internal/app/{cloud_agent_director.go => cloud_agent_previs.go} (52%) create mode 100644 backend/internal/app/cloud_agent_previs_mutation.go create mode 100644 backend/internal/app/cloud_agent_previs_mutation_test.go rename backend/internal/app/{cloud_agent_director_test.go => cloud_agent_previs_test.go} (93%) create mode 100644 tools/refactor_director_to_previs.py rename web/public/canvas/models/{director-repro-triangle.gltf => previs-repro-triangle.gltf} (100%) rename web/scripts/{director-p0-chrome-e2e.mjs => previs-p0-chrome-e2e.mjs} (80%) delete mode 100644 web/src/components/canvas/director/canvas-director-node-panel.tsx delete mode 100644 web/src/components/canvas/director/canvas-director-workbench.tsx delete mode 100644 web/src/components/canvas/director/director-view-toolbar.tsx delete mode 100644 web/src/components/canvas/director/director-viewport.tsx create mode 100644 web/src/components/canvas/previs/canvas-previs-node-card.css create mode 100644 web/src/components/canvas/previs/canvas-previs-node-panel.tsx rename web/src/components/canvas/{director/canvas-director-onboarding.tsx => previs/canvas-previs-onboarding.tsx} (85%) rename web/src/components/canvas/{director/canvas-director-template-modal.tsx => previs/canvas-previs-template-modal.tsx} (51%) create mode 100644 web/src/components/canvas/previs/canvas-previs-workbench.css create mode 100644 web/src/components/canvas/previs/canvas-previs-workbench.tsx create mode 100644 web/src/components/canvas/previs/previs-canvas-dock.tsx rename web/src/components/canvas/{director/director-inspectors.tsx => previs/previs-inspectors.tsx} (66%) create mode 100644 web/src/components/canvas/previs/previs-mini-viewport.tsx rename web/src/components/canvas/{director/director-sequencer.tsx => previs/previs-sequencer.tsx} (62%) create mode 100644 web/src/components/canvas/previs/previs-view-toolbar.tsx rename web/src/components/canvas/{director/director-viewport-capture.ts => previs/previs-viewport-capture.ts} (94%) rename web/src/components/canvas/{director/director-viewport-dock.tsx => previs/previs-viewport-dock.tsx} (73%) rename web/src/components/canvas/{director/director-viewport-rig.ts => previs/previs-viewport-rig.ts} (62%) create mode 100644 web/src/components/canvas/previs/previs-viewport.tsx rename web/src/components/canvas/{director/use-director-save-coordinator.ts => previs/use-previs-save-coordinator.ts} (76%) delete mode 100644 web/src/lib/canvas/director/director-preview.ts rename web/src/lib/canvas/{director/director-animation-semantics.ts => previs/previs-animation-semantics.ts} (55%) rename web/src/lib/canvas/{director/director-clay-materials.ts => previs/previs-clay-materials.ts} (71%) rename web/src/lib/canvas/{director/director-diagnostics-recorder.ts => previs/previs-diagnostics-recorder.ts} (65%) rename web/src/lib/canvas/{director/director-diagnostics.ts => previs/previs-diagnostics.ts} (56%) rename web/src/lib/canvas/{director/director-gesture-transaction.ts => previs/previs-gesture-transaction.ts} (84%) rename web/src/lib/canvas/{director/director-modes.ts => previs/previs-modes.ts} (70%) rename web/src/lib/canvas/{director/director-onboarding.ts => previs/previs-onboarding.ts} (66%) create mode 100644 web/src/lib/canvas/previs/previs-path.ts rename web/src/lib/canvas/{director/director-placement.ts => previs/previs-placement.ts} (71%) create mode 100644 web/src/lib/canvas/previs/previs-preview.ts rename web/src/lib/canvas/{director/director-prompt-compiler.ts => previs/previs-prompt-compiler.ts} (51%) rename web/src/lib/canvas/{director/director-recovery.ts => previs/previs-recovery.ts} (64%) rename web/src/lib/canvas/{director/director-repro-fixture.ts => previs/previs-repro-fixture.ts} (81%) rename web/src/lib/canvas/{director/director-repro-runtime.ts => previs/previs-repro-runtime.ts} (92%) rename web/src/lib/canvas/{director/director-resources.ts => previs/previs-resources.ts} (81%) rename web/src/lib/canvas/{director/director-save-wiring.ts => previs/previs-save-wiring.ts} (73%) rename web/src/lib/canvas/{director/director-save.ts => previs/previs-save.ts} (83%) rename web/src/lib/canvas/{director/director-scene.ts => previs/previs-scene.ts} (64%) rename web/src/lib/canvas/{director/director-session.ts => previs/previs-session.ts} (71%) rename web/src/lib/canvas/{director/director-shortcuts.ts => previs/previs-shortcuts.ts} (77%) rename web/src/lib/canvas/{director/director-templates.ts => previs/previs-templates.ts} (66%) rename web/src/lib/canvas/{director/director-view-modes.ts => previs/previs-view-modes.ts} (64%) rename web/src/pages/canvas/{use-canvas-director.ts => use-canvas-previs.ts} (61%) rename web/src/pages/dev/{director-repro-lab.tsx => previs-repro-lab.tsx} (75%) rename web/src/stores/canvas/{use-director-workbench-store.ts => use-previs-workbench-store.ts} (65%) create mode 100644 web/src/styles/admin-ui-1.css create mode 100644 web/src/styles/admin-ui-2.css create mode 100644 web/src/styles/admin-ui-3.css create mode 100644 web/src/styles/admin-ui-4.css create mode 100644 web/src/styles/globals-1.css create mode 100644 web/src/styles/globals-2.css create mode 100644 web/src/styles/globals-3.css create mode 100644 web/src/styles/globals-4.css create mode 100644 web/src/styles/globals-5.css create mode 100644 web/src/styles/globals-6.css create mode 100644 web/src/styles/globals-7.css delete mode 100644 web/src/types/director.ts create mode 100644 web/src/types/previs.ts create mode 100644 web/test/previs-path.test.ts create mode 100644 web/test/previs-preview-request.test.ts diff --git a/PREVIS_REDESIGN_EXECUTION_PLAN.md b/PREVIS_REDESIGN_EXECUTION_PLAN.md new file mode 100644 index 000000000..84c5ca2fe --- /dev/null +++ b/PREVIS_REDESIGN_EXECUTION_PLAN.md @@ -0,0 +1,677 @@ +# Previs 预演台 UI 重构执行计划 + +## 执行原则 + +1. **严格按顺序执行**:每完成一个 Phase,运行验证命令通过后才继续下一个 +2. **保持功能完整**:所有现有能力必须保留,E2E 测试必须通过 +3. **增量提交**:每个 Phase 完成后独立提交,commit message 用 conventional-commits skill +4. **视觉优先**:UI 必须高端、有质感、专业,参考 Blender / Unreal / Unity 的深色专业界面 + +## 当前问题诊断 + +### P0 缺陷 +1. **画布卡片无缩略图**:`canvas-previs-node-panel.tsx` 只在已回写构图时显示 ``,未回写时显示文字占位 +2. **高频工作流功能不全**:缺少新建物品(盒子/球体/圆柱/平面)、灯光、相机的直接入口 +3. **角色人偶材质单薄**:当前 `applyActorReferenceMaterial` 只是纯色 `MeshStandardMaterial`,无层次感 +4. **整体布局陈旧**:间距、圆角、字号、层次、对比度不足,缺少高级感 + +### 设计目标 +- **画布卡片**:实时 3D 缩略图(俯视/透视小视口),显示对象数/机位数/时长,玻璃态卡片 +- **高频工作流**:覆盖 100% 高频操作,分组清晰(演员/道具/灯光/机位/姿态/颜色/运镜),大图标+文字 +- **角色人偶**:程序化生成质感材质(fresnel rim、ambient occlusion 近似、subtle gradient),不同演员默认不同鲜明颜色,面板可改色 +- **工作台布局**:深色玻璃态、更大间距、清晰层次、减少嵌套、增加留白 + +--- + +## Phase 1: 角色人偶材质升级(独立,无 UI 依赖) + +### 目标 +高质量程序化人偶材质,不依赖贴图,纯 shader 实现 rim light + 环境色 + 颜色差异化。 + +### 实现文件 +- `web/src/components/canvas/previs/previs-viewport-rig.ts` + +### 具体改动 + +#### 1.1 新增高级材质构造函数 + +在 `previs-viewport-rig.ts` 中,找到 `applyActorReferenceMaterial` 函数,**完全替换**为: + +```typescript +/** + * 为演员人偶应用程序化高质感材质: + * - 基础颜色由 object.color 控制 + * - Fresnel rim light 提供轮廓光 + * - 微妙的顶部-底部渐变模拟天光与地面反射 + * - roughness 0.7 + metalness 0.05 产生柔和但有质感的反射 + */ +export function applyActorReferenceMaterial(model: Object3D, baseColor: string) { + const color = new Color(baseColor); + + // 构造自定义 ShaderMaterial 提供 rim + gradient + const material = new ShaderMaterial({ + uniforms: { + uBaseColor: { value: color }, + uRimColor: { value: new Color(0xffffff) }, + uRimPower: { value: 3.0 }, + uRimIntensity: { value: 0.35 }, + uTopColor: { value: new Color(0x87ceeb).multiplyScalar(0.15) }, + uBottomColor: { value: new Color(0x2c2c2c).multiplyScalar(0.3) }, + }, + vertexShader: ` + varying vec3 vNormal; + varying vec3 vViewPosition; + varying float vHeight; + + void main() { + vNormal = normalize(normalMatrix * normal); + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + vViewPosition = -mvPosition.xyz; + vHeight = position.y; + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + uniform vec3 uBaseColor; + uniform vec3 uRimColor; + uniform float uRimPower; + uniform float uRimIntensity; + uniform vec3 uTopColor; + uniform vec3 uBottomColor; + + varying vec3 vNormal; + varying vec3 vViewPosition; + varying float vHeight; + + void main() { + vec3 normal = normalize(vNormal); + vec3 viewDir = normalize(vViewPosition); + + // Fresnel rim + float rim = 1.0 - max(dot(normal, viewDir), 0.0); + rim = pow(rim, uRimPower) * uRimIntensity; + + // 顶部-底部渐变(模拟天光) + float heightFactor = clamp((vHeight + 0.5) / 2.0, 0.0, 1.0); + vec3 ambientGradient = mix(uBottomColor, uTopColor, heightFactor); + + // 合成 + vec3 finalColor = uBaseColor + ambientGradient + rim * uRimColor; + + gl_FragColor = vec4(finalColor, 1.0); + } + `, + }); + + model.traverse((child) => { + const mesh = child as Mesh; + if (mesh.isMesh && !mesh.userData.previsNoMaterialOverride) { + disposePrevisMaterials(mesh.material); + mesh.material = material; + } + }); +} + +export function updateActorReferenceColor(model: Object3D, baseColor: string) { + const color = new Color(baseColor); + model.traverse((child) => { + const mesh = child as Mesh; + if (mesh.isMesh && mesh.material && "uniforms" in mesh.material) { + const uniforms = (mesh.material as ShaderMaterial).uniforms; + if (uniforms.uBaseColor) uniforms.uBaseColor.value = color; + } + }); +} +``` + +#### 1.2 确保新演员自动分配不同颜色 + +在 `web/src/components/canvas/previs/canvas-previs-workbench.tsx` 中,找到 `addActor` 函数,确保: + +```typescript +const addActor = () => { + const existingActors = draft.objects.filter((obj) => obj.kind === "actor" || obj.primitive === "character"); + const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length; + const nextColor = PREVIS_ACTOR_COLORS[colorIndex]; + addObject(createPrevisActor(`演员 ${existingActors.length + 1}`, [0, 0, 0], nextColor)); +}; +``` + +#### 1.3 验证命令 + +```bash +cd web +./node_modules/.bin/tsc --noEmit +./node_modules/.bin/vite build +node scripts/previs-p0-chrome-e2e.mjs +``` + +**通过标准**:TypeScript 无错误,Vite 构建成功,E2E 62/62 通过。 + +**提交**: +```bash +git add web/src/components/canvas/previs/previs-viewport-rig.ts web/src/components/canvas/previs/canvas-previs-workbench.tsx +# 用 conventional-commits skill 生成 message +``` + +--- + +## Phase 2: 画布节点卡片实时 3D 缩略图 + +### 目标 +未回写构图时,画布卡片也能显示场景内容:渲染一个小的 3D 俯视图。 + +### 实现文件 +- `web/src/components/canvas/previs/canvas-previs-node-panel.tsx` +- `web/src/components/canvas/previs/previs-mini-viewport.tsx` (新建) + +### 具体改动 + +#### 2.1 新建 `previs-mini-viewport.tsx` + +创建 `web/src/components/canvas/previs/previs-mini-viewport.tsx`: + +```typescript +import { Canvas } from "@react-three/fiber"; +import { OrthographicCamera } from "@react-three/drei"; +import { Suspense } from "react"; +import type { PrevisScene } from "@/types/previs"; + +/** + * 画布卡片缩略图:固定俯视正交视角,只显示对象轮廓。 + * 不加载真实模型,用简化几何体代替,确保快速渲染。 + */ +export function PrevisMiniViewport({ scene }: { scene: PrevisScene }) { + return ( +
+ + + + + + + + {scene.objects.filter((obj) => obj.visible !== false).map((obj) => { + const pos = obj.transform.position; + const color = obj.color || "#d6d9dd"; + const primitive = obj.primitive || "box"; + + if (primitive === "sphere") { + return ; + } + if (primitive === "cylinder") { + return ; + } + if (primitive === "character" || obj.kind === "actor") { + return ; + } + // 默认 box + return ; + })} + + + +
+ ); +} +``` + +#### 2.2 改造 `canvas-previs-node-panel.tsx` + +在 `EmptyPreview` 组件中,**替换** `loading ? "正在准备场景" : "尚未生成预览"` 的分支为: + +```typescript +if (scene && scene.objects.length > 0) { + return ( +
+ +
+ } value={scene.objects.length} label="对象" /> + } value={scene.cameras.length} label="机位" /> + } value={scene.lights.length} label="灯光" /> +
+
+ ); +} + +return ( +
+ + + + {loading ? "正在准备场景" : "进入预演台开始创作"} +
+); +``` + +导入新组件: +```typescript +import { PrevisMiniViewport } from "./previs-mini-viewport"; +``` + +#### 2.3 验证命令 + +```bash +cd web +./node_modules/.bin/tsc --noEmit +./node_modules/.bin/vite build +``` + +手动检查:打开画布,创建一个预演场景节点,确认卡片显示 3D 俯视缩略图。 + +**提交**: +```bash +git add web/src/components/canvas/previs/canvas-previs-node-panel.tsx web/src/components/canvas/previs/previs-mini-viewport.tsx +``` + +--- + +## Phase 3: 高频工作流功能完整化 + +### 目标 +高频工作流模式包含所有高频操作:新建演员/盒子/球体/圆柱/平面、灯光、相机、姿态、颜色、景别、运镜。 + +### 实现文件 +- `web/src/components/canvas/previs/canvas-previs-workbench.tsx` + +### 具体改动 + +#### 3.1 高频工作流左侧面板重构 + +找到快速模式的 ` - : null} - {!quickMode ? <> -
- - -
- -
{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s
- - addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} /> -
- - -
- - {/* 时间轴只属于动画模式:其他模式下它不渲染,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} - : null} - - ); -} - -/** 渲染视图全集。实际可选项由当前模式的 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; - const offsets: Record = { static: [0, 0, 0], push_in: [0, 0, -2], pull_out: [0, 0, 2], pan_left: [-2, 0, 0], pan_right: [2, 0, 0], tilt_up: [0, 1.5, 0], tilt_down: [0, -1.2, 0], orbit_left: [-2.5, 0, -1.5], orbit_right: [2.5, 0, -1.5], handheld: [0.18, 0.08, -0.15] }; - const offset = offsets[move]; - return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] }; -} diff --git a/web/src/components/canvas/director/director-view-toolbar.tsx b/web/src/components/canvas/director/director-view-toolbar.tsx deleted file mode 100644 index 1ceda0cb2..000000000 --- a/web/src/components/canvas/director/director-view-toolbar.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Tooltip } from "@/components/ui/base/tooltip"; - -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.tsx b/web/src/components/canvas/director/director-viewport.tsx deleted file mode 100644 index 6e619f930..000000000 --- a/web/src/components/canvas/director/director-viewport.tsx +++ /dev/null @@ -1,1008 +0,0 @@ -import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber"; -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, Camera, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Plane, Quaternion, Raycaster, Scene, SkeletonHelper, Texture, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three"; -import { GLTFLoader, SkeletonUtils } from "three-stdlib"; - -import { applyClaySceneMaterials } from "@/lib/canvas/director/director-clay-materials"; -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, disposeDirectorModelResources, disposeDirectorObject3D, resolveDirectorLoadOwnership } from "@/lib/canvas/director/director-resources"; -import { DIRECTOR_DEFAULT_ACTOR_URL, 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 { DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director"; -import { applyActorReferenceMaterial, applyDirectorBoneTracks, directorFingerGroup, inferDirectorRig, normalizeModel, readRigRestRotations, screenPixelsToWorldRadius, updateActorReferenceColor } from "./director-viewport-rig"; -import { captureFrame, recordCanvas } from "./director-viewport-capture"; - -export type DirectorOrbitControls = ComponentRef; - -export type DirectorViewportHandle = { - capture: (mode: DirectorRenderMode) => Promise; - recordVideo: (duration: number, fps: number) => Promise; - readCameraTransform: () => DirectorTransform | null; - /** 只读放置意图。上下文不可用或从未产生合法点时返回空意图,绝不抛异常。 */ - readPlacementIntent: () => DirectorPlacementIntent; -}; - -type DirectorViewportProps = { - scene: DirectorScene; - selectedObjectId: string | null; - selectedBone: string | null; - transformMode: "translate" | "rotate" | "scale"; - 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; - /** 快速镜头模式下,点击地面直接落位;不提供则保持高级工作台原有行为。 */ - onGroundClick?: (point: DirectorGroundPoint) => void; - onObjectTransform: (id: string, from: DirectorTransform, to: DirectorTransform) => void; - onBoneTransform: (id: string, bone: string, rotation: DirectorQuat) => void; - onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void; -}; - -export type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: Camera; suspendDisplayMaterialOverride: () => () => void }; - -// 稳定空值:identity 不匹配时返回同一引用,避免下游 effect 依赖每次 render 都变化。 -const emptyAnimations: AnimationClip[] = []; -const emptyRestRotations: Partial> = {}; - -// Canvas 配置必须是稳定引用:inline literal 每次父级 render 都是新对象, -// context lost 的 dispatch 触发重渲染后 R3F 会 configure 并在失效 context 上 -// 重建 WebGLRenderer,抛 getMaxPrecision / autoReset。稳定后 lost 只显示 notice。 -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); - const orbitControlsRef = useRef(null); - /** pointermove 高频路径只写 ref,不触发 render;清空只允许由该点的 owner 发起。 */ - const onGroundPoint = useCallback((owner: HTMLCanvasElement, point: DirectorGroundPoint | null) => { - if (point) { - groundRef.current = { owner, point }; - return; - } - if (groundRef.current?.owner === owner) groundRef.current = null; - }, []); - const onOrbitControls = useCallback((controls: DirectorOrbitControls | null) => { - orbitControlsRef.current = controls; - }, []); - // retryKey 变化会真正重建 Canvas 与 ErrorBoundary,而不是只换文案。 - const [retryKey, setRetryKey] = useState(0); - // capture 可用性:上下文丢失期间不得再使用失效 renderer;恢复后需重新登记。 - const [capture, dispatchCapture] = useReducer(reduceDirectorCapture, directorCaptureInitial); - const captureRef = useRef(capture); - captureRef.current = capture; - // 加载失败的对象 id -> 该对象自己的 retry;Canvas 内部无法呈现可操作提示,统一提到 DOM 层。 - const [failedLoads, setFailedLoads] = useState({}); - const onCaptureContext = useCallback((context: CaptureContext) => { - captureContext.current = context; - dispatchCapture("register"); - }, []); - /** 子树卸载 / boundary 捕获:清掉指向已销毁 renderer 的引用并打回不可用。 */ - const releaseCapture = useCallback(() => { - releaseDirectorCapture({ - clearContext: () => { - captureContext.current = null; - }, - onAvailability: dispatchCapture, - }); - // 地面点与 controls 都属于已销毁的 renderer,必须一并作废。 - groundRef.current = null; - orbitControlsRef.current = null; - }, []); - const retry = useCallback(() => { - releaseCapture(); - setFailedLoads({}); - setRetryKey((value) => value + 1); - }, [releaseCapture]); - const onLoadStateChange = useCallback((id: string, signal: DirectorLoadSignal, retryLoad: () => void) => { - setFailedLoads((current) => upsertDirectorFailedLoad(current, id, signal, retryLoad)); - }, []); - // 稳定引用:否则新的监听 effect 会在每次父级 render 时摘除重装。 - const onContextLost = useCallback(() => { - // 上下文丢失后相机与射线结果都不可信,旧地面点必须作废,恢复后需重新移动 pointer。 - groundRef.current = null; - recordDirectorDiagnostic("DIRECTOR_VIEWPORT_CONTEXT_LOST"); - dispatchCapture("lost"); - }, []); - const onContextRestored = useCallback(() => { - recordDirectorDiagnostic("DIRECTOR_VIEWPORT_CONTEXT_RESTORED"); - dispatchCapture("restored"); - }, []); - const failedIds = Object.keys(failedLoads); - // 上下文失效时 renderer 不可用:capture/record/readCamera 必须明确失败而不是画出脏帧。 - const usableContext = () => { - if (!directorCaptureUsable(captureRef.current)) return null; - return captureContext.current; - }; - useImperativeHandle(ref, () => ({ - capture: (mode) => captureFrame(usableContext(), mode), - recordVideo: (duration, fps) => recordCanvas(usableContext(), duration, fps), - readCameraTransform: () => { - const camera = usableContext()?.camera; - return camera ? { position: camera.position.toArray() as DirectorTransform["position"], rotation: [camera.rotation.x, camera.rotation.y, camera.rotation.z], scale: [1, 1, 1] } : null; - }, - readPlacementIntent: () => { - const context = usableContext(); - if (!context) return emptyDirectorPlacementIntent; - // owner 校验:上下文重建后,旧 renderer canvas 记录的点一律不采用。 - const tracked = groundRef.current; - const pointer = tracked && tracked.owner === context.gl.domElement ? tracked.point : null; - // 读实例当前 target,而不是 activeCamera.target prop;投影到 y=0 即取 x/z。 - const target = orbitControlsRef.current?.target; - return { pointer, orbitTarget: finiteDirectorGroundPoint(target?.x, target?.z) }; - }, - }), []); - - return ( - // data-renderer-ready 直接来自 directorCaptureUsable:capture context 已登记且未 lost。 - // 这是真实就绪信号,供 E2E 在触发 context loss 前确定监听器已安装。 -
- - - - {/* 取景切换是纯视口状态:放在 DOM 层,不随 Canvas 重建而丢失。 */} - {onViewModeChange ? : null} - {capture.contextLost ? ( - - ) : null} - {!capture.contextLost && failedIds.length ? ( - { - // 重试触发新的 load generation,旧的晚到回调会被忽略并释放资源。 - Object.values(failedLoads).forEach((retryLoad) => retryLoad()); - setFailedLoads({}); - }} - /> - ) : null} -
- ); -}); - -// onViewModeChange 被显式排除:切换器活在 DOM 层,Canvas 子树只需要 viewMode 取值。 -type DirectorCanvasSurfaceProps = Omit & { - 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; -}; - -/** - * Canvas 表面独立 memo。 - * - * R3F 本地 CanvasImpl 的 layout effect 没有依赖数组,父级每次 rerender 都会 configure。 - * context lost 时 DirectorViewport 会 dispatchCapture 触发 rerender,若 Canvas 跟着重渲染, - * configure 就会在已失效的 canvas 上重建 WebGLRenderer 并抛 getMaxPrecision / autoReset。 - * 隔离在 memo 子组件后,contextLost / failedLoads 这类外层状态变化不再穿透到 Canvas; - * 只有 props 真的变化才重渲染,retryKey 变化仍由外层 key 触发真正 remount。 - */ -const DirectorCanvasSurface = memo(function DirectorCanvasSurface(props: DirectorCanvasSurfaceProps) { - const { onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls, ...sceneProps } = props; - const onSelectObject = sceneProps.onSelectObject; - const onPointerMissed = useCallback(() => onSelectObject(null), [onSelectObject]); - - return ( - - - - - - ); -}); - -/** 本地失败隔离:3D 视口异常只替换视口本身,不影响画布/项目其余部分。 */ -class DirectorViewportErrorBoundary extends Component<{ children: ReactNode; onRelease: () => void; onRetry: () => void }, { failed: boolean }> { - state = { failed: false }; - - static getDerivedStateFromError() { - return { failed: true }; - } - - componentDidCatch() { - // 只记录稳定错误码:原始 error / componentStack 可能带素材 URL 或正文,不落日志。 - recordDirectorDiagnostic("DIRECTOR_VIEWPORT_RENDER_FAILED"); - // 子树已被 React 卸载,capture context 指向已销毁的 renderer,必须同步释放。 - this.props.onRelease(); - } - - render() { - if (!this.state.failed) return this.props.children; - return ( - { - this.setState({ failed: false }); - this.props.onRetry(); - }} - /> - ); - } -} - -function DirectorViewportNotice({ title, description, actionLabel, onAction, variant = "cover" }: { title: string; description: string; actionLabel: string; onAction: () => void; variant?: "cover" | "corner" }) { - return ( -
-
{title}
-

{description}

- -
- ); -} - -function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onGroundClick, 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); - // 三台相机各司其职、互不共享: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?.(); - displayClayRestoreRef.current = null; - return () => { - if (suspended) displayClayRestoreRef.current = applyClaySceneMaterials(threeScene); - }; - }, [threeScene]); - - const readCaptureContext = useCallback((): CaptureContext => ({ gl, camera: camera as Camera, scene: threeScene, suspendDisplayMaterialOverride }), [camera, gl, suspendDisplayMaterialOverride, threeScene]); - - useEffect(() => { - onCaptureContext(readCaptureContext()); - }, [onCaptureContext, readCaptureContext]); - - // 子树卸载(重试重建、boundary 捕获后 React 卸载)时同步释放: - // 此后 captureContext 指向已销毁的 renderer,必须清空并打回不可用。 - // 单独 effect 且只依赖稳定的 onRelease,不会被 readCaptureContext 变化连带触发。 - useEffect(() => () => onRelease(), [onRelease]); - - // 上下文丢失/恢复监听绑定在 renderer 自己的 canvas 上,随 renderer 与重试精确摘除。 - useEffect(() => installDirectorContextListeners(gl.domElement, { - onLost: onContextLost, - // 恢复后 registered 会被置回 false,必须用当前 renderer 重新登记, - // 否则 capture/record/readCameraTransform 会一直不可用。走与测试共用的序列 helper。 - onRestored: () => restoreDirectorCapture({ - readContext: readCaptureContext, - onAvailability: onContextRestored, - onRegister: onCaptureContext, - invalidate, - }), - }), [gl, invalidate, onCaptureContext, onContextLost, onContextRestored, readCaptureContext]); - - /** - * 地面拾取:renderer 自己的 canvas 上做 pointer 监听,再用真实 Raycaster 与 - * 世界 y=0 Plane 求交。不走 mesh onPointerMove(会被物体 stopPropagation 截断), - * 也不做 DOM 像素伪换算;因此 pointer 悬停在物体上方时射线仍与地面相交。 - * 高频路径只写 ref,绝不 setState。 - */ - useEffect(() => { - const canvasElement = gl.domElement; - const raycaster = new Raycaster(); - const groundPlane = new Plane(new Vector3(0, 1, 0), 0); - const hit = new Vector3(); - const ndc = new Vector2(); - let pointerDown: { id: number; x: number; y: number; button: number } | null = null; - - const resolveGroundPoint = (event: PointerEvent) => { - const bounds = canvasElement.getBoundingClientRect(); - if (bounds.width <= 0 || bounds.height <= 0) return null; - ndc.set(((event.clientX - bounds.left) / bounds.width) * 2 - 1, -((event.clientY - bounds.top) / bounds.height) * 2 + 1); - raycaster.setFromCamera(ndc, camera); - if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null; - return finiteDirectorGroundPoint(hit.x, hit.z); - }; - - const onPointerMove = (event: PointerEvent) => { - const point = resolveGroundPoint(event); - // 射线与地面平行或背离时保留上一个合法点,不写非法值。 - if (point) onGroundPoint(canvasElement, point); - }; - - const onPointerDown = (event: PointerEvent) => { - if (!onGroundClick || event.button !== 0 || event.isPrimary === false) return; - pointerDown = { id: event.pointerId, x: event.clientX, y: event.clientY, button: event.button }; - }; - - const onPointerUp = (event: PointerEvent) => { - const start = pointerDown; - pointerDown = null; - if (!start || start.id !== event.pointerId || start.button !== 0 || event.button !== 0) return; - // OrbitControls 旋转、平移或缩放结束时也会收到 pointerup,移动阈值避免误落位。 - const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); - if (distance > 5) return; - const point = resolveGroundPoint(event); - if (point) onGroundClick?.(point); - }; - - canvasElement.addEventListener("pointermove", onPointerMove, { passive: true }); - canvasElement.addEventListener("pointerdown", onPointerDown, { passive: true }); - canvasElement.addEventListener("pointerup", onPointerUp, { passive: true }); - return () => { - canvasElement.removeEventListener("pointermove", onPointerMove); - canvasElement.removeEventListener("pointerdown", onPointerDown); - canvasElement.removeEventListener("pointerup", onPointerUp); - pointerDown = null; - // 这个 renderer 的 canvas 卸载后,它记录的地面点不得再被读到。 - onGroundPoint(canvasElement, null); - }; - }, [camera, gl, onGroundClick, onGroundPoint]); - - // OrbitControls 的真实 target 只能从实例读;activeCamera.target 只是初始 prop。 - // 挂载期登记一次即可:drei 重建实例会连带重跑本 effect。 - useEffect(() => { - onOrbitControls(orbitRef.current); - return () => onOrbitControls(null); - }, [onOrbitControls]); - - useEffect(() => { - threeScene.background = new Color(scene.background); - invalidate(); - }, [invalidate, scene.background, threeScene]); - - useEffect(() => { - const material = renderMode === "depth" ? new MeshDepthMaterial() : renderMode === "normal" ? new MeshNormalMaterial() : renderMode === "pose" ? new MeshBasicMaterial({ color: "#ffffff", wireframe: true }) : null; - if (renderMode === "clay") displayClayRestoreRef.current = applyClaySceneMaterials(threeScene); - threeScene.overrideMaterial = material; - invalidate(); - return () => { - if (threeScene.overrideMaterial === material) threeScene.overrideMaterial = null; - displayClayRestoreRef.current?.(); - displayClayRestoreRef.current = null; - material?.dispose(); - }; - }, [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} - - - - - {actorMotionPaths.map((object) => )} - {cameraMotionPaths.map((item) => )} - {scene.objects.filter((item) => item.visible).map((object) => ( - onSelectObject(object.id)} - onSelectBone={(bone) => { onSelectObject(object.id); onSelectBone(bone); }} - onTransforming={setTransforming} - onTransform={(from, to) => onObjectTransform(object.id, from, to)} - onBoneTransform={(bone, rotation) => onBoneTransform(object.id, bone, rotation)} - onActorRigReady={(rig, animations) => onActorRigReady(object.id, rig, animations)} - onLoadStateChange={onLoadStateChange} - /> - ))} - {/* 只有有效 free 回落允许环绕:drei 只在 enabled 时调 controls.update(),CAM/正交下这是 - 真正的锁定,不会有 controls 每帧把相机拽回自己 target 的回写竞争。 - camera 显式绑定 freeCamera:即使 state.camera 当前指向别的相机,也绝不会 - 被这份环绕状态误伤。CAM 取景失败时 orbit 打开,用已有的自由视角,不改场景。 */} - - - ); -} - -/** 起点、终点、路径点、方向与当前进度共用一条 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); - const framingKey = directorViewFramingKey(framing); - useEffect(() => { - 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(); - // framing 只通过 framingKey 参与依赖:草稿对象身份变化不触发无谓回写。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [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; -} - -function DirectorObjectView({ object, selected, selectedBone, transformMode, playhead, onSelect, onSelectBone, onTransforming, onTransform, onBoneTransform, onActorRigReady, onLoadStateChange }: { object: DirectorObject; selected: boolean; selectedBone: string | null; transformMode: DirectorViewportProps["transformMode"]; playhead: number; onSelect: () => void; onSelectBone: (bone: string | null) => void; onTransforming: (value: boolean) => void; onTransform: (from: DirectorTransform, to: DirectorTransform) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void }) { - const [target, setTarget] = useState(null); - const resolved = interpolateDirectorTransform(object.transform, object.keyframes, playhead); - // 手势进行中冻结声明式 transform,交由 gizmo 直接改写 Object3D;终态后再由场景状态接管。 - const [frozen, setFrozen] = useState(null); - const transform = frozen || resolved; - return ( - <> - { - event.stopPropagation(); - onSelect(); - }} - > - - - {selected && target ? ( - - ) : null} - - ); -} - -function DirectorObjectGizmo({ target, transformMode, onFreeze, onTransforming, onTransform }: { target: Group; transformMode: DirectorViewportProps["transformMode"]; onFreeze: (transform: DirectorTransform | null) => void; onTransforming: (value: boolean) => void; onTransform: (from: DirectorTransform, to: DirectorTransform) => void }) { - const transaction = useDirectorGizmoTransaction({ - read: () => readObject3DTransform(target), - restore: (snapshot) => applyObject3DTransform(target, snapshot), - commit: onTransform, - onActive: (active, snapshot) => { - onFreeze(active ? snapshot : null); - onTransforming(active); - }, - }); - return ( - transaction.begin()} - onMouseUp={() => transaction.end("commit")} - /> - ); -} - -/** - * gizmo 事务的统一接线:对象与骨骼共用,避免两套实现产生偏差。 - * 监听在挂载期间常驻安装(不以「当前是否有手势」为安装条件), - * 非活跃时 end 自身是空操作。 - */ -function useDirectorGizmoTransaction({ read, restore, commit, onActive }: { read: () => TSnapshot | null; restore: (snapshot: TSnapshot) => void; commit: (from: TSnapshot, to: TSnapshot) => void; onActive: (active: boolean, snapshot: TSnapshot | null) => void }) { - const hooksRef = useRef({ read, restore, commit, onActive }); - useEffect(() => { hooksRef.current = { read, restore, commit, onActive }; }, [commit, onActive, read, restore]); - - const transaction = useMemo(() => createDirectorTransaction({ - read: () => hooksRef.current.read(), - restore: (snapshot) => hooksRef.current.restore(snapshot), - commit: (from, to) => hooksRef.current.commit(from, to), - setActive: (active) => hooksRef.current.onActive(active, active ? hooksRef.current.read() : null), - // stdlib 在 domElement.ownerDocument 上注册 pointerup,其 pointerUp({button:0}) - // 会 dispatch mouseUp 后清 dragging=false / axis=null。因此用真实事件走它自己的 - // 收尾通道,而不是写它的私有字段(.d.ts 中 dragging/axis 均为 private)。 - terminateDrag: () => document.dispatchEvent(new PointerEvent("pointerup", { button: 0, bubbles: true })), - }), []); - - useEffect(() => installDirectorTerminalListeners(transaction, { - window, - document, - isHidden: () => document.visibilityState === "hidden", - }), [transaction]); - - // 取消选中、删除对象、切换模型或卸载都必须回到快照并释放 transforming。 - useEffect(() => () => transaction.end("cancel"), [transaction]); - - return transaction; -} - -function readObject3DTransform(target: Object3D): DirectorTransform { - return { position: target.position.toArray() as DirectorVec3, rotation: [target.rotation.x, target.rotation.y, target.rotation.z], scale: target.scale.toArray() as DirectorVec3 }; -} - -function applyObject3DTransform(target: Object3D, transform: DirectorTransform) { - target.position.set(...transform.position); - target.rotation.set(...transform.rotation); - target.scale.set(...transform.scale); - target.updateMatrixWorld(true); -} - -function DirectorObjectVisual({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady, onLoadStateChange }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void }) { - if ((object.kind === "model" || object.kind === "actor" || object.primitive === "character") && (object.url || object.primitive === "character")) return ; - if (object.kind === "billboard" && object.url) return ; - const material = ; - return ( - - {object.primitive === "sphere" ? : object.primitive === "cylinder" ? : object.primitive === "plane" ? : } - {material} - - ); -} - -function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady, onLoadStateChange }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void }) { - const [load, dispatchLoad] = useReducer(reduceDirectorLoad, directorLoadInitial); - const loadRef = useRef(load); - loadRef.current = load; - const loadGeneration = load.generation; - const modelUrl = object.kind === "actor" || object.primitive === "character" ? DIRECTOR_DEFAULT_ACTOR_URL : object.url; - // 展示身份 = generation + 解析输入。render 阶段用它屏蔽旧资源, - // 因此 prop/retry 变化的第一次 render 就已卸下上一代 model,随后 cleanup 才 dispose。 - const identity = directorLoadIdentity({ generation: loadGeneration, url: modelUrl, storageKey: object.storageKey, kind: object.kind }); - type LoadedModel = { model: Object3D; rig: DirectorRig; animations: AnimationClip[]; restRotations: Partial> }; - const [loaded, setLoaded] = useState<{ identity: string; value: LoadedModel } | null>(null); - const display = resolveDirectorDisplay(loaded, identity); - const model = display?.model ?? null; - const rig = display?.rig ?? null; - const animations = display?.animations ?? emptyAnimations; - const restRotations = display?.restRotations ?? emptyRestRotations; - const setLoadPhase = useCallback((phase: "loading" | "ready" | "error") => { - if (phase === "loading") dispatchLoad({ type: "start" }); - else dispatchLoad({ type: phase === "ready" ? "loaded" : "failed", generation: loadRef.current.generation }); - }, []); - const mixerRef = useRef(null); - const ownedRef = useRef<{ model: Object3D | null; mixer: AnimationMixer | null }>({ model: null, mixer: null }); - const onActorRigReadyRef = useRef(onActorRigReady); - const invalidate = useThree((state) => state.invalidate); - // helper 只跟随「当前 identity 下真正要展示的 model」,不会挂在已卸下的旧 model 上。 - const helper = useMemo(() => model ? new SkeletonHelper(model) : null, [model]); - useEffect(() => () => disposeDirectorHelper(helper), [helper]); - - // 把 loading/error/ready 报到 DOM 层,Canvas 内部无法呈现可操作提示。 - // 依赖必须收敛到稳定原始值:object 每次场景编辑都是新引用, - // 若挂在 [object] 上会让下面的注销 effect 每次编辑都误报 unmounted, - // 失败模型的重试入口会随之消失。 - const objectId = object.id; - const objectKind = directorDiagnosticObjectKind(object); - const retryLoad = useCallback(() => { - recordDirectorDiagnostic("DIRECTOR_MODEL_LOAD_RETRY", { objectId, objectKind, attempt: loadRef.current.generation, userInitiated: true }); - dispatchLoad({ type: "retry" }); - }, [objectId, objectKind]); - const onLoadStateChangeRef = useRef(onLoadStateChange); - useEffect(() => { onLoadStateChangeRef.current = onLoadStateChange; }, [onLoadStateChange]); - useEffect(() => { - onLoadStateChangeRef.current(object.id, load.phase, retryLoad); - }, [load.phase, object.id, retryLoad]); - - // 卸载(删除、隐藏分支、换 URL/kind、Canvas 重建)必须注销自己,避免通知残留陈旧条目。 - // 单独 effect 且只依赖 id:phase 变化不会反复注销重登。 - useEffect(() => () => onLoadStateChangeRef.current(object.id, "unmounted", retryLoad), [object.id, retryLoad]); - const selectedBoneObject = selectedBone && rig?.boneMap[selectedBone as DirectorHumanoidBone] ? model?.getObjectByName(rig.boneMap[selectedBone as DirectorHumanoidBone]!) : null; - const motion = object.motionClips?.find((item) => item.id === object.activeMotionClipId); - const activeAnimation = motion ? animations.find((item) => item.name === motion.sourceAnimation) : undefined; - const handleModelPointerDown = useCallback((event: ThreeEvent) => { - if (!selected || !rig) return; - let nearestBone: string | null = null; - let nearestDistance = Number.POSITIVE_INFINITY; - Object.entries(rig.boneMap).forEach(([bone, name]) => { - if (!name) return; - const target = model?.getObjectByName(name); - if (!target) return; - const distance = event.ray.distanceSqToPoint(target.getWorldPosition(new Vector3())); - if (distance <= 0.12 ** 2 && distance < nearestDistance) { - nearestBone = bone; - nearestDistance = distance; - } - }); - if (!nearestBone) return; - // 模型表面可能先于关节控制球被射线命中,用最近骨骼保证点击仍可选中。 - event.stopPropagation(); - onSelectBone(nearestBone); - }, [model, onSelectBone, rig, selected]); - - useEffect(() => { onActorRigReadyRef.current = onActorRigReady; }, [onActorRigReady]); - - useEffect(() => { - const generation = loadRef.current.generation; - let active = true; - // render 阶段已由 identity 屏蔽旧资源;这里再清一次作为防御,不作为安全性依据。 - setLoaded(null); - setLoadPhase("loading"); - const failLoad = () => { - if (!active || generation !== loadRef.current.generation) return; - // 只记录稳定码与安全枚举:URL / storageKey / 原始 error 都不落日志。 - recordDirectorDiagnostic("DIRECTOR_MODEL_LOAD_FAILED", { objectId: object.id, objectKind: directorDiagnosticObjectKind(object), attempt: generation }); - setLoadPhase("error"); - }; - const loader = new GLTFLoader(); - void resolveMediaUrl(object.storageKey, modelUrl) - .then((url) => { - // 解析完成时已失效:不再发起无意义的网络加载。 - if (!active || generation !== loadRef.current.generation) return; - loader.load( - url, - (gltf) => { - const ownership = resolveDirectorLoadOwnership({ active, generation, currentGeneration: loadRef.current.generation }); - if (!ownership.adopt) { - // 未被采纳的晚到 source 必须释放,否则孤儿泄漏。 - if (ownership.disposeSource) disposeDirectorObject3D(gltf.scene); - return; - } - // 采纳流程整体包裹:clone / normalize / rig 推断 / 材质替换 / mixer / - // setLoaded / onActorRigReady 任一步抛错都不得逃出,也不得留下半采纳资源。 - let clone: Object3D | null = null; - let mixer: AnimationMixer | null = null; - try { - // clone 与 source 共享 geometry/material/texture。 - // 采纳后 source 的 Object3D 层级可被 GC,共享 GPU 资源由 owned clone 持有, - // 最终只在 clone 的 cleanup 里释放一次;这里绝不 dispose source。 - clone = SkeletonUtils.clone(gltf.scene); - normalizeModel(clone, object.castShadow, object.receiveShadow); - const nextRig = inferDirectorRig( - clone, - gltf.animations.map((clip) => clip.name), - ); - if (object.kind === "actor" || object.primitive === "character") applyActorReferenceMaterial(clone, object.color); - mixer = new AnimationMixer(clone); - mixerRef.current = mixer; - ownedRef.current = { model: clone, mixer }; - // 一次性写入带 identity 的展示记录,避免 model/rig/animations 之间出现中间态。 - setLoaded({ - identity, - value: { model: clone, rig: nextRig, animations: gltf.animations, restRotations: readRigRestRotations(clone, nextRig) }, - }); - setLoadPhase("ready"); - onActorRigReadyRef.current(nextRig, gltf.animations); - } catch { - // 只记录稳定错误码:原始 error / URL / 素材正文都不落日志。 - recordDirectorDiagnostic("DIRECTOR_MODEL_ADOPT_FAILED", { objectId: object.id, objectKind: directorDiagnosticObjectKind(object) }); - // 先摘掉 owned 记录,避免 effect cleanup 二次释放同一批资源。 - ownedRef.current = { model: null, mixer: null }; - mixerRef.current = null; - setLoaded(null); - disposeDirectorAdoptionFailure({ clone, mixer, source: gltf.scene }); - // 回到既有 error/retry/占位人偶路径。 - failLoad(); - } - }, - undefined, - failLoad, - ); - }) - // 地址解析失败与 GLTFLoader onError 走同一条 error/retry 通道。 - .catch(failLoad); - return () => { - active = false; - // 替换/重试/卸载:释放本 generation 拥有的 clone 与 mixer(共享资源随之恰好释放一次)。 - const owned = ownedRef.current; - ownedRef.current = { model: null, mixer: null }; - mixerRef.current = null; - disposeDirectorModelResources({ model: owned.model, mixer: owned.mixer }); - }; - }, [identity, modelUrl, object.storageKey]); - - useEffect(() => { - if (!model) return; - model.traverse((child) => { - const mesh = child as Mesh; - if (!mesh.isMesh) return; - mesh.castShadow = object.castShadow; - mesh.receiveShadow = object.receiveShadow; - }); - invalidate(); - }, [invalidate, model, object.castShadow, object.receiveShadow]); - - useEffect(() => { - if (!model || (object.kind !== "actor" && object.primitive !== "character")) return; - updateActorReferenceColor(model, object.color); - invalidate(); - }, [invalidate, model, object.color, object.kind]); - - useEffect(() => { - if (!model || !mixerRef.current) return; - const mixer = mixerRef.current; - mixer.stopAllAction(); - if (!activeAnimation) return; - mixer.clipAction(activeAnimation).setLoop(motion?.loop ? LoopRepeat : LoopOnce, motion?.loop ? Infinity : 1).play(); - return () => { - mixer.stopAllAction(); - }; - }, [activeAnimation, model, motion?.loop]); - - useEffect(() => { - if (!model || !mixerRef.current) return; - if (activeAnimation && motion) { - const localTime = Math.max(0, playhead - motion.start) * motion.playbackRate; - const clipDuration = motion.duration || activeAnimation.duration; - mixerRef.current.setTime(motion.loop && clipDuration > 0 ? localTime % clipDuration : localTime); - } - applyDirectorBoneTracks(model, object, playhead, rig, restRotations, Boolean(activeAnimation && motion)); - helper?.updateMatrixWorld(true); - invalidate(); - }, [activeAnimation, helper, invalidate, model, motion, object.boneOverrides, object.boneTracks, object.pose, playhead, restRotations, rig]); - - useFrame(() => { - if (selectedBoneObject && selected) selectedBoneObject.updateMatrixWorld(true); - }); - - if (!model) return ; - return - - {selected && helper ? : null} - {selected && rig ? Object.entries(rig.boneMap).filter(([, name]) => Boolean(name)).map(([bone, name]) => { - const fingerGroup = directorFingerGroup(bone); - const selectedFingerGroup = directorFingerGroup(selectedBone); - return onSelectBone(bone)} />; - }) : null} - {selected && selectedBoneObject && selectedBone ? onBoneTransform(selectedBone, rotation)} /> : null} - ; -} - -/** 骨骼 gizmo 与对象 gizmo 共用事务接线,取消时恢复骨骼 quaternion。 */ -function DirectorBoneGizmo({ bone, onCommit }: { bone: Object3D; onCommit: (rotation: DirectorQuat) => void }) { - const transaction = useDirectorGizmoTransaction({ - read: () => bone.quaternion.toArray() as DirectorQuat, - restore: (snapshot) => { - bone.quaternion.fromArray(snapshot); - bone.updateMatrixWorld(true); - }, - commit: (_from, to) => onCommit(to), - onActive: () => undefined, - }); - return transaction.begin()} onMouseUp={() => transaction.end("commit")} />; -} - -function DirectorMannequin({ color, selected }: { color: string; selected: boolean }) { - const resolvedColor = selected ? new Color(color).lerp(new Color("#78a9ff"), 0.18).getStyle() : color; - const joints: DirectorVec3[] = [[0, 1.72, 0], [0, 1.48, 0], [-0.3, 1.4, 0], [0.3, 1.4, 0], [-0.32, 1.05, 0], [0.32, 1.05, 0], [-0.33, 0.72, 0], [0.33, 0.72, 0], [-0.13, 0.88, 0], [0.13, 0.88, 0], [-0.13, 0.46, 0], [0.13, 0.46, 0], [-0.13, 0.05, 0], [0.13, 0.05, 0]]; - const bones: Array<[DirectorVec3, DirectorVec3]> = [[joints[0], joints[1]], [joints[1], joints[2]], [joints[1], joints[3]], [joints[2], joints[4]], [joints[4], joints[6]], [joints[3], joints[5]], [joints[5], joints[7]], [joints[1], [0, 0.92, 0]], [[0, 0.92, 0], joints[8]], [[0, 0.92, 0], joints[9]], [joints[8], joints[10]], [joints[10], joints[12]], [joints[9], joints[11]], [joints[11], joints[13]]]; - return - {bones.map(([from, to], index) => )} - {joints.map((position, index) => )} - ; -} - -function LoadingBone({ from, to, color }: { from: DirectorVec3; to: DirectorVec3; color: string }) { - const start = useMemo(() => new Vector3(...from), [from]); - const end = useMemo(() => new Vector3(...to), [to]); - const direction = useMemo(() => end.clone().sub(start), [end, start]); - const midpoint = useMemo(() => start.clone().add(end).multiplyScalar(0.5), [end, start]); - const rotation = useMemo(() => new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction.clone().normalize()), [direction]); - return ; -} - -function BoneController({ bone, selected, dimmed, onSelect }: { bone: Object3D | undefined; selected: boolean; dimmed: boolean; onSelect: () => void }) { - const ref = useRef(null); - const visibleRef = useRef(null); - const hitRef = useRef(null); - const world = useMemo(() => new Vector3(), []); - const local = useMemo(() => new Vector3(), []); - const cameraWorld = useMemo(() => new Vector3(), []); - const { camera, size } = useThree(); - useFrame(() => { - if (!bone || !ref.current) return; - const parent = ref.current.parent; - if (!parent) return; - // 控制点与模型根节点是兄弟关系,必须转换到控制点父级坐标,否则点击区域会与骨骼错位。 - bone.getWorldPosition(world); - camera.getWorldPosition(cameraWorld); - local.copy(world); - parent.worldToLocal(local); - ref.current.position.copy(local); - const distance = cameraWorld.distanceTo(world); - const visualPixels = selected ? 5 : dimmed ? 2.5 : 3.5; - const hitPixels = selected ? 12 : 10; - visibleRef.current?.scale.setScalar(screenPixelsToWorldRadius(camera, distance, visualPixels, size.height)); - hitRef.current?.scale.setScalar(screenPixelsToWorldRadius(camera, distance, hitPixels, size.height)); - }); - if (!bone) return null; - const handlePointerDown = (event: ThreeEvent) => { event.stopPropagation(); onSelect(); }; - return - - - - - {/* 可视点保持小尺寸,透明球只负责提供稳定的点击面积。 */} - - - - - ; -} - -function DirectorBillboard({ object, selected }: { object: DirectorObject; selected: boolean }) { - // 展示状态带 url 身份:只有与当前 object.url 匹配才交给 material, - // 这样换 URL 的第一次 render 就卸下旧纹理,随后 cleanup 才 dispose。 - const [loaded, setLoaded] = useState<{ identity: string; value: Texture } | null>(null); - const identity = object.url || ""; - const texture = resolveDirectorDisplay(loaded, identity); - useEffect(() => { - let active = true; - let owned: Texture | null = null; - setLoaded(null); - new TextureLoader().load(object.url!, (next) => { - // 晚到的回调:本组件已换 URL 或卸载,直接释放这张纹理。 - if (!active) { - next.dispose(); - return; - } - owned = next; - setLoaded({ identity, value: next }); - }, undefined, () => active && setLoaded(null)); - return () => { - active = false; - owned?.dispose(); - owned = null; - }; - }, [identity, object.url]); - return ( - - - - - ); -} - -function DirectorLightView({ light }: { light: DirectorLight }) { - const position = light.transform.position; - if (light.type === "ambient") return ; - if (light.type === "point") return ; - if (light.type === "spot") return ; - return ; -} diff --git a/web/src/components/canvas/previs/canvas-previs-node-card.css b/web/src/components/canvas/previs/canvas-previs-node-card.css new file mode 100644 index 000000000..36e11d7ed --- /dev/null +++ b/web/src/components/canvas/previs/canvas-previs-node-card.css @@ -0,0 +1,22 @@ +/* 画布上的预演台节点卡片样式。必须随节点面板加载,不能依赖懒加载的预演台工作台 CSS。 */ +.previs-node-card { display: flex; width: 100%; height: 100%; min-width: 0; min-height: 0; flex-direction: column; gap: 10px; padding: 16px 16px 14px; overflow: hidden; background: linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.018)); } +.previs-node-card-header, .previs-node-card-title-row, .previs-node-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.previs-node-card-kicker, .previs-node-card-status, .previs-node-card-footer-action, .previs-node-card-duration { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } +.previs-node-card-kicker { color: color-mix(in srgb, currentColor 62%, transparent); font-size: 9px; font-weight: 700; letter-spacing: .14em; } +.previs-node-card-mark { display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; background: rgba(255,255,255,.07); color: currentColor; } +.previs-node-card-status { color: color-mix(in srgb, currentColor 62%, transparent); font-size: 10px; } +.previs-node-card-title-row { align-items: flex-end; } +.previs-node-card-title { margin: 0; overflow: hidden; color: currentColor; font-size: 16px; font-weight: 700; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } +.previs-node-card-shot { margin: 4px 0 0; overflow: hidden; color: color-mix(in srgb, currentColor 56%, transparent); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.previs-node-card-duration { color: color-mix(in srgb, currentColor 65%, transparent); font-size: 10px; } +.previs-node-preview { position: relative; display: flex; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; border: 1px solid; border-radius: 14px; text-align: left; cursor: pointer; } +.previs-node-preview:disabled { cursor: default; } +.previs-node-preview-image { width: 100%; height: 100%; object-fit: cover; } +.previs-node-preview-cta { position: absolute; inset-inline: 0; bottom: 0; display: flex; height: 38px; align-items: center; justify-content: center; gap: 6px; opacity: 0; font-size: 11px; font-weight: 700; transition: opacity 160ms cubic-bezier(.16,1,.3,1); } +.previs-node-preview:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.previs-node-card-footer { min-height: 24px; border-top: 1px solid; padding-top: 9px; color: color-mix(in srgb, currentColor 62%, transparent); font-size: 10px; } +.previs-node-card-footer b { color: currentColor; font-variant-numeric: tabular-nums; } +.previs-node-card-footer-action { margin-left: auto; color: color-mix(in srgb, currentColor 48%, transparent); } +.previs-node-preview .previs-mini-viewport-canvas, +.previs-node-preview .previs-mini-viewport-canvas canvas { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; } +.previs-node-preview { min-height: 120px; } diff --git a/web/src/components/canvas/previs/canvas-previs-node-panel.tsx b/web/src/components/canvas/previs/canvas-previs-node-panel.tsx new file mode 100644 index 000000000..0f068742f --- /dev/null +++ b/web/src/components/canvas/previs/canvas-previs-node-panel.tsx @@ -0,0 +1,86 @@ +import { useState, type ReactNode } from "react"; +import { Box, Camera, Clapperboard, Lightbulb, LockKeyhole, Move3d, Sparkles, Timer } from "lucide-react"; + +import { CachedResourceImage } from "@/components/cached-resource-image"; +import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; +import { gatePrevisPreviewFailure, resolvePrevisActiveShot, resolvePrevisPreviewSource, type PrevisNodeContentReader } from "@/lib/canvas/previs/previs-preview"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; +import type { CanvasNodeData } from "@/types/canvas"; +import type { PrevisScene } from "@/types/previs"; +import { PrevisMiniViewport } from "./previs-mini-viewport"; +import "./canvas-previs-node-card.css"; + +export function CanvasPrevisNodePanel({ node, scene, readNodeContent, onOpen, professional = true }: { node: CanvasNodeData; scene: PrevisScene | null; readNodeContent: PrevisNodeContentReader; onOpen: () => void; professional?: boolean }) { + const theme = canvasThemes[useActiveTheme()]; + const shot = resolvePrevisActiveShot(scene, node.metadata?.previsShotId); + const [failedUrl, setFailedUrl] = useState(null); + const preview = gatePrevisPreviewFailure( + resolvePrevisPreviewSource({ scene, shot, previewNodeId: node.metadata?.previsPreviewNodeId, readNodeContent }), + failedUrl, + ); + const title = node.metadata?.workflowTitle || node.title; + const objectCount = scene?.objects.filter((item) => item.visible !== false).length || 0; + const cameraCount = scene?.cameras.length || 0; + const lightCount = scene?.lights.length || 0; + + return ( +
+
+
PREVIS / 3D
+ 可编辑 +
+
+
+

{title}

+

{shot?.name || "未设置镜头"}

+
+ {shot?.duration ?? 0}s +
+ + + +
+ } value={objectCount} label="对象" /> + } value={cameraCount} label="机位" /> + } value={lightCount} label="灯光" /> + 双击打开 +
+
+ ); +} + +function PrevisPreviewState({ kind, theme }: { kind: "loading" | "empty"; theme: CanvasTheme }) { + const loading = kind === "loading"; + return ( +
+ + + + {loading ? "正在准备场景" : "尚未生成预览"} + {loading ? "场景数据加载完成后显示" : "进入预演台并回写构图后显示"} +
+ ); +} + +function Stat({ icon, value, label }: { icon: ReactNode; value: number; label: string }) { + return {icon}{value}{label}; +} diff --git a/web/src/components/canvas/director/canvas-director-onboarding.tsx b/web/src/components/canvas/previs/canvas-previs-onboarding.tsx similarity index 85% rename from web/src/components/canvas/director/canvas-director-onboarding.tsx rename to web/src/components/canvas/previs/canvas-previs-onboarding.tsx index 3efd40f93..5cc9aa179 100644 --- a/web/src/components/canvas/director/canvas-director-onboarding.tsx +++ b/web/src/components/canvas/previs/canvas-previs-onboarding.tsx @@ -1,19 +1,19 @@ 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"; + advancePrevisOnboarding, + createPrevisOnboardingGate, + loadPrevisOnboardingProgress, + resetPrevisOnboardingProgress, + resolvePrevisOnboardingView, + type PrevisOnboardingAction, + type PrevisOnboardingProgress, + type PrevisOnboardingStorage, + type PrevisOnboardingView, +} from "@/lib/canvas/previs/previs-onboarding"; /** - * 导演台首次上手引导浮层。 + * 预演台首次上手引导浮层。 * * 硬约束: * - 非阻塞。不是 dialog,没有遮罩,不抢焦点,不做焦点陷阱:引导旁边就是要动手的工作台, @@ -23,9 +23,9 @@ import { * - 读不到持久进度(IndexedDB 失败)时不展示:无法确认用户是否已经跳过,就不要再骚扰他。 */ -type DirectorOnboardingController = { +type PrevisOnboardingController = { /** null 表示不展示:未加载完、已跳过、已完成、或持久层不可用。 */ - view: DirectorOnboardingView | null; + view: PrevisOnboardingView | null; /** 有动作在写盘。用于禁用按钮,避免连点产生并发写。 */ busy: boolean; next: () => void; @@ -53,11 +53,11 @@ type DirectorOnboardingController = { * `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); +export function usePrevisOnboarding({ scope, enabled = true, storage }: { scope: string; enabled?: boolean; storage?: PrevisOnboardingStorage }): PrevisOnboardingController { + const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); const generation = useRef(0); - const gateRef = useRef(createDirectorOnboardingGate()); + const gateRef = useRef(createPrevisOnboardingGate()); const normalizedScope = scope.trim(); const active = enabled && normalizedScope.length > 0; @@ -65,11 +65,11 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop generation.current += 1; const current = generation.current; // 换新实例而不是 release 旧实例:旧实例可能仍被上一代在途的写入持有。 - gateRef.current = createDirectorOnboardingGate(); + gateRef.current = createPrevisOnboardingGate(); setProgress(null); setBusy(false); if (!active) return; - void loadDirectorOnboardingProgress(normalizedScope, storage) + void loadPrevisOnboardingProgress(normalizedScope, storage) .then((loaded) => { if (generation.current === current) setProgress(loaded); }) @@ -79,7 +79,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop }, [active, normalizedScope, storage]); const run = useCallback( - (action: DirectorOnboardingAction) => { + (action: PrevisOnboardingAction) => { if (!active || !progress) return; // 同步捕获本次调用时刻的锁实例:即使换代把 gateRef.current 换成新实例, // 这次调用后续的 tryEnter/release 也只作用在捕获到的这一份上。 @@ -88,7 +88,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop if (!gate.tryEnter()) return; const current = generation.current; setBusy(true); - void advanceDirectorOnboarding(normalizedScope, progress, action, storage) + void advancePrevisOnboarding(normalizedScope, progress, action, storage) .then((next) => { if (generation.current === current) setProgress(next); }) @@ -119,7 +119,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop generation.current += 1; const current = generation.current; setBusy(true); - void resetDirectorOnboardingProgress(normalizedScope, storage) + void resetPrevisOnboardingProgress(normalizedScope, storage) .then((next) => { if (generation.current === current) setProgress(next); }) @@ -127,7 +127,7 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop if (generation.current !== current) return; // 重置失败后把同一 scope 的持久进度重新读回来。重置开始时已经让旧读取 // 失效,若这里不补读,加载期触发重置的用户会一直停在 progress=null。 - return loadDirectorOnboardingProgress(normalizedScope, storage) + return loadPrevisOnboardingProgress(normalizedScope, storage) .then((loaded) => { if (generation.current === current) setProgress(loaded); }) @@ -141,10 +141,10 @@ export function useDirectorOnboarding({ scope, enabled = true, storage }: { scop }); }, [active, normalizedScope, storage]); - return { view: progress ? resolveDirectorOnboardingView(progress) : null, busy, next, back, dismiss, complete, reset }; + return { view: progress ? resolvePrevisOnboardingView(progress) : null, busy, next, back, dismiss, complete, reset }; } -/** 焦点环与既有 director 控件一致:token 化的 outline,不引入新的全局样式。 */ +/** 焦点环与既有 previs 控件一致:token 化的 outline,不引入新的全局样式。 */ const FOCUS_RING = "focus-visible:[outline:var(--stroke-2)_solid_var(--control-focus-ring)] focus-visible:[outline-offset:1px]"; /** @@ -153,8 +153,8 @@ const FOCUS_RING = "focus-visible:[outline:var(--stroke-2)_solid_var(--control-f * 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 }); +export function CanvasPrevisOnboarding({ scope, open = true, restartSignal = 0, className, storage }: { scope: string; open?: boolean; restartSignal?: number; className?: string; storage?: PrevisOnboardingStorage }) { + const { view, busy, next, back, dismiss, reset } = usePrevisOnboarding({ scope, enabled: open, storage }); const previousRestartSignal = useRef(restartSignal); useEffect(() => { @@ -170,7 +170,7 @@ export function CanvasDirectorOnboarding({ scope, open = true, restartSignal = 0 return (
void; onSelect: (templateId: DirectorTemplateId) => void }) { +export function CanvasPrevisTemplateModal({ open, onClose, onSelect }: { open: boolean; onClose: () => void; onSelect: (templateId: PrevisTemplateId) => void }) { return ( -
- {DIRECTOR_TEMPLATES.map((template) => ( +
+ {PREVIS_TEMPLATES.map((template) => ( ))}
diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.css b/web/src/components/canvas/previs/canvas-previs-workbench.css new file mode 100644 index 000000000..8bf8b70f3 --- /dev/null +++ b/web/src/components/canvas/previs/canvas-previs-workbench.css @@ -0,0 +1,1494 @@ +/* ============================================================ + Previs Workbench — Design System + Awwwards-quality rewrite. All values are tokens; no magic numbers. + OKLCH color ramp · modular type scale · unified easing · coherent + active states · 3-level elevation · 3 radii · motion with purpose. + ============================================================ */ + +/* ── Design tokens ────────────────────────────────────────────────────────── + Surface ramp: s0 (deepest) → s5 (highest). + OKLCH notation: L (0-1) C (0-0.4) H (hue degrees) + ─────────────────────────────────────────────────────────────────────────── */ +[data-canvas-previs-workbench].previs-desk { + /* Surfaces — cool-blue-tinted dark ramp */ + --pd-s0: oklch(0.08 0.018 240); /* deepest bg */ + --pd-s1: oklch(0.11 0.018 238); /* panel bg */ + --pd-s2: oklch(0.14 0.018 236); /* raised surface */ + --pd-s3: oklch(0.17 0.020 234); /* hover surface */ + --pd-s4: oklch(0.21 0.022 232); /* active surface */ + --pd-s5: oklch(0.25 0.024 230); /* elevated (menu bg) */ + + /* Borders */ + --pd-b0: oklch(1 0 0 / 0.08); /* subtle separator */ + --pd-b1: oklch(1 0 0 / 0.16); /* standard border */ + --pd-b2: oklch(1 0 0 / 0.28); /* strong border */ + + /* Text */ + --pd-t0: oklch(0.97 0.008 240); /* primary text */ + --pd-t1: oklch(0.70 0.016 240); /* secondary text */ + --pd-t2: oklch(0.50 0.016 240); /* muted text */ + --pd-t3: oklch(0.36 0.014 240); /* faint text */ + + --pd-a0: var(--previs-accent, oklch(0.72 0.18 240)); + --pd-a1: var(--previs-accent, oklch(0.80 0.14 240)); + --pd-a-bg: var(--pd-a-bg-theme, oklch(0.72 0.18 240 / 0.12)); + --pd-a-bd: var(--pd-a-bd-theme, oklch(0.72 0.18 240 / 0.40)); + --pd-a-on: var(--pd-a-on-theme, #fff); + + /* Semantic */ + --pd-green: oklch(0.75 0.14 155); /* success */ + --pd-orange: oklch(0.78 0.12 60); /* warning */ + --pd-red: oklch(0.65 0.18 22); /* danger */ + + /* Type scale (base 12px) */ + --pd-ts-base: 12px; + --pd-ts-sm: 11px; + --pd-ts-xs: 10px; + --pd-ts-2xs: 9px; + --pd-ts-lg: 13px; + --pd-ts-xl: 15px; + --pd-ts-2xl: 20px; + + /* Radii — 3 values only */ + --pd-r-sm: 4px; + --pd-r-md: 8px; + --pd-r-lg: 12px; + + /* Easing */ + --pd-ease: cubic-bezier(0.16, 1, 0.3, 1); + --pd-ease-snap: cubic-bezier(0.4, 0, 0.2, 1); + --pd-ease-out: cubic-bezier(0, 0, 0.3, 1); + + /* Elevation shadows */ + --pd-shadow-raised: 0 1px 3px oklch(0 0 0 / 0.24), 0 1px 2px oklch(0 0 0 / 0.16); + --pd-shadow-floating: 0 8px 32px oklch(0 0 0 / 0.48), 0 2px 8px oklch(0 0 0 / 0.28); + + /* Layout dimensions */ + --pd-topbar: 50px; + --pd-sidebar: 238px; + --pd-inspector: 340px; + --pd-timeline: 286px; + + /* Legacy aliases — keep existing JS that reads these */ + --pd-bg: var(--pd-s0); + --pd-surface: var(--pd-s1); + --pd-surface-2: var(--pd-s2); + --pd-surface-3: var(--pd-s3); + --pd-line: var(--pd-b0); + --pd-line-strong: var(--pd-b1); + --pd-text: var(--pd-t0); + --pd-muted: var(--pd-t1); + --pd-faint: var(--pd-t2); + --pd-accent: var(--previs-accent, #4f8cff); + --pd-accent-2: var(--pd-a1); + + background: var(--pd-s0) !important; + color: var(--pd-t0) !important; + font-size: var(--pd-ts-base); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Global reset inside workbench ────────────────────────────────────────── */ +.previs-desk, +.previs-desk > *, +.previs-desk-header, +.pv-layout, +.pv-panel, +.pv-viewport-area { min-width: 0; } +.previs-desk * { box-sizing: border-box; } +.previs-desk button, +.previs-desk input, +.previs-desk select, +.previs-desk textarea { font: inherit; } +.previs-desk button { color: inherit; cursor: pointer; } +.previs-desk button:disabled { cursor: default; } + +/* ── Workbench entrance animation ─────────────────────────────────────────── */ +@keyframes pd-appear { + from { opacity: 0; transform: scale(0.993) translateY(4px); } + to { opacity: 1; transform: none; } +} +[data-canvas-previs-workbench].previs-desk { + animation: pd-appear 280ms var(--pd-ease) both; +} +@media (prefers-reduced-motion: reduce) { + [data-canvas-previs-workbench].previs-desk { animation: none; } +} + +/* ── Header ───────────────────────────────────────────────────────────────── */ +.previs-desk-header { + display: flex; + align-items: center; + gap: 6px; + height: var(--pd-topbar); + min-width: 0; + overflow: hidden; + padding: 0 12px; + border-bottom: 1px solid var(--pd-b0); + background: var(--pd-s1); + flex: 0 0 var(--pd-topbar); + /* Subtle top-border highlight */ + box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.06); +} + +.previs-desk-brand { + display: flex; + align-items: center; + gap: 8px; + min-width: 217px; +} + +/* Logo mark — film-frame motif */ +.previs-desk-logo { + display: grid; + place-items: center; + width: 40px; + height: 26px; + border-radius: var(--pd-r-sm); + background: linear-gradient(135deg, oklch(0.92 0.04 240), oklch(0.98 0.01 240)); + color: var(--pd-s0); + font-weight: 900; + font-size: 15px; + letter-spacing: -0.02em; + flex: 0 0 40px; + box-shadow: 0 1px 3px oklch(0 0 0 / 0.3); +} + +.previs-desk-brand-title { + font-size: var(--pd-ts-lg); + font-weight: 700; + letter-spacing: -0.01em; + color: var(--pd-t0); +} + +.previs-desk-brand-sub { + font-size: var(--pd-ts-2xs); + color: var(--pd-t2); + letter-spacing: 0.03em; + margin-left: 2px; +} + +.previs-desk-divider { + width: 1px; + height: 20px; + background: var(--pd-b0); + flex: 0 0 auto; + margin: 0 2px; +} + +.previs-desk-scene-title { + width: 180px; + min-width: 120px; + height: 30px; + border: 1px solid var(--pd-b1); + border-radius: var(--pd-r-sm); + background: var(--pd-s0); + color: var(--pd-t0); + padding: 0 8px; + font-size: var(--pd-ts-base); + font-weight: 600; + letter-spacing: -0.01em; + transition: border-color 150ms var(--pd-ease-snap), + background 150ms var(--pd-ease-snap), + box-shadow 150ms var(--pd-ease-snap); +} +.previs-desk-scene-title:hover { + border-color: var(--pd-b2); + background: var(--pd-s2); +} +.previs-desk-scene-title:focus-visible { + border-color: var(--pd-a0); + background: var(--pd-s2); + outline: none; + box-shadow: 0 0 0 3px var(--pd-a-bg); +} + +/* Undo/redo history buttons */ +.previs-desk-history { display: flex; gap: 2px; } + +.previs-desk-history button, +.previs-desk-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap); +} +.previs-desk-history button:hover:not(:disabled), +.previs-desk-icon-button:hover:not(:disabled) { + background: var(--pd-s3); + color: var(--pd-t0); +} +.previs-desk-history button:disabled, +.previs-desk-icon-button:disabled { opacity: 0.32; } +.previs-desk-history button:focus-visible, +.previs-desk-icon-button:focus-visible { + outline: 2px solid var(--pd-a0); + outline-offset: 2px; +} + +/* Mode switch — pill style */ +.previs-mode-switch-button { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + border: 1px solid var(--pd-b0); + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + font-size: var(--pd-ts-sm); + font-weight: 500; + letter-spacing: 0.01em; + white-space: nowrap; + transition: background 140ms var(--pd-ease-snap), + border-color 140ms var(--pd-ease-snap), + color 140ms var(--pd-ease-snap); +} +.previs-mode-switch-button:hover { + background: var(--pd-s3); + color: var(--pd-t0); + border-color: var(--pd-b1); +} +.previs-mode-switch-button.is-active { + background: var(--pd-s4); + border-color: var(--pd-b2); + color: var(--pd-t0); + font-weight: 600; +} +.previs-mode-switch-button:focus-visible { + outline: 2px solid var(--pd-a0); + outline-offset: 2px; +} + +/* Tab nav (contextual layout) */ +.previs-desk-tabs { display: flex; align-items: center; gap: 2px; margin-left: 4px; } +.previs-desk-tab { + height: 28px; + padding: 0 11px; + border: 1px solid transparent; + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + font-size: var(--pd-ts-sm); + font-weight: 500; + transition: background 120ms var(--pd-ease-snap), + color 120ms var(--pd-ease-snap), + border-color 120ms var(--pd-ease-snap); +} +.previs-desk-tab:hover { color: var(--pd-t0); background: var(--pd-s2); } +.previs-desk-tab.is-active { + color: var(--pd-t0); + background: var(--pd-s4); + border-color: var(--pd-b1); +} +.previs-desk-tab:focus-visible, .previs-desk-tool-button:focus-visible, .previs-view-toolbar-button:focus-visible { + outline: 2px solid var(--pd-a0); + outline-offset: 2px; +} + +/* View toolbar */ +.previs-view-toolbar-inline { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--pd-b0); + border-radius: var(--pd-r-sm); + background: var(--pd-s0); +} +.previs-view-toolbar-inline .previs-view-toolbar-button { + height: 24px; + min-width: 40px; + padding: 0 9px; + border-radius: calc(var(--pd-r-sm) - 1px); + color: var(--pd-t2); + font-size: var(--pd-ts-xs); + transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap); +} +.previs-view-toolbar-inline .previs-view-toolbar-button:hover { background: var(--pd-s3); color: var(--pd-t0); } +.previs-view-toolbar-inline .previs-view-toolbar-button[aria-pressed="true"] { background: var(--pd-s4); color: var(--pd-t0); } +.previs-view-toolbar-inline .previs-view-toolbar-button[aria-pressed="true"]::after { display: none; } + +/* Header right */ +.previs-desk-header-spacer { flex: 1; } +.previs-desk-save { color: var(--pd-t2); font-size: var(--pd-ts-sm); white-space: nowrap; } +.previs-desk-save.is-busy { color: var(--pd-orange); } +.previs-desk-save.is-danger { color: var(--pd-red); } + +.previs-desk-select, .previs-desk-header-select { + height: 28px; + border: 1px solid var(--pd-b1); + border-radius: var(--pd-r-sm); + background: var(--pd-s2); + color: var(--pd-t0); + padding: 0 8px; + font-size: var(--pd-ts-sm); +} + +.previs-desk-header-action { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 12px; + border: 1px solid var(--pd-b1); + border-radius: var(--pd-r-sm); + background: var(--pd-s2); + color: var(--pd-t0); + font-size: var(--pd-ts-sm); + font-weight: 500; + white-space: nowrap; + transition: background 140ms var(--pd-ease-snap), + border-color 140ms var(--pd-ease-snap), + transform 120ms var(--pd-ease); +} +.previs-desk-header-action:hover { + background: var(--pd-s3); + border-color: var(--pd-b2); + transform: translateY(-1px); +} +.previs-desk-header-action:active { transform: translateY(0); } +.previs-desk-header-action.is-primary { + background: var(--pd-a0); + border-color: var(--pd-a0); + color: var(--pd-a-on); + font-weight: 600; + box-shadow: 0 2px 8px var(--pd-a-bg); +} +.previs-desk-header-action.is-primary:hover { + background: var(--pd-a1); + border-color: var(--pd-a1); + box-shadow: 0 4px 16px var(--pd-a-bg); +} +.previs-desk-header-action:focus-visible { + outline: 2px solid var(--pd-a0); + outline-offset: 2px; +} + +/* ── Viewport shell ────────────────────────────────────────────────────────── */ +.previs-desk .previs-viewport-shell, +.previs-desk .previs-viewport-shell canvas { + touch-action: none; + cursor: grab; +} +.previs-desk .previs-viewport-shell canvas:active { cursor: grabbing; } +.previs-desk .previs-viewport-shell canvas { user-select: none; } + +/* ── Contextual workbench body ─────────────────────────────────────────────── */ +.previs-desk-body { + display: grid; + grid-template-columns: var(--pd-sidebar) minmax(0, 1fr) var(--pd-inspector); + min-height: 0; + flex: 1 1 auto; +} + +.previs-desk-sidebar, .previs-desk-inspector { + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + background: var(--pd-s1); + scrollbar-width: thin; + scrollbar-color: var(--pd-b1) transparent; +} +.previs-desk-sidebar { border-right: 1px solid var(--pd-b0); } +.previs-desk-inspector { border-left: 1px solid var(--pd-b0); } + +.previs-desk-sidebar-head, .previs-desk-inspector-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 14px 12px 10px; +} +.previs-desk-sidebar-title, .previs-desk-inspector-title { + font-size: var(--pd-ts-lg); + font-weight: 700; + letter-spacing: -0.01em; +} +.previs-desk-sidebar-sub { color: var(--pd-t2); font-size: var(--pd-ts-2xs); margin-top: 2px; } + +.previs-desk-search { + display: flex; + align-items: center; + gap: 6px; + height: 30px; + margin: 2px 10px 10px; + padding: 0 9px; + border: 1px solid var(--pd-b0); + border-radius: var(--pd-r-sm); + background: var(--pd-s0); + color: var(--pd-t2); + transition: border-color 150ms var(--pd-ease-snap); +} +.previs-desk-search:focus-within { border-color: var(--pd-a0); } +.previs-desk-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--pd-t0); font-size: var(--pd-ts-sm); } +.previs-desk-search input::placeholder { color: var(--pd-t3); } + +/* Section headers */ +.previs-desk-section { border-top: 1px solid var(--pd-b0); } +.previs-desk-section:first-of-type { border-top: 0; } +.previs-desk-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 6px; + color: var(--pd-t2); + font-size: var(--pd-ts-2xs); + text-transform: uppercase; + letter-spacing: 0.10em; + font-weight: 700; +} +.previs-desk-section-head button { + width: 20px; + height: 20px; + border: 0; + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + transition: background 120ms, color 120ms; +} +.previs-desk-section-head button:hover { background: var(--pd-s3); color: var(--pd-t0); } + +/* Entity list rows */ +.previs-desk-list { padding: 0 6px 8px; } +.previs-desk-row { + display: flex; + align-items: center; + gap: 7px; + min-height: 34px; + width: 100%; + padding: 5px 7px; + border: 1px solid transparent; + border-radius: var(--pd-r-md); + background: transparent; + text-align: left; + color: var(--pd-t0); + transition: background 120ms var(--pd-ease-snap), + border-color 120ms var(--pd-ease-snap); +} +.previs-desk-row:hover { background: var(--pd-s2); } +.previs-desk-row.is-active { + background: var(--pd-a-bg); + border-color: var(--pd-a-bd); +} +.previs-desk-row-icon { width: 18px; display: grid; place-items: center; color: var(--pd-t2); flex: 0 0 18px; } +.previs-desk-row-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--pd-ts-sm); } +.previs-desk-row-meta { color: var(--pd-t3); font-size: var(--pd-ts-2xs); } +.previs-desk-row-delete { + opacity: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + transition: opacity 120ms, color 120ms; +} +.previs-desk-row:hover .previs-desk-row-delete, +.previs-desk-row.is-active .previs-desk-row-delete { opacity: 1; } +.previs-desk-row-delete:hover { color: var(--pd-red); } + +/* Add grid */ +.previs-desk-add-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 4px; + padding: 0 8px 12px; +} +.previs-desk-add-grid button { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 9px; + border: 1px solid var(--pd-b0); + border-radius: var(--pd-r-sm); + background: var(--pd-s0); + color: var(--pd-t2); + font-size: var(--pd-ts-xs); + transition: border-color 120ms, color 120ms, background 120ms; +} +.previs-desk-add-grid button:hover { border-color: var(--pd-b2); color: var(--pd-t0); background: var(--pd-s2); } + +/* Empty state */ +.previs-desk-empty { + margin: 4px 10px 12px; + padding: 20px 12px; + border: 1px dashed var(--pd-b1); + border-radius: var(--pd-r-md); + color: var(--pd-t2); + text-align: center; + font-size: var(--pd-ts-sm); + line-height: 1.6; +} + +/* ── Center viewport shell (contextual layout) ──────────────────────────────────── */ +.previs-desk-center { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--pd-s0); +} +.previs-desk-center-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 40px; + padding: 5px 10px; + border-bottom: 1px solid var(--pd-b0); + background: var(--pd-s1); + box-shadow: inset 0 -1px 0 var(--pd-b0); +} +.previs-desk-tool-group { display: flex; align-items: center; gap: 3px; } +.previs-desk-tool-button { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + font-size: var(--pd-ts-sm); + transition: background 120ms var(--pd-ease-snap), + color 120ms var(--pd-ease-snap), + border-color 120ms var(--pd-ease-snap); +} +.previs-desk-tool-button:hover { background: var(--pd-s3); color: var(--pd-t0); } +.previs-desk-tool-button.is-active { border-color: var(--pd-b1); background: var(--pd-s4); color: var(--pd-t0); } +.previs-desk-tool-button.is-accent { background: var(--pd-a0); color: var(--pd-s0); border-color: var(--pd-a0); } +.previs-desk-view-tabs { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: var(--pd-s0); } +.previs-desk-view-tabs button { height: 24px; padding: 0 10px; border: 0; border-radius: calc(var(--pd-r-sm) - 1px); background: transparent; color: var(--pd-t2); font-size: var(--pd-ts-xs); transition: background 120ms, color 120ms; } +.previs-desk-view-tabs button.is-active { background: var(--pd-s4); color: var(--pd-t0); } + +/* Viewport grid for contextual layout */ +.previs-desk-viewport-grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 1px; min-height: 0; flex: 1 1 auto; background: var(--pd-b0); } +.previs-desk-viewport-panel { position: relative; min-width: 0; min-height: 260px; overflow: hidden; background: var(--pd-s0); } +.previs-desk-viewport-panel .previs-viewport-shell { position: absolute; inset: 0; } + +/* Panel overlay bar */ +.previs-desk-panel-bar { + display: flex; + align-items: center; + justify-content: space-between; + position: absolute; + top: 0; left: 0; right: 0; + z-index: 5; + min-height: 28px; + padding: 0 10px; + background: oklch(0.10 0.016 240 / 0.88); + backdrop-filter: blur(8px); + color: var(--pd-t1); + font-size: var(--pd-ts-xs); +} +.previs-desk-panel-bar strong { color: var(--pd-t0); font-weight: 600; } +.previs-desk-panel-bar span { display: inline-flex; align-items: center; gap: 6px; } +.previs-desk-panel-bar-center { position: absolute; left: 50%; transform: translateX(-50%); } +.previs-desk-panel-bar .pd-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--pd-green); } +.previs-desk-shot-placeholder { position: absolute; inset: 28px 0 0; display: grid; place-items: center; pointer-events: none; color: oklch(1 0 0 / 0.35); font-size: var(--pd-ts-sm); } + +/* Monitor strip */ +.previs-desk-monitor-strip { + display: flex; + align-items: center; + gap: 6px; + min-height: 36px; + padding: 4px 10px; + border-top: 1px solid var(--pd-b0); + background: var(--pd-s1); +} +.previs-desk-monitor-strip-label { margin-right: 4px; color: var(--pd-t2); font-size: var(--pd-ts-xs); } +.previs-desk-monitor-strip button { + height: 26px; + padding: 0 9px; + border: 1px solid var(--pd-b0); + border-radius: var(--pd-r-sm); + background: var(--pd-s2); + color: var(--pd-t2); + font-size: var(--pd-ts-xs); + transition: color 120ms, border-color 120ms, background 120ms; +} +.previs-desk-monitor-strip button:hover { color: var(--pd-t0); border-color: var(--pd-b2); background: var(--pd-s3); } +.previs-desk-monitor-strip .pd-monitor-spacer { flex: 1; } + +/* ── Inspector (contextual layout) ──────────────────────────────────────────────── */ +.previs-desk-inspector-head { border-bottom: 1px solid var(--pd-b0); } +.previs-desk-inspector-tabs { display: flex; gap: 2px; padding: 6px 10px; border-bottom: 1px solid var(--pd-b0); } +.previs-desk-inspector-tabs button { + flex: 1; + height: 28px; + border: 1px solid transparent; + border-radius: var(--pd-r-sm); + background: transparent; + color: var(--pd-t2); + font-size: var(--pd-ts-sm); + transition: background 120ms, color 120ms, border-color 120ms; +} +.previs-desk-inspector-tabs button.is-active { background: var(--pd-s4); color: var(--pd-t0); border-color: var(--pd-b1); } +.previs-desk-inspector-content { min-height: 0; padding: 10px 10px 18px; display: flex; flex-direction: column; gap: 12px; } +.previs-desk-inspector-content .previs-inspector { padding: 0; } +.previs-desk-inspector-content .previs-inspector-title { color: var(--pd-t0); } +.previs-desk-inspector-content .ant-input, +.previs-desk-inspector-content .ant-input-number, +.previs-desk-inspector-content select, +.previs-desk-inspector-content input { background: var(--pd-s0) !important; border-color: var(--pd-b1) !important; color: var(--pd-t0) !important; } +.previs-desk-inspector-content .ant-btn { background: var(--pd-s2); border-color: var(--pd-b1); color: var(--pd-t0); } +.previs-desk-inspector-content .ant-btn-primary { background: var(--pd-a0); border-color: var(--pd-a0); color: var(--pd-s0); } +.previs-desk-inspector-content .previs-inspector-section-title { color: var(--pd-t1); } +.previs-desk-inspector-content .previs-field-label, +.previs-desk-inspector-content label { color: var(--pd-t1); } +.previs-desk-inspector-footer { padding: 10px; border-top: 1px solid var(--pd-b0); } +.previs-desk-ai-note { padding: 10px; border: 1px solid var(--pd-b1); border-radius: var(--pd-r-md); background: var(--pd-s0); color: var(--pd-t1); line-height: 1.55; font-size: var(--pd-ts-sm); } +.previs-desk-ai-note strong { display: block; margin-bottom: 4px; color: var(--pd-t0); } + +/* Asset panel */ +.previs-desk-assets-panel, .previs-desk-prompt-panel { margin-top: 12px; padding: 10px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-md); background: var(--pd-s0); } +.previs-desk-assets-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--pd-t0); } +.previs-desk-assets-head > div { min-width: 0; } +.previs-desk-assets-head strong { display: block; font-size: var(--pd-ts-sm); } +.previs-desk-assets-head span { display: block; margin-top: 3px; color: var(--pd-t1); font-size: var(--pd-ts-xs); } +.previs-desk-assets-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } +.previs-desk-asset-row { display: flex; align-items: center; gap: 5px; min-width: 0; } +.previs-desk-asset-main { display: flex; min-width: 0; flex: 1; align-items: center; gap: 7px; padding: 5px; border: 1px solid transparent; border-radius: var(--pd-r-sm); background: transparent; color: var(--pd-t0); text-align: left; transition: background 120ms, border-color 120ms; } +.previs-desk-asset-main:hover { background: var(--pd-s3); border-color: var(--pd-b0); } +.previs-desk-asset-main img { width: 28px; height: 24px; flex: 0 0 28px; border-radius: 3px; object-fit: cover; background: var(--pd-s2); } +.previs-desk-asset-main span { min-width: 0; } +.previs-desk-asset-main strong, .previs-desk-asset-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.previs-desk-asset-main strong { font-size: var(--pd-ts-xs); } +.previs-desk-asset-main small { margin-top: 2px; color: var(--pd-t2); font-size: var(--pd-ts-2xs); } +.previs-desk-asset-actions { display: flex; align-items: center; gap: 2px; } +.previs-desk-asset-actions button, .previs-desk-assets-footer button { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 24px; padding: 0 5px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: transparent; color: var(--pd-t1); font-size: var(--pd-ts-2xs); transition: border-color 120ms, color 120ms; } +.previs-desk-asset-actions button:hover, .previs-desk-assets-footer button:hover { border-color: var(--pd-a0); color: var(--pd-t0); } +.previs-desk-assets-footer { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 8px; color: var(--pd-t1); font-size: var(--pd-ts-2xs); line-height: 1.4; } +.previs-desk-prompt-panel textarea { width: 100%; min-height: 115px; margin-top: 8px; resize: vertical; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: var(--pd-s0); color: var(--pd-t0); padding: 7px; font-size: var(--pd-ts-2xs); line-height: 1.45; outline: none; transition: border-color 150ms; } +.previs-desk-prompt-panel textarea:focus { border-color: var(--pd-a0); } +.previs-desk-prompt-panel p { margin: 7px 0 0; color: var(--pd-t1); font-size: var(--pd-ts-2xs); line-height: 1.45; } + +/* ── Timeline (contextual layout) ───────────────────────────────────────────────── */ +.previs-desk-timeline { flex: 0 0 var(--pd-timeline); min-height: 180px; border-top: 1px solid var(--pd-b1); background: var(--pd-s1); } +.previs-desk-timeline .previs-sequencer { height: 100% !important; border: 0 !important; background: transparent !important; } +.previs-desk-timeline .previs-sequencer-resizer { display: none; } +.previs-desk-timeline .previs-sequencer > header { background: var(--pd-s2); border-bottom-color: var(--pd-b0); } +.previs-desk-timeline .previs-sequencer-body { background: var(--pd-s1); } +.previs-desk-timeline .previs-sequencer-label { background: var(--pd-s2); color: var(--pd-t1); } +.previs-desk-timeline .previs-sequencer-row.is-selected .previs-sequencer-label { color: var(--pd-t0); background: var(--pd-a-bg); } +.previs-desk-timeline .previs-sequencer-track { background: var(--pd-s1); } +.previs-desk-timeline .previs-sequencer-ruler { background: var(--pd-s2); } + +/* ── Output step cards ───────────────────────────────────────────────────── */ +.previs-output-step-output-panel { color: var(--pd-t0); } +.previs-output-step-step-card { display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px; padding: 12px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-md); background: var(--pd-s1); } +.previs-output-step-step-title { display: flex; align-items: flex-start; gap: 9px; } +.previs-output-step-step-title strong { display: block; color: var(--pd-t0); font-size: var(--pd-ts-sm); } +.previs-output-step-step-title p { margin: 3px 0 0; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.5; } +.previs-output-step-step-number { display: grid; width: 22px; height: 22px; flex: 0 0 22px; place-items: center; border-radius: 7px; background: var(--pd-a-bg); color: var(--pd-a0); font-size: var(--pd-ts-xs); font-weight: 700; } +.previs-output-step-field { display: flex; flex-direction: column; gap: 4px; color: var(--pd-t1); font-size: var(--pd-ts-xs); } +.previs-output-step-field input, .previs-output-step-field select { width: 100%; height: 28px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: var(--pd-s0); color: var(--pd-t0); padding: 0 7px; outline: none; transition: border-color 150ms; } +.previs-output-step-field input:focus, .previs-output-step-field select:focus { border-color: var(--pd-a0); box-shadow: 0 0 0 3px var(--pd-a-bg); } +.previs-output-step-help { margin: -2px 0 0; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.55; } +.previs-output-step-output-card { border-color: oklch(0.75 0.14 155 / 0.28); background: linear-gradient(180deg, oklch(0.75 0.14 155 / 0.06), var(--pd-s1)); } +.previs-output-step-output-status { display: flex; align-items: flex-start; gap: 7px; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.45; } +.previs-output-step-output-status.is-ready { color: var(--pd-green); } +.previs-output-step-output-dot { width: 5px; height: 5px; flex: 0 0 5px; margin-top: 5px; border-radius: 999px; background: currentColor; } +.previs-output-step-output-note { display: flex; flex-direction: column; gap: 4px; margin-top: 3px; padding: 2px 2px 14px; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.55; } +.previs-output-step-output-note strong { color: var(--pd-t0); font-size: var(--pd-ts-sm); } + +/* ── Viewport navigation overlay ────────────────────────────────────────────── */ +.previs-viewport-navigation { + position: absolute; right: 12px; bottom: 12px; z-index: 5; + display: inline-flex; align-items: center; gap: 2px; + padding: 3px; + border: 1px solid oklch(1 0 0 / 0.12); + border-radius: var(--pd-r-sm); + background: oklch(0.10 0.016 240 / 0.80); + backdrop-filter: blur(12px); + box-shadow: var(--pd-shadow-raised); +} +.previs-viewport-navigation button { + min-width: 28px; height: 24px; padding: 0 7px; + border: 0; border-radius: calc(var(--pd-r-sm) - 1px); + color: oklch(1 0 0 / 0.70); background: transparent; + font-size: var(--pd-ts-2xs); + transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap); +} +.previs-viewport-navigation button:hover:not(:disabled), +.previs-viewport-navigation button:focus-visible:not(:disabled) { + color: var(--pd-t0); background: oklch(1 0 0 / 0.12); outline: none; +} +.previs-viewport-navigation button:disabled { opacity: 0.32; } +.previs-viewport-navigation-divider { width: 1px; height: 14px; background: oklch(1 0 0 / 0.14); } + +/* ── Dropdown overlay (add menus) ────────────────────────────────────────────── */ +.previs-add-menu-overlay { min-width: 200px; } +.previs-add-menu-overlay .ant-dropdown-menu { + background: var(--pd-s5); + border: 1px solid var(--pd-b1); + border-radius: var(--pd-r-lg); + padding: 4px; + box-shadow: var(--pd-shadow-floating); +} +.previs-add-menu-overlay .ant-dropdown-menu-item { + border-radius: var(--pd-r-sm); + color: var(--pd-t1); + font-size: var(--pd-ts-sm); + padding: 7px 12px; + gap: 8px; + transition: background 100ms var(--pd-ease-snap), color 100ms var(--pd-ease-snap); +} +.previs-add-menu-overlay .ant-dropdown-menu-item:hover, +.previs-add-menu-overlay .ant-dropdown-menu-item-active { background: var(--pd-s3); color: var(--pd-t0); } +.previs-add-menu-overlay .ant-dropdown-menu-item-divider { background: var(--pd-b0); margin: 3px 0; } +.previs-add-menu-overlay .ant-dropdown-menu-submenu-title { + border-radius: var(--pd-r-sm); color: var(--pd-t1); font-size: var(--pd-ts-sm); padding: 7px 12px; + transition: background 100ms; +} +.previs-add-menu-overlay .ant-dropdown-menu-submenu-title:hover { background: var(--pd-s3); color: var(--pd-t0); } +.previs-add-menu-overlay .ant-dropdown-menu-submenu-arrow { color: var(--pd-t2); } + +/* ── Responsive ──────────────────────────────────────────────────────────────── */ +@media (max-width: 1100px) { + [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 204px; --pd-inspector: 288px; } + .previs-desk-brand { min-width: 140px; } + .previs-desk-brand-sub { display: none; } + .previs-desk-scene-title { width: 148px; } +} +@media (max-width: 820px) { + [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 188px; --pd-inspector: 0px; } + .previs-desk-inspector { display: none; } + .previs-desk-viewport-grid { grid-template-columns: 1fr; } + .previs-desk-viewport-panel:nth-child(2) { display: none; } + .previs-desk-header-select, .previs-desk-header-action:not(.is-primary), .previs-desk-save { display: none; } +} + +/* 画布预演节点:以镜头卡为主视觉,保留足够的俯视画面和清晰的状态层级。 */ + +.previs-desk-timeline { flex: 0 0 var(--pd-timeline); min-height: 180px; border-top: 1px solid var(--pd-line-strong); background: #121416; } +.previs-desk-timeline .previs-sequencer { height: 100% !important; border: 0 !important; background: transparent !important; } +.previs-desk-timeline .previs-sequencer-resizer { display: none; } +.previs-desk-timeline .previs-sequencer > header { background: #17191c; border-bottom-color: var(--pd-line); } +.previs-desk-timeline .previs-sequencer-body { background: #111315; } +.previs-desk-timeline .previs-sequencer-label { background: #17191c; color: var(--pd-muted); } +.previs-desk-timeline .previs-sequencer-row.is-selected .previs-sequencer-label { color: #fff; background: rgba(109,92,255,.2); } +.previs-desk-timeline .previs-sequencer-track { background: #101214; } +.previs-desk-timeline .previs-sequencer-ruler { background: #17191c; } + +.previs-output-step-output-panel { color: var(--pd-text); } +.previs-output-step-step-card { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; padding: 12px; border: 1px solid var(--pd-line); border-radius: 10px; background: rgba(255,255,255,.025); } +.previs-output-step-step-title { display: flex; align-items: flex-start; gap: 9px; } +.previs-output-step-step-title strong { display: block; color: var(--pd-text); font-size: 12px; } +.previs-output-step-step-title p { margin: 3px 0 0; color: var(--pd-muted); font-size: 10px; line-height: 1.5; } +.previs-output-step-step-number { display: grid; width: 22px; height: 22px; flex: 0 0 22px; place-items: center; border-radius: 7px; background: rgba(155,188,255,.14); color: var(--pd-accent-2); font-size: 11px; font-weight: 700; } +.previs-output-step-field { display: flex; flex-direction: column; gap: 4px; color: var(--pd-muted); font-size: 10px; } +.previs-output-step-field input, .previs-output-step-field select { width: 100%; height: 29px; border: 1px solid var(--pd-line); border-radius: 5px; background: #101113; color: var(--pd-text); padding: 0 7px; outline: none; } +.previs-output-step-field input:focus, .previs-output-step-field select:focus { border-color: var(--pd-accent); box-shadow: 0 0 0 2px rgba(155,188,255,.14); } +.previs-output-step-help { margin: -2px 0 0; color: var(--pd-muted); font-size: 10px; line-height: 1.55; } +.previs-output-step-output-card { border-color: rgba(101,215,162,.25); background: linear-gradient(180deg, rgba(101,215,162,.06), rgba(255,255,255,.025)); } +.previs-output-step-output-status { display: flex; align-items: flex-start; gap: 7px; color: var(--pd-muted); font-size: 10px; line-height: 1.45; } +.previs-output-step-output-status.is-ready { color: var(--pd-green); } +.previs-output-step-output-dot { width: 6px; height: 6px; flex: 0 0 6px; margin-top: 4px; border-radius: 999px; background: currentColor; } +.previs-output-step-output-note { display: flex; flex-direction: column; gap: 4px; margin-top: 3px; padding: 2px 2px 16px; color: var(--pd-muted); font-size: 10px; line-height: 1.55; } +.previs-output-step-output-note strong { color: var(--pd-text); font-size: 11px; } + +@media (max-width: 1100px) { + [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 204px; --pd-inspector: 286px; } + .previs-desk-brand { min-width: 142px; } + .previs-desk-brand-sub { display: none; } + .previs-desk-scene-title { width: 140px; } +} +@media (max-width: 820px) { + [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 190px; --pd-inspector: 0px; } + .previs-desk-inspector { display: none; } + .previs-desk-viewport-grid { grid-template-columns: 1fr; } + .previs-desk-viewport-panel:nth-child(2) { display: none; } + .previs-desk-header-select, .previs-desk-header-action:not(.is-primary), .previs-desk-save { display: none; } +} + +.previs-viewport-navigation { position: absolute; right: 12px; bottom: 12px; z-index: 5; display: inline-flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; background: rgba(20,25,31,.78); backdrop-filter: blur(14px); box-shadow: 0 8px 24px rgba(0,0,0,.18); } +.previs-viewport-navigation button { min-width: 30px; height: 26px; padding: 0 7px; border: 0; border-radius: 6px; color: rgba(255,255,255,.76); background: transparent; font-size: 10px; transition: background .15s ease, color .15s ease; } +.previs-viewport-navigation button:hover:not(:disabled), .previs-viewport-navigation button:focus-visible:not(:disabled) { color: #fff; background: rgba(255,255,255,.12); outline: none; } +.previs-viewport-navigation button:disabled { opacity: .35; } +.previs-viewport-navigation-divider { width: 1px; height: 16px; background: rgba(255,255,255,.16); } + +/* ── 添加角色 / 添加机位 dropdown overlay ─────────────────────────────────── */ +.previs-add-menu-overlay { min-width: 200px; } +.previs-add-menu-overlay .ant-dropdown-menu { background: #1a1e26; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 4px; box-shadow: 0 16px 40px rgba(0,0,0,.52), 0 2px 8px rgba(0,0,0,.28); } +.previs-add-menu-overlay .ant-dropdown-menu-item { border-radius: 6px; color: rgba(255,255,255,.82); font-size: 12px; padding: 7px 12px; gap: 8px; transition: background .12s ease, color .12s ease; } +.previs-add-menu-overlay .ant-dropdown-menu-item:hover, .previs-add-menu-overlay .ant-dropdown-menu-item-active { background: rgba(255,255,255,.08); color: #fff; } +.previs-add-menu-overlay .ant-dropdown-menu-item-divider { background: rgba(255,255,255,.1); margin: 3px 0; } +.previs-add-menu-overlay .ant-dropdown-menu-submenu-title { border-radius: 6px; color: rgba(255,255,255,.82); font-size: 12px; padding: 7px 12px; transition: background .12s ease; } +.previs-add-menu-overlay .ant-dropdown-menu-submenu-title:hover { background: rgba(255,255,255,.08); color: #fff; } +.previs-add-menu-overlay .ant-dropdown-menu-submenu-arrow { color: rgba(255,255,255,.38); } + +/* ═══════════════════════════════════════════════════════════════════════════ + Previs workbench component system — pv-* component system + Design tokens re-use --pd-* already defined on [data-canvas-previs-workbench] + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Layout: three columns, full height */ +.pv-layout { + display: grid; + grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(320px, 360px); + width: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; + flex: 1 1 auto; +} + +/* 侧栏可收起:视口占满,工具条居中保持在画面上。 */ +.pv-layout { transition: grid-template-columns 220ms var(--pd-ease); } +.pv-layout.is-scene-collapsed { grid-template-columns: 0 minmax(0, 1fr) minmax(320px, 360px); } +.pv-layout.is-inspector-collapsed { grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) 0; } +.pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; } +.pv-layout.is-scene-collapsed > .pv-panel--left, +.pv-layout.is-inspector-collapsed > .pv-panel--right { visibility: hidden; border: 0; } +@media (prefers-reduced-motion: reduce) { .pv-layout { transition: none; } } + +/* ── Panels ─────────────────────────────────────────────────────────────── */ +.pv-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + background: var(--pd-surface); +} +.pv-panel--left { border-right: 1px solid var(--pd-line); } +.pv-panel--right { border-left: 1px solid var(--pd-line); } +.pv-panel-close { display: none; } + +/* ── Panel overlay affordances ──────────────────────────────────────────── */ + +/* ── Panel header ───────────────────────────────────────────────────────── */ +.pv-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--pd-line); + flex: 0 0 auto; + position: sticky; + top: 0; + z-index: 2; + background: var(--pd-surface); +} +.pv-panel-header__title { display: flex; align-items: baseline; gap: 6px; min-width: 0; } +.pv-panel-header__label { font-size: 13px; font-weight: 700; color: var(--pd-text); } +.pv-panel-header__count { font-size: 10px; font-weight: 600; color: var(--pd-muted); background: var(--pd-surface-2); border-radius: 8px; padding: 1px 6px; } +.pv-panel-header__sub { font-size: 10px; color: var(--pd-muted); } +.pv-panel-header__actions { display: flex; gap: 4px; flex: 0 0 auto; } +.pv-scene-search { + display: flex; align-items: center; gap: 6px; height: 30px; margin: 8px 10px 6px; padding: 0 8px; + border: 1px solid var(--pd-line); border-radius: 7px; background: color-mix(in srgb, var(--pd-surface-2) 78%, transparent); color: var(--pd-muted); +} +.pv-scene-search:focus-within { border-color: var(--pd-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--pd-accent) 18%, transparent); } +.pv-scene-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--pd-text); font-size: 11px; } +.pv-scene-search input::placeholder { color: var(--pd-faint); } +.pv-scene-search button { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: 0; border-radius: 4px; background: transparent; color: var(--pd-muted); } +.pv-scene-search button:hover { color: var(--pd-text); background: var(--pd-surface-3); } +.pv-icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border: 1px solid var(--pd-line); border-radius: 6px; + background: var(--pd-surface-2); color: var(--pd-muted); cursor: pointer; + transition: border-color .12s, color .12s, background .12s; +} +.pv-icon-btn:hover { border-color: var(--pd-accent); color: var(--pd-text); background: var(--pd-surface-3); } +.pv-icon-btn:focus-visible { outline: 2px solid var(--pd-accent); outline-offset: 2px; } + +/* ── Entity sections ────────────────────────────────────────────────────── */ +.pv-entity-section { border-top: 1px solid var(--pd-line); padding: 6px 0 2px; } +.pv-entity-section:first-of-type { border-top: none; } +.pv-entity-section__label { + display: flex; align-items: center; gap: 5px; + padding: 4px 12px 5px; + font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: .08em; + color: var(--pd-muted); +} +.pv-entity-section__label span { + font-size: 10px; font-weight: 600; color: var(--pd-faint); + background: var(--pd-surface-2); border-radius: 6px; padding: 1px 5px; +} +.pv-entity-section__add-btn { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px; border-radius: 5px; border: 0; + background: rgba(155,188,255,.12); color: var(--pd-accent); + font-size: 10px; font-weight: 600; cursor: pointer; + transition: background .12s, color .12s; +} +.pv-entity-section__add-btn:hover { background: rgba(155,188,255,.22); color: #fff; } +.pv-entity-section__count { color: var(--pd-muted); font-size: 10px; font-weight: 400; } + +.pv-entity-section__add { + margin-left: auto; display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; border: 0; border-radius: 4px; background: transparent; + color: var(--pd-muted); cursor: pointer; +} +.pv-entity-section__add:hover { background: var(--pd-surface-3); color: var(--pd-text); } + +/* ── Entity rows ────────────────────────────────────────────────────────── */ +.pv-entity-row { + display: flex; flex-direction: column; + margin: 0 6px 1px; border: 1px solid transparent; border-radius: 7px; + transition: background .1s, border-color .1s; +} +.pv-entity-row:hover { background: var(--pd-surface-2); } +.pv-entity-row--active { + background: rgba(100,130,255,.1); + border-color: rgba(140,165,255,.35); +} +.pv-entity-row__main { + display: flex; align-items: center; gap: 8px; + width: 100%; min-height: 34px; padding: 5px 8px; + border: 0; background: transparent; color: var(--pd-text); + text-align: left; cursor: pointer; +} +.pv-entity-row__avatar { + display: grid; place-items: center; + width: 22px; height: 22px; flex: 0 0 22px; + border-radius: 50%; color: #fff; +} +.pv-entity-row__icon { display: grid; place-items: center; width: 22px; flex: 0 0 22px; color: var(--pd-muted); } +.pv-entity-row__color-dot { width: 10px; height: 10px; flex: 0 0 10px; border-radius: 50%; } +.pv-entity-row__info { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; } +.pv-entity-row__name { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pv-entity-row__meta { font-size: 10px; color: var(--pd-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pv-entity-row__badge { + font-size: 9px; font-weight: 700; letter-spacing: .06em; + color: var(--pd-accent); background: rgba(155,188,255,.14); + border-radius: 4px; padding: 1px 5px; flex: 0 0 auto; +} +.pv-entity-row__del { + opacity: .45; display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border: 0; border-radius: 4px; + background: transparent; color: var(--pd-muted); cursor: pointer; + position: absolute; right: 8px; + transition: opacity .1s, color .1s; +} +.pv-entity-row { position: relative; } +.pv-entity-row:hover .pv-entity-row__del, +.pv-entity-row--active .pv-entity-row__del { opacity: 1; } +.pv-entity-row__del:hover { color: #ff8c8c; } + +.previs-workbench-tooltip-anchor { position: relative; display: inline-flex; } +.previs-workbench-tooltip { + position: absolute; left: 50%; top: calc(100% + 6px); z-index: 40; + transform: translateX(-50%); pointer-events: none; white-space: nowrap; + border: 1px solid var(--pd-b1); border-radius: 5px; padding: 4px 7px; + background: var(--pd-s5); color: var(--pd-t0); font-size: 10px; line-height: 1.2; + box-shadow: 0 8px 20px rgba(0,0,0,.28); +} + +/* Inline actor controls (shown when selected) */ +.pv-entity-row__inline-controls { padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 7px; } +.pv-entity-row__footer { display: flex; gap: 6px; } + +/* ── Pose grid ──────────────────────────────────────────────────────────── */ +.pv-pose-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; } +.pv-pose-chip { + padding: 4px 2px; border: 1px solid var(--pd-line); border-radius: 5px; + background: rgba(255,255,255,.04); color: var(--pd-muted); + font-size: 10px; cursor: pointer; + transition: border-color .1s, color .1s, background .1s; +} +.pv-pose-chip:hover { border-color: var(--pd-line-strong); color: var(--pd-text); } +.pv-pose-chip--active { border-color: var(--pd-accent); background: rgba(155,188,255,.12); color: var(--pd-accent); } + +/* ── Color strip ────────────────────────────────────────────────────────── */ +.pv-color-strip { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.pv-color-dot { + width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent; + cursor: pointer; transition: transform .1s, border-color .1s; +} +.pv-color-dot:hover { transform: scale(1.15); } +.pv-color-dot--active { border-color: #fff; box-shadow: 0 0 0 1px rgba(255,255,255,.4); } +.pv-color-custom { display: inline-flex; cursor: pointer; } +.pv-color-custom__swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px dashed rgba(255,255,255,.4); } + +/* ── Buttons ────────────────────────────────────────────────────────────── */ +.pv-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 4px; + border-radius: 5px; border: 1px solid transparent; cursor: pointer; + font-size: 11px; font-weight: 500; transition: background .1s, border-color .1s, color .1s; + white-space: nowrap; +} +.pv-btn--xs { height: 24px; padding: 0 8px; } +.pv-btn--sm { height: 28px; padding: 0 11px; } +.pv-btn--ghost { background: transparent; border-color: var(--pd-line); color: var(--pd-muted); } +.pv-btn--ghost:hover { border-color: var(--pd-line-strong); color: var(--pd-text); background: var(--pd-surface-3); } +.pv-btn--accent { background: var(--pd-a0); border-color: var(--pd-a0); color: var(--pd-a-on); } +.pv-btn--accent:hover { background: var(--pd-a1); border-color: var(--pd-a1); } +.pv-btn--danger:hover { border-color: rgba(255,120,120,.5); color: #ff8c8c; } + +/* ── Empty hint ─────────────────────────────────────────────────────────── */ +.pv-empty-hint { + display: flex; flex-direction: column; align-items: center; gap: 8px; + margin: 16px 12px; padding: 18px; + border: 1px dashed var(--pd-line); border-radius: 8px; + color: var(--pd-muted); font-size: 12px; text-align: center; +} + +/* ── Prop library ───────────────────────────────────────────────────────── */ +.pv-entity-section--prop-lib { padding-bottom: 6px; } +.pv-prop-cats { display: flex; flex-direction: column; gap: 6px; padding: 0 8px 4px; } +.pv-prop-cat__label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--pd-faint); margin-bottom: 3px; } +.pv-prop-cat__items { display: flex; flex-wrap: wrap; gap: 3px; } +.pv-prop-chip { + display: flex; flex-direction: column; align-items: flex-start; gap: 1px; + padding: 4px 7px; border: 1px solid var(--pd-line); border-radius: 5px; + background: rgba(255,255,255,.03); cursor: pointer; + transition: border-color .1s, background .1s; +} +.pv-prop-chip:hover { border-color: var(--pd-line-strong); background: var(--pd-surface-2); } +.pv-prop-chip__name { font-size: 11px; color: var(--pd-text); } +.pv-prop-chip__size { font-size: 9px; color: var(--pd-faint); } + +/* ── Background images ──────────────────────────────────────────────────── */ +.pv-bg-list { display: flex; flex-direction: column; gap: 3px; padding: 0 8px 4px; } +.pv-bg-row { display: flex; align-items: center; gap: 5px; } +.pv-bg-row__thumb { + display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; + padding: 4px 6px; border: 1px solid var(--pd-line); border-radius: 5px; + background: rgba(255,255,255,.03); cursor: pointer; + transition: border-color .1s; +} +.pv-bg-row__thumb:hover { border-color: var(--pd-line-strong); } +.pv-bg-row__img { width: 32px; height: 24px; object-fit: cover; border-radius: 3px; flex: 0 0 32px; background: #222; } +.pv-bg-row__name { font-size: 10px; color: var(--pd-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pv-bg-row__btns { display: flex; gap: 3px; flex: 0 0 auto; } +.pv-bg-btn { + height: 24px; padding: 0 6px; border: 1px solid var(--pd-line); border-radius: 4px; + background: transparent; color: var(--pd-muted); font-size: 9px; cursor: pointer; + display: inline-flex; align-items: center; + transition: border-color .1s, color .1s; +} +.pv-bg-btn:hover { border-color: var(--pd-accent); color: var(--pd-text); } +.pv-bg-btn--active { border-color: var(--pd-accent); color: var(--pd-accent); } +.pv-bg-btn--danger:hover { border-color: rgba(255,120,120,.5); color: #ff8c8c; } +.pv-panorama-badge { + display: flex; align-items: center; justify-content: space-between; + margin: 4px 8px; padding: 5px 9px; + border-radius: 5px; background: rgba(240,181,106,.1); + color: var(--pd-orange); font-size: 10px; +} +.pv-panorama-badge button { border: 0; background: transparent; color: rgba(255,255,255,.55); cursor: pointer; font-size: 10px; } +.pv-panorama-badge button:hover { color: #fff; } + +/* ── Viewport area: single view ───────────────────────────────────────────── */ +.pv-viewport-area { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--pd-s0); +} +.pv-viewport-pane { + position: relative; min-width: 0; min-height: 0; overflow: hidden; + background: var(--pd-s0); +} +.pv-viewport-pane .previs-viewport-shell, +.pv-viewport-pane .previs-viewport-shell canvas { position: absolute; inset: 0; width: 100%; height: 100%; } + +/* ── Prop grid upload row ────────────────────────────────────────────────── */ +.pv-prop-upload-row { + padding: 6px 8px 2px; +} +.pv-prop-upload-full { + display: flex; align-items: center; justify-content: center; gap: 6px; + width: 100%; padding: 7px 0; + border: 1px dashed var(--pd-line); border-radius: 6px; + background: transparent; color: var(--pd-muted); + font-size: 11px; cursor: pointer; + transition: border-color .12s, color .12s, background .12s; +} +.pv-prop-upload-full:hover { + border-color: var(--pd-accent); color: var(--pd-accent); + background: rgba(var(--pd-accent-rgb, 100 140 255) / .06); +} + +.pv-pane-label { + position: absolute; top: 8px; left: 8px; z-index: 4; + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: 5px; + background: rgba(13,15,17,.72); backdrop-filter: blur(10px); + color: rgba(255,255,255,.7); font-size: 10px; font-weight: 600; + letter-spacing: .04em; pointer-events: none; +} +.pv-pane-label--cam { color: var(--pd-accent); } +.pv-pane-label__meta { color: rgba(255,255,255,.4); font-weight: 400; } +.pv-pane-hint { + position: absolute; bottom: 12px; left: 50%; z-index: 4; + transform: translateX(-50%); + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: var(--pd-r-md); + background: var(--pd-s5); border: 1px solid var(--pd-b1); + box-shadow: var(--pd-shadow-raised); + color: var(--pd-t1); font-size: var(--pd-ts-xs); font-weight: 500; white-space: nowrap; + pointer-events: none; +} +.pv-trajectory-guide { + position: absolute; top: 54px; left: 50%; z-index: 5; transform: translateX(-50%); + display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px 8px; + width: max-content; max-width: calc(100% - 24px); padding: 8px 12px; + border: 1px solid var(--pd-a-bd); border-radius: var(--pd-r-md); + background: var(--pd-s5); color: var(--pd-t0); box-shadow: var(--pd-shadow-floating); + font-size: var(--pd-ts-sm); text-align: center; pointer-events: none; +} +.pv-trajectory-guide small { color: var(--pd-t1); font-size: var(--pd-ts-xs); } +.pv-pane-no-cam { + position: absolute; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 8px; + color: rgba(255,255,255,.4); font-size: 11px; +} + +/* ── Right panel controls ───────────────────────────────────────────────── */ +.pv-control-block { padding: 10px 12px 0; } +.pv-control-block__label { + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; + color: var(--pd-t1); margin-bottom: 6px; +} + +.pv-shot-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin-bottom: 4px; } +.pv-shot-chip { + padding: 6px 4px; border: 1px solid var(--pd-b1); border-radius: var(--pd-r-sm); + background: var(--pd-s1); color: var(--pd-t1); + font-size: 10px; cursor: pointer; text-align: center; + transition: all 0.15s var(--pd-ease-snap); +} +.pv-shot-chip:hover { border-color: var(--pd-b2); color: var(--pd-t0); background: var(--pd-s2); } +.pv-shot-chip--active { border-color: var(--pd-a-bd); background: var(--pd-a-bg); color: var(--pd-a-on); font-weight: 500; } + +.pv-move-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; } +.pv-move-chip { + padding: 5px 4px; border: 1px solid var(--pd-b1); border-radius: var(--pd-r-sm); + background: var(--pd-s1); color: var(--pd-t1); + font-size: 10px; cursor: pointer; text-align: center; + transition: all 0.15s var(--pd-ease-snap); +} +.pv-move-chip:hover { border-color: var(--pd-b2); color: var(--pd-t0); background: var(--pd-s2); } +.pv-move-chip--active { border-color: oklch(0.75 0.14 155 / 0.4); background: oklch(0.75 0.14 155 / 0.1); color: var(--pd-green); font-weight: 500; } + +.pv-dur-row { display: grid; grid-template-columns: 1fr; gap: 8px; } +.pv-dur-row > * { min-width: 0; } +.pv-dur-field { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--pd-muted); } +.pv-dur-field input, .pv-dur-field select { + height: 28px; border: 1px solid var(--pd-line); border-radius: 5px; + background: var(--pd-surface-2); color: var(--pd-text); padding: 0 8px; + font: inherit; outline: none; +} +.pv-dur-field input:focus, .pv-dur-field select:focus { border-color: var(--pd-accent); } + +.pv-textarea { + width: 100%; border: 1px solid var(--pd-line); border-radius: 6px; + background: var(--pd-surface-2); color: var(--pd-text); + padding: 7px 9px; font: inherit; font-size: 11px; line-height: 1.5; + resize: vertical; outline: none; min-height: 60px; +} +.pv-textarea:focus { border-color: var(--pd-accent); } +.pv-textarea::placeholder { color: var(--pd-faint); } + +/* Sliders */ +.pv-slider-list { display: flex; flex-direction: column; gap: 6px; } +.pv-slider-row { + display: grid; grid-template-columns: 42px 1fr 36px; align-items: center; gap: 6px; + font-size: 10px; color: var(--pd-muted); +} +.pv-slider-row input[type=range] { + width: 100%; height: 3px; accent-color: var(--pd-accent); + cursor: pointer; +} +.pv-slider-val { text-align: right; color: var(--pd-text); font-variant-numeric: tabular-nums; } + +/* ── Output block ───────────────────────────────────────────────────────── */ +.pv-output-block { + margin: 10px 12px 0; padding: 10px 10px 10px; + border: 1px solid var(--pd-line); border-radius: 8px; + background: rgba(255,255,255,.02); +} +.pv-output-block__header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 6px; font-size: 11px; font-weight: 600; color: var(--pd-text); +} +.pv-output-dot { + width: 7px; height: 7px; border-radius: 50%; background: var(--pd-muted); + transition: background .3s; +} +.pv-output-dot--done { background: var(--pd-green); } +.pv-output-dot--recording { background: #f0b56a; animation: pv-pulse 1s ease-in-out infinite; } +@keyframes pv-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } } + +.pv-output-status { font-size: 10px; color: var(--pd-muted); margin-bottom: 8px; line-height: 1.5; } + +.pv-prompt-preview { border-top: 1px solid var(--pd-line); padding-top: 7px; } +.pv-prompt-preview__label { + display: flex; align-items: center; justify-content: space-between; + font-size: 10px; font-weight: 600; color: var(--pd-muted); margin-bottom: 5px; +} +.pv-prompt-copy { + border: 0; background: transparent; color: var(--pd-accent); font-size: 9px; cursor: pointer; +} +.pv-prompt-copy:hover { color: var(--pd-accent-2); } +.pv-prompt-preview__text { + max-height: 72px; overflow-y: auto; border-radius: 4px; + background: rgba(0,0,0,.25); padding: 6px 7px; + font-size: 9px; line-height: 1.55; color: rgba(255,255,255,.45); +} + +/* ── Animation link ──────────────────────────────────────────────────────── */ +.pv-animation-link { + display: flex; align-items: center; gap: 7px; justify-content: center; + margin: 10px 12px 14px; height: 34px; + border: 1px dashed var(--pd-line); border-radius: 7px; + background: transparent; color: var(--pd-muted); + font-size: 11px; cursor: pointer; + transition: border-color .12s, color .12s, background .12s; +} +.pv-animation-link:hover { border-color: var(--pd-line-strong); color: var(--pd-text); background: var(--pd-surface-2); } + +/* ── Responsive ─────────────────────────────────────────────────────────── */ +@media (max-width: 1100px) { + .pv-layout { grid-template-columns: minmax(220px, 260px) minmax(0,1fr) minmax(300px, 320px); } + .pv-layout.is-scene-collapsed { grid-template-columns: 0 minmax(0,1fr) minmax(300px, 320px); } + .pv-layout.is-inspector-collapsed { grid-template-columns: minmax(220px, 260px) minmax(0,1fr) 0; } + .pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: 0 minmax(0,1fr) 0; } +} +@media (max-width: 860px) { + .pv-layout, + .pv-layout.is-scene-collapsed, + .pv-layout.is-inspector-collapsed, + .pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: minmax(0,1fr); } + .pv-layout > .pv-panel--left, + .pv-layout > .pv-panel--right { visibility: visible; } + .pv-panel--left { + position: absolute; + top: 0; + left: 0; + bottom: 0; + z-index: 31; + width: min(88vw, 300px); + border-right: 1px solid var(--pd-line); + box-shadow: 18px 0 40px rgba(0,0,0,.28); + transform: translateX(-100%); + transition: transform 180ms cubic-bezier(.16,1,.3,1); + } + .pv-panel--left.is-open { transform: translateX(0); } + .pv-panel-close { display: inline-flex; } + .pv-panel--right { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 30; + width: min(92vw, 360px); + border-left: 1px solid var(--pd-line); + box-shadow: -18px 0 40px rgba(0,0,0,.28); + transform: translateX(100%); + transition: transform 180ms cubic-bezier(.16,1,.3,1); + } + .pv-panel--right.is-open { transform: translateX(0); } + .pv-viewport-area { grid-template-columns: 1fr; } + .pv-viewport-pane--camera { display: none; } + .pv-mobile-inspector-close { display: inline-flex; } +} +@media (max-width: 640px) { + .pv-layout, + .pv-layout.is-scene-collapsed, + .pv-layout.is-inspector-collapsed, + .pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: minmax(0,1fr); } + .pv-layout > .pv-panel--left, + .pv-layout > .pv-panel--right { visibility: visible; } + .pv-panel--left { font-size: 0.95em; } +} + +/* ── Prop scale inline controls ─────────────────────────────────────────── */ +.pv-scale-row { + display: grid; grid-template-columns: 14px 1fr 14px 1fr 14px 1fr; + align-items: center; gap: 3px; padding: 4px 6px; +} +.pv-scale-label { font-size: 9px; font-weight: 700; color: var(--pd-faint); text-align: center; } +.pv-scale-input { + width: 100%; padding: 3px 5px; border-radius: 4px; + border: 1px solid var(--pd-line); background: var(--pd-surface); + color: var(--pd-text); font-size: 11px; text-align: right; +} +.pv-scale-input:focus { outline: none; border-color: var(--pd-accent); } + + +/* ── Rotation chips ─────────────────────────────────────────────────────── */ +.pv-rot-row { + display: flex; align-items: center; gap: 4px; + padding: 4px 6px; +} +.pv-rot-label { + font-size: 9px; font-weight: 700; color: var(--pd-faint); + text-transform: uppercase; letter-spacing: .06em; + white-space: nowrap; flex: 0 0 auto; margin-right: 2px; +} +.pv-rot-chip { + flex: 1; padding: 4px 0; border-radius: 4px; + border: 1px solid var(--pd-line); background: transparent; + color: var(--pd-muted); font-size: 10px; font-weight: 600; + cursor: pointer; text-align: center; + transition: border-color .1s, color .1s, background .1s; +} +.pv-rot-chip:hover { border-color: var(--pd-line-strong); color: var(--pd-text); } +.pv-rot-chip--active { + border-color: var(--previs-accent, #4f8cff); + color: var(--previs-accent, #4f8cff); + background: rgba(79,140,255,.1); +} + +/* ── Viewport dock: same FloatingDock as the canvas toolbar ────────────────── */ +.pv-canvas-dock { + position: absolute; + left: 50%; + bottom: 14px; + z-index: 12; + transform: translateX(-50%); + max-width: calc(100% - 24px); +} +.pv-canvas-dock__menu { + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + z-index: 30; + width: 280px; + margin-left: -140px; + padding: 10px; + border-radius: var(--panel-radius, 14px); + backdrop-filter: blur(24px); +} +.pv-canvas-dock__menu-title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 2px 8px; font-size: var(--pd-ts-sm); font-weight: 600; } +.pv-canvas-dock__menu-title small { font-size: var(--pd-ts-2xs); font-weight: 400; } +.pv-canvas-dock__menu-title.is-sub { padding-top: 10px; font-size: var(--pd-ts-xs); } +.pv-canvas-dock__view-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; } +.pv-canvas-dock__view-grid button, +.pv-canvas-dock__camera-list button { + border: 0; + border-radius: var(--dock-item-radius, 8px); + background: var(--dock-command-bg, rgba(255,255,255,.04)); + color: inherit; + transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap); +} +.pv-canvas-dock__view-grid button { height: 28px; font-size: var(--pd-ts-2xs); font-weight: 600; letter-spacing: .04em; opacity: .78; } +.pv-canvas-dock__view-grid button:hover, +.pv-canvas-dock__camera-list button:hover { background: var(--dock-command-hover, rgba(255,255,255,.1)); opacity: 1; } +.pv-canvas-dock__view-grid button.is-active, +.pv-canvas-dock__camera-list button.is-active { background: var(--dock-command-active, #fff); color: var(--dock-command-active-text, #000); opacity: 1; } +.pv-canvas-dock__view-grid button:focus-visible, +.pv-canvas-dock__camera-list button:focus-visible { outline: 2px solid var(--pd-a0); outline-offset: 2px; } +.pv-canvas-dock__camera-list { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; } +.pv-canvas-dock__camera-list button { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto 14px; align-items: center; gap: 8px; height: 30px; padding: 0 8px; text-align: left; font-size: var(--pd-ts-xs); } +.pv-canvas-dock__camera-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pv-canvas-dock__camera-list button small { font-size: var(--pd-ts-2xs); opacity: .6; font-variant-numeric: tabular-nums; } +.pv-cam-hud { + position: absolute; top: 12px; left: 12px; z-index: 6; + display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; + border-radius: 999px; background: color-mix(in srgb, var(--pd-s1) 72%, transparent); + backdrop-filter: blur(16px); color: var(--pd-t0); font-size: var(--pd-ts-xs); font-weight: 600; pointer-events: none; +} +.pv-cam-hud__meta { color: var(--pd-t2); font-weight: 400; font-variant-numeric: tabular-nums; } +@media (prefers-reduced-motion: reduce) { + .pv-canvas-dock__view-grid button, .pv-canvas-dock__camera-list button { transition: none; } +} +.pv-shot-list { display: flex; flex-direction: column; gap: 4px; margin: 10px 12px 0; } +.pv-shot-list__item { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + width: 100%; + min-height: 34px; + padding: 6px 8px; + border: 1px solid var(--pd-line); + border-radius: 6px; + background: transparent; + color: var(--pd-muted); + text-align: left; + cursor: pointer; +} +.pv-shot-list__item span { color: var(--pd-accent); font-variant-numeric: tabular-nums; } +.pv-shot-list__item strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pd-text); font-size: 10px; } +.pv-shot-list__item small { color: var(--pd-faint); font-size: 9px; } +.pv-shot-list__item:hover { border-color: var(--pd-line-strong); background: var(--pd-s2); } +.pv-shot-list__item.is-active { border-color: var(--pd-a-bd); background: var(--pd-a-bg); } +.pv-detail-block { padding-top: 2px; } +.pv-detail-block .previs-inspector { padding: 0; } +.pv-inline-timeline { + flex: 0 0 min(34vh, 360px); + min-height: 190px; + border-top: 1px solid var(--pd-b1); + background: var(--pd-s1); + overflow: hidden; +} +.pv-inline-timeline .previs-sequencer { height: 100% !important; border: 0 !important; background: transparent !important; } +.pv-inline-timeline .previs-sequencer-resizer { display: none; } +.pv-inline-timeline .previs-sequencer > header { background: var(--pd-s2); border-bottom-color: var(--pd-b0); } +.pv-mobile-inspector-close { display: none; } +.pv-move-grid--compact { margin-top: 8px; } + +@media (max-width: 860px) { + .pv-mobile-inspector-close { display: inline-flex; } + .pv-inline-timeline { flex-basis: 280px; } +} + +/* Inspector swatches stay usable on narrow panels and expose clear interaction states. */ +.previs-desk .previs-actor-colors { + display: flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: 4px; + flex-wrap: wrap; + overflow-x: auto; + overflow-y: hidden; + padding: 2px 0; + scrollbar-width: thin; +} +.previs-desk .previs-actor-color { + flex: 0 0 20px; + width: 20px; + height: 20px; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease, outline-color 120ms ease; +} +.previs-desk .previs-actor-color:hover { transform: scale(1.08); } +.previs-desk .previs-actor-color:focus-visible { + outline: 2px solid var(--pd-a0); + outline-offset: 2px; +} +.previs-desk .previs-actor-color.is-active { + outline: 2px solid var(--pd-a0); + outline-offset: 2px; + box-shadow: inset 0 0 0 1px var(--previs-swatch-inset), 0 0 0 2px var(--pd-a-bg); +} +.previs-desk .previs-actor-colors .ant-color-picker-trigger { + flex: 0 0 24px; + width: 24px; + height: 24px; + padding: 2px; + border-color: var(--pd-b1); + background: var(--pd-s2); +} +.previs-desk .previs-actor-colors .ant-color-picker-trigger:hover, +.previs-desk .previs-actor-colors .ant-color-picker-trigger:focus-visible { + border-color: var(--pd-a0); +} diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.tsx b/web/src/components/canvas/previs/canvas-previs-workbench.tsx new file mode 100644 index 000000000..d1fa4799b --- /dev/null +++ b/web/src/components/canvas/previs/canvas-previs-workbench.tsx @@ -0,0 +1,1567 @@ +import { cameraMoveOptions, poseEditBones, BoneRotationFields, shotSizeOptions, ObjectInspector, LightInspector, ShotInspector } from "./previs-inspectors"; +import { snapPrevisTime, advancePrevisPlayhead, resolvePrevisKeyframeRecord, resolvePrevisObjectTransformEdit, resolvePrevisCameraMoveKeyframes, resolvePrevisCameraAlignment } from "@/lib/canvas/previs/previs-animation-semantics"; +import { interpolatePrevisTransform, touchPrevisScene, createPrevisObject, createPrevisActor, PREVIS_ACTOR_COLORS, resolvePrevisActorColor, createPrevisModel, createPrevisBillboard, createPrevisCamera, createPrevisLight, upsertPrevisBoneKeyframe, removePrevisSceneKeyframe, setPrevisSceneKeyframeEasing, previsPoseLabel, previsBoneLabel, previsActorProfileForArchetype, previsActorArchetypeLabel, uniquePrevisName } from "@/lib/canvas/previs/previs-scene"; +import { type PrevisObject, type PrevisLight, type PrevisShot, type PrevisHumanoidBone, type PrevisQuat, type PrevisKeyframeDeleteTarget, type PrevisKeyframeEasing, type PrevisRig, type PrevisShotSize, type PrevisActorProfile } from "@/types/previs"; +import { resolvePrevisPlacementAnchor, resolvePrevisPlacement, type PrevisGroundPoint } from "@/lib/canvas/previs/previs-placement"; +import { uploadMediaFile } from "@/services/file-storage"; +import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync"; +import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, RotateCcw, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react"; +import { PrevisCanvasDock } from "./previs-canvas-dock"; +import { Dropdown } from "antd"; +import { nanoid } from "nanoid"; +import { type PrevisShortcutAction, resolvePrevisShortcut, blocksPrevisShortcut, releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts"; +import { Euler, Vector3, type AnimationClip } from "three"; +import { compilePrevisPrompt } from "@/lib/canvas/previs/previs-prompt-compiler"; +import { Select } from "@/components/ui/base/select"; +import { PrevisViewport } from "./previs-viewport"; +import { CanvasPrevisOnboarding } from "@/components/canvas/previs/canvas-previs-onboarding"; +import { PrevisSequencer } from "./previs-sequencer"; +import { App, type MenuProps, Input, Button } from "antd"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent } from "react"; +import { type PrevisViewportHandle } from "@/components/canvas/previs/previs-viewport"; +import { transcodeVideoToMp4 } from "@/lib/canvas/canvas-video-merge"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { type PrevisTransaction, createPrevisTransaction, installPrevisTerminalListeners } from "@/lib/canvas/previs/previs-gesture-transaction"; +import { buildPrevisPathKeyframes } from "@/lib/canvas/previs/previs-path"; +import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder"; +import { shouldReinitializePrevisSession, isPrevisOutputSnapshotCurrent } from "@/lib/canvas/previs/previs-session"; +import { isPrevisPreviewRequestForWorkbench, previsPreviewRequestKey, type PrevisPreviewRequest } from "@/lib/canvas/previs/previs-preview"; +import { describePrevisSaveStatus, shouldOfferPrevisDraftRecovery, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring"; +import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs-save-coordinator"; +import "./canvas-previs-workbench.css"; +import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store"; +import { usePrevisWorkbenchStore } from "@/stores/canvas/use-previs-workbench-store"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; +import type { CanvasNodeData } from "@/types/canvas"; +import type { PrevisCameraMove, PrevisRenderMode, PrevisScene, PrevisSceneOutput, PrevisTransform, PrevisVec3 } from "@/types/previs"; +export { cameraMoveOptions, poseOptions, shotSizeOptions } from "./previs-inspectors"; + +export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboardingScope, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; canvasId?: string; scene: PrevisScene | null; imageNodes: CanvasNodeData[]; onboardingScope: string; onClose: () => void; onChange: (scene: PrevisScene) => void; onApply: (output: PrevisSceneOutput) => Promise; onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise }) { + const { message, modal } = App.useApp(); + const theme = canvasThemes[useActiveTheme()]; + const effectiveCanvasId = useMemo(() => { + const explicit = canvasId?.trim(); + if (explicit) return explicit; + if (typeof window === "undefined") return undefined; + const match = window.location.pathname.match(/^\/canvas\/([^/]+)/u); + return match ? decodeURIComponent(match[1]) : undefined; + }, [canvasId]); + const viewportRef = useRef(null); + const modelInputRef = useRef(null); + const [draft, setDraft] = useState(null); + const [history, setHistory] = useState([]); + const [future, setFuture] = useState([]); + const [saving, setSaving] = useState(false); + const [recording, setRecording] = useState(false); + const [lastClayExport, setLastClayExport] = useState<{ shotId: string; at: number } | null>(null); + const [trajectoryDrawing, setTrajectoryDrawing] = useState(false); + const [inspectorOpen, setInspectorOpen] = useState(false); + const [scenePanelOpen, setScenePanelOpen] = useState(false); + // 桌面端侧栏常驻,可收起换取沉浸视口;窄屏下改为 scenePanelOpen/inspectorOpen 控制的浮层。 + const [sceneDocked, setSceneDocked] = useState(true); + const [inspectorDocked, setInspectorDocked] = useState(true); + const [compactLayout, setCompactLayout] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 860px)").matches); + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + const media = window.matchMedia("(max-width: 860px)"); + const update = () => setCompactLayout(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0); + const viewMode = usePrevisWorkbenchStore((state) => state.viewMode); + const setViewMode = usePrevisWorkbenchStore((state) => state.setViewMode); + const selectedObjectId = usePrevisWorkbenchStore((state) => state.selectedObjectId); + const selectedLightId = usePrevisWorkbenchStore((state) => state.selectedLightId); + const transformMode = usePrevisWorkbenchStore((state) => state.transformMode); + const renderMode = usePrevisWorkbenchStore((state) => state.renderMode); + const playhead = usePrevisWorkbenchStore((state) => state.playhead); + const playing = usePrevisWorkbenchStore((state) => state.playing); + const selectedBone = usePrevisWorkbenchStore((state) => state.selectedBone); + const autoKey = usePrevisWorkbenchStore((state) => state.autoKey); + const sequencerHeight = usePrevisWorkbenchStore((state) => state.sequencerHeight); + const sequencerVisible = usePrevisWorkbenchStore((state) => state.sequencerVisible); + const setSelectedObjectId = usePrevisWorkbenchStore((state) => state.setSelectedObjectId); + const [selectedCameraId, setSelectedCameraId] = useState(null); + const [sceneQuery, setSceneQuery] = useState(""); + const setSelectedLightId = usePrevisWorkbenchStore((state) => state.setSelectedLightId); + const setTransformMode = usePrevisWorkbenchStore((state) => state.setTransformMode); + const setRenderMode = usePrevisWorkbenchStore((state) => state.setRenderMode); + const setPlayhead = usePrevisWorkbenchStore((state) => state.setPlayhead); + const setPlaying = usePrevisWorkbenchStore((state) => state.setPlaying); + const setSelectedBone = usePrevisWorkbenchStore((state) => state.setSelectedBone); + const setAutoKey = usePrevisWorkbenchStore((state) => state.setAutoKey); + const setSequencerHeight = usePrevisWorkbenchStore((state) => state.setSequencerHeight); + const setSequencerVisible = usePrevisWorkbenchStore((state) => state.setSequencerVisible); + const resetWorkbench = usePrevisWorkbenchStore((state) => state.reset); + const stopPrevisEntityPointer = useCallback((event: MouseEvent | PointerEvent) => { + event.stopPropagation(); + }, []); + const selectSceneObject = useCallback((id: string) => { + setSelectedObjectId(id); + setSelectedCameraId(null); + setSelectedBone(null); + setInspectorOpen(true); setInspectorDocked(true); + }, [setSelectedBone, setSelectedObjectId]); + const selectSceneCamera = useCallback((id: string) => { + setSelectedObjectId(null); + setSelectedLightId(null); + setSelectedCameraId(id); + setSelectedBone(null); + // CAM hides camera gizmos; keep the editable scene view so the selected camera is visible. + setViewMode("free"); + setInspectorOpen(true); setInspectorDocked(true); + }, [setSelectedBone, setSelectedLightId, setSelectedObjectId, setViewMode]); + const assets = useAssetStore((state) => state.assets); + const addAsset = useAssetStore((state) => state.addAsset); + const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]); + + const capabilities = { timeline: true, keyframes: sequencerVisible, bones: true, cameraTools: Boolean(selectedCameraId && !selectedObjectId && !selectedLightId), renderModes: PREVIS_RENDER_MODE_LABELS.map((option) => option.value) }; + const renderModeOptions = PREVIS_RENDER_MODE_LABELS; + + const draftRef = useRef(null); + const stagedRef = useRef(null); + const initializedSceneIdRef = useRef(null); + const onChangeRef = useRef(onChange); + const onFlushRef = useRef(onFlush); + useEffect(() => { onChangeRef.current = onChange; onFlushRef.current = onFlush; }, [onChange, onFlush]); + + const closingRef = useRef(false); + const recoveryPromptedRef = useRef(null); + const [retrying, setRetrying] = useState(false); + const handledPreviewRequestsRef = useRef(new Set()); + + // 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。 + const saveController = usePrevisSaveCoordinator({ + sceneId: scene?.id ?? null, + initialScene: scene, + persistScene: (next) => onChangeRef.current(next), + flushPersistence: () => onFlushRef.current?.(), + }); + const saveControllerRef = useRef(saveController); + saveControllerRef.current = saveController; + const saveIndicator = describePrevisSaveStatus(saveController.progress); + + const retrySave = async () => { + setRetrying(true); + try { + if (await saveController.retry()) { + recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true }); + message.success("已保存到项目"); + return; + } + // 只有真的存在合法本地候选才敢说草稿已保留。 + const draftStored = Boolean(saveController.restoreCandidate()); + recordPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true }); + if (!draftStored) recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision }); + if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试"); + else message.error("远端和本地都未保存,请不要关闭预演台并继续重试"); + } finally { + setRetrying(false); + } + }; + + const writeDraft = useCallback((next: PrevisScene | null) => { + draftRef.current = next; + setDraft(next); + }, []); + + /** + * 仅镜像当前 draft 到项目 previsScenes,不产生 canonical 提交。 + * 用于取消预览、idle pagehide、卸载兜底 —— 这些都不是新的用户改动。 + */ + const mirrorDraft = useCallback(() => { + const current = draftRef.current; + if (!current || initializedSceneIdRef.current !== current.id) return; + onChangeRef.current(current); + }, []); + + /** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */ + const commitDraft = useCallback(() => { + const current = draftRef.current; + if (!current || initializedSceneIdRef.current !== current.id) return; + saveControllerRef.current?.commitScene(current); + onChangeRef.current(current); + }, []); + + const writeAndPublish = useCallback((next: PrevisScene) => { + writeDraft(next); + saveControllerRef.current?.commitScene(next); + onChangeRef.current(next); + }, [writeDraft]); + + // 会话初始化只认 scene id:同 id 的父级镜像回流不得重建会话。 + useEffect(() => { + if (!open || !scene) return; + if (!shouldReinitializePrevisSession({ initializedSceneId: initializedSceneIdRef.current, nextSceneId: scene.id })) return; + const next = structuredClone(scene); + next.objects = next.objects.map((object) => object.kind === "actor" || object.primitive === "character" ? { ...object, color: resolvePrevisActorColor(object.color), archetype: object.archetype || "adult" } : object); + next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 })); + stagedRef.current?.end("cancel"); + initializedSceneIdRef.current = scene.id; + writeDraft(next); + setHistory([]); + setFuture([]); + resetWorkbench(); + }, [open, resetWorkbench, scene, writeDraft]); + + // 父级项目收到 Agent 的预演台语义补丁后,scene.id 不变但 updatedAt 会变化; + // 不能只按 scene.id 忽略,否则打开着的工作台永远看不到 Agent 的修改。 + useEffect(() => { + if (!open || !scene || initializedSceneIdRef.current !== scene.id) return; + const current = draftRef.current; + if (!current || scene.updatedAt === current.updatedAt) return; + stagedRef.current?.end("cancel"); + const next = structuredClone(scene); + writeDraft(next); + setHistory([]); + setFuture([]); + setSelectedObjectId(null); + setSelectedLightId(null); + setSelectedBone(null); + setPlayhead(0); + message.info("Agent 已更新预演场景"); + }, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]); + + // 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。 + useEffect(() => { + if (!open || !scene) return; + if (recoveryPromptedRef.current === scene.id) return; + recoveryPromptedRef.current = scene.id; + + const controller = saveControllerRef.current; + const candidate = controller?.restoreCandidate() ?? null; + if (!controller || !candidate) return; + if (!shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene: scene })) return; + + modal.confirm({ + title: "发现未保存的本地草稿", + content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`, + okText: "恢复草稿", + cancelText: "放弃草稿", + closable: false, + mask: { closable: false }, + keyboard: false, + onOk: () => { + if (!controller.restoreDraft(candidate)) { + message.error("草稿恢复失败,已保留当前场景"); + return; + } + writeDraft(candidate.scene); + message.success("已恢复本地草稿并写回项目"); + }, + onCancel: () => { + if (controller.discardDraft()) message.success("已放弃本地草稿"); + else message.error("草稿删除失败,下次打开可能仍会提示"); + }, + }); + }, [message, modal, open, scene, writeDraft]); + + const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null; + const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null; + useEffect(() => { + const cameras = draft?.cameras || []; + if (!cameras.length) { + setSelectedCameraId(null); + return; + } + if (!selectedCameraId || !cameras.some((camera) => camera.id === selectedCameraId)) { + setSelectedCameraId(null); + } + }, [activeCamera?.id, draft?.cameras, selectedCameraId]); + useEffect(() => { + if (activeShot?.id) setSelectedCameraId(activeShot.cameraId || null); + }, [activeShot?.id]); + const compiledPrompt = useMemo(() => draft && activeShot ? compilePrevisPrompt(draft, activeShot) : "", [activeShot, draft]); + const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null; + const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null; + const selectedCamera = draft?.cameras?.find((item) => item.id === selectedCameraId) || null; + const inspectorCamera = selectedCamera || activeCamera; + // 写入关键帧的目的时间用吸附值;取值/显示/手势起点一律用 raw playhead, + // 否则处在两个帧格之间时 AutoKey OFF 的增量会从错误起点计算而产生漂移。 + const snappedPlayhead = snapPrevisTime(playhead, activeShot?.fps || 24); + const selectedObjectRendered = selectedObject ? interpolatePrevisTransform(selectedObject.transform, selectedObject.keyframes, playhead) : null; + + useEffect(() => { + 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) => { + pending += Math.max(0, (now - last) / 1000); + last = now; + if (pending >= frameInterval) { + const elapsed = Math.floor(pending / frameInterval) * frameInterval; + pending -= elapsed; + setPlayhead(advancePrevisPlayhead(usePrevisWorkbenchStore.getState().playhead, elapsed, activeShot.duration)); + } + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [activeShot, playing, setPlayhead]); + + const commit = useCallback((updater: (current: PrevisScene) => PrevisScene) => { + // 普通提交前先终结暂存手势,避免新动作消费旧 base。 + stagedRef.current?.end("commit"); + const current = draftRef.current; + if (!current) return; + setHistory((items) => [...items.slice(-49), structuredClone(current)]); + setFuture([]); + writeAndPublish(touchPrevisScene(updater(current))); + }, [writeAndPublish]); + + /** 暂存型手势(数值滑杆):实时预览写草稿但不产生历史,也不镜像到项目。 */ + const stagedTransaction = useMemo(() => createPrevisTransaction({ + read: () => draftRef.current, + // 取消:恢复快照且绝不发布被取消的值。 + restore: (snapshot) => writeDraft(snapshot), + commit: (from) => { + setHistory((items) => [...items.slice(-49), from]); + setFuture([]); + // 手势成功终态是真实 canonical 提交。 + commitDraft(); + }, + setActive: () => undefined, + }), [commitDraft, writeDraft]); + stagedRef.current = stagedTransaction; + + const stageGesture = useCallback((updater: (current: PrevisScene) => PrevisScene) => { + const current = draftRef.current; + if (!current) return; + stagedTransaction.begin(); + writeDraft(touchPrevisScene(updater(current))); + }, [stagedTransaction, writeDraft]); + + /** 无历史但持久的变化(标题、rig/motionClips 等)同样要镜像。 */ + const replaceWithoutHistory = useCallback((updater: (current: PrevisScene) => PrevisScene) => { + const current = draftRef.current; + if (current) writeAndPublish(touchPrevisScene(updater(current))); + }, [writeAndPublish]); + + // 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。 + useEffect(() => installPrevisTerminalListeners(stagedTransaction, { + window, + document, + isHidden: () => document.visibilityState === "hidden", + }), [stagedTransaction]); + + // 切换选择/骨骼、关闭或卸载前必须先终止旧手势,不能让新选择消费旧 base。 + useEffect(() => () => stagedTransaction.end("cancel"), [open, selectedBone, selectedObjectId, stagedTransaction]); + + // 离开页面:active 预览由 end("commit") 完成真实提交,idle 只镜像;落盘统一交给 controller。 + useEffect(() => { + const onPageHide = () => { + if (stagedTransaction.active()) stagedTransaction.end("commit"); + else mirrorDraft(); + // 只调用 handlePageHide:dirty 时它自己会 persist + flush,组件再叠一次就是重复落盘。 + void saveControllerRef.current?.handlePageHide(); + }; + // 异步 flush 不可能阻塞卸载:这里只同步声明「仍有未确认改动」,让浏览器自己弹保护。 + const onBeforeUnload = (event: BeforeUnloadEvent) => { + const controller = saveControllerRef.current; + if (!controller || !shouldBlockPrevisUnload(controller.progress)) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("pagehide", onPageHide); + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [mirrorDraft, stagedTransaction]); + + // 卸载兜底:只把最新 draft 镜像回项目,不制造新的 canonical revision。 + useEffect(() => () => { + stagedRef.current?.end("cancel"); + mirrorDraft(); + }, [mirrorDraft]); + + const undo = () => { + const previous = history.at(-1); + if (!previous || !draft) return; + setHistory((items) => items.slice(0, -1)); + setFuture((items) => [structuredClone(draft), ...items].slice(0, 50)); + writeAndPublish(previous); + }; + const redo = () => { + const next = future[0]; + if (!next || !draft) return; + setFuture((items) => items.slice(1)); + setHistory((items) => [...items, structuredClone(draft)].slice(-50)); + writeAndPublish(next); + }; + + /** + * 关闭统一入口:取消未结束的预览、镜像当前 draft,再按 prepareClose 决策是否真的退出。 + * 这里只镜像不提交:取消预览不是新的 canonical 变化。 + */ + const closeWorkbench = () => { + if (closingRef.current) return; + closingRef.current = true; + stagedTransaction.end("cancel"); + mirrorDraft(); + + // 异步生成缩略图,不阻塞关闭流程 + if (viewportRef.current && draftRef.current && activeShot) { + const currentScene = draftRef.current; + const currentShot = activeShot; + setTimeout(() => { + void (async () => { + try { + const thumbnail = await viewportRef.current?.capture("clay"); + if (thumbnail) { + await onApply({ + scene: currentScene, + shot: currentShot, + prompt: compilePrevisPrompt(currentScene, currentShot), + beauty: thumbnail, + clayVideo: undefined, + clayVideoMimeType: "" + }); + } + } catch (error) { + console.warn("预演台缩略图后台生成失败", error); + } + })(); + }, 100); + } + + void (async () => { + let decision; + try { + decision = resolvePrevisCloseOutcome(await saveController.prepareClose()); + } catch { + message.error("关闭前的保存检查失败,已留在预演台"); + closingRef.current = false; + return; + } + + if (decision.kind === "close") { + onClose(); + return; + } + if (decision.kind === "blocked") { + recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored }); + message.error(decision.message); + closingRef.current = false; + return; + } + + // 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。 + modal.confirm({ + title: "远端保存失败", + content: decision.message, + okText: "仍然离开", + cancelText: "留在预演台", + closable: false, + mask: { closable: false }, + keyboard: false, + onOk: () => onClose(), + onCancel: () => { + closingRef.current = false; + }, + }); + })(); + }; + + const updateObject = (id: string, patch: Partial) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) })); + const duplicateObject = (id: string) => { + const source = draft?.objects.find((item) => item.id === id); + if (!source) return; + const copy = structuredClone(source); + copy.id = nanoid(); + copy.name = `${source.name} 副本`; + copy.transform = { ...copy.transform, position: [source.transform.position[0] + 0.8, source.transform.position[1], source.transform.position[2] + 0.8] }; + copy.keyframes = copy.keyframes.map((keyframe) => ({ ...keyframe, id: nanoid() })); + copy.boneTracks = copy.boneTracks?.map((track) => ({ ...track, keyframes: track.keyframes.map((keyframe) => ({ ...keyframe, id: nanoid() })) })); + commit((current) => ({ ...current, objects: [...current.objects, copy] })); + setSelectedObjectId(copy.id); + setSelectedBone(null); + message.success("对象已复制"); + }; + const updateLight = (id: string, patch: Partial) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) })); + const updateShot = (id: string, patch: Partial) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) })); + const removeObject = (id: string) => { + commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== id) })); + if (selectedObjectId === id) { + setSelectedObjectId(null); + setSelectedBone(null); + } + }; + const removeLight = (id: string) => { + commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== id) })); + if (selectedLightId === id) setSelectedLightId(null); + }; + const removeCamera = (id: string) => { + if (!draft || draft.cameras.length <= 1) { + message.warning("至少保留一台摄影机"); + return; + } + const fallback = draft.cameras.find((item) => item.id !== id); + if (!fallback) return; + commit((current) => ({ + ...current, + cameras: current.cameras.filter((item) => item.id !== id), + shots: current.shots.map((shot) => shot.cameraId === id ? { ...shot, cameraId: fallback.id } : shot), + })); + }; + + /** + * 所有「新增到场景」的唯一入口。 + * 在 commit 内读取一次 placement intent:因此模型上传等异步路径拿到的是 + * 「点击添加完成那一刻」的意图,而不是发起上传时捕获的过时坐标。 + * 锚点只提供 XZ,Y 严格保留构造器给定值,再交给 resolvePrevisPlacement 做碰撞避让。 + */ + const addObject = (object: PrevisObject) => { + commit((current) => { + const anchored = resolvePrevisPlacementAnchor({ + intent: viewportRef.current?.readPlacementIntent() ?? null, + fallback: object.transform.position, + }); + const position = resolvePrevisPlacement({ object: { ...object, transform: { ...object.transform, position: anchored } }, existing: current.objects }); + return { ...current, objects: [...current.objects, { ...object, transform: { ...object.transform, position } }] }; + }); + setSelectedObjectId(object.id); + }; + + const addPrimitive = (primitive: PrevisObject["primitive"], name: string) => addObject(createPrevisObject(primitive, name)); + + const addActor = () => { + const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || []; + const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length; + addObject(createPrevisActor(`演员 ${existingActors.length + 1}`, [0, 0, 0], PREVIS_ACTOR_COLORS[colorIndex])); + }; + + const addModelAsset = (asset: ModelAsset) => addObject(createPrevisModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType })); + + const uploadModel = async (file?: File) => { + if (!file || !/\.(glb|gltf)$/i.test(file.name)) return; + const uploaded = await uploadMediaFile(file, "model"); + const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "预演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "previs" } }); + const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model"); + if (asset) addModelAsset(asset); + try { + await saveRemoteUserDataNow(); + message.success("3D 模型已加入场景和素材库"); + } catch (error) { + message.warning(localSavedRemotePendingMessage("3D 模型已加入场景和本机素材库", error)); + } + }; + + const addBillboard = async (node: CanvasNodeData) => { + const url = node.metadata?.content || node.metadata?.storageKey; + if (!url) { + message.warning("该图片节点没有内容,无法添加到场景"); + return; + } + // 如果是 storageKey,需要解析成实际URL + let actualUrl = url; + if (node.metadata?.storageKey && !url.startsWith('http') && !url.startsWith('blob:') && !url.startsWith('data:')) { + try { + const { resolveImageUrl } = await import("@/services/image-storage"); + actualUrl = await resolveImageUrl(node.metadata.storageKey, url); + } catch (error) { + console.warn("解析图片URL失败,使用原URL:", error); + } + } + const billboard = createPrevisBillboard(node.title || "背板", actualUrl, node.metadata?.storageKey, node.id); + addObject(billboard); + message.success(`已添加背板:${node.title || "图片"}`); + }; + const setPanoramaEnvironment = (node: CanvasNodeData) => { + const url = node.metadata?.content; + if (!url) return; + commit((current) => ({ ...current, environment: { mode: "panorama", url, storageKey: node.metadata?.storageKey, sourceNodeId: node.id, rotationY: 0, opacity: 1, exposure: 0, useAsLighting: false, lightingIntensity: 0.2 } })); + message.success("已设为 360° 背景;请确认图片是 2:1 全景图"); + }; + const clearPanoramaEnvironment = () => commit((current) => ({ ...current, environment: { mode: "color" } })); + + const copyCompiledPrompt = async () => { + if (!compiledPrompt) return; + try { + await navigator.clipboard.writeText(compiledPrompt); + message.success("已复制可直接交给视频模型的提示词"); + } catch { + message.warning("当前浏览器不允许自动复制,请从提示词预览中手动复制"); + } + }; + + const addCamera = () => { + const camera = createPrevisCamera(`摄影机 ${draft?.cameras.length ? draft.cameras.length + 1 : 1}`); + commit((current) => ({ ...current, cameras: [...current.cameras, camera] })); + if (activeShot) updateShot(activeShot.id, { cameraId: camera.id }); + }; + + const addLight = (type: PrevisLight["type"] = "point", label = "灯光", position: PrevisVec3 = [2, 3, 2], intensity = 1.5) => { + const light = createPrevisLight(type, `${label} ${draft?.lights.length ? draft.lights.length + 1 : 1}`, position, intensity); + commit((current) => ({ ...current, lights: [...current.lights, light] })); + setSelectedLightId(light.id); + }; + + const addCameraMenuItems: MenuProps["items"] = [ + { key: "camera", icon: , label: "添加摄影机", onClick: addCamera }, + ]; + const addLightMenuItems: MenuProps["items"] = [ + { key: "directional", icon: , label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) }, + { key: "point", icon: , label: "点光源", onClick: () => addLight("point", "点光源") }, + { key: "spot", icon: , label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) }, + { key: "ambient", icon: , label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) }, + ]; + const addObjectMenuItems: MenuProps["items"] = [ + { key: "actor", icon: , label: "演员", onClick: addActor }, + { key: "box", icon: , label: "立方体", onClick: () => addPrimitive("box", "立方体") }, + { key: "sphere", icon: , label: "球体", onClick: () => addPrimitive("sphere", "球体") }, + { key: "cylinder", icon: , label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") }, + { key: "model", icon: , label: "上传模型", onClick: () => modelInputRef.current?.click() }, + ]; + + const addShot = () => { + if (!activeCamera) return; + const shot: PrevisShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" }; + commit((current) => ({ ...current, shots: [...current.shots, shot], activeShotId: shot.id })); + setPlayhead(0); + }; + + const addObjectKeyframe = () => { + if (!selectedObject) return; + // 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。 + const record = resolvePrevisKeyframeRecord({ base: selectedObject.transform, keyframes: selectedObject.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }); + updateObject(selectedObject.id, { keyframes: record.keyframes }); + }; + + const addCameraKeyframe = () => { + if (!activeCamera) return; + commit((current) => ({ + ...current, + cameras: current.cameras.map((item) => item.id === activeCamera.id + ? { ...item, keyframes: resolvePrevisKeyframeRecord({ base: item.transform, keyframes: item.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }).keyframes } + : item), + })); + }; + + const recordSelectedKeyframe = () => { + if (selectedObject && selectedBone) { + const rotation = selectedObject.boneOverrides?.[selectedBone as PrevisHumanoidBone] || [0, 0, 0, 1] as PrevisQuat; + updateObject(selectedObject.id, { boneTracks: upsertPrevisBoneKeyframe(selectedObject.boneTracks || [], selectedBone as PrevisHumanoidBone, snappedPlayhead, rotation) }); + return; + } + if (selectedObject) addObjectKeyframe(); + else addCameraKeyframe(); + }; + + /** + * 时间轴删除关键帧的唯一入口。 + * + * 未命中(对象/摄影机/关键帧已不存在)时 removePrevisSceneKeyframe 返回同一引用, + * 此时不进 commit:不记历史、不产生修订、不触发保存。 + */ + const deleteKeyframe = useCallback((target: PrevisKeyframeDeleteTarget) => { + const current = draftRef.current; + if (!current || removePrevisSceneKeyframe(current, target) === current) return; + commit((scene) => removePrevisSceneKeyframe(scene, target)); + }, [commit]); + + const setKeyframeEasing = useCallback((target: PrevisKeyframeDeleteTarget, easing: PrevisKeyframeEasing) => { + const current = draftRef.current; + if (!current || setPrevisSceneKeyframeEasing(current, target, easing) === current) return; + commit((scene) => setPrevisSceneKeyframeEasing(scene, target, easing)); + }, [commit]); + + /** + * 快捷键执行器。放在 ref 里:监听只在 open 变化时注册一次, + * 但每次渲染都能拿到最新的选择、历史和 draft,避免闭包读到过期状态。 + * + * 返回值表示「动作真的执行了」,只有执行了才 preventDefault: + * 没有选中对象时的 Delete 仍然交还给浏览器。 + */ + const runShortcut = (action: PrevisShortcutAction): 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 = resolvePrevisShortcut({ + key: event.key, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + isInteractiveTarget: blocksPrevisShortcut(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: PrevisTransform, to: PrevisTransform) => { + commit((current) => ({ + ...current, + objects: current.objects.map((item) => { + if (item.id !== id) return item; + const edit = resolvePrevisObjectTransformEdit({ base: item.transform, keyframes: item.keyframes, rendered: from, edited: to, autoKey, time: snappedPlayhead }); + return { ...item, transform: edit.transform, keyframes: edit.keyframes }; + }), + })); + }, [autoKey, commit, snappedPlayhead]); + + /** 摄影机 transform gizmo 写入:直接覆盖摄影机 transform(不走 object autoKey 逻辑)。 */ + const handleCameraTransform = useCallback((id: string, from: PrevisTransform, to: PrevisTransform) => { + commit((current) => ({ + ...current, + cameras: current.cameras.map((item) => { + if (item.id !== id) return item; + // 机位取景由 target 决定:平移时保持盯住原焦点;旋转时按新朝向把焦点推到同样距离。 + const rotated = to.rotation.some((value, index) => Math.abs(value - from.rotation[index]) > 1e-4); + if (!rotated) return { ...item, transform: { ...to, scale: [1, 1, 1] } }; + const distance = Math.max(0.5, new Vector3(...item.target).distanceTo(new Vector3(...from.position))); + const forward = new Vector3(0, 0, -1).applyEuler(new Euler(...to.rotation)).multiplyScalar(distance); + const target: PrevisVec3 = [to.position[0] + forward.x, to.position[1] + forward.y, to.position[2] + forward.z]; + return { ...item, transform: { ...to, scale: [1, 1, 1] }, target }; + }), + })); + }, [commit]); + + /** 骨骼写入语义:静态覆盖 + autoKey 时在吸附播放头补关键帧。gizmo 与数值编辑器共用。 */ + const writeBoneRotation = useCallback((id: string, bone: string, rotation: PrevisQuat, mode: "stage" | "commit") => { + const write = mode === "stage" ? stageGesture : commit; + write((current) => ({ + ...current, + objects: current.objects.map((item) => item.id === id ? { + ...item, + boneOverrides: { ...item.boneOverrides, [bone]: rotation }, + boneTracks: autoKey ? upsertPrevisBoneKeyframe(item.boneTracks || [], bone as PrevisHumanoidBone, snappedPlayhead, rotation) : item.boneTracks, + } : item), + })); + }, [autoKey, commit, snappedPlayhead, stageGesture]); + + const handleBoneTransform = useCallback((id: string, bone: string, rotation: PrevisQuat) => writeBoneRotation(id, bone, rotation, "commit"), [writeBoneRotation]); + + const handleActorRigReady = useCallback((id: string, rig: PrevisRig, animations: AnimationClip[]) => { + replaceWithoutHistory((current) => ({ + ...current, + objects: current.objects.map((item) => { + if (item.id !== id) return item; + const existing = item.motionClips || []; + const motionClips = existing.length ? existing : animations.map((clip) => ({ id: nanoid(), name: clip.name || "动作片段", sourceAnimation: clip.name, start: 0, duration: Math.max(0.1, clip.duration), playbackRate: 1, loop: true })); + return { ...item, rig, motionClips }; + }), + })); + }, [replaceWithoutHistory]); + + const applyCameraMove = () => { + if (!activeCamera || !activeShot) return; + 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: resolvePrevisCameraMoveKeyframes(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 ? resolvePrevisCameraAlignment(item, transform, snappedPlayhead) : item) })); + message.success("摄影机已对齐当前视图"); + }; + + const applyToCanvas = async () => { + 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 (!isPrevisOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("输出期间场景或镜头已变化,请重试"); + const prompt = compilePrevisPrompt(current, activeShot); + // 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。 + const next = touchPrevisScene(current); + writeAndPublish(next); + await onApply({ scene: next, shot: activeShot, prompt, beauty }); + message.success("预演台构图已回写画布"); + } catch (error) { + message.error(error instanceof Error ? error.message : "预演台输出失败"); + } finally { + setSaving(false); + } + }; + + const exportClayVideo = async () => { + 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; + setPlayhead(0); + setPlaying(true); + try { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const recordedVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps); + const clayVideo = recordedVideo.type === "video/mp4" ? recordedVideo : await transcodeVideoToMp4(recordedVideo); + if (!isPrevisOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("录制期间场景或镜头已变化,请重试"); + const next = touchPrevisScene(draftRef.current || current); + writeAndPublish(next); + const beauty = await viewportRef.current.capture("beauty"); + if (!isPrevisOutputSnapshotCurrent(draftRef.current, { scene: next, shotId: expected.shotId })) throw new Error("输出期间场景或镜头已变化,请重试"); + await onApply({ scene: next, shot: activeShot, prompt: compilePrevisPrompt(next, activeShot), beauty, clayVideo, clayVideoMimeType: clayVideo.type }); + setLastClayExport({ shotId: expected.shotId, at: Date.now() }); + message.success("白膜 MP4 视频已回写画布"); + } catch (error) { + message.error(error instanceof Error ? error.message : "白膜视频导出失败"); + } finally { + setPlaying(wasPlaying); + setPlayhead(previousPlayhead); + setRecording(false); + } + }; + + const sceneActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || []; + const sceneProps = draft?.objects.filter((item) => item.kind !== "actor" && item.primitive !== "character") || []; + const normalizedSceneQuery = sceneQuery.trim().toLowerCase(); + const matchesSceneQuery = (name: string, meta = "") => !normalizedSceneQuery || `${name} ${meta}`.toLowerCase().includes(normalizedSceneQuery); + const visibleSceneActors = sceneActors.filter((actor) => matchesSceneQuery(actor.name, `${actor.archetype || ""} ${actor.pose || ""}`)); + const visibleSceneProps = sceneProps.filter((object) => matchesSceneQuery(object.name, `${object.kind} ${object.primitive || ""}`)); + const visibleCameras = draft?.cameras.filter((camera) => matchesSceneQuery(camera.name, `${camera.focalLength}mm`)) || []; + const visibleLights = draft?.lights.filter((light) => matchesSceneQuery(light.name, light.type)) || []; + const selectedActor = sceneActors.find((item) => item.id === selectedObjectId) ?? null; + const handleGroundClick = useCallback((point: PrevisGroundPoint) => { + if (!selectedActor || !selectedObjectId) { + message.info("先在左侧列表选中一个演员,再点击地面落位"); + return; + } + setSelectedObjectId(selectedActor.id); + handleObjectTransform(selectedActor.id, selectedActor.transform, { + ...selectedActor.transform, + position: [point.x, selectedActor.transform.position[1], point.z], + }); + message.success("演员已落位"); + }, [handleObjectTransform, message, selectedActor]); + const addActorToScene = () => { + addActor(); + message.success("演员已加入;直接点击视口地面即可落位"); + }; + + const trajectoryTarget = selectedActor ? { kind: "actor" as const, id: selectedActor.id, name: selectedActor.name } : selectedCamera ? { kind: "camera" as const, id: selectedCamera.id, name: selectedCamera.name } : null; + const trajectoryPreviousViewModeRef = useRef(viewMode); + const trajectorySessionRef = useRef(false); + useEffect(() => { + if (trajectoryDrawing && !trajectorySessionRef.current) { + trajectorySessionRef.current = true; + trajectoryPreviousViewModeRef.current = viewMode; + if (viewMode !== "top") setViewMode("top"); + message.info(trajectoryTarget ? `${trajectoryTarget.kind === "camera" ? "为摄影机绘制运镜路径" : "为角色绘制走位"}:按住地面拖动,松开完成,重新点击可取消` : "已进入画轨迹模式:请先在左侧选择演员或摄影机,再按住地面拖动"); + return; + } + if (!trajectoryDrawing && trajectorySessionRef.current) { + trajectorySessionRef.current = false; + setViewMode(trajectoryPreviousViewModeRef.current); + } + }, [message, setViewMode, trajectoryDrawing, trajectoryTarget, viewMode]); + const handleTrajectoryComplete = useCallback((points: PrevisGroundPoint[]) => { + const current = draftRef.current; + const shot = current?.shots.find((item) => item.id === current.activeShotId) || current?.shots[0]; + if (!current || !shot || points.length < 2) { + setTrajectoryDrawing(false); + if (points.length >= 2 && !trajectoryTarget) message.info("轨迹已绘制,但还没有选中的演员或摄影机;请先选择目标再重试"); + return; + } + if (selectedActor) { + const y = selectedActor.transform.position[1]; + const path = [[selectedActor.transform.position[0], y, selectedActor.transform.position[2]] as PrevisVec3, ...points.map((point) => [point.x, y, point.z] as PrevisVec3)]; + const keyframes = buildPrevisPathKeyframes({ base: selectedActor.transform, points: path, duration: shot.duration, orientToPath: true }); + commit((scene) => ({ ...scene, objects: scene.objects.map((object) => object.id === selectedActor.id ? { ...object, keyframes, motionPath: { points: path, speed: "walk", startDelay: 0, orientToPath: true } } : object) })); + message.success("演员轨迹已写入时间轴"); + } else if (selectedCamera) { + const y = selectedCamera.transform.position[1]; + const path = [[selectedCamera.transform.position[0], y, selectedCamera.transform.position[2]] as PrevisVec3, ...points.map((point) => [point.x, y, point.z] as PrevisVec3)]; + const keyframes = buildPrevisPathKeyframes({ base: selectedCamera.transform, points: path, duration: shot.duration }); + commit((scene) => ({ ...scene, cameras: scene.cameras.map((camera) => camera.id === selectedCamera.id ? { ...camera, keyframes, motionPath: { points: path, speed: "walk", startDelay: 0, orientToPath: false } } : camera) })); + message.success("摄影机运镜路径已写入时间轴"); + } + setTrajectoryDrawing(false); + }, [commit, message, selectedActor, selectedCamera, trajectoryTarget]); + + // ── 演员体型模板 ────────────────────────────────────────────────────────── + /** 每个模板:name 显示名、archetype、体型 overrides(可覆盖 previsActorProfileForArchetype 默认值)*/ + const ACTOR_TEMPLATES: Array<{ key: string; label: string; archetype: import("@/types/previs").PrevisActorArchetype; profileOverrides?: Partial; crowd?: boolean }> = [ + { key: "std-male", label: "标准男性", archetype: "man" }, + { key: "std-female", label: "标准女性", archetype: "woman" }, + { key: "muscular", label: "健硕", archetype: "man", profileOverrides: { shoulderWidth: 1.32, torsoRatio: 1.12, height: 1.06 } }, + { key: "slim", label: "纤细", archetype: "woman", profileOverrides: { shoulderWidth: 0.78, torsoRatio: 0.88, height: 0.98 } }, + { key: "teen", label: "少年", archetype: "child", profileOverrides: { height: 0.86, headRatio: 1.12 } }, + { key: "child", label: "儿童", archetype: "child" }, + { key: "stocky", label: "宽厚", archetype: "man", profileOverrides: { shoulderWidth: 1.18, torsoRatio: 1.24, height: 0.96 } }, + { key: "chibi", label: "二头身", archetype: "adult", profileOverrides: { height: 0.62, headRatio: 1.68 } }, + ]; + + const addActorFromTemplate = (template: typeof ACTOR_TEMPLATES[number]) => { + const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || []; + const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length; + const baseProfile = previsActorProfileForArchetype(template.archetype); + const actorProfile = template.profileOverrides ? { ...baseProfile, ...template.profileOverrides } : baseProfile; + const actor = { ...createPrevisActor(`${template.label} ${existingActors.length + 1}`, [0, 0, 0], PREVIS_ACTOR_COLORS[colorIndex], template.archetype), actorProfile }; + addObject(actor); + message.success(`已添加${template.label}`); + }; + + const addCrowdActors = () => { + const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || []; + const base = existingActors.length; + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + const idx = base + row * 3 + col; + const colorIndex = idx % PREVIS_ACTOR_COLORS.length; + const actor = createPrevisActor(`群众 ${idx + 1}`, [col * 1.1 - 1.1, 0, row * 1.1 - 1.1], PREVIS_ACTOR_COLORS[colorIndex]); + addObject(actor); + } + } + message.success("已添加 3×3 群众阵列"); + }; + + const actorTemplateMenuItems: MenuProps["items"] = [ + { key: "upload", icon: , label: "本地上传", onClick: () => modelInputRef.current?.click() }, + { type: "divider" }, + ...ACTOR_TEMPLATES.map((template) => ({ + key: template.key, + icon: , + label: template.label, + onClick: () => addActorFromTemplate(template), + })), + { type: "divider" }, + { key: "crowd", icon: , label: "群众 (3×3)", onClick: addCrowdActors }, + { + key: "geometry", + icon: , + label: "几何模型", + children: [ + { key: "geo-upload", icon: , label: "上传文件", onClick: () => modelInputRef.current?.click() }, + { key: "geo-box", icon: , label: "立方体", onClick: () => addPrimitive("box", "立方体") }, + { key: "geo-sphere", icon: , label: "球体", onClick: () => addPrimitive("sphere", "球体") }, + { key: "geo-cyl", icon: , label: "圆柱体", onClick: () => addPrimitive("cylinder", "圆柱体") }, + { key: "geo-torus", icon: , label: "环状体", onClick: () => addPrimitive("sphere", "环状体") }, + { key: "geo-cone", icon: , label: "圆锥", onClick: () => addPrimitive("plane", "圆锥") }, + { key: "geo-plane", icon: , label: "平面", onClick: () => addPrimitive("plane", "平面") }, + { key: "geo-null", icon: , label: "添加空对象", onClick: () => addPrimitive("box", "空对象") }, + ], + }, + ]; + + // ── 机位预设 ──────────────────────────────────────────────────────────────── + type CameraPreset = { + key: string; + label: string; + position: [number, number, number]; + target: [number, number, number]; + fov: number; + isCurrentView?: boolean; + }; + const CAMERA_PRESETS: CameraPreset[] = [ + { key: "current", label: "当前视角", position: [4.8, 2.7, 6.8], target: [0, 1, 0], fov: 50, isCurrentView: true }, + { key: "front-medium", label: "正面中景", position: [0, 1.4, 4.0], target: [0, 1, 0], fov: 50 }, + { key: "front-cu", label: "正面特写", position: [0, 1.65, 1.8], target: [0, 1.6, 0], fov: 40 }, + { key: "front-wide", label: "正面全景", position: [0, 2.0, 7.5], target: [0, 1, 0], fov: 60 }, + { key: "side-follow", label: "侧面跟拍", position: [3.8, 1.4, 0 ], target: [0, 1, 0], fov: 50 }, + { key: "side-cu", label: "侧面近景", position: [2.0, 1.65, 0 ], target: [0, 1.6, 0], fov: 45 }, + { key: "back-medium", label: "背面中景", position: [0, 1.4, -4.0], target: [0, 1, 0], fov: 50 }, + { key: "bird-wide", label: "俯拍全景", position: [0, 6.5, 3.5], target: [0, 0, 0], fov: 65 }, + { key: "angle-45", label: "45° 俯拍", position: [3.0, 4.5, 4.5], target: [0, 0.8, 0], fov: 55 }, + { key: "low-uptilt", label: "低角度仰拍", position: [0, 0.3, 3.5], target: [0, 2.2, 0], fov: 55 }, + { key: "low-wide", label: "低角度广角", position: [0, 0.5, 4.5], target: [0, 1.2, 0], fov: 75 }, + { key: "ots-left", label: "过肩镜头", position: [-1.1, 1.75, 2.2], target: [0.6, 1.65, 0], fov: 50 }, + { key: "ots-right", label: "过肩镜头(右)",position: [1.1, 1.75, 2.2], target: [-0.6, 1.65, 0], fov: 50 }, + { key: "birds-eye", label: "鸟瞰", position: [0, 9.0, 0.1], target: [0, 0, 0], fov: 70 }, + { key: "dutch", label: "荷兰角", position: [2.8, 1.7, 4.0], target: [0, 1.2, 0], fov: 52 }, + ]; + + const addCameraPreset = (preset: CameraPreset) => { + if (preset.isCurrentView) { + const transform = viewportRef.current?.readCameraTransform(); + if (transform) { + const cam = { ...createPrevisCamera(`机位 ${(draft?.cameras.length || 0) + 1}`), transform, target: preset.target }; + commit((current) => ({ ...current, cameras: [...current.cameras, cam] })); + if (activeShot) updateShot(activeShot.id, { cameraId: cam.id }); + setViewMode("camera"); + message.success("已从当前视角创建机位"); + } else { + addCamera(); + message.info("已添加默认机位"); + } + return; + } + const cam = { + ...createPrevisCamera(`机位 ${(draft?.cameras.length || 0) + 1}`), + transform: { position: preset.position, rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] }, + target: preset.target, + fov: preset.fov, + }; + commit((current) => ({ ...current, cameras: [...current.cameras, cam] })); + if (activeShot) updateShot(activeShot.id, { cameraId: cam.id }); + setViewMode("camera"); + message.success(`已添加「${preset.label}」机位`); + }; + + const cameraPresetMenuItems: MenuProps["items"] = CAMERA_PRESETS.map((preset) => ({ + key: preset.key, + icon: , + label: preset.label, + onClick: () => addCameraPreset(preset), + })); + + // ── 道具库 (语义化尺寸,单位米) ───────────────────────────────────────── + const PROP_LIBRARY: Array<{ label: string; items: Array<{ name: string; size: string; primitive: "box" | "sphere" | "cylinder" | "plane"; scale: [number, number, number] }> }> = [ + { label: "家具", items: [ + { name: "单人沙发", size: "0.9×0.85×0.85m", primitive: "box", scale: [0.9, 0.85, 0.85] }, + { name: "三人沙发", size: "2.2×0.85×0.85m", primitive: "box", scale: [2.2, 0.85, 0.85] }, + { name: "餐桌", size: "1.6×0.75×0.9m", primitive: "box", scale: [1.6, 0.75, 0.9] }, + { name: "茶几", size: "1.0×0.45×0.6m", primitive: "box", scale: [1.0, 0.45, 0.6] }, + { name: "凳子", size: "0.4×0.45×0.4m", primitive: "box", scale: [0.4, 0.45, 0.4] }, + { name: "双人床", size: "1.8×0.5×2.1m", primitive: "box", scale: [1.8, 0.5, 2.1] }, + ]}, + { label: "空间结构", items: [ + { name: "墙板", size: "3×2.8×0.2m", primitive: "box", scale: [3, 2.8, 0.2] }, + { name: "门", size: "0.9×2.1×0.1m", primitive: "box", scale: [0.9, 2.1, 0.1] }, + { name: "窗框", size: "1.2×1.4×0.1m", primitive: "box", scale: [1.2, 1.4, 0.1] }, + { name: "地面板", size: "4×0.1×4m", primitive: "box", scale: [4, 0.1, 4] }, + { name: "台阶", size: "1×0.2×0.3m", primitive: "box", scale: [1, 0.2, 0.3] }, + ]}, + { label: "自然/外景", items: [ + { name: "山体", size: "8×4×6m", primitive: "box", scale: [8, 4, 6] }, + { name: "岩石", size: "1.5×1×1.2m", primitive: "sphere", scale: [1.5, 1, 1.2] }, + { name: "树干", size: "0.3×3×0.3m", primitive: "cylinder", scale: [0.3, 3, 0.3] }, + { name: "地面背板", size: "6×0.05×6m", primitive: "plane", scale: [6, 0.05, 6] }, + ]}, + { label: "道具", items: [ + { name: "汽车轮廓", size: "4.5×1.5×2m", primitive: "box", scale: [4.5, 1.5, 2] }, + { name: "柱子", size: "0.4×3×0.4m", primitive: "cylinder", scale: [0.4, 3, 0.4] }, + { name: "箱子", size: "0.6×0.6×0.6m", primitive: "box", scale: [0.6, 0.6, 0.6] }, + { name: "球体道具", size: "0.5m直径", primitive: "sphere", scale: [0.5, 0.5, 0.5] }, + ]}, + ]; + + const enterAnimationMode = useCallback(() => { + stagedTransaction.end("commit"); + if (selectedActor) { + setSelectedObjectId(selectedActor.id); + setSelectedLightId(null); + setSelectedBone(null); + } + setPlaying(false); + setSequencerVisible(true); + }, [selectedActor, setPlaying, setSelectedBone, setSelectedLightId, setSelectedObjectId, setSequencerVisible, stagedTransaction]); + + useEffect(() => { + const onPreviewRequested = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!isPrevisPreviewRequestForWorkbench({ request: detail, canvasId: effectiveCanvasId, sceneId: draft?.id, activeShotId: activeShot?.id })) return; + const requestKey = previsPreviewRequestKey(detail); + if (handledPreviewRequestsRef.current.has(requestKey)) return; + // 标记发生在 recording 判断之前:录制期间的重复/并发事件也不能在录制结束后再次执行。 + handledPreviewRequestsRef.current.add(requestKey); + if (handledPreviewRequestsRef.current.size > 64) { + const oldest = handledPreviewRequestsRef.current.values().next().value; + if (typeof oldest === "string") handledPreviewRequestsRef.current.delete(oldest); + } + if (recording) return; + void exportClayVideo(); + }; + window.addEventListener("previs:preview-requested", onPreviewRequested); + return () => window.removeEventListener("previs:preview-requested", onPreviewRequested); + }, [activeShot?.id, draft?.id, effectiveCanvasId, exportClayVideo]); + + if (!open || !draft || !activeShot) return null; + + const saveStatusClass = saveIndicator.tone === "danger" ? "is-danger" : saveIndicator.busy ? "is-busy" : ""; + // 位于 early return 之后,不能使用 Hook;每次渲染直接派生主题变量。 + const workbenchThemeStyle = { + "--pd-s0": theme.canvas.background, + "--pd-s1": theme.toolbar.panel, + "--pd-s2": theme.toolbar.activeBg, + "--pd-s3": theme.toolbar.itemHover, + "--pd-s4": theme.toolbar.activeBg, + "--pd-s5": theme.spatial.elevated, + "--pd-b0": theme.toolbar.border, + "--pd-b1": theme.node.stroke, + "--pd-b2": theme.node.activeStroke, + "--pd-t0": theme.node.text, + "--pd-t1": theme.node.muted, + "--pd-t2": theme.node.muted, + "--pd-t3": theme.node.faint, + "--pd-a0": theme.accent.primary, + "--pd-a1": theme.accent.primary, + "--pd-a-bg": theme.accent.primarySoft, + "--pd-a-bd": theme.node.activeStroke, + "--pd-a-on": theme.accent.onPrimary, + "--pd-accent-rgb": theme.accent.primary === "#171717" || theme.accent.primary === "#f5f5f5" ? "255 255 255" : "79 140 255", + "--previs-accent": theme.accent.primary, + "--pd-a-on-theme": theme.accent.onPrimary, + "--pd-a-bg-theme": theme.accent.primarySoft, + "--pd-a-bd-theme": theme.node.activeStroke, + } as CSSProperties; + + return ( +
+
+ +
+ 影策 + 预演台 + AI 短剧预演 +
+ + replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} /> +
+ + +
+
+ + {selectedActor ? `角色 · ${selectedActor.name}` : selectedLight ? `灯光 · ${selectedLight.name}` : selectedCameraId ? `机位 · ${selectedCamera?.name || activeCamera?.name || "摄影机"}` : `镜头 · ${activeShot.name}`} +
+
+ {saveIndicator.label} + {saveIndicator.retryable ? : null} + {onboardingScope ? : null} + setSceneQuery(event.target.value)} placeholder="搜索对象、机位、灯光…" /> + {sceneQuery ? : null} + + + {visibleSceneActors.length > 0 ? ( +
+
角色 {visibleSceneActors.length}{visibleSceneActors.length !== sceneActors.length ? `/${sceneActors.length}` : ""}
+ {visibleSceneActors.map((actor) => { + const isSelected = selectedActor?.id === actor.id; + const color = resolvePrevisActorColor(actor.color); + return ( +
+ + +
+ ); + })} +
+ ) : ( +
+ + 还没有角色 + + + +
+ )} + +
+
+ 机位 {visibleCameras.length}{visibleCameras.length !== draft.cameras.length ? `/${draft.cameras.length}` : ""} +
+ {visibleCameras.map((camera) => { + const isViewed = (selectedCameraId || activeShot?.cameraId) === camera.id; + const isActiveShotCamera = activeShot?.cameraId === camera.id; + return ( +
+ + {draft.cameras.length > 1 ? ( + + ) : null} +
+ ); + })} +
+ + {visibleSceneProps.length > 0 ? ( +
+
道具 {visibleSceneProps.length}{visibleSceneProps.length !== sceneProps.length ? `/${sceneProps.length}` : ""}
+ {visibleSceneProps.map((obj) => ( +
+ + +
+ ))} +
+ ) : null} + +
+
道具库
+
+ {PROP_LIBRARY.map((cat) => ( +
+
{cat.label}
+
+ {cat.items.map((item) => ( + + ))} +
+
+ ))} +
+
+ +
+
+ + {modelAssets.length ? ( +
+
已上传模型 {modelAssets.length}
+ {modelAssets.map((asset) => ( + + ))} +
+ ) : null} + + {imageNodes.length > 0 ? ( +
+
背景图片
+
+ {imageNodes.slice(0, 4).map((node) => { + const linked = draft.objects.find((o) => o.sourceNodeId === node.id); + const isPanorama = draft.environment?.sourceNodeId === node.id; + return ( +
+ +
+ + {linked ? + : } +
+
+ ); + })} +
+ {draft.environment?.mode === "panorama" ? ( +
360° 全景已启用
+ ) : null} +
+ ) : null} + +
+
灯光 {visibleLights.length}{visibleLights.length !== draft.lights.length ? `/${draft.lights.length}` : ""}
+ {visibleLights.map((light) => ( +
+ + +
+ ))} +
+ + + {/* CENTER: Single viewport with contextual tool layer */} +
+ object.keyframes.length > 1) || draft.cameras.some((camera) => camera.keyframes.length > 1)} + trajectoryDrawing={trajectoryDrawing} + onTrajectoryComplete={handleTrajectoryComplete} + viewMode={viewMode} + onSelectObject={setSelectedObjectId} + onSelectBone={setSelectedBone} + onGroundClick={handleGroundClick} + onObjectTransform={handleObjectTransform} + onBoneTransform={handleBoneTransform} + onActorRigReady={handleActorRigReady} + selectedCameraId={selectedCameraId} + onSelectCamera={(id) => { setSelectedCameraId(id); setSelectedObjectId(null); setSelectedLightId(null); setViewMode("camera"); }} + onCameraTransform={handleCameraTransform} + showModelLoadNotice + showNavigation={false} + /> + {viewMode === "camera" && (selectedCamera || activeCamera) ? ( +
+ + {(selectedCamera || activeCamera)?.name} + {(selectedCamera || activeCamera)?.focalLength}mm +
+ ) : null} + {trajectoryDrawing ?
{trajectoryTarget ? <>正在为「{trajectoryTarget.name}」绘制{trajectoryTarget.kind === "camera" ? "运镜路径" : "走位"}按住地面拖动,松开完成 · 再点按钮取消 : <>请先选择演员或摄影机左侧列表选择目标后,再按住地面拖动绘制轨迹}
: null} + setTrajectoryDrawing((active) => !active)} + viewMode={viewMode} + onViewModeChange={setViewMode} + cameras={draft.cameras} + viewedCameraId={selectedCameraId || activeShot.cameraId || null} + onViewCamera={(id) => { setSelectedCameraId(id); setSelectedObjectId(null); setSelectedLightId(null); setViewMode("camera"); }} + onFocusSelected={() => viewportRef.current?.focusSelected()} + onFrameScene={() => viewportRef.current?.frameScene()} + onZoom={(factor) => viewportRef.current?.zoom(factor)} + timelineOpen={sequencerVisible} + onOpenTimeline={enterAnimationMode} + scenePanelOpen={compactLayout ? scenePanelOpen : sceneDocked} + onToggleScenePanel={() => compactLayout ? setScenePanelOpen((value) => !value) : setSceneDocked((value) => !value)} + inspectorOpen={compactLayout ? inspectorOpen : inspectorDocked} + onToggleInspector={() => compactLayout ? setInspectorOpen((value) => !value) : setInspectorDocked((value) => !value)} + /> +
+ + {/* RIGHT: Shot settings + output */} +