From 482d649f8493a7bc8ff0fb1f9d79603afb2faec6 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Sat, 19 Sep 2026 02:43:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=97=B6=E9=97=B4=E8=BD=B4):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E6=9C=BA=E4=BD=8D=E8=87=AA=E5=8A=A8=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=B8=8E=E5=85=B3=E9=94=AE=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 getActiveCameraIdAtTime,按时间轴关键帧自动选择机位 - 导出与播放时预览按切点自动切机位,保持一致 - 时间轴工具栏新增当前机位选择器 - 无选中物体时给当前机位打关键帧,支持多机位独立打点 --- src/editor/io/videoExport.ts | 57 ++++--- src/editor/panels/TimelinePanel.test.tsx | 38 ++++- src/editor/panels/TimelinePanel.tsx | 155 +++++++++++++++--- .../runtime/ActiveCameraPlaybackSync.tsx | 61 ++++++- src/editor/runtime/playbackStore.ts | 5 + src/editor/runtime/playbackTransforms.test.ts | 51 ++++++ src/editor/schema/timeline.ts | 32 ++++ src/styles/index.css | 119 ++++++++++++++ 8 files changed, 464 insertions(+), 54 deletions(-) diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index 81e22f3..0c4f057 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -8,7 +8,7 @@ import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from ".. import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry"; import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; -import { normalizeTimelineFps } from "../schema/timeline"; +import { getActiveCameraIdAtTime, normalizeTimelineFps } from "../schema/timeline"; export type VideoExportView = "director" | "camera"; @@ -182,18 +182,9 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { // 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。 await waitNextFrame(); - // 切完视角再取当前机位:setViewMode("camera") 会在 activeCameraId 为空时补上第一个机位, - // 所以不能用上面的 directorStore 快照(那时可能还是 null)。 - const exportState = useDirectorStore.getState(); - const activeCameraShot = - view === "camera" - ? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null - : null; - // 机位线框 / 三轴 gizmo 都在场景组里,导出相机也要落在世界坐标(场景变换之内), - // 否则场景一缩放 / 旋转,导出画面里机位所在的世界位置和编辑器里看到的对不上。 - const activeCameraView = activeCameraShot - ? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(activeCameraShot), exportState.project.scene) - : null; + // 切完视角无需固定单一机位:机位视角下每帧按关键帧时间智能切机位 + // (getActiveCameraIdAtTime 取「≤ 当前时刻最近关键帧」所属机位),导出画面随切点自动切换。 + // 因此下面循环里逐帧计算「该帧应显示的机位」,而不在循环外写死。 const frameDurationUs = Math.round(1_000_000 / fps); try { @@ -218,11 +209,21 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { usePlaybackStore.getState().editingObjectId, { objects: directorStore.project.objects, duration } ); + // 机位视角下逐帧决定「该帧显示哪个机位」:取所有相机轨道里「≤ 当前时刻最近关键帧」 + // 时间最大者所属机位;没有任何机位在 t 之前打过关键帧时退回当前激活机位。 + const displayCameraId = + view === "camera" + ? getActiveCameraIdAtTime( + directorStore.project.timeline.cameraTracks, + directorStore.project.activeCameraId, + time + ) + : null; // 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机 const appliedCameraTrack = applyActiveCameraViewAtTime({ camera: frameCamera, cameraTracks: directorStore.project.timeline.cameraTracks, - activeCameraId: exportState.project.activeCameraId, + activeCameraId: displayCameraId, playhead: time, viewMode: view, editingObjectId: usePlaybackStore.getState().editingObjectId, @@ -230,8 +231,18 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { // 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机, // 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。 - if (!appliedCameraTrack && activeCameraView) { - applyCameraSnapshotToFrameCamera(frameCamera, activeCameraView); + if (!appliedCameraTrack && displayCameraId) { + const shot = + directorStore.project.cameras.find((item) => item.id === displayCameraId) ?? null; + if (shot) { + // 机位线框 / 三轴 gizmo 都在场景组里,导出相机也要落在世界坐标(场景变换之内), + // 否则场景一缩放 / 旋转,导出画面里机位所在的世界位置和编辑器里看到的对不上。 + const worldView = getWorldCameraViewSnapshot( + getCameraViewSnapshotFromShot(shot), + directorStore.project.scene + ); + applyCameraSnapshotToFrameCamera(frameCamera, worldView); + } } // 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来, @@ -375,8 +386,11 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise { - const previousLoop = usePlaybackStore.getState().loop; - usePlaybackStore.getState().setLoop(false); + const playback = usePlaybackStore.getState(); + const previousLoop = playback.loop; + playback.setLoop(false); + // 标记正在导出:机位视角下让屏幕预览按时间轴自动切机位,和成片一致(导出时并未播放) + playback.setExporting(true); try { if (typeof VideoEncoder !== "undefined") { @@ -385,8 +399,9 @@ export async function exportVideo(view: VideoExportView): Promise { } await exportVideoWithMediaRecorder(view); } finally { - const playback = usePlaybackStore.getState(); - playback.setLoop(previousLoop); - playback.setPlaying(false); + const restored = usePlaybackStore.getState(); + restored.setExporting(false); + restored.setLoop(previousLoop); + restored.setPlaying(false); } } diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx index a7e55f5..615ad8d 100644 --- a/src/editor/panels/TimelinePanel.test.tsx +++ b/src/editor/panels/TimelinePanel.test.tsx @@ -209,9 +209,43 @@ it("toggles to pause when play button clicked", () => { expect(screen.getByRole("button", { name: "暂停" })).toBeInTheDocument(); }); -it("disables add-keyframe button when no object is selected", () => { +it("adds a camera keyframe for the active camera when no object is selected", () => { + useDirectorStore.getState().selectObject(null); + const activeCameraId = useDirectorStore.getState().project.activeCameraId!; + usePlaybackStore.getState().setPlayhead(1); + render(); - expect(screen.getByRole("button", { name: /在指针处打关键帧/ })).toBeDisabled(); + const addButton = screen.getByRole("button", { name: /在指针处打关键帧/ }); + expect(addButton).toBeEnabled(); + fireEvent.click(addButton); + + const track = useDirectorStore + .getState() + .project.timeline.cameraTracks.find((t) => t.cameraId === activeCameraId); + expect(track?.keyframes).toHaveLength(1); +}); + +it("adds camera keyframes for multiple cameras independently", () => { + const secondId = useDirectorStore.getState().addCameraShot(); + // 新增机位会被设为当前激活机位 + expect(useDirectorStore.getState().project.activeCameraId).toBe(secondId); + usePlaybackStore.getState().setPlayhead(2); + + render(); + // 给第二个机位打关键帧 + fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); + + // 切回第一个机位再打一个关键帧 + const firstId = useDirectorStore.getState().project.cameras[0].id; + act(() => { + useDirectorStore.getState().setActiveCamera(firstId); + usePlaybackStore.getState().setPlayhead(3); + }); + fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); + + const tracks = useDirectorStore.getState().project.timeline.cameraTracks; + expect(tracks.find((t) => t.cameraId === secondId)?.keyframes).toHaveLength(1); + expect(tracks.find((t) => t.cameraId === firstId)?.keyframes).toHaveLength(1); }); it("adds an object keyframe for the selected object at the playhead", () => { diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index aa3f653..465d5da 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -8,6 +8,7 @@ import type { import { Box, Camera, + Check, ChevronDown, ChevronRight, ChevronUp, @@ -46,7 +47,7 @@ import { type CameraKeyframeValue, type KeyframeTransformValue, } from "../schema/timeline"; -import type { MotionClip, MotionKind } from "../schema/directorProject"; +import type { DirectorCameraShot, MotionClip, MotionKind } from "../schema/directorProject"; import { TRAJECTORY_POINT_MIN_GAP, getMotionEndTime, @@ -512,15 +513,16 @@ export function TimelinePanel() { const timeline = project.timeline; - // 选中目标解析:物体 or 相机 + // 选中目标解析:物体 / 相机 const selectedObject = project.objects.find((o) => o.id === selectedObjectId) ?? null; - const selectedCameraObject = - selectedObject?.kind === "camera" ? selectedObject : null; - const selectedShot = selectedCameraObject?.linkedCameraId - ? project.cameras.find((c) => c.id === selectedCameraObject.linkedCameraId) ?? null - : null; - const canAddCameraKeyframe = Boolean(selectedShot && selectedShot.targetMode === "manual"); - const selectionIsManualCamera = Boolean(selectedCameraObject); + const activeCamera = + project.activeCameraId + ? project.cameras.find((c) => c.id === project.activeCameraId) ?? null + : null; + // 打关键帧时:选中非相机物体 -> 给该物体打位姿关键帧; + // 否则(选中相机物体 / 没选中任何物体)-> 给「当前机位(activeCameraId)」打运镜关键帧。 + const addTargetsCamera = Boolean(activeCamera) && (!selectedObject || selectedObject.kind === "camera"); + const canAddCameraKeyframe = Boolean(activeCamera && activeCamera.targetMode === "manual"); function snapshotObjectTransform(): KeyframeTransformValue { if (!selectedObject) throw new Error("no selected object"); @@ -530,19 +532,18 @@ export function TimelinePanel() { scale: [...selectedObject.transform.scale] as [number, number, number], }; } - function snapshotShot(): CameraKeyframeValue { - if (!selectedShot) throw new Error("no selected shot"); - return { - position: [...selectedShot.transform.position] as [number, number, number], - lookRotation: [...selectedShot.lookRotation] as [number, number, number], - fov: selectedShot.fov, - }; - } function handleAddKeyframe() { - if (selectionIsManualCamera) { - if (!selectedShot || selectedShot.targetMode !== "manual") return; - addCameraKeyframe(selectedShot.id, playhead, snapshotShot()); + if (addTargetsCamera) { + if (!activeCamera || activeCamera.targetMode !== "manual") return; + // 直接按「当前机位」打关键帧:运镜关键帧始终属于 activeCameraId, + // 与选中了哪个物体无关,这样多个机位都能各自打关键帧 + // (旧逻辑只认「选中物体关联的机位」,第二个机位往往因为没选中其物体而打不上)。 + addCameraKeyframe(activeCamera.id, playhead, { + position: [...activeCamera.transform.position] as [number, number, number], + lookRotation: [...activeCamera.lookRotation] as [number, number, number], + fov: activeCamera.fov, + }); } else if (selectedObject && selectedObject.kind !== "character") { // 角色由轨迹(trajectory)驱动,不占 transform 关键帧轨道, // 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。 @@ -1469,10 +1470,10 @@ export function TimelinePanel() { togglePlaying(); } - const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject; - const addKeyframeTitle = selectionIsManualCamera + const addKeyframeDisabled = addTargetsCamera ? !canAddCameraKeyframe : !selectedObject; + const addKeyframeTitle = addTargetsCamera ? canAddCameraKeyframe - ? `把相机「${selectedShot?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧(快捷键 K)` + ? `把相机「${activeCamera?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧(快捷键 K)` : "锁定物体的相机(targetMode=object)不支持运镜关键帧" : selectedObject ? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧(快捷键 K)` @@ -1557,6 +1558,15 @@ export function TimelinePanel() { +
+
@@ -2330,3 +2340,102 @@ export function TimelinePanel() { ); } + +/** + * 时间轴工具栏的「当前机位」选择器:自定义下拉(非原生