feat: 优化时间轴
This commit is contained in:
@@ -64,6 +64,173 @@ it("keeps the playhead where the export found it", async () => {
|
||||
expect(usePlaybackStore.getState().playhead).toBe(2.5);
|
||||
});
|
||||
|
||||
/** 逐帧导出在 jsdom 里需要的几个全局桩(视口画布 / WebCodecs 都没有) */
|
||||
function stubVideoCodecs() {
|
||||
const VideoEncoderStub = class {
|
||||
static isConfigSupported = () => Promise.resolve({ supported: true });
|
||||
output = () => {};
|
||||
error = () => {};
|
||||
encodeQueueSize = 0;
|
||||
configure() {}
|
||||
encode() {}
|
||||
flush() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
const VideoFrameStub = class {
|
||||
close() {}
|
||||
};
|
||||
vi.stubGlobal("VideoEncoder", VideoEncoderStub);
|
||||
vi.stubGlobal("VideoFrame", VideoFrameStub);
|
||||
vi.stubGlobal("URL", {
|
||||
...URL,
|
||||
createObjectURL: () => "blob:fake",
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 造一套假的渲染上下文,并记录「每一帧渲染瞬间」相机在哪儿、fov 多少 */
|
||||
function createFakeBridge() {
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera();
|
||||
const rendered: Array<{
|
||||
position: [number, number, number];
|
||||
quaternion: [number, number, number, number];
|
||||
fov: number;
|
||||
}> = [];
|
||||
const gl = {
|
||||
domElement: document.createElement("canvas"),
|
||||
render: vi.fn(() => {
|
||||
rendered.push({
|
||||
position: [camera.position.x, camera.position.y, camera.position.z],
|
||||
quaternion: [camera.quaternion.x, camera.quaternion.y, camera.quaternion.z, camera.quaternion.w],
|
||||
fov: camera.fov,
|
||||
});
|
||||
}),
|
||||
getSize: (size: Vector2) => size.set(300, 150),
|
||||
getPixelRatio: () => 1,
|
||||
setPixelRatio: () => {},
|
||||
setSize: () => {},
|
||||
};
|
||||
|
||||
contextRef.current = { gl, scene, camera };
|
||||
|
||||
return { camera, gl, scene, rendered };
|
||||
}
|
||||
|
||||
/** 给默认机位换一个明显区别于导演视角的位姿(更高 / 更远 / 更窄) */
|
||||
function placeCameraShotAt(position: [number, number, number], target: [number, number, number], fov: number) {
|
||||
const store = useDirectorStore.getState();
|
||||
const shot = store.project.cameras[0];
|
||||
store.updateCamera(shot.id, {
|
||||
transform: { position, rotation: [0, 0, 0], scale: [1, 1, 1] },
|
||||
target,
|
||||
fov,
|
||||
});
|
||||
store.setActiveCamera(shot.id);
|
||||
// 短时长,缩小导出帧数,加速测试
|
||||
store.setTimelineDuration(0.1);
|
||||
|
||||
return shot.id;
|
||||
}
|
||||
|
||||
it("renders camera-view exports from the camera shot itself when it has no camera track", async () => {
|
||||
const shotId = placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
const bridge = createFakeBridge();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("camera");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 没有运镜轨道时 applyActiveCameraViewAtTime 不会动相机,导出必须自己把机位视角写进去,
|
||||
// 否则画面取决于「此刻 R3F 默认相机是谁」,换相机没及时落地就渲成导演视角(更高、更远)。
|
||||
const expected = new PerspectiveCamera();
|
||||
expected.position.set(0, 3, 9);
|
||||
expected.lookAt(0, 1.2, 0);
|
||||
|
||||
expect(bridge.rendered.length).toBeGreaterThan(0);
|
||||
for (const frame of bridge.rendered) {
|
||||
expect(frame.position[0]).toBeCloseTo(0, 5);
|
||||
expect(frame.position[1]).toBeCloseTo(3, 5);
|
||||
expect(frame.position[2]).toBeCloseTo(9, 5);
|
||||
expect(frame.fov).toBeCloseTo(35, 5);
|
||||
expect(frame.quaternion[0]).toBeCloseTo(expected.quaternion.x, 5);
|
||||
expect(frame.quaternion[1]).toBeCloseTo(expected.quaternion.y, 5);
|
||||
expect(frame.quaternion[2]).toBeCloseTo(expected.quaternion.z, 5);
|
||||
expect(frame.quaternion[3]).toBeCloseTo(expected.quaternion.w, 5);
|
||||
}
|
||||
// 机位自己仍在,没被导出改掉
|
||||
expect(useDirectorStore.getState().project.activeCameraId).toBe(shotId);
|
||||
});
|
||||
|
||||
it("lets the camera track win over the static shot pose in camera-view exports", async () => {
|
||||
const shotId = placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
// 给它加一条运镜轨道:0s 处机位在 [5, 1, 0],和机位自身的 [0, 3, 9] 明显不同
|
||||
useDirectorStore.getState().addCameraKeyframe(shotId, 0, {
|
||||
position: [5, 1, 0],
|
||||
target: [0, 1, 0],
|
||||
fov: 40,
|
||||
});
|
||||
const bridge = createFakeBridge();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("camera");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 有轨道时走的是预览同款插值,导出不该再用静态机位覆盖它
|
||||
expect(bridge.rendered.length).toBeGreaterThan(0);
|
||||
for (const frame of bridge.rendered) {
|
||||
expect(frame.position[0]).toBeCloseTo(5, 5);
|
||||
expect(frame.position[1]).toBeCloseTo(1, 5);
|
||||
expect(frame.position[2]).toBeCloseTo(0, 5);
|
||||
expect(frame.fov).toBeCloseTo(40, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not move the render camera to the shot when exporting the director view", async () => {
|
||||
placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
const bridge = createFakeBridge();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("director");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 上帝视角导出就该用导演相机,不能被机位自身的视角带跑
|
||||
expect(bridge.rendered.length).toBeGreaterThan(0);
|
||||
expect(bridge.rendered.every((frame) => frame.position.every((value) => value === 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("puts the render camera back where it was after a camera-view export", async () => {
|
||||
placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
const bridge = createFakeBridge();
|
||||
bridge.camera.position.set(1, 2, 3);
|
||||
bridge.camera.lookAt(0, 0, 0);
|
||||
bridge.camera.fov = 60;
|
||||
const savedQuaternion = bridge.camera.quaternion.clone();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("camera");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 导出期间为了「所见即所得」改写了相机,结束必须还原,否则切回导演视角会变成机位的视角
|
||||
expect(bridge.camera.position.toArray()).toEqual([1, 2, 3]);
|
||||
expect(bridge.camera.fov).toBe(60);
|
||||
expect(bridge.camera.quaternion.equals(savedQuaternion)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides editor helpers (gizmo / grid / axes) while rendering exported frames, then restores them", async () => {
|
||||
// 模拟场景里有一个「导出时隐藏」的辅助物体(也就是选中对象拖拽 gizmo 的那三条红绿蓝轴)
|
||||
const scene = new Scene();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user