diff --git a/src/editor/canvas/DirectorCanvas.test.tsx b/src/editor/canvas/DirectorCanvas.test.tsx index 504edde..2118cfc 100644 --- a/src/editor/canvas/DirectorCanvas.test.tsx +++ b/src/editor/canvas/DirectorCanvas.test.tsx @@ -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(); + + 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(); + + const canvasHost = screen.getByTestId("director-canvas"); + + expect(canvasHost).not.toHaveClass("is-camera-frame"); + expect(canvasHost.style.getPropertyValue("--camera-frame-width")).toBe(""); +}); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index fd7caed..be75678 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -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(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 传 position / quaternion / fov prop, * 这里再用 ref 兜底把这三个量直接写回相机实例。否则手改机位角度后,画面要等 R3F 对 * makeDefault 相机 prop 的更新时序走到才跟着转,表现为"水平角/俯仰角/横滚角改了没反应"。 */ @@ -1003,8 +1064,12 @@ export function DirectorCanvas() { return (
event.preventDefault()} diff --git a/src/editor/canvas/LensMonitor.test.tsx b/src/editor/canvas/LensMonitor.test.tsx new file mode 100644 index 0000000..5a29d57 --- /dev/null +++ b/src/editor/canvas/LensMonitor.test.tsx @@ -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("@react-three/fiber"); + + return { + ...actual, + Canvas: ({ children, className }: { children?: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + 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(); + + 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(); + + 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(); + + expect(within(screen.getByLabelText("镜头监视器")).getByText(/对焦 9\.0m/)).toBeInTheDocument(); +}); + +it("shows the lens info and the depth-of-field band in the fullscreen monitor", () => { + render(); + + 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(); + + 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(); +}); diff --git a/src/editor/canvas/LensMonitor.tsx b/src/editor/canvas/LensMonitor.tsx index 7a7fe95..b31007b 100644 --- a/src/editor/canvas/LensMonitor.tsx +++ b/src/editor/canvas/LensMonitor.tsx @@ -1,17 +1,20 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei"; -import { useMemo, useRef, useState } from "react"; +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"; @@ -105,7 +108,7 @@ function LensMonitorCamera({ // 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套 // EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。 // 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。 - renderSceneWithBokeh(gl, scene, renderCamera, lens); + renderSceneWithBokeh(gl, scene, renderCamera, lens, `镜头监视器·${camera.name}`); }, 1); return ( @@ -123,8 +126,101 @@ 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 —— 两边都挂等于把整个场景和景深后处理白渲染两遍。 + */ +function LensMonitorStage({ + camera, + lens, + sceneSettings, + panoramaAsset, + sceneRef, + camRef, + playhead, + cameraTracks, +}: { + camera: DirectorCameraShot; + lens: ReturnType; + sceneSettings: SceneSettings; + panoramaAsset: Parameters[0]["panoramaAsset"]; + sceneRef: React.MutableRefObject; + camRef: React.MutableRefObject; + playhead: number; + cameraTracks: CameraTimelineTrack[]; +}) { + return ( + + + {/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、 + backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */} + + + + {/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。 + SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层, + 监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同, + 清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */} + + + + + ); +} + +/** + * 把「传感器画幅」的矩形尽可能大地塞进容器,返回精确像素尺寸。 + * 全屏弹窗里必须给 R3F 一个确定尺寸:只用 CSS 的 aspect-ratio + max-height 组合在高度受限时 + * 会破坏比例(画面被拉伸),所以这里实测容器尺寸后按画幅算一次。 + * + * 用「回调 ref」而不是 useRef 对象:容器只在弹窗打开时才渲染,用对象 ref + useEffect 的话 + * 首次 effect 跑在容器还不存在的时候(current 为 null)→ 直接 return,且此后依赖不变不会再跑, + * 尺寸永远是 null —— 表现就是「全屏打开后一片空白」(Canvas 从未挂载)。 + */ +function useFittedSize(aspect: number) { + const [container, setContainer] = useState(null); + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + + useEffect(() => { + 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]); + + return { containerRef: setContainer, size }; +} export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) { const sceneSettings = useDirectorStore((state) => state.project.scene); @@ -142,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),点击/拖到关键帧时数字同步刷新 @@ -164,7 +272,29 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) { // 触屏对焦需要的监视器场景 / 渲染相机引用(由 LensMonitorCamera 每帧回填) const monitorSceneRef = useRef(null); const monitorCamRef = useRef(null); - const stageRef = useRef(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([]); + useEffect(() => { + if (!fullscreen) return; + const update = () => setBokehDebug(getBokehDebugSnapshots()); + update(); + const timer = window.setInterval(update, 500); + return () => window.clearInterval(timer); + }, [fullscreen]); // 在监视器画面上「按住拖动」改变机位注视角度:普通拖动改水平角 + 俯仰角;Shift+拖动改横滚角。 // 用基础机位的 lookRotation 作为起点累加位移,避免逐帧累积误差;写入走 updateCameraLookRotationAtTime, @@ -200,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; @@ -229,65 +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 ? ( + + ) : ( +
无激活机位
+ ); + return ( + <>
- {camera && lens ? ( - // 无论主视口是导演视角还是机位视角,监视器都自行渲染机位画面。 - // 曾试过「机位视角下镜像主视口」省一份渲染,但主视口会带上网格 / 地面坐标轴 / 轨迹 / - // 角色标签 / 选中 gizmo,且虚化按主视口宽度计算 —— 切到机位视角后监视器就和导演视角下 - // 的干净机位画面对不上。统一自渲染才能保证两个视角下所见完全一致。 - - - {/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、 - backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */} - - - - {/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。 - SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层, - 监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同, - 清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */} - - - - + {camera && lens && fullscreen ? ( + // 全屏打开时小窗让位:同一份场景不开两个 Canvas(否则等于白渲染两遍 + 两套景深后处理) +
全屏查看中…
) : ( -
无激活机位
+ monitorStage )}
- {lens ? formatLensInfo(lens) : "无激活机位"} + {lens ? formatLensInfo(lens) : "无激活机位"} +
+ {fullscreen && typeof document !== "undefined" + ? createPortal( + <> +