import { beforeEach, expect, it, vi } from "vitest"; import { Object3D, PerspectiveCamera, Scene, Vector2 } from "three"; import { usePlaybackStore } from "../runtime/playbackStore"; import { useDirectorStore } from "../store/directorStore"; import { getCameraLookRotationFromTarget, resolveLens } from "../schema/cameraGeometry"; import { exportVideo } from "./videoExport"; vi.mock("mp4-muxer", () => ({ Muxer: class { target = { buffer: new ArrayBuffer(8) }; addVideoChunk() {} finalize() {} }, ArrayBufferTarget: class {}, })); const contextRef = vi.hoisted(() => ({ current: null as null | { gl: any; scene: Scene; camera: PerspectiveCamera }, })); vi.mock("./videoExportBridge", () => ({ getVideoExportContext: () => contextRef.current, })); // 相机视角导出会走 renderSceneWithBokeh(真实 EffectComposer / BokehPass 在 jsdom 下无 WebGL)。 // 桩里直接委托给 gl.render,既能避开 WebGL,又保留「记录每帧相机位姿」的测试意图 // (createFakeBridge 只在 gl.render 时把相机位姿写入 bridge.rendered)。 vi.mock("../canvas/viewportBokeh", () => ({ renderSceneWithBokeh: (gl: { render: (s: unknown, c: unknown) => void }, scene: unknown, camera: unknown) => gl.render(scene, camera), })); beforeEach(() => { usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null }); }); /** * jsdom 里既没有视口画布也没有 WebCodecs,导出会立刻失败 —— * 但这里要测的正是「导出期间 / 导出之后」的播放状态收口,与导出是否成功无关。 */ it("turns the playback loop off while exporting and puts it back afterwards", async () => { usePlaybackStore.getState().setLoop(true); const loopHistory: boolean[] = []; const unsubscribe = usePlaybackStore.subscribe((state) => loopHistory.push(state.loop)); await expect(exportVideo("director")).rejects.toThrow(); unsubscribe(); // 导出一开始就关掉循环:导出永远只播一遍,不会录到回卷的一段 expect(loopHistory[0]).toBe(false); // 导出结束后循环设置原样还回去 expect(usePlaybackStore.getState().loop).toBe(true); }); it("stops playback after an export instead of resuming an endless loop", async () => { // 导出前正在播放,而且开着循环 —— 这就是「播完一直循环播放」的现场 usePlaybackStore.getState().setLoop(true); usePlaybackStore.getState().setPlaying(true); await expect(exportVideo("director")).rejects.toThrow(); const state = usePlaybackStore.getState(); expect(state.isPlaying).toBe(false); expect(state.loop).toBe(true); }); it("keeps the playhead where the export found it", async () => { usePlaybackStore.getState().setPlayhead(2.5); await expect(exportVideo("director")).rejects.toThrow(); 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] }, lookRotation: getCameraLookRotationFromTarget(position, 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); // 导出相机 fov 走「按播放头解析出的镜头 fov」(与监视器一致),而非机位里可能陈旧的 fov 字段 const shot = useDirectorStore.getState().project.cameras.find((c) => c.id === shotId)!; const resolvedFov = resolveLens(shot).fov; 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(resolvedFov, 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], lookRotation: getCameraLookRotationFromTarget([5, 1, 0], [0, 1, 0]), fov: 40, }); const bridge = createFakeBridge(); stubVideoCodecs(); try { await exportVideo("camera"); } finally { vi.unstubAllGlobals(); } // 有轨道时走的是预览同款插值,导出不该再用静态机位覆盖它 // fov 走解析后的镜头 fov(与监视器一致),与关键帧里可能陈旧的 fov 字段无关 const shot2 = useDirectorStore.getState().project.cameras.find((c) => c.id === shotId)!; const resolvedFov2 = resolveLens(shot2).fov; 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(resolvedFov2, 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(); const helper = new Object3D(); helper.userData.hideFromViewportCapture = true; const solidObject = new Object3D(); scene.add(helper, solidObject); const visibilityDuringRender: boolean[] = []; const gl = { domElement: document.createElement("canvas"), render: vi.fn(() => { // 在渲染这一帧的瞬间记录辅助物体的可见性 visibilityDuringRender.push(helper.visible); }), getSize: (size: Vector2) => size.set(300, 150), getPixelRatio: () => 1, setPixelRatio: () => {}, setSize: () => {}, }; const camera = new PerspectiveCamera(); contextRef.current = { gl, scene, camera }; // 短时长,缩小导出帧数,加速测试 useDirectorStore.getState().setTimelineDuration(0.1); // jsdom 没有 WebCodecs,这里把逐帧导出需要的几个全局桩好 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: () => {}, }); try { await exportVideo("director"); } finally { vi.unstubAllGlobals(); } expect(gl.render).toHaveBeenCalled(); // 每一帧渲染时,辅助物体都必须藏起来,否则导出的视频里会带着 gizmo 红绿蓝轴 expect(visibilityDuringRender.length).toBeGreaterThan(0); expect(visibilityDuringRender.every((v) => v === false)).toBe(true); // 导出结束后原样恢复 expect(helper.visible).toBe(true); expect(solidObject.visible).toBe(true); });