feat(camera): 机位视角增加景深虚化与镜头参数编辑

- 截图 / 视频导出在机位视角下走 bokeh 后处理渲染,按传感器画幅裁切
- 镜头监视器复用同一套解析与渲染,隐藏编辑器辅助元素
- 镜头面板支持焦段、画幅、光圈、对焦、虚化编辑,并按播放头写入关键帧
- 位置 / 角度编辑同步进选中关键帧,锁定物体时回填实测对焦距离
- 轨迹点支持整体锁定与隐藏,角色姿势关键帧按播放头插值
This commit is contained in:
2026-09-19 21:11:47 +08:00
parent 91fcb6841a
commit 7e28c73643
23 changed files with 1968 additions and 229 deletions

View File

@@ -2,7 +2,7 @@ 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 } from "../schema/cameraGeometry";
import { getCameraLookRotationFromTarget, resolveLens } from "../schema/cameraGeometry";
import { exportVideo } from "./videoExport";
vi.mock("mp4-muxer", () => ({
@@ -22,6 +22,14 @@ 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 });
});
@@ -152,13 +160,16 @@ it("renders camera-view exports from the camera shot itself when it has no camer
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(35, 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);
@@ -186,12 +197,15 @@ it("lets the camera track win over the static shot pose in camera-view exports",
}
// 有轨道时走的是预览同款插值,导出不该再用静态机位覆盖它
// 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(40, 5);
expect(frame.fov).toBeCloseTo(resolvedFov2, 5);
}
});