feat: 优化时间轴

This commit is contained in:
tongtong committed 2026-09-18 00:52:28 +08:00
1 parent e66e3374bf
commit 8645ea3007
19 files changed
+1538 -391

No files matched your search

+60 -6
View File
@@ -1,11 +1,12 @@
import { flushSync } from "react-dom";
import { ArrayBufferTarget, Muxer } from "mp4-muxer";
import { Vector2 } from "three";
import { Vector2, type PerspectiveCamera } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getVideoExportContext } from "./videoExportBridge";
import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getCameraViewSnapshotFromShot, type CameraViewSnapshot } from "../schema/cameraGeometry";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { normalizeTimelineFps } from "../schema/timeline";
@@ -55,6 +56,23 @@ function waitNextFrame(): Promise<void> {
);
}
/**
* 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。
*
* 机位视角下 applyActiveCameraViewAtTime 只在有运镜轨道时才覆盖相机(无轨道直接 return false),
* 也就是说无轨道时导出画面完全取决于「此刻 R3F 的默认相机是谁」。默认相机本来应该是
* 机位视角挂载的那颗 drei 相机,但换相机要等 React/R3F 走一轮,稍有差池就会渲成
* 导演视角(默认更高、更远)—— 所以这里不再赌它,直接按机位自身的视角写死。
*/
function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: CameraViewSnapshot) {
camera.position.set(view.position[0], view.position[1], view.position[2]);
camera.lookAt(view.target[0], view.target[1], view.target[2]);
if (Math.abs(camera.fov - view.fov) > 0.001) {
camera.fov = view.fov;
}
camera.updateProjectionMatrix();
}
/**
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
@@ -131,6 +149,10 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
gl.getSize(previousSize);
const previousPixelRatio = gl.getPixelRatio();
const previousAspect = camera.aspect;
// 兜底改写相机会动到位姿,导出结束要还回去(切回导演视角后不该变成机位的视角)
const previousCameraPosition = camera.position.clone();
const previousCameraQuaternion = camera.quaternion.clone();
const previousCameraFov = camera.fov;
const muxer = new Muxer({
target: new ArrayBufferTarget(),
@@ -155,9 +177,19 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
camera.aspect = width / height;
camera.updateProjectionMatrix();
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera),
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera)。
// 必须等一整帧:换相机要 React 重渲染 + R3F 换默认相机 + VideoExportBridge 重新注册,
// 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。
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;
const activeCameraView = activeCameraShot ? getCameraViewSnapshotFromShot(activeCameraShot) : null;
const frameDurationUs = Math.round(1_000_000 / fps);
try {
@@ -182,15 +214,22 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
usePlaybackStore.getState().editingObjectId,
{ objects: directorStore.project.objects, duration }
);
applyActiveCameraViewAtTime({
// 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机
const appliedCameraTrack = applyActiveCameraViewAtTime({
camera: frameCamera,
cameraTracks: directorStore.project.timeline.cameraTracks,
activeCameraId: directorStore.project.activeCameraId,
activeCameraId: exportState.project.activeCameraId,
playhead: time,
viewMode: view,
editingObjectId: usePlaybackStore.getState().editingObjectId,
});
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,
// 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。
if (!appliedCameraTrack && activeCameraView) {
applyCameraSnapshotToFrameCamera(frameCamera, activeCameraView);
}
// 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来,
// 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。
withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera));
@@ -222,9 +261,24 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
canvas.classList.remove("is-video-exporting");
gl.setPixelRatio(previousPixelRatio);
gl.setSize(previousSize.x, previousSize.y, false);
// 导出期间相机可能换过(切视角 → drei 挂载机位相机),两颗都按导出前的样子还原:
// - aspect 两颗都要还,否则切回导演视角后画面比例还停在导出的 16:9;
// - 位姿只在「导出用的就是导出前那颗相机」时还,否则等于把导演相机的位姿
// 写到机位相机上,机位视角就会被带歪。
const restoreCamera = getVideoExportContext()?.camera ?? camera;
restoreCamera.aspect = previousAspect;
restoreCamera.updateProjectionMatrix();
camera.aspect = previousAspect;
camera.updateProjectionMatrix();
if (restoreCamera === camera) {
restoreCamera.position.copy(previousCameraPosition);
restoreCamera.quaternion.copy(previousCameraQuaternion);
restoreCamera.fov = previousCameraFov;
restoreCamera.updateProjectionMatrix();
}
directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}