Files
ttstd-3d-director/src/editor/io/videoExport.test.ts
TongTongStudio 7e28c73643 feat(camera): 机位视角增加景深虚化与镜头参数编辑
- 截图 / 视频导出在机位视角下走 bokeh 后处理渲染,按传感器画幅裁切
- 镜头监视器复用同一套解析与渲染,隐藏编辑器辅助元素
- 镜头面板支持焦段、画幅、光圈、对焦、虚化编辑,并按播放头写入关键帧
- 位置 / 角度编辑同步进选中关键帧,锁定物体时回填实测对焦距离
- 轨迹点支持整体锁定与隐藏,角色姿势关键帧按播放头插值
2026-09-19 21:11:47 +08:00

313 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});