feat: 优化时间轴

This commit is contained in:
2026-09-18 00:52:28 +08:00
parent e66e3374bf
commit 8645ea3007
19 changed files with 1539 additions and 392 deletions

View File

@@ -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();