Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ac44c418 | |||
| 8fcc25955d |
@@ -750,3 +750,48 @@ it("captures every four-view screenshot using the selected viewport aspect ratio
|
||||
expect(drawImage).toHaveBeenCalledTimes(4);
|
||||
expect(cropCanvas.width / cropCanvas.height).toBeCloseTo(4 / 3, 2);
|
||||
});
|
||||
|
||||
function mockViewportFrameRect(width: number, height: number) {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
width,
|
||||
height,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
}
|
||||
|
||||
/**
|
||||
* 机位视角把主视口画布锁成机位画幅(letterbox):画布 = 传感器画幅后,相机 aspect(R3F 按画布尺寸设)
|
||||
* 就等于传感器画幅 → 「主视口 = 镜头监视器 = 视频导出」取景逐像素同源,虚化归一化系数也变成 1。
|
||||
*/
|
||||
it("locks the main viewport canvas to the camera frame aspect in camera view", () => {
|
||||
mockViewportFrameRect(1920, 919);
|
||||
useDirectorStore.getState().setViewMode("camera");
|
||||
|
||||
render(<App />);
|
||||
|
||||
const canvasHost = screen.getByTestId("director-canvas");
|
||||
|
||||
expect(canvasHost).toHaveClass("is-camera-frame");
|
||||
// 全画幅 36/24 = 1.5 → 满高、水平居中:宽 = 919 × 1.5 = 1378.5,左右各让出 270.75
|
||||
expect(canvasHost.style.getPropertyValue("--camera-frame-width")).toBe("1378.5px");
|
||||
expect(canvasHost.style.getPropertyValue("--camera-frame-height")).toBe("919px");
|
||||
expect(canvasHost.style.getPropertyValue("--camera-frame-left")).toBe("270.75px");
|
||||
expect(canvasHost.style.getPropertyValue("--camera-frame-top")).toBe("0px");
|
||||
});
|
||||
|
||||
it("keeps the full-bleed viewport in director view so the frame lock never leaks", () => {
|
||||
mockViewportFrameRect(1920, 919);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const canvasHost = screen.getByTestId("director-canvas");
|
||||
|
||||
expect(canvasHost).not.toHaveClass("is-camera-frame");
|
||||
expect(canvasHost.style.getPropertyValue("--camera-frame-width")).toBe("");
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getCameraLookPoint,
|
||||
getCameraRenderQuaternion,
|
||||
getCameraViewSnapshotFromShot,
|
||||
getSensorPreset,
|
||||
getWorldCameraViewSnapshot,
|
||||
resolveLens,
|
||||
type CameraViewSnapshot,
|
||||
@@ -45,7 +46,45 @@ import { LensMonitor } from "./LensMonitor";
|
||||
import { renderSceneWithBokeh } from "./viewportBokeh";
|
||||
import { ViewportBackground } from "./ViewportBackground";
|
||||
import { ViewportToolbar } from "./ViewportToolbar";
|
||||
import { getViewportAspectFrameRect, type ViewportSafeAreaInsets } from "./viewportAspectFrame";
|
||||
import { fitSensorFrameRect, getViewportAspectFrameRect, type ViewportSafeAreaInsets } from "./viewportAspectFrame";
|
||||
|
||||
/**
|
||||
* 量出主视口容器(= 画布容器)的精确尺寸。
|
||||
*
|
||||
* 机位视角要把画布锁成机位画幅,必须拿到像素值自己算尺寸 —— 只用 CSS 的 aspect-ratio + max-height
|
||||
* 组合在高度受限时会破坏比例(画面会被拉伸),监视器那边的 useFittedSize 已经踩过同一个坑。
|
||||
*
|
||||
* 用「回调 ref」而不是 useRef 对象 + 固定依赖的 effect:容器一直渲染,ref 在挂载时立刻挂上,
|
||||
* 由挂载后的 effect 触发测量(写成 ref 挂在「量到尺寸才渲染」的元素上会永远量不到)。
|
||||
*/
|
||||
function useViewportFrameSize() {
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const update = () => {
|
||||
const { width, height } = element.getBoundingClientRect();
|
||||
if (width <= 0 || height <= 0) return;
|
||||
setSize((current) =>
|
||||
current && current.width === width && current.height === height ? current : { width, height }
|
||||
);
|
||||
};
|
||||
|
||||
update();
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", update);
|
||||
return () => window.removeEventListener("resize", update);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [element]);
|
||||
|
||||
return [setElement, size] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导演视角的初始位姿:OrbitControls 需要一个轨道中心点,所以这里存的是「注视点」;
|
||||
@@ -877,6 +916,28 @@ export function DirectorCanvas() {
|
||||
/** 截图桥需要的机位视角:在世界机位视角上补一个 id,用来把截图归到对应机位 */
|
||||
const activeCameraCaptureView =
|
||||
activeCamera && activeCameraWorldView ? { id: activeCamera.id, ...activeCameraWorldView } : undefined;
|
||||
/** 机位画幅(传感器宽高比):镜头监视器 / 视频导出 / 截图裁剪都以它为准,
|
||||
* 主视口的机位画幅遮幅也用它 —— 三处同一口径。 */
|
||||
const activeCameraSensor = activeCamera ? getSensorPreset(activeCamera.sensorPreset) : null;
|
||||
const activeCameraSensorAspect = activeCameraSensor
|
||||
? activeCameraSensor.widthMm / activeCameraSensor.heightMm
|
||||
: null;
|
||||
const [setViewportFrameElement, viewportFrameSize] = useViewportFrameSize();
|
||||
/** 机位视角下把主视口画布锁成机位画幅的 letterbox 矩形:居中、四周留边。
|
||||
* 画布 = 机位画幅后,相机 aspect(R3F 按画布尺寸设)就等于传感器画幅 ——
|
||||
* 「主视口 = 镜头监视器 = 视频导出」取景逐像素同源,虚化归一化系数也自然变成 1。 */
|
||||
const cameraFrameRect =
|
||||
viewMode === "camera" && activeCameraSensorAspect && viewportFrameSize
|
||||
? fitSensorFrameRect(viewportFrameSize.width, viewportFrameSize.height, activeCameraSensorAspect)
|
||||
: null;
|
||||
const cameraFrameStyle = cameraFrameRect
|
||||
? ({
|
||||
"--camera-frame-width": `${cameraFrameRect.width}px`,
|
||||
"--camera-frame-height": `${cameraFrameRect.height}px`,
|
||||
"--camera-frame-left": `${cameraFrameRect.left}px`,
|
||||
"--camera-frame-top": `${cameraFrameRect.top}px`,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
/** 机位视角渲染相机的实例引用:除了给 drei <PerspectiveCamera> 传 position / quaternion / fov prop,
|
||||
* 这里再用 ref 兜底把这三个量直接写回相机实例。否则手改机位角度后,画面要等 R3F 对
|
||||
* makeDefault 相机 prop 的更新时序走到才跟着转,表现为"水平角/俯仰角/横滚角改了没反应"。 */
|
||||
@@ -1003,8 +1064,12 @@ export function DirectorCanvas() {
|
||||
return (
|
||||
<div className="canvas-frame">
|
||||
<div
|
||||
className={`director-canvas${trajectoryPlacementObjectId ? " is-placing-trajectory" : ""}`}
|
||||
className={`director-canvas${trajectoryPlacementObjectId ? " is-placing-trajectory" : ""}${
|
||||
cameraFrameRect ? " is-camera-frame" : ""
|
||||
}`}
|
||||
data-testid="director-canvas"
|
||||
ref={setViewportFrameElement}
|
||||
style={cameraFrameStyle}
|
||||
// 兜底:拦截画布上的浏览器右键上下文菜单(与平移无关——平移已改中键)。
|
||||
// 相机视角下没有 OrbitControls 接管,右键仍可能弹出系统菜单,这里统一拦掉。
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
|
||||
123
src/editor/canvas/LensMonitor.test.tsx
Normal file
123
src/editor/canvas/LensMonitor.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { PerspectiveCamera } from "three";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { LensMonitor } from "./LensMonitor";
|
||||
|
||||
// 监视器内部是一个独立的 R3F Canvas:jsdom 里没有 WebGL,把 Canvas / 相机 / 场景全部桩掉,
|
||||
// 只验证 DOM 结构与交互(小窗 → 全屏弹窗)。
|
||||
vi.mock("@react-three/fiber", async () => {
|
||||
const actual = await vi.importActual<typeof import("@react-three/fiber")>("@react-three/fiber");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Canvas: ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
||||
<div className={className} data-testid="lens-monitor-canvas-stub">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
useFrame: () => undefined,
|
||||
useThree: () => ({
|
||||
camera: new PerspectiveCamera(),
|
||||
scene: { children: [] },
|
||||
gl: { render: () => undefined, getSize: () => ({ x: 320, y: 213 }), getPixelRatio: () => 1 },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@react-three/drei", () => ({ PerspectiveCamera: () => null }));
|
||||
vi.mock("./SceneRoot", () => ({ SceneRoot: () => null }));
|
||||
vi.mock("./ViewportBackground", () => ({ ViewportBackground: () => null }));
|
||||
vi.mock("./PlaybackTransformSync", () => ({ PlaybackTransformSync: () => null }));
|
||||
|
||||
// jsdom 里所有元素都是 0 尺寸,全屏弹窗靠实测容器尺寸决定画面大小,这里给一个固定视口尺寸。
|
||||
const VIEWPORT_RECT = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(VIEWPORT_RECT as DOMRect);
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
...createInitialDirectorState(),
|
||||
});
|
||||
usePlaybackStore.setState({ isPlaying: false, isScrubbing: false, isExporting: false, playhead: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows the lens info next to the small monitor with an entry to the fullscreen view", () => {
|
||||
render(<LensMonitor />);
|
||||
|
||||
const monitor = screen.getByLabelText("镜头监视器");
|
||||
expect(within(monitor).getByText(/对焦 5\.0m/)).toBeInTheDocument();
|
||||
expect(within(monitor).getByLabelText("全屏查看镜头监视器")).toBeInTheDocument();
|
||||
expect(within(monitor).getByTestId("lens-monitor-canvas-stub")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("镜头监视器全屏")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the monitor picture inside the fullscreen dialog and moves it back on close", () => {
|
||||
render(<LensMonitor />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("全屏查看镜头监视器"));
|
||||
|
||||
const dialog = screen.getByLabelText("镜头监视器全屏");
|
||||
// 回归点:弹窗容器是打开后才挂载的,实测不到尺寸就永远不挂 Canvas —— 表现是全屏一片空白
|
||||
expect(within(dialog).getByTestId("lens-monitor-canvas-stub")).toBeInTheDocument();
|
||||
// 小窗让位,避免同一份场景挂两个 Canvas
|
||||
expect(within(screen.getByLabelText("镜头监视器")).queryByTestId("lens-monitor-canvas-stub")).not.toBeInTheDocument();
|
||||
expect(within(screen.getByLabelText("镜头监视器")).getByText("全屏查看中…")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByLabelText("镜头监视器全屏")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByLabelText("镜头监视器")).getByTestId("lens-monitor-canvas-stub")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("静止编辑时监视器跟随手动选中的机位(与主视口机位视角同一口径)", () => {
|
||||
// 第二台机位对焦 9m 并选中它:监视器应显示「选中那台」的镜头。
|
||||
// 播放 / scrub / 导出时两边都切回时间轴机位(同一分支:autoCut ? 时间轴 : 手动选中)。
|
||||
const secondCameraId = useDirectorStore.getState().addCameraShot();
|
||||
useDirectorStore.getState().updateCamera(secondCameraId, { focusDistance: 9 });
|
||||
useDirectorStore.getState().setActiveCamera(secondCameraId);
|
||||
|
||||
render(<LensMonitor />);
|
||||
|
||||
expect(within(screen.getByLabelText("镜头监视器")).getByText(/对焦 9\.0m/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the lens info and the depth-of-field band in the fullscreen monitor", () => {
|
||||
render(<LensMonitor />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("全屏查看镜头监视器"));
|
||||
|
||||
const dialog = screen.getByLabelText("镜头监视器全屏");
|
||||
// 镜头参数(与面板、导出同一份 resolveLensAtTime)
|
||||
expect(within(dialog).getByText(/对焦 5\.0m/)).toBeInTheDocument();
|
||||
// 实际生效的景深带宽:f/2.8 + 默认景深强度 0.012 + 对焦 5m ≈ ±4.2m
|
||||
expect(within(dialog).getByText(/±4\.2 m 以外即最大虚化/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the fullscreen monitor from its close button and from the backdrop", () => {
|
||||
render(<LensMonitor />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("全屏查看镜头监视器"));
|
||||
fireEvent.click(screen.getByLabelText("关闭全屏镜头监视器"));
|
||||
expect(screen.queryByLabelText("镜头监视器全屏")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("全屏查看镜头监视器"));
|
||||
fireEvent.click(screen.getByLabelText("镜头监视器全屏").previousElementSibling as Element);
|
||||
expect(screen.queryByLabelText("镜头监视器全屏")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei";
|
||||
import { Maximize2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { PerspectiveCamera as ThreePerspectiveCamera, Raycaster, Scene, Vector2 } from "three";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import { SceneRoot } from "./SceneRoot";
|
||||
import { ViewportBackground } from "./ViewportBackground";
|
||||
import { PlaybackTransformSync } from "./PlaybackTransformSync";
|
||||
import { renderSceneWithBokeh } from "./viewportBokeh";
|
||||
import { getBokehDebugSnapshots, renderSceneWithBokeh, type BokehDebugSnapshot } from "./viewportBokeh";
|
||||
import {
|
||||
formatLensInfo,
|
||||
getCameraRenderQuaternion,
|
||||
getCameraViewSnapshotFromShot,
|
||||
getDepthOfFieldHalfWidth,
|
||||
getWorldCameraViewSnapshot,
|
||||
} from "../schema/cameraGeometry";
|
||||
import { resolveLensAtTime } from "../runtime/playbackTransforms";
|
||||
@@ -29,7 +32,9 @@ import { evaluateCameraViewAtTime, getActiveCameraIdAtTime } from "../schema/tim
|
||||
* getActiveCameraIdAtTime 解析;位姿、镜头(焦段/光圈/对焦/景深强度)、画幅全部取自这台机位,
|
||||
* 保证监视器所见与导出的机位视角严格一致。
|
||||
*
|
||||
* 刻意做成独立的第二个 Canvas:景深后处理只作用在监视器上,不影响主视口 / 截图的渲染管线。
|
||||
* 刻意做成独立的第二个 Canvas,并且无论主视口处于「导演视角」还是「机位视角」都自行渲染:
|
||||
* 景深后处理只作用在监视器上,不影响主视口 / 截图的渲染管线;画面内容 / 虚化 / 取景
|
||||
* 也不会因为主视口切换视角而改变。
|
||||
*/
|
||||
|
||||
function LensMonitorCamera({
|
||||
@@ -103,7 +108,7 @@ function LensMonitorCamera({
|
||||
// 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套
|
||||
// EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。
|
||||
// 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。
|
||||
renderSceneWithBokeh(gl, scene, renderCamera, lens);
|
||||
renderSceneWithBokeh(gl, scene, renderCamera, lens, `镜头监视器·${camera.name}`);
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
@@ -121,73 +126,104 @@ function LensMonitorCamera({
|
||||
const LOOK_DRAG_SENSITIVITY = 0.3;
|
||||
/** 俯仰角夹到 ±89°,避免越过天顶后镜头翻转 */
|
||||
const PITCH_LIMIT = 89;
|
||||
/** 区分「单击对焦」与「拖拽瞄准」的位移阈值(像素) */
|
||||
const CLICK_THRESHOLD_PX = 4;
|
||||
/**
|
||||
* 区分「单击对焦」与「拖拽瞄准」的位移阈值(像素)。
|
||||
* 放宽到 6:4px 时触控板 / 手抖很容易被判定成拖拽瞄准,导致「点了画面某点却没对上焦」。
|
||||
* 6px 仍然远小于有意的拖拽,不会误触发瞄准。
|
||||
*/
|
||||
const CLICK_THRESHOLD_PX = 6;
|
||||
|
||||
/**
|
||||
* 机位视角下监视器的「镜像」:主视口此刻渲染的就是机位画面,监视器没必要再开一个 Canvas
|
||||
* 把整个场景重渲一遍(第二份场景 + 第二套景深后处理,既卡,又容易和主视口取景 / 虚化漂移)。
|
||||
* 这里直接把主视口画布按「传感器画幅」居中裁一块拷过来,逐像素与主视口一致。
|
||||
*
|
||||
* fov 是垂直的:视口比传感器画幅更宽时按宽度裁,垂直取景保持不变 ——
|
||||
* 结果与监视器自己用传感器画幅渲染完全一致。
|
||||
* 监视器画面本体。小窗和全屏弹窗复用同一份定义(同一场景 / 同一机位 / 同一套景深参数),
|
||||
* 但同一时刻只挂一个 Canvas —— 两边都挂等于把整个场景和景深后处理白渲染两遍。
|
||||
*/
|
||||
function MonitorMirror({ sensorAspect }: { sensorAspect: number }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
function LensMonitorStage({
|
||||
camera,
|
||||
lens,
|
||||
sceneSettings,
|
||||
panoramaAsset,
|
||||
sceneRef,
|
||||
camRef,
|
||||
playhead,
|
||||
cameraTracks,
|
||||
}: {
|
||||
camera: DirectorCameraShot;
|
||||
lens: ReturnType<typeof resolveLensAtTime>;
|
||||
sceneSettings: SceneSettings;
|
||||
panoramaAsset: Parameters<typeof ViewportBackground>[0]["panoramaAsset"];
|
||||
sceneRef: React.MutableRefObject<Scene | null>;
|
||||
camRef: React.MutableRefObject<ThreePerspectiveCamera | null>;
|
||||
playhead: number;
|
||||
cameraTracks: CameraTimelineTrack[];
|
||||
}) {
|
||||
return (
|
||||
<Canvas
|
||||
className="lens-monitor-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ antialias: true }}
|
||||
camera={{ fov: lens.fov, position: [0, 1, 5] }}
|
||||
>
|
||||
<color attach="background" args={[sceneSettings.backgroundColor]} />
|
||||
{/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、
|
||||
backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */}
|
||||
<ViewportBackground
|
||||
backgroundColor={sceneSettings.backgroundColor}
|
||||
panoramaAsset={panoramaAsset}
|
||||
panoramaRadius={sceneSettings.panoramaRadius}
|
||||
panoramaYaw={sceneSettings.panoramaYaw}
|
||||
/>
|
||||
<ambientLight intensity={1.15} />
|
||||
<directionalLight intensity={1.2} position={[8, 10, 6]} />
|
||||
{/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。
|
||||
SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层,
|
||||
监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同,
|
||||
清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */}
|
||||
<PlaybackTransformSync />
|
||||
<LensMonitorCamera
|
||||
camera={camera}
|
||||
sceneSettings={sceneSettings}
|
||||
sceneRef={sceneRef}
|
||||
camRef={camRef}
|
||||
playhead={playhead}
|
||||
cameraTracks={cameraTracks}
|
||||
/>
|
||||
<SceneRoot editorHelpers={false} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「传感器画幅」的矩形尽可能大地塞进容器,返回精确像素尺寸。
|
||||
* 全屏弹窗里必须给 R3F 一个确定尺寸:只用 CSS 的 aspect-ratio + max-height 组合在高度受限时
|
||||
* 会破坏比例(画面被拉伸),所以这里实测容器尺寸后按画幅算一次。
|
||||
*
|
||||
* 用「回调 ref」而不是 useRef 对象:容器只在弹窗打开时才渲染,用对象 ref + useEffect 的话
|
||||
* 首次 effect 跑在容器还不存在的时候(current 为 null)→ 直接 return,且此后依赖不变不会再跑,
|
||||
* 尺寸永远是 null —— 表现就是「全屏打开后一片空白」(Canvas 从未挂载)。
|
||||
*/
|
||||
function useFittedSize(aspect: number) {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
const draw = () => {
|
||||
frame = requestAnimationFrame(draw);
|
||||
const target = canvasRef.current;
|
||||
const source = document.querySelector<HTMLCanvasElement>(".director-canvas canvas");
|
||||
if (!target || !source) return;
|
||||
|
||||
// 输出尺寸跟随容器(CSS 像素)
|
||||
const cssWidth = target.clientWidth;
|
||||
const cssHeight = target.clientHeight;
|
||||
if (!cssWidth || !cssHeight) return;
|
||||
if (target.width !== cssWidth || target.height !== cssHeight) {
|
||||
target.width = cssWidth;
|
||||
target.height = cssHeight;
|
||||
}
|
||||
|
||||
const sourceWidth = source.width;
|
||||
const sourceHeight = source.height;
|
||||
if (!sourceWidth || !sourceHeight) return;
|
||||
|
||||
let cropWidth = sourceWidth;
|
||||
let cropHeight = Math.round(sourceWidth / sensorAspect);
|
||||
if (cropHeight > sourceHeight) {
|
||||
cropHeight = sourceHeight;
|
||||
cropWidth = Math.round(sourceHeight * sensorAspect);
|
||||
}
|
||||
|
||||
const context = target.getContext("2d");
|
||||
if (!context) return;
|
||||
context.drawImage(
|
||||
source,
|
||||
Math.round((sourceWidth - cropWidth) / 2),
|
||||
Math.round((sourceHeight - cropHeight) / 2),
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
0,
|
||||
0,
|
||||
target.width,
|
||||
target.height
|
||||
);
|
||||
if (!container) return;
|
||||
const update = () => {
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
if (width <= 0 || height <= 0) return;
|
||||
setSize(width / height > aspect ? { width: height * aspect, height } : { width, height: width / aspect });
|
||||
};
|
||||
update();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [container, aspect]);
|
||||
|
||||
frame = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [sensorAspect]);
|
||||
|
||||
return <canvas className="lens-monitor-canvas" ref={canvasRef} />;
|
||||
return { containerRef: setContainer, size };
|
||||
}
|
||||
|
||||
export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
|
||||
const sceneSettings = useDirectorStore((state) => state.project.scene);
|
||||
const viewMode = useDirectorStore((state) => state.viewMode);
|
||||
const assets = useDirectorStore((state) => state.project.assets);
|
||||
const panoramaAssetId = useDirectorStore((state) => state.project.panoramaAssetId);
|
||||
const panoramaAsset = assets.find((item) => item.id === panoramaAssetId);
|
||||
@@ -202,13 +238,25 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
|
||||
const cameras = useDirectorStore((state) => state.project.cameras);
|
||||
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
|
||||
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
|
||||
// 当前时刻「正在显示」的机位:与视频导出用同一个 getActiveCameraIdAtTime 解析。
|
||||
// 多机位自动切换 / 过渡时,监视器画面就是这台机位;它生效的镜头(焦段 / 光圈 / 对焦 / 景深强度)
|
||||
// 和画幅也必须取这台机位 —— 否则会出现「画面是 B 机位、虚化却用了激活的 A 机位」,
|
||||
// 导出(按显示机位解析镜头)就会和监视器对不上。
|
||||
// 当前时刻「正在显示」的机位 —— 与主视口机位视角(ActiveCameraPlaybackSync)用**同一套口径**:
|
||||
// · 播放 / scrub / 导出(自动切机位)→ 时间轴此刻应显示的机位 getActiveCameraIdAtTime;
|
||||
// · 静止编辑 → 手动选中的机位。
|
||||
// 口径不一致会直接导致「同一帧两个视图的镜头不是一套」:主视口解析机位 A 的对焦 / 景深,
|
||||
// 监视器解析机位 B 的,画面自然一个清楚一个糊,怎么调都对不上。多机位自动切换 / 过渡时
|
||||
// 两边都取时间轴机位,镜头(焦段 / 光圈 / 对焦 / 景深强度)与画幅也随之一致,导出同理。
|
||||
// 注意:三个订阅必须各自独立调用。写成 `useX(...) || useX(...) || useX(...)` 会短路 ——
|
||||
// isPlaying 为真时后两个 hook 不会被调用,hook 数量随状态变化,React 会直接报
|
||||
// 「Should have a queue」(播放一按就崩)。
|
||||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||||
const isExporting = usePlaybackStore((state) => state.isExporting);
|
||||
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
|
||||
const autoCut = isPlaying || isExporting || isScrubbing;
|
||||
const displayCameraId = useMemo(
|
||||
() => getActiveCameraIdAtTime(transitionClips, cameraTracks, activeCamera?.id ?? null, playhead),
|
||||
[transitionClips, cameraTracks, activeCamera?.id, playhead]
|
||||
() =>
|
||||
autoCut
|
||||
? getActiveCameraIdAtTime(transitionClips, cameraTracks, activeCamera?.id ?? null, playhead)
|
||||
: activeCamera?.id ?? null,
|
||||
[autoCut, transitionClips, cameraTracks, activeCamera?.id, playhead]
|
||||
);
|
||||
const camera = cameras.find((item) => item.id === displayCameraId) ?? activeCamera;
|
||||
// 小字镜头信息也按播放头解析(与画面渲染同一份 resolveLensAtTime),点击/拖到关键帧时数字同步刷新
|
||||
@@ -224,7 +272,29 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
|
||||
// 触屏对焦需要的监视器场景 / 渲染相机引用(由 LensMonitorCamera 每帧回填)
|
||||
const monitorSceneRef = useRef<Scene | null>(null);
|
||||
const monitorCamRef = useRef<ThreePerspectiveCamera | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
// 全屏查看:小窗和弹窗复用同一份画面,同一时刻只挂一个 Canvas(见 LensMonitorStage)
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
// 全屏弹窗里画面必须严格保持传感器画幅比例,所以按容器实测尺寸算
|
||||
const fullscreenFit = useFittedSize(sensorAspect);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setFullscreen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [fullscreen]);
|
||||
// 全屏诊断读数:把「机位视角」和「镜头监视器」实际写进 BokehPass 的参数并排摆出来(每 0.5s 刷新)。
|
||||
// 两边只要有一项不同(机位名 / 对焦 / 光圈 / 最大虚化 / 画布尺寸),就能立刻定位差异来源。
|
||||
const [bokehDebug, setBokehDebug] = useState<BokehDebugSnapshot[]>([]);
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return;
|
||||
const update = () => setBokehDebug(getBokehDebugSnapshots());
|
||||
update();
|
||||
const timer = window.setInterval(update, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [fullscreen]);
|
||||
|
||||
// 在监视器画面上「按住拖动」改变机位注视角度:普通拖动改水平角 + 俯仰角;Shift+拖动改横滚角。
|
||||
// 用基础机位的 lookRotation 作为起点累加位移,避免逐帧累积误差;写入走 updateCameraLookRotationAtTime,
|
||||
@@ -260,11 +330,12 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
|
||||
|
||||
// 监视器触屏对焦:在已选中机位的前提下,单击画面某点(非拖拽)时,
|
||||
// 从渲染相机发出射线拾取场景,取命中点到相机的距离写入对焦距离。
|
||||
const handleTouchFocus = (clientX: number, clientY: number) => {
|
||||
// 画面元素由调用方传入(event.currentTarget):小窗和全屏弹窗的矩形不一样,
|
||||
// 固定读某一个 ref 会让全屏下的 NDC 按小窗算,点哪都对不上。
|
||||
const handleTouchFocus = (clientX: number, clientY: number, stage: HTMLElement | null) => {
|
||||
if (!camera) return;
|
||||
const scene = monitorSceneRef.current;
|
||||
const renderCamera = monitorCamRef.current;
|
||||
const stage = stageRef.current;
|
||||
if (!scene || !renderCamera || !stage) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
@@ -289,68 +360,129 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
|
||||
dragRef.current = null;
|
||||
setDragging(false);
|
||||
endUndoBatch();
|
||||
if (wasClick) handleTouchFocus(event.clientX, event.clientY);
|
||||
if (wasClick) handleTouchFocus(event.clientX, event.clientY, event.currentTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lens-monitor" aria-label="镜头监视器" style={{ right: `${rightOffset}px` }}>
|
||||
<div
|
||||
className="lens-monitor-stage"
|
||||
ref={stageRef}
|
||||
style={{ aspectRatio: String(sensorAspect), cursor: dragging ? "grabbing" : "grab" }}
|
||||
title="拖拽改变机位注视角度(Shift+拖拽旋转横滚角);单击画面中某点使其对焦清晰"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
{camera && lens ? (
|
||||
// 机位视角下主视口本身就是机位画面 → 监视器直接镜像它,不再开第二个 Canvas 重渲整个场景
|
||||
//(省掉一份场景渲染 + 一套景深后处理,而且与主视口逐像素一致)。
|
||||
// 其它视角(例如导演视角下想同时看机位画面)才需要监视器自己渲染。
|
||||
viewMode === "camera" ? (
|
||||
<MonitorMirror sensorAspect={sensorAspect} />
|
||||
) : (
|
||||
<Canvas
|
||||
className="lens-monitor-canvas"
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ antialias: true }}
|
||||
camera={{ fov: lens.fov, position: [0, 1, 5] }}
|
||||
>
|
||||
<color attach="background" args={[sceneSettings.backgroundColor]} />
|
||||
{/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、
|
||||
backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */}
|
||||
<ViewportBackground
|
||||
backgroundColor={sceneSettings.backgroundColor}
|
||||
panoramaAsset={panoramaAsset}
|
||||
panoramaRadius={sceneSettings.panoramaRadius}
|
||||
panoramaYaw={sceneSettings.panoramaYaw}
|
||||
/>
|
||||
<ambientLight intensity={1.15} />
|
||||
<directionalLight intensity={1.2} position={[8, 10, 6]} />
|
||||
{/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。
|
||||
SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层,
|
||||
监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同,
|
||||
清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */}
|
||||
<PlaybackTransformSync />
|
||||
<LensMonitorCamera
|
||||
// 小窗和全屏弹窗共用的交互(拖拽瞄准 / 单击对焦)与光标
|
||||
const stageInteractionProps = {
|
||||
title: "拖拽改变机位注视角度(Shift+拖拽旋转横滚角);单击画面中某点使其对焦清晰",
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: endDrag,
|
||||
onPointerCancel: endDrag,
|
||||
};
|
||||
const stageCursor = dragging ? "grabbing" : "grab";
|
||||
// 全屏弹窗里把「实际生效的景深带宽」摆出来:调对焦 / 光圈 / 景深强度时能直接看到差多少
|
||||
const dofHalfWidth = lens ? getDepthOfFieldHalfWidth(lens) : Infinity;
|
||||
const dofHint = lens
|
||||
? Number.isFinite(dofHalfWidth)
|
||||
? `对焦 ${lens.focusDistance.toFixed(1)} m · ±${dofHalfWidth.toFixed(1)} m 以外即最大虚化(光圈越大 / 景深强度越大越窄)`
|
||||
: "景深强度为 0 · 全画面清晰"
|
||||
: "";
|
||||
// 同一份画面定义,小窗 / 全屏各用一次(同一时刻只会挂载一个)
|
||||
const monitorStage =
|
||||
camera && lens ? (
|
||||
<LensMonitorStage
|
||||
camera={camera}
|
||||
lens={lens}
|
||||
sceneSettings={sceneSettings}
|
||||
panoramaAsset={panoramaAsset}
|
||||
sceneRef={monitorSceneRef}
|
||||
camRef={monitorCamRef}
|
||||
playhead={playhead}
|
||||
cameraTracks={cameraTracks}
|
||||
/>
|
||||
<SceneRoot editorHelpers={false} />
|
||||
</Canvas>
|
||||
)
|
||||
) : (
|
||||
<div className="lens-monitor-empty">无激活机位</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lens-monitor" aria-label="镜头监视器" style={{ right: `${rightOffset}px` }}>
|
||||
<div
|
||||
className="lens-monitor-stage"
|
||||
style={{ aspectRatio: String(sensorAspect), cursor: stageCursor }}
|
||||
{...stageInteractionProps}
|
||||
>
|
||||
{camera && lens && fullscreen ? (
|
||||
// 全屏打开时小窗让位:同一份场景不开两个 Canvas(否则等于白渲染两遍 + 两套景深后处理)
|
||||
<div className="lens-monitor-stage-placeholder">全屏查看中…</div>
|
||||
) : (
|
||||
monitorStage
|
||||
)}
|
||||
</div>
|
||||
<div className="lens-monitor-info">
|
||||
<span className="lens-monitor-info-text">{lens ? formatLensInfo(lens) : "无激活机位"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lens-monitor-expand"
|
||||
aria-label="全屏查看镜头监视器"
|
||||
onClick={() => setFullscreen(true)}
|
||||
>
|
||||
<Maximize2 aria-hidden="true" size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{fullscreen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<>
|
||||
<div
|
||||
className="lens-monitor-fullscreen-backdrop"
|
||||
aria-hidden="true"
|
||||
onClick={() => setFullscreen(false)}
|
||||
/>
|
||||
<div className="lens-monitor-fullscreen" role="dialog" aria-modal="true" aria-label="镜头监视器全屏">
|
||||
<div className="lens-monitor-fullscreen-header">
|
||||
<span className="lens-monitor-fullscreen-title">镜头监视器</span>
|
||||
<span className="lens-monitor-fullscreen-lens">{lens ? formatLensInfo(lens) : "无激活机位"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lens-monitor-fullscreen-close"
|
||||
aria-label="关闭全屏镜头监视器"
|
||||
onClick={() => setFullscreen(false)}
|
||||
>
|
||||
<X aria-hidden="true" size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="lens-monitor-fullscreen-stage" ref={fullscreenFit.containerRef}>
|
||||
{camera && lens ? (
|
||||
<div
|
||||
className="lens-monitor-fullscreen-frame"
|
||||
style={{
|
||||
width: fullscreenFit.size ? `${fullscreenFit.size.width}px` : "100%",
|
||||
height: fullscreenFit.size ? `${fullscreenFit.size.height}px` : undefined,
|
||||
aspectRatio: fullscreenFit.size ? undefined : String(sensorAspect),
|
||||
cursor: stageCursor,
|
||||
}}
|
||||
{...stageInteractionProps}
|
||||
>
|
||||
{/* 拿到实测尺寸后才挂 Canvas:R3F 需要一个确定的尺寸,否则会按 0 渲染 */}
|
||||
{fullscreenFit.size ? monitorStage : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="lens-monitor-empty">无激活机位</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="lens-monitor-info">
|
||||
{lens ? formatLensInfo(lens) : "无激活机位"}
|
||||
<div className="lens-monitor-fullscreen-footer">
|
||||
<div className="lens-monitor-fullscreen-hint">{dofHint}</div>
|
||||
{bokehDebug.length > 0 ? (
|
||||
<div className="lens-monitor-fullscreen-debug" aria-label="实际景深参数">
|
||||
{bokehDebug.map((item) => (
|
||||
<span key={item.key}>
|
||||
<b>{item.key}</b>
|
||||
{`:对焦 ${item.focusDistance.toFixed(1)}m · 光圈 ${item.aperture.toFixed(4)} · 最大虚化 ${item.maxblur.toFixed(
|
||||
4
|
||||
)} · ${item.width}×${item.height}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitFrameWithinViewport } from "./viewportAspectFrame";
|
||||
import { fitFrameWithinViewport, fitSensorFrameRect } from "./viewportAspectFrame";
|
||||
|
||||
function sensorFrame(width: number, height: number, sensorAspect: number) {
|
||||
const frame = fitSensorFrameRect(width, height, sensorAspect);
|
||||
if (!frame) throw new Error("机位画幅矩形不应为空");
|
||||
return frame;
|
||||
}
|
||||
|
||||
describe("fitFrameWithinViewport", () => {
|
||||
it("keeps framed aspect layouts inside the visible safe area between overlay side panels", () => {
|
||||
@@ -16,3 +22,42 @@ describe("fitFrameWithinViewport", () => {
|
||||
expect(frame.top + frame.height).toBeLessThanOrEqual(574);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 机位画幅遮幅:主视口画布比机位画幅宽时,画幅 = 满高 + 水平居中。
|
||||
* 这块矩形必须与镜头监视器 / 视频导出的取景完全重合(它们都按传感器画幅渲染)。
|
||||
*/
|
||||
describe("fitSensorFrameRect", () => {
|
||||
it("画布比画幅宽时:满高、水平居中、宽 = 高 × 画幅比", () => {
|
||||
// 实测数据:机位视角主视口 1920×919(≈2.09:1)、机位画幅 = 全画幅 36/24
|
||||
const frame = sensorFrame(1920, 919, 36 / 24);
|
||||
|
||||
expect(frame.height).toBeCloseTo(919, 6);
|
||||
expect(frame.width).toBeCloseTo(919 * (36 / 24), 6);
|
||||
expect(frame.left).toBeCloseTo((1920 - 919 * (36 / 24)) / 2, 6);
|
||||
expect(frame.top).toBeCloseTo(0, 6);
|
||||
// 左右各约 14% 是镜头拍不到的区域 —— 这正是「主视口看起来更广」的来源
|
||||
expect((1920 - frame.width) / 2 / 1920).toBeCloseTo(0.141, 3);
|
||||
});
|
||||
|
||||
it("窗口比画幅还窄时:满宽、垂直居中", () => {
|
||||
const frame = sensorFrame(900, 1200, 1.5);
|
||||
|
||||
expect(frame.width).toBeCloseTo(900, 6);
|
||||
expect(frame.height).toBeCloseTo(600, 6);
|
||||
expect(frame.top).toBeCloseTo(300, 6);
|
||||
expect(frame.left).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("矩形宽高比严丝合缝等于画幅比(保证遮幅 = 监视器取景)", () => {
|
||||
const frame = sensorFrame(1920, 919, 36 / 24);
|
||||
|
||||
expect(frame.width / frame.height).toBeCloseTo(36 / 24, 8);
|
||||
});
|
||||
|
||||
it("尺寸非法时返回 null,不画遮幅", () => {
|
||||
expect(fitSensorFrameRect(0, 919, 1.5)).toBeNull();
|
||||
expect(fitSensorFrameRect(1920, 0, 1.5)).toBeNull();
|
||||
expect(fitSensorFrameRect(1920, 919, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,3 +67,29 @@ export function getViewportAspectFrameRect(
|
||||
|
||||
return fitFrameWithinViewport(width, height, ratioValue, bottomPadding, safeAreaInsets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 机位画幅矩形:这台机位「实际拍到的范围」在画布里的位置。
|
||||
*
|
||||
* 机位视角下主视口画布按「整窗宽高比」渲染(相机 aspect 跟着画布走),而机位画幅由传感器决定
|
||||
* (全画幅 36×24 = 1.5),通常比画布更窄。相机用垂直 FOV,所以画幅 = 满高、水平居中、
|
||||
* 宽度 = 高度 × 画幅比;窗口比画幅还窄时反过来取满宽。这块矩形正是镜头监视器 / 视频导出 /
|
||||
* 截图裁剪的同一块范围。
|
||||
*/
|
||||
export function fitSensorFrameRect(
|
||||
width: number,
|
||||
height: number,
|
||||
sensorAspect: number
|
||||
): ViewportFrameRect | null {
|
||||
if (!(width > 0) || !(height > 0) || !(sensorAspect > 0)) return null;
|
||||
|
||||
const frameWidth = Math.min(width, height * sensorAspect);
|
||||
const frameHeight = Math.min(height, width / sensorAspect);
|
||||
|
||||
return {
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
left: (width - frameWidth) / 2,
|
||||
top: (height - frameHeight) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
40
src/editor/canvas/viewportBokeh.test.ts
Normal file
40
src/editor/canvas/viewportBokeh.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBokehMaxBlur } from "./viewportBokeh";
|
||||
|
||||
const FULL_FRAME_ASPECT = 36 / 24; // 全画幅 1.5
|
||||
|
||||
/**
|
||||
* BokehShader 的模糊像素半径 = maxblur × 渲染宽度,而主体像素尺寸 ∝ 渲染高度,
|
||||
* 所以「虚化相对主体」正比于画布宽高比。这里用该比值守住「与视口宽窄 / 分辨率无关」的契约。
|
||||
*/
|
||||
function blurOverSubjectHeight(width: number, height: number, sensorAspect: number) {
|
||||
return (resolveBokehMaxBlur(0.012, width, height, sensorAspect) * width) / height;
|
||||
}
|
||||
|
||||
it("监视器 / 视频导出用的传感器画幅(宽高比 = 画幅比)保持原值不变", () => {
|
||||
expect(resolveBokehMaxBlur(0.012, 1200, 800, FULL_FRAME_ASPECT)).toBeCloseTo(0.012, 8);
|
||||
expect(resolveBokehMaxBlur(0.012, 318, 212, FULL_FRAME_ASPECT)).toBeCloseTo(0.012, 3);
|
||||
});
|
||||
|
||||
it("主视口这种更宽的画布按画幅归一化:虚化相对主体的强度与监视器一致", () => {
|
||||
// 实测截图:机位视角 1080×517(2.09)、监视器 318×211(1.51)
|
||||
const mainViewport = blurOverSubjectHeight(1080, 517, FULL_FRAME_ASPECT);
|
||||
const monitor = blurOverSubjectHeight(318, 211, FULL_FRAME_ASPECT);
|
||||
|
||||
expect(mainViewport).toBeCloseTo(monitor, 8);
|
||||
// 归一化会显著减弱宽画布的虚化(约 0.72 倍),这正是修掉「视口比监视器糊」的量
|
||||
expect(resolveBokehMaxBlur(0.012, 1080, 517, FULL_FRAME_ASPECT)).toBeCloseTo(0.012 * 0.718, 3);
|
||||
});
|
||||
|
||||
it("与分辨率无关:同一宽高比下大小画布得到相同的虚化占比", () => {
|
||||
expect(blurOverSubjectHeight(2160, 1034, FULL_FRAME_ASPECT)).toBeCloseTo(
|
||||
blurOverSubjectHeight(1080, 517, FULL_FRAME_ASPECT),
|
||||
8
|
||||
);
|
||||
});
|
||||
|
||||
it("尺寸非法时回退为原始 maxblur,避免除零把画面糊死", () => {
|
||||
expect(resolveBokehMaxBlur(0.012, 0, 0, FULL_FRAME_ASPECT)).toBe(0.012);
|
||||
expect(resolveBokehMaxBlur(0.012, 100, 0, FULL_FRAME_ASPECT)).toBe(0.012);
|
||||
expect(resolveBokehMaxBlur(0.012, 100, 100, 0)).toBe(0.012);
|
||||
});
|
||||
@@ -40,10 +40,15 @@ function ensureBokehState(
|
||||
camera: PerspectiveCamera
|
||||
): BokehState | null {
|
||||
const { width, height, pixelRatio } = readSize(gl);
|
||||
// 景深后处理要渲染两遍场景(颜色 + 深度)再加 41 抽样合成,是实时预览里最贵的一步。
|
||||
// 高 DPI 屏上 gl 的 pixelRatio 可能是 2,像素量直接翻 4 倍,而画面本来就是虚化的 ——
|
||||
// 实时预览把合成器像素比压到 1(导出走的是 gl.setPixelRatio(1) + 导出尺寸,不受影响,仍是满画质)。
|
||||
const effectivePixelRatio = Math.min(pixelRatio, 1);
|
||||
// 合成器按「画布自身的像素比」渲染,上限 2(3x 屏上像素量再翻倍、收益已经很小)。
|
||||
//
|
||||
// 这里此前压到 1,代价是「机位视角比导演视角明显糊」:压到 1 后合成器按 CSS 尺寸渲染,
|
||||
// 画布缓冲区却是 CSS×dpr,于是整幅画面被浏览器放大到设备像素 —— dpr 2 的屏上等于把 1080 宽的
|
||||
// 图拉成 2160,全是软边;而导演视角走的是 gl.render(原生 dpr),边缘锐利。
|
||||
// 更糟的是主视口(dpr)和镜头监视器(min(dpr,1.5))放大倍率不同 —— 同一颗镜头在两处的
|
||||
// 锐度/锯齿观感也对不上(放大倍率大的那侧更糊)。按原生像素比渲染后三处观感才一致。
|
||||
// 导出不受影响:导出前会 gl.setPixelRatio(1) + 按导出尺寸 setSize,本来就是原生分辨率。
|
||||
const effectivePixelRatio = Math.min(pixelRatio, 2);
|
||||
const existing = bokehCache.get(gl);
|
||||
if (
|
||||
existing &&
|
||||
@@ -78,12 +83,63 @@ function ensureBokehState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「按传感器画幅定义的虚化强度」换算成当前渲染尺寸下的 maxblur。
|
||||
*
|
||||
* BokehShader 的抽样偏移是 UV 量:横向 dx·maxblur(占画面宽度),纵向 dy·aspect·maxblur
|
||||
* —— 换算成像素,两个方向都等于 maxblur × 渲染宽度。而取景用的是「垂直 FOV」,
|
||||
* 主体在画面里的像素尺寸只随画面**高度**走。于是:
|
||||
*
|
||||
* 虚化相对主体 = maxblur × 宽 / 主体像素高 ∝ maxblur × (宽 / 高) = maxblur × 画布宽高比
|
||||
*
|
||||
* 也就是说同一颗镜头,画布越宽虚化看起来越强:主视口机位视角是整窗比例(不受限),
|
||||
* 典型 ≈2.1,而镜头监视器 / 视频导出都按传感器画幅(全画幅 1.5)渲染 ——
|
||||
* 同一画面在视口里会比监视器强约 2.1 / 1.5 ≈ 1.4 倍。
|
||||
*
|
||||
* 这里乘上 (高/宽)·传感器宽高比,把模糊半径改成「∝ 画面高度」:等价于始终按传感器画幅
|
||||
* 计算虚化。监视器 / 导出本来就是传感器画幅,系数为 1,观感与改前完全一致;
|
||||
* 只有宽画布的主视口被拉回同一基准,于是「机位视角 = 监视器 = 导出」的虚化占比严格一致,
|
||||
* 且与分辨率、视口宽窄都无关。
|
||||
*/
|
||||
export function resolveBokehMaxBlur(
|
||||
bokehMaxBlur: number,
|
||||
width: number,
|
||||
height: number,
|
||||
sensorAspect: number
|
||||
): number {
|
||||
if (!(width > 0) || !(height > 0) || !(sensorAspect > 0)) return bokehMaxBlur;
|
||||
return bokehMaxBlur * (height / width) * sensorAspect;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近一次渲染实际写进 BokehPass 的参数,按调用方 key 记录(主视口 / 监视器 / 导出各一份)。
|
||||
* 用途:核对「同一机位、同一帧,为什么机位视角和监视器虚化不一样」——
|
||||
* 把两边的镜头(对焦 / 光圈 / 最大模糊)和渲染尺寸摆在一起,就能立刻分清差异出在
|
||||
* 「镜头解析(不同机位 / 不同关键帧)」还是「渲染尺寸(画布宽高比 / 像素比)」。
|
||||
*/
|
||||
export interface BokehDebugSnapshot {
|
||||
key: string;
|
||||
focusDistance: number;
|
||||
aperture: number;
|
||||
maxblur: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const debugSnapshots = new Map<string, BokehDebugSnapshot>();
|
||||
|
||||
export function getBokehDebugSnapshots(): BokehDebugSnapshot[] {
|
||||
return [...debugSnapshots.values()];
|
||||
}
|
||||
|
||||
/** 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。 */
|
||||
export function renderSceneWithBokeh(
|
||||
gl: WebGLRenderer,
|
||||
scene: Scene,
|
||||
camera: PerspectiveCamera,
|
||||
lens: ResolvedLens
|
||||
lens: ResolvedLens,
|
||||
/** 诊断用 key:标记这次渲染属于哪个视图(如「机位视角」/「镜头监视器」),见 getBokehDebugSnapshots */
|
||||
debugKey?: string
|
||||
): void {
|
||||
const state = ensureBokehState(gl, scene, camera);
|
||||
if (!state) {
|
||||
@@ -92,12 +148,30 @@ export function renderSceneWithBokeh(
|
||||
}
|
||||
|
||||
const uniforms = (state.bokeh as unknown as { uniforms?: Record<string, { value: number }> }).uniforms;
|
||||
const aperture = apertureToBokeh(lens.apertureFNumber, lens.focusDistance);
|
||||
// 按传感器画幅归一化:否则宽画布(主视口)的虚化会比监视器 / 导出强「画布宽高比 / 画幅比」倍
|
||||
const maxblur = resolveBokehMaxBlur(
|
||||
lens.bokehMaxBlur,
|
||||
state.width,
|
||||
state.height,
|
||||
lens.sensor.widthMm / lens.sensor.heightMm
|
||||
);
|
||||
if (uniforms) {
|
||||
if (uniforms.focus) uniforms.focus.value = lens.focusDistance;
|
||||
// aperture 必须带上对焦距离:景深清晰带要随对焦距离平方展开,
|
||||
// 否则对焦到远景时清晰带仍只有约 ±1.1m,连远景都糊(详见 apertureToBokeh 注释)。
|
||||
if (uniforms.aperture) uniforms.aperture.value = apertureToBokeh(lens.apertureFNumber, lens.focusDistance);
|
||||
if (uniforms.maxblur) uniforms.maxblur.value = lens.bokehMaxBlur;
|
||||
// aperture 必须带上对焦距离:景深过渡带要随对焦距离平方展开,
|
||||
// 否则对焦到远景时带宽恒定(默认 f/2.8 约 ±4.2m),连远景都糊(详见 apertureToBokeh 注释)。
|
||||
if (uniforms.aperture) uniforms.aperture.value = aperture;
|
||||
if (uniforms.maxblur) uniforms.maxblur.value = maxblur;
|
||||
}
|
||||
if (debugKey) {
|
||||
debugSnapshots.set(debugKey, {
|
||||
key: debugKey,
|
||||
focusDistance: lens.focusDistance,
|
||||
aperture,
|
||||
maxblur,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
});
|
||||
}
|
||||
state.composer.render();
|
||||
}
|
||||
|
||||
@@ -125,6 +125,24 @@ it("uses the provided right inspector layout for camera properties", () => {
|
||||
expect(fovField).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selected subject distance and focuses the camera onto it in one click", async () => {
|
||||
const user = userEvent.setup();
|
||||
// 选中可对焦物体(角色)后,面板直接给出「机位 → 物体」的实测距离
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
selectedObjectId: "char_default_a",
|
||||
});
|
||||
render(<CameraPanel />);
|
||||
|
||||
// 默认机位 [0, 1.55, 5.4] → 角色焦点 [0, 0.99, 0] ≈ 5.4m
|
||||
expect(screen.getByLabelText("所选物体距离")).toHaveTextContent("所选物体 5.4 m");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "对焦到所选物体" }));
|
||||
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
expect(camera.focusDistance).toBeCloseTo(5.43, 1);
|
||||
});
|
||||
|
||||
it("keeps camera panel tab labels in fixed slots while switching tabs", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<CameraPanel />);
|
||||
|
||||
@@ -17,10 +17,14 @@ import { postDirectorDeskCapturesToHost } from "../io/hostBridge";
|
||||
import {
|
||||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
|
||||
getCameraLookRotationFromTarget,
|
||||
getCameraViewSnapshotFromShot,
|
||||
getDepthOfFieldHalfWidth,
|
||||
getSensorPreset,
|
||||
getWorldCameraViewSnapshot,
|
||||
computeVerticalFovFromLens,
|
||||
computeFocalLengthFromFov,
|
||||
} from "../schema/cameraGeometry";
|
||||
import { evaluateCameraViewAtTime } from "../schema/timeline";
|
||||
import { SENSOR_PRESETS, type SensorPresetId } from "../schema/directorProject";
|
||||
import { getDirectorObjectFocusTarget, isCameraFocusableObject } from "../schema/cameraTarget";
|
||||
import type { DirectorCameraCapture } from "../schema/directorProject";
|
||||
@@ -103,6 +107,9 @@ export function CameraPanel() {
|
||||
// 面板里的「机位位置 X/Y/Z」也要显示同一套世界坐标,否则场景一缩放 / 旋转,
|
||||
// 手柄(世界)和数值(局部)就差得越来越多。编辑时把世界值反算回场景局部坐标再存。
|
||||
const scene = useDirectorStore((state) => state.project.scene);
|
||||
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
|
||||
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
|
||||
const globalTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
|
||||
const sceneMatrix = useMemo(() => getSceneGroupMatrix(scene), [scene]);
|
||||
const sceneMatrixInverse = useMemo(() => sceneMatrix.clone().invert(), [sceneMatrix]);
|
||||
const localToWorldAxis = (local: [number, number, number], axis: 0 | 1 | 2): number =>
|
||||
@@ -112,6 +119,34 @@ export function CameraPanel() {
|
||||
|
||||
if (!camera) return null;
|
||||
const currentCamera = camera;
|
||||
// 当前时刻「真正渲染」的机位世界位姿(含运镜关键帧 / 多机位过渡)——与主视口、监视器、导出同源。
|
||||
// 「对焦到所选物体」必须按这个位姿量距离:有运镜关键帧时,基础机位的位置并不是画面里的位置,
|
||||
// 按基础机位量出来的距离会把对焦对到别处 —— 看起来就是「明明对焦了还是糊」。
|
||||
const activeCameraWorldView = (() => {
|
||||
const getStaticPose = (cameraId: string) => {
|
||||
const shot = cameras.find((item) => item.id === cameraId);
|
||||
return shot ? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(shot), scene) : null;
|
||||
};
|
||||
const view = evaluateCameraViewAtTime(
|
||||
transitionClips,
|
||||
cameraTracks,
|
||||
globalTransition,
|
||||
currentCamera.id,
|
||||
playhead,
|
||||
getStaticPose
|
||||
);
|
||||
const localView = view
|
||||
? { fov: view.pose.fov, position: view.pose.position, lookRotation: view.pose.lookRotation }
|
||||
: getCameraViewSnapshotFromShot(currentCamera);
|
||||
return getWorldCameraViewSnapshot(localView, scene);
|
||||
})();
|
||||
const selectedFocusObject = objects.find((item) => item.id === selectedObjectId) ?? null;
|
||||
const selectedFocusDistance =
|
||||
selectedFocusObject && isCameraFocusableObject(selectedFocusObject)
|
||||
? new Vector3(...activeCameraWorldView.position).distanceTo(
|
||||
new Vector3(...getDirectorObjectFocusTarget(selectedFocusObject))
|
||||
)
|
||||
: null;
|
||||
const captures = useMemo(() => currentCamera.captures ?? [], [currentCamera.captures]);
|
||||
const cameraCaptureGroups = useMemo(
|
||||
() =>
|
||||
@@ -471,6 +506,8 @@ export function CameraPanel() {
|
||||
* 按播放头解析(resolveLensAtTime):无运镜轨道回落机位静态值;有轨道则显示该时刻插值出的镜头参数,
|
||||
* 点击/拖到关键帧即显示那帧的镜头数值。 */
|
||||
const lens = resolveLensAtTime(currentCamera, playhead, cameraTracks);
|
||||
// 与渲染同一套换算:超过这个半径的物体一律吃满最大模糊(画面"整片糊"就是没有主体落在带宽内)
|
||||
const depthOfFieldHalfWidth = getDepthOfFieldHalfWidth(lens);
|
||||
|
||||
function handleFovChange(value: string) {
|
||||
const fov = Number(value);
|
||||
@@ -504,6 +541,23 @@ export function CameraPanel() {
|
||||
updateCameraLensAtTime(currentCamera.id, { focusDistance }, playhead);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键对焦到所选物体:把对焦距离设成「当前机位世界位姿 → 所选物体」的实测距离。
|
||||
*
|
||||
* 走 allKeyframes:该机位有运镜轨道时,画面用的是关键帧插值出来的镜头,只改最近一帧的话,
|
||||
* 当前时刻的对焦距离仍然是插值结果,设了也不会立刻生效("对焦到物体上了还是糊")。
|
||||
* 这是一次性动作,语义就是「这台机位对焦在这个物体上」,所以整条轨道统一改掉。
|
||||
*/
|
||||
function handleFocusSelectedObject() {
|
||||
if (selectedFocusDistance === null) return;
|
||||
updateCameraLensAtTime(
|
||||
currentCamera.id,
|
||||
{ focusDistance: Number(selectedFocusDistance.toFixed(2)) },
|
||||
playhead,
|
||||
{ allKeyframes: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handleBokehChange(value: string) {
|
||||
const bokehMaxBlur = Number(value);
|
||||
if (!Number.isFinite(bokehMaxBlur)) return;
|
||||
@@ -962,6 +1016,24 @@ export function CameraPanel() {
|
||||
value={lens.bokehMaxBlur}
|
||||
onValueChange={handleBokehChange}
|
||||
/>
|
||||
{/* 把「对焦距离 + 光圈 + 景深强度」换算成实际带宽,避免只能靠试:
|
||||
主体不在带宽内 → 整片糊;想两个主体都清楚就把光圈 f 值调大。 */}
|
||||
<p className="camera-lens-hint" aria-label="景深过渡范围">
|
||||
{Number.isFinite(depthOfFieldHalfWidth)
|
||||
? `对焦 ${lens.focusDistance.toFixed(1)} m · ±${depthOfFieldHalfWidth.toFixed(1)} m 以外即最大虚化`
|
||||
: "景深强度为 0 · 全画面清晰"}
|
||||
</p>
|
||||
{/* 主体距离直接给出来,不用再靠估:选中角色 / 道具即可一键把对焦对上去 */}
|
||||
{selectedFocusDistance !== null ? (
|
||||
<div className="camera-lens-focus">
|
||||
<span className="camera-lens-focus-distance" aria-label="所选物体距离">
|
||||
所选物体 {selectedFocusDistance.toFixed(1)} m
|
||||
</span>
|
||||
<button type="button" className="camera-lens-focus-button" onClick={handleFocusSelectedObject}>
|
||||
对焦到所选物体
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</InspectorSection>
|
||||
<InspectorSection title="相机截图" className="camera-capture-section">
|
||||
<button
|
||||
|
||||
@@ -88,7 +88,15 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
|
||||
transition: cameraTransition,
|
||||
fallbackCameraId: activeCameraId,
|
||||
});
|
||||
renderSceneWithBokeh(gl, scene, camera, resolveLensAtTime(shot, playhead, cameraTracks));
|
||||
renderSceneWithBokeh(
|
||||
gl,
|
||||
scene,
|
||||
camera,
|
||||
resolveLensAtTime(shot, playhead, cameraTracks),
|
||||
// 带上机位名:全屏监视器会把两个视图实际用的景深参数并排显示,
|
||||
// 若这里和监视器解析出的机位不是同一台,一眼就能看出来
|
||||
`机位视角·${shot.name}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
VIEWPORT_CAMERA_FRUSTUM_FRAME_WIDTH,
|
||||
VIEWPORT_CAMERA_VISUAL_SCALE,
|
||||
apertureToBokeh,
|
||||
getDepthOfFieldHalfWidth,
|
||||
getCameraLookPoint,
|
||||
getCameraLookRotationFromTarget,
|
||||
getCameraRigPositionFromViewSnapshot,
|
||||
@@ -65,16 +66,31 @@ it("scales the viewport camera viewfinder from one visual scale", () => {
|
||||
expect(VIEWPORT_CAMERA_FRUSTUM_FRAME_WIDTH).toBeCloseTo(3.2 * VIEWPORT_CAMERA_VISUAL_SCALE);
|
||||
});
|
||||
|
||||
it("reports the depth-of-field transition half width so the panel can explain 'everything is blurry'", () => {
|
||||
// halfWidth = maxblur / aperture:f/2.8 + 默认景深强度 0.012 → 约 ±4.2m。
|
||||
const halfWidth = getDepthOfFieldHalfWidth({ apertureFNumber: 2.8, focusDistance: 5, bokehMaxBlur: 0.012 });
|
||||
expect(halfWidth).toBeCloseTo(4.2, 1);
|
||||
// 关键回归:离焦 1m 的物体只吃到 1/4 左右的模糊(旧基准 0.03/f 时是 91%,红偶会糊成一团)
|
||||
expect(1 / halfWidth).toBeLessThan(0.3);
|
||||
// 光圈收小(f 值变大)→ aperture 变小 → 带宽明显变宽,前后两个主体可以同时清楚
|
||||
const narrowAperture = getDepthOfFieldHalfWidth({ apertureFNumber: 8, focusDistance: 5, bokehMaxBlur: 0.012 });
|
||||
expect(narrowAperture).toBeGreaterThan(10);
|
||||
// 景深强度调到 0 → 关掉虚化 → 整幅画面清晰
|
||||
expect(getDepthOfFieldHalfWidth({ apertureFNumber: 2.8, focusDistance: 5, bokehMaxBlur: 0 })).toBe(Infinity);
|
||||
// 对焦越近清晰带越窄(aperture 按 (5/对焦距离)² 放大)
|
||||
expect(getDepthOfFieldHalfWidth({ apertureFNumber: 2.8, focusDistance: 1, bokehMaxBlur: 0.012 })).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("widens the depth-of-field band with the focus distance instead of a fixed sharp band", () => {
|
||||
// BokehShader: dofblur = clamp((focus - 距离) × aperture, ±maxblur)
|
||||
// 于是「清晰带宽度」= maxblur / aperture。aperture 若不随对焦距离缩放,
|
||||
// 这个宽度就恒定(默认 f/2.8 约 ±1.1m):把对焦设到远景时,连远景都超出 1.1m
|
||||
// 这个宽度就恒定:把对焦设到远景时,连远景都超出带宽
|
||||
// 而被判成最大模糊 —— 表现就是「整片画面全糊」。
|
||||
const maxblur = 0.012;
|
||||
const sharpBand = (focusDistance: number) => maxblur / apertureToBokeh(2.8, focusDistance);
|
||||
|
||||
// 默认对焦 5m 与原先行为一致
|
||||
expect(sharpBand(5)).toBeCloseTo(maxblur / (0.03 / 2.8), 5);
|
||||
// 默认对焦 5m 的带宽 = maxblur / (0.008 / 2.8) ≈ ±4.2m(基准系数见 apertureToBokeh 注释)
|
||||
expect(sharpBand(5)).toBeCloseTo(maxblur / (0.008 / 2.8), 5);
|
||||
// 对焦 40m(8 倍)→ 清晰带按平方展开成 64 倍,远景才清晰得起来
|
||||
expect(sharpBand(40) / sharpBand(5)).toBeCloseTo(64, 5);
|
||||
// 光圈越大(f 值越小)虚化越强
|
||||
|
||||
@@ -210,25 +210,56 @@ export function formatLensInfo(lens: ResolvedLens): string {
|
||||
* 必须带上对焦距离一起算,原因在 BokehShader 的模糊公式:
|
||||
* dofblur = clamp( (focus - 距离) × aperture, -maxblur, maxblur )
|
||||
* 它只是「对焦距离与物体距离之差」的线性斜坡。若不随对焦距离缩放,清晰带宽度恒为
|
||||
* maxblur / aperture(默认 f/2.8 时约 ±1.1m),**与对焦多远完全无关** ——
|
||||
* 把对焦设到远景(如 40m)时,近景固然糊,但远景/地面离 40m 也远超 1.1m,
|
||||
* maxblur / aperture,**与对焦多远完全无关** ——
|
||||
* 把对焦设到远景(如 40m)时,近景固然糊,但远景/地面离 40m 也远超带宽,
|
||||
* 同样被判成最大模糊,结果就是「整片画面全糊」。
|
||||
*
|
||||
* 真实镜头的景深随对焦距离按平方展开(DOF ≈ 2·N·c·S²/f²),所以这里按
|
||||
* (默认对焦距离 / 当前对焦距离)² 缩放斜坡:对焦越远清晰带越宽,
|
||||
* 才能得到「远景清晰、近景模糊」的正常观感;对焦 5m(默认值)时缩放为 1,行为与原先一致。
|
||||
* 才能得到「远景清晰、近景模糊」的正常观感;对焦 5m(默认值)时缩放为 1。
|
||||
*
|
||||
* 基准系数取 0.008 / f(原为 0.03 / f):斜坡是线性的,所以带宽直接决定「离焦多远就糊到看不出」。
|
||||
* 0.03 / f + 默认景深强度 0.012 ⇒ 半宽只有 ≈1.1m,意味着**离焦平面 1m 就吃满最大虚化**,
|
||||
* 稍有前后景深的画面(两个主体一前一后、主体比背景近一点)就会除焦平面外整片糊。
|
||||
* 0.008 / f 把半宽放到 ≈4.2m:主体前后 1m 左右只吃到约 25% 模糊、仍然清楚,
|
||||
* 而背景(离焦 10m 以上)照样吃满虚化 —— 也就是正常镜头「主体清楚、背景虚」的观感。
|
||||
* 想要更浅的景深仍照旧:把光圈 f 值调小(f/1.4 ≈ ±2.1m)或把景深强度调大、对焦调近。
|
||||
*/
|
||||
export function apertureToBokeh(
|
||||
apertureFNumber: number,
|
||||
focusDistance: number = DEFAULT_FOCUS_DISTANCE
|
||||
): number {
|
||||
const clampedFNumber = Math.min(32, Math.max(0.5, apertureFNumber));
|
||||
const base = Math.min(0.05, Math.max(0.0002, 0.03 / clampedFNumber));
|
||||
const base = Math.min(0.05, Math.max(0.0002, 0.008 / clampedFNumber));
|
||||
const focus = Math.max(0.1, focusDistance);
|
||||
const focusScale = (DEFAULT_FOCUS_DISTANCE / focus) ** 2;
|
||||
return Math.min(0.05, Math.max(0.00001, base * focusScale));
|
||||
}
|
||||
|
||||
/**
|
||||
* 景深过渡带半径:由「对焦距离 + 光圈 + 景深强度」算出画面里"还没糊到底"的距离范围。
|
||||
*
|
||||
* BokehShader 的模糊量是:
|
||||
* blur = clamp( (对焦距离 − 物体距离) × aperture, ±maxblur )
|
||||
* 所以
|
||||
* - 距离 = 对焦距离 → 完全清晰;
|
||||
* - 距离差到 halfWidth = maxblur / aperture 处 → 已经吃满最大模糊;
|
||||
* - 再远也只是同一个最大模糊值(远处物体不会更糊)。
|
||||
*
|
||||
* 于是「整片都糊」的典型症状就是:**没有任何主体落在 ±halfWidth 以内**。
|
||||
* f/2.8 + 默认景深强度 0.012 时 halfWidth ≈ 4.2 m(离焦 1m 只吃到约 25% 模糊、基本清楚)。
|
||||
* 想再放宽:光圈 f 值调大(aperture 变小 → 带宽变宽,f/8 约 ±12m),或把景深强度调小。
|
||||
* 景深强度为 0(关掉虚化)时整幅画面都清晰,返回 Infinity。
|
||||
*/
|
||||
export function getDepthOfFieldHalfWidth(
|
||||
lens: Pick<ResolvedLens, "apertureFNumber" | "focusDistance" | "bokehMaxBlur">
|
||||
): number {
|
||||
if (!(lens.bokehMaxBlur > 0)) return Infinity;
|
||||
const aperture = apertureToBokeh(lens.apertureFNumber, lens.focusDistance);
|
||||
if (!(aperture > 0)) return Infinity;
|
||||
return lens.bokehMaxBlur / aperture;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「场景内局部坐标的机位视角」换算成「世界坐标的机位视角」。
|
||||
*
|
||||
|
||||
45
src/editor/store/directorStore.cameraLens.test.ts
Normal file
45
src/editor/store/directorStore.cameraLens.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, expect, it } from "vitest";
|
||||
import { createDefaultDirectorProject, useDirectorStore } from "./directorStore";
|
||||
import { resolveLensAtTime } from "../runtime/playbackTransforms";
|
||||
|
||||
beforeEach(() => {
|
||||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||||
});
|
||||
|
||||
/**
|
||||
* 「明明对焦到物体上了,画面还是糊」的根因:有运镜轨道时镜头是按关键帧插值出来的,
|
||||
* 只写最近一帧的话当前时刻拿到的仍是插值结果。allKeyframes 让一次性动作写满整条轨道。
|
||||
*/
|
||||
it("allKeyframes 写入让对焦距离在当前时刻立刻生效,而不是被关键帧插值摊掉", () => {
|
||||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||||
const readFocus = (time: number) => {
|
||||
const state = useDirectorStore.getState();
|
||||
const camera = state.project.cameras.find((item) => item.id === cameraId)!;
|
||||
return resolveLensAtTime(camera, time, state.project.timeline.cameraTracks).focusDistance;
|
||||
};
|
||||
|
||||
useDirectorStore.getState().addCameraKeyframe(cameraId, 0, {
|
||||
position: [0, 1.55, 5.4],
|
||||
lookRotation: [0, 0, 0],
|
||||
fov: 40,
|
||||
focusDistance: 5,
|
||||
});
|
||||
useDirectorStore.getState().addCameraKeyframe(cameraId, 4, {
|
||||
position: [0, 1.55, 5.4],
|
||||
lookRotation: [0, 0, 0],
|
||||
fov: 40,
|
||||
focusDistance: 5,
|
||||
});
|
||||
|
||||
// 播放头在两帧之间:只改最近一帧 → 当前时刻读到的是插值结果(8 与 5 的中点 6.5)
|
||||
useDirectorStore.getState().updateCameraLensAtTime(cameraId, { focusDistance: 8 }, 2);
|
||||
expect(readFocus(2)).toBeCloseTo(6.5, 5);
|
||||
|
||||
// allKeyframes(「对焦到所选物体」)→ 写满整条轨道,当前时刻就是设进去的值
|
||||
useDirectorStore.getState().updateCameraLensAtTime(cameraId, { focusDistance: 8 }, 2, {
|
||||
allKeyframes: true,
|
||||
});
|
||||
expect(readFocus(2)).toBeCloseTo(8, 5);
|
||||
expect(readFocus(0)).toBeCloseTo(8, 5);
|
||||
expect(readFocus(4)).toBeCloseTo(8, 5);
|
||||
});
|
||||
@@ -352,7 +352,9 @@ export interface DirectorActions {
|
||||
updateCameraLensAtTime: (
|
||||
cameraId: string,
|
||||
lens: Partial<Pick<DirectorCameraShot, "fov" | "focalLengthMm" | "apertureFNumber" | "focusDistance" | "bokehMaxBlur">>,
|
||||
time: number
|
||||
time: number,
|
||||
/** allKeyframes:一次性动作(如「对焦到所选物体」)把值写进该轨道所有关键帧 —— 见实现注释 */
|
||||
options?: { allKeyframes?: boolean }
|
||||
) => void;
|
||||
// 时长
|
||||
setTimelineDuration: (seconds: number) => void;
|
||||
@@ -3978,7 +3980,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
timeline: updateTimelineDuration(state.project.timeline, seconds),
|
||||
},
|
||||
})),
|
||||
updateCameraLensAtTime: (cameraId, lens, time) =>
|
||||
updateCameraLensAtTime: (cameraId, lens, time, options) =>
|
||||
commitMutation((state) => {
|
||||
const camera = state.project.cameras.find((item) => item.id === cameraId);
|
||||
if (!camera) return state;
|
||||
@@ -3997,6 +3999,16 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
const timeline = state.project.timeline;
|
||||
let nextTimeline = timeline;
|
||||
if (track && track.keyframes.length > 0 && timeline) {
|
||||
if (options?.allKeyframes) {
|
||||
// 一次性动作(「对焦到所选物体」)写进**所有**关键帧:播放头落在两帧之间时,镜头是按
|
||||
// 关键帧插值出来的(resolveLensAtTime → evaluateCameraTrackAtTime),只改最近一帧的话
|
||||
// 当前时刻拿到的仍是插值结果 —— 表现就是「明明对焦到物体上了,画面还是糊」。
|
||||
nextTimeline = track.keyframes.reduce(
|
||||
(acc, keyframe) =>
|
||||
updateTimelineCameraKeyframeValue(acc, cameraId, keyframe.id, { ...keyframe.value, ...patch }),
|
||||
timeline
|
||||
);
|
||||
} else {
|
||||
// 关键帧感知:把本次改动的镜头字段合并进播放头最近的关键帧(其它字段保留)
|
||||
const nearest = [...track.keyframes].reduce((best, keyframe) =>
|
||||
Math.abs(keyframe.time - time) < Math.abs(best.time - time) ? keyframe : best
|
||||
@@ -4006,6 +4018,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
project: {
|
||||
|
||||
@@ -2301,6 +2301,28 @@ button.inspector-dropdown-option.is-selected {
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
/* 机位视角:把主视口画布本身锁成机位画幅(传感器画幅,居中 letterbox)。
|
||||
画布 = 机位画幅后,相机 aspect 就等于传感器画幅,于是「主视口 = 镜头监视器 = 视频导出」
|
||||
取景逐像素同源(虚化归一化系数也自然变成 1)。
|
||||
尺寸由 JS 按容器实测值算好写成 px 变量(见 useViewportFrameSize 注释):
|
||||
只靠 CSS aspect-ratio + max-height 在高度受限时会破坏比例、把画面拉变形。 */
|
||||
.director-canvas.is-camera-frame {
|
||||
/* 画幅外留边:用比字段色更暗的底,画幅边界一眼可辨 */
|
||||
background: rgb(6 8 12);
|
||||
}
|
||||
|
||||
.director-canvas.is-camera-frame > div {
|
||||
position: absolute;
|
||||
left: var(--camera-frame-left, 0);
|
||||
top: var(--camera-frame-top, 0);
|
||||
width: var(--camera-frame-width, 100%) !important;
|
||||
height: var(--camera-frame-height, 100%) !important;
|
||||
}
|
||||
|
||||
.director-canvas.is-camera-frame canvas {
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.18);
|
||||
}
|
||||
|
||||
.viewport-aspect-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -2884,6 +2906,47 @@ button.inspector-dropdown-option.is-selected {
|
||||
background: rgb(var(--field-rgb));
|
||||
}
|
||||
|
||||
/* 镜头面板的景深带宽读数:一行小字,说明「对焦距离 ± 多少以外就一定吃满最大虚化」 */
|
||||
.camera-lens-hint {
|
||||
margin: -2px 0 0;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* 「所选物体距离 + 一键对焦」:把主体距离直接写出来,不用再靠估 */
|
||||
.camera-lens-focus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: -2px 0 0;
|
||||
}
|
||||
|
||||
.camera-lens-focus-distance {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.camera-lens-focus-button {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgb(var(--accent-rgb) / 0.5);
|
||||
border-radius: 6px;
|
||||
color: rgb(var(--text-rgb));
|
||||
background: rgb(var(--accent-rgb) / 0.14);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.camera-lens-focus-button:hover,
|
||||
.camera-lens-focus-button:focus-visible {
|
||||
background: rgb(var(--accent-rgb) / 0.26);
|
||||
}
|
||||
|
||||
.camera-capture-section {
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -5227,6 +5290,10 @@ body.is-timeline-dragging * {
|
||||
}
|
||||
|
||||
.lens-monitor-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
@@ -5237,12 +5304,166 @@ body.is-timeline-dragging * {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
}
|
||||
|
||||
.lens-monitor-info-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 展开成全屏查看:小窗只有 320px 宽,看不清虚化细节 */
|
||||
.lens-monitor-expand {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(var(--border-rgb) / 0.32);
|
||||
border-radius: 5px;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
background: rgb(var(--panel-rgb) / 0.66);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lens-monitor-expand:hover,
|
||||
.lens-monitor-expand:focus-visible {
|
||||
color: rgb(var(--text-rgb));
|
||||
background: rgb(var(--surface-hover-rgb) / 0.72);
|
||||
}
|
||||
|
||||
.lens-monitor-stage-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===== 镜头监视器 · 全屏查看 ===== */
|
||||
.lens-monitor-fullscreen-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
background: rgb(0 0 0 / 0.74);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px 18px 14px;
|
||||
/* 只有内部控件接收事件,其余区域透给背板(点背板关闭) */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-title {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--text-rgb));
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-lens {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(255 255 255 / 0.14);
|
||||
border-radius: 8px;
|
||||
color: rgb(255 255 255 / 0.9);
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-close:hover,
|
||||
.lens-monitor-fullscreen-close:focus-visible {
|
||||
color: #ffffff;
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(var(--border-rgb) / 0.35);
|
||||
border-radius: 10px;
|
||||
background: #000;
|
||||
box-shadow: var(--ui-shadow-panel);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 诊断读数:机位视角 / 镜头监视器各自实际用的景深参数,差异一眼可见 */
|
||||
.lens-monitor-fullscreen-debug {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2px 18px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lens-monitor-fullscreen-debug b {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--text-rgb));
|
||||
}
|
||||
|
||||
/* 时间轴整条铺满视口宽度(左右只留和其它悬浮卡片一致的 16px 内缩),
|
||||
左右对称 → 工具条中段自动落在屏幕正中;上下由 --floating-card-max-height 让位,不会和侧栏重叠 */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user