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);
}
});

View File

@@ -3,10 +3,12 @@ import { ArrayBufferTarget, Muxer } from "mp4-muxer";
import { Vector2, type PerspectiveCamera } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import type { Scene } from "three";
import { getVideoExportContext } from "./videoExportBridge";
import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime, resolveLensAtTime } from "../runtime/playbackTransforms";
import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, resolveLens, type CameraViewSnapshot } from "../schema/cameraGeometry";
import { renderSceneWithBokeh } from "../canvas/viewportBokeh";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { getActiveCameraIdAtTime, normalizeTimelineFps } from "../schema/timeline";
import { EXPORT_CODEC_OPTIONS, EXPORT_QUALITY_OPTIONS, type ExportCodecPreference, type ExportSettings } from "../schema/directorProject";
@@ -75,12 +77,17 @@ function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: Camer
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边取自导出设置(默认 720),宽高取偶(编码器要求)。
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
* 否则视口是扁的时导出也会跟着扁。
* aspectOverride 供「机位视角」导出使用:直接指定画幅(传感器宽高比),覆盖视口比例,
* 让导出画面与镜头监视器(同样按传感器画幅裁切)的取景 / 景深虚化占比一致。
*/
export function computeExportSize(
aspectRatioId: ReturnType<typeof useDirectorStore.getState>["viewportAspectRatio"],
resolutionShortEdge: number
resolutionShortEdge: number,
aspectOverride?: number
): { width: number; height: number } {
const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9;
const aspect = Number.isFinite(aspectOverride) && (aspectOverride as number) > 0
? (aspectOverride as number)
: getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9;
let width: number;
let height: number;
if (aspect >= 1) {
@@ -149,7 +156,26 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const directorStore = useDirectorStore.getState();
const exportSettings = directorStore.project.exportSettings;
const { width, height } = computeExportSize(directorStore.viewportAspectRatio, exportSettings.resolutionShortEdge);
// 机位视角导出:按「激活机位的传感器画幅」裁切,与镜头监视器完全一致
// (监视器同样按传感器宽高比裁切)。否则用默认 16:9 视口比例会比监视器更宽,
// 取景与景深虚化在画面中的占比都对不上。
// 画幅统一走 resolveLens(而不是直接读 sensorPreset):监视器也是 resolveLens,
// 老工程 / 缺字段的机位会被补成默认画幅,两边无论如何都取到同一个比例。
const activeShot =
view === "camera"
? directorStore.project.cameras.find((item) => item.id === directorStore.project.activeCameraId) ?? null
: null;
const exportSensorAspect = activeShot
? (() => {
const sensor = resolveLens(activeShot).sensor;
return sensor.widthMm / sensor.heightMm;
})()
: undefined;
const { width, height } = computeExportSize(
directorStore.viewportAspectRatio,
exportSettings.resolutionShortEdge,
exportSensorAspect
);
const duration = directorStore.project.timeline.duration;
// 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致
const fps = normalizeTimelineFps(directorStore.project.timeline.fps);
@@ -270,7 +296,26 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来,
// 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。
withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera));
// 机位视角下带镜头参数 → bokeh 后处理渲染(浅景深,且随移焦关键帧逐帧变化);否则普通渲染。
const exportLens =
view === "camera" && displayCameraId
? (() => {
const exportCamera = directorStore.project.cameras.find((item) => item.id === displayCameraId);
return exportCamera
? resolveLensAtTime(exportCamera, time, directorStore.project.timeline.cameraTracks)
: null;
})()
: null;
// 严格对齐监视器:渲染相机的垂直 fov 用「按播放头解析出的镜头 fov」(与监视器一致),
// 而非关键帧里可能陈旧的 fov 字段(如中途改过传感器画幅后),避免导出焦距 / 取景与监视器不同。
if (exportLens && Math.abs(frameCamera.fov - exportLens.fov) > 0.001) {
frameCamera.fov = exportLens.fov;
frameCamera.updateProjectionMatrix();
}
withViewportCaptureHelpersHidden(scene, () => {
if (exportLens) renderSceneWithBokeh(gl, scene as Scene, frameCamera, exportLens);
else gl.render(scene, frameCamera);
});
const frame = new VideoFrame(canvas, {
timestamp: frameIndex * frameDurationUs,