- 截图 / 视频导出在机位视角下走 bokeh 后处理渲染,按传感器画幅裁切 - 镜头监视器复用同一套解析与渲染,隐藏编辑器辅助元素 - 镜头面板支持焦段、画幅、光圈、对焦、虚化编辑,并按播放头写入关键帧 - 位置 / 角度编辑同步进选中关键帧,锁定物体时回填实测对焦距离 - 轨迹点支持整体锁定与隐藏,角色姿势关键帧按播放头插值
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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);
|
||
});
|