Compare commits

2 Commits

Author SHA1 Message Date
89ac44c418 feat(editor): 机位画幅遮幅与监视器全屏对齐
主视口机位视角按传感器画幅加 letterbox 遮幅,使主视口、镜头监视器与视频导出的取景逐像素同源,虚化按传感器画幅归一化。镜头监视器新增全屏查看(Esc 关闭),支持拖拽瞄准与单击对焦并显示景深诊断读数,像素比上限改为 2 以消除机位视角的放大模糊。监视器与主视口统一按自动切机位口径解析当前机位。相机面板新增所选物体距离显示与一键对焦。
2026-09-20 18:10:03 +08:00
8fcc25955d fix(editor): 监视器在两个视角下统一自渲染 2026-09-20 10:35:37 +08:00
16 changed files with 1123 additions and 149 deletions

View File

@@ -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("");
});

View File

@@ -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()}

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

View File

@@ -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);
};
// 小窗和全屏弹窗共用的交互(拖拽瞄准 / 单击对焦)与光标
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}
/>
) : (
<div className="lens-monitor-empty">无激活机位</div>
);
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}
style={{ aspectRatio: String(sensorAspect), cursor: stageCursor }}
{...stageInteractionProps}
>
{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
camera={camera}
sceneSettings={sceneSettings}
sceneRef={monitorSceneRef}
camRef={monitorCamRef}
playhead={playhead}
cameraTracks={cameraTracks}
/>
<SceneRoot editorHelpers={false} />
</Canvas>
)
{camera && lens && fullscreen ? (
// 全屏打开时小窗让位:同一份场景不开两个 Canvas(否则等于白渲染两遍 + 两套景深后处理)
<div className="lens-monitor-stage-placeholder">全屏查看中…</div>
) : (
<div className="lens-monitor-empty">无激活机位</div>
monitorStage
)}
</div>
<div className="lens-monitor-info">
{lens ? formatLensInfo(lens) : "无激活机位"}
<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-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}
</>
);
}

View File

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

View File

@@ -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,
};
}

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

View File

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

View File

@@ -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 />);

View File

@@ -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

View File

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

View File

@@ -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 值越小)虚化越强

View File

@@ -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;
}
/**
* 把「场景内局部坐标的机位视角」换算成「世界坐标的机位视角」。
*

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

View File

@@ -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,14 +3999,25 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
const timeline = state.project.timeline;
let nextTimeline = timeline;
if (track && track.keyframes.length > 0 && timeline) {
// 关键帧感知:把本次改动的镜头字段合并进播放头最近的关键帧(其它字段保留)
const nearest = [...track.keyframes].reduce((best, keyframe) =>
Math.abs(keyframe.time - time) < Math.abs(best.time - time) ? keyframe : best
);
nextTimeline = updateTimelineCameraKeyframeValue(timeline, cameraId, nearest.id, {
...nearest.value,
...patch,
});
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
);
nextTimeline = updateTimelineCameraKeyframeValue(timeline, cameraId, nearest.id, {
...nearest.value,
...patch,
});
}
}
return {
...state,

View File

@@ -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 让位,不会和侧栏重叠 */