feat(时间轴): 支持多机位自动切换与关键帧
- 新增 getActiveCameraIdAtTime,按时间轴关键帧自动选择机位 - 导出与播放时预览按切点自动切机位,保持一致 - 时间轴工具栏新增当前机位选择器 - 无选中物体时给当前机位打关键帧,支持多机位独立打点
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user