feat(时间轴): 支持多机位自动切换与关键帧

- 新增 getActiveCameraIdAtTime,按时间轴关键帧自动选择机位
- 导出与播放时预览按切点自动切机位,保持一致
- 时间轴工具栏新增当前机位选择器
- 无选中物体时给当前机位打关键帧,支持多机位独立打点
This commit is contained in:
2026-09-19 02:43:47 +08:00
parent 2015d8d3c3
commit 482d649f84
8 changed files with 464 additions and 54 deletions

View File

@@ -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<void> {
// 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。
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<void> {
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<void> {
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,
// 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。
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<void
* 只把指针放回导出前的位置,循环设置原样还回去。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
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<void> {
}
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);
}
}