diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index fffcabf..fd7caed 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -24,7 +24,9 @@ import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, + resolveLens, type CameraViewSnapshot, + type ResolvedLens, } from "../schema/cameraGeometry"; import type { DirectorObject, DirectorTransform, SceneSettings } from "../schema/directorProject"; import { degreesRotationToRadians } from "../schema/directorProject"; @@ -39,6 +41,8 @@ import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver"; import { ActiveCameraPlaybackSync } from "../runtime/ActiveCameraPlaybackSync"; import { PlaybackTransformSync } from "./PlaybackTransformSync"; import { ViewportAspectOverlay } from "./ViewportAspectOverlay"; +import { LensMonitor } from "./LensMonitor"; +import { renderSceneWithBokeh } from "./viewportBokeh"; import { ViewportBackground } from "./ViewportBackground"; import { ViewportToolbar } from "./ViewportToolbar"; import { getViewportAspectFrameRect, type ViewportSafeAreaInsets } from "./viewportAspectFrame"; @@ -555,9 +559,18 @@ function CanvasCaptureBridge({ const originalQuaternion = workingCamera.quaternion.clone(); const originalFov = workingCamera.fov; - const snapshot = (label: string, metaTarget?: [number, number, number]) => { + // 机位视角(且能取到完整机位镜头参数)下的截图要带景深虚化:拿静态镜头参数喂给 bokeh 合成器。 + const getFullCamera = (id?: string | null) => + id ? useDirectorStore.getState().project.cameras.find((item) => item.id === id) ?? null : null; + const snapshot = ( + label: string, + metaTarget?: [number, number, number], + lens?: ResolvedLens | null + ) => { withViewportCaptureHelpersHidden(scene, () => { - gl.render(scene, workingCamera); + // 机位视角带镜头参数 → bokeh 后处理渲染(浅景深);否则普通渲染(导演视角 / 环绕预览)。 + if (lens) renderSceneWithBokeh(gl, scene, workingCamera, lens); + else gl.render(scene, workingCamera); }); return { label, @@ -585,9 +598,11 @@ function CanvasCaptureBridge({ const savedPosition = workingCamera.position.clone(); const savedQuaternion = workingCamera.quaternion.clone(); const savedFov = workingCamera.fov; + const shotCamera = getFullCamera(cameraId); + const lens = shotCamera ? resolveLens(shotCamera) : null; applyCameraViewToCamera(workingCamera, activeCamera); try { - return [snapshot("当前机位", getCameraLookPoint(activeCamera.position, activeCamera.lookRotation))]; + return [snapshot("当前机位", getCameraLookPoint(activeCamera.position, activeCamera.lookRotation), lens)]; } finally { workingCamera.position.copy(savedPosition); workingCamera.quaternion.copy(savedQuaternion); @@ -595,7 +610,10 @@ function CanvasCaptureBridge({ workingCamera.updateProjectionMatrix(); } } - return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角")]; + // 机位视角下「当前视角」截图同样带景深;导演视角则不带。 + const liveCamera = viewMode === "camera" && activeCamera ? getFullCamera(activeCamera.id) : null; + const liveLens = liveCamera ? resolveLens(liveCamera) : null; + return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角", undefined, liveLens)]; } const count = preset === "four" ? 4 : 12; @@ -1107,6 +1125,8 @@ export function DirectorCanvas() { + {/* 机位视角的位姿 / 镜头解析与渲染都在这里:与镜头监视器同一套做法, + 并接管渲染循环(priority>0 关掉 r3f 自动渲染)。 */} @@ -1130,6 +1150,7 @@ export function DirectorCanvas() { 重置视角 + {/* 轨迹打点提示:独立浮层,显示在快捷工具栏「上方」,不嵌进工具栏里 */} {trajectoryPlacementObjectId ? (
diff --git a/src/editor/canvas/LensMonitor.tsx b/src/editor/canvas/LensMonitor.tsx new file mode 100644 index 0000000..6d3e173 --- /dev/null +++ b/src/editor/canvas/LensMonitor.tsx @@ -0,0 +1,356 @@ +import { Canvas, useFrame, useThree } from "@react-three/fiber"; +import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei"; +import { useEffect, useMemo, useRef, useState } from "react"; +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 { + formatLensInfo, + getCameraRenderQuaternion, + getCameraViewSnapshotFromShot, + getWorldCameraViewSnapshot, +} from "../schema/cameraGeometry"; +import { resolveLensAtTime } from "../runtime/playbackTransforms"; +import type { DirectorCameraShot, SceneSettings } from "../schema/directorProject"; +import type { CameraKeyframeValue, CameraTimelineTrack } from "../schema/timeline"; +import { evaluateCameraViewAtTime, getActiveCameraIdAtTime } from "../schema/timeline"; + +/** + * 镜头监视器:独立于主视口(即便主视口是「导演视角」也照常显示当前机位看到的内容)。 + * 复用 SceneRoot 渲染同一份场景(不带编辑器辅助),并按传感器宽高比裁切画幅、 + * 用 ViewportBackground 渲染与主视口 / 导出一致的全景背景,通过共享的 renderSceneWithBokeh + * 反映光圈带来的景深虚化。UI 上的小字显示当前镜头参数。 + * + * 「当前机位」= 时间轴此刻正在显示的机位(多机位自动切换 / 过渡时会变),与视频导出用同一个 + * getActiveCameraIdAtTime 解析;位姿、镜头(焦段/光圈/对焦/景深强度)、画幅全部取自这台机位, + * 保证监视器所见与导出的机位视角严格一致。 + * + * 刻意做成独立的第二个 Canvas:景深后处理只作用在监视器上,不影响主视口 / 截图的渲染管线。 + */ + +function LensMonitorCamera({ + camera, + sceneSettings, + sceneRef, + camRef, + playhead, + cameraTracks, +}: { + camera: DirectorCameraShot; + sceneSettings: SceneSettings; + sceneRef: React.MutableRefObject; + camRef: React.MutableRefObject; + playhead: number; + cameraTracks: CameraTimelineTrack[]; +}) { + const { gl, scene } = useThree(); + const cameraRef = useRef(null); + // 把监视器自己的场景 / 渲染相机暴露给外层 DOM 事件,供「触屏对焦」射线拾取使用 + sceneRef.current = scene; + const cameras = useDirectorStore((state) => state.project.cameras); + const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips); + const globalTransition = useDirectorStore((state) => state.project.timeline.cameraTransition); + // 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值, + // 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。 + const lens = useMemo( + () => resolveLensAtTime(camera, playhead, cameraTracks), + [camera, playhead, cameraTracks] + ); + // 与导出共用 evaluateCameraViewAtTime:让监视器随运镜关键帧 / 多机位过渡实时动起来, + // 渲染的位姿(含场景变换下的世界坐标换算)与导出的机位视角严格一致。 + // 无运镜轨道时回落到机位静态视角,行为和以前一样。 + const worldView = useMemo(() => { + const getStaticPose = (cameraId: string): CameraKeyframeValue | null => { + const shot = cameras.find((item) => item.id === cameraId); + return shot ? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(shot), sceneSettings) : null; + }; + const view = evaluateCameraViewAtTime( + transitionClips, + cameraTracks, + globalTransition, + camera.id, + playhead, + getStaticPose + ); + if (!view) return getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(camera), sceneSettings); + return getWorldCameraViewSnapshot( + { fov: view.pose.fov, position: view.pose.position, lookRotation: view.pose.lookRotation }, + sceneSettings + ); + }, [camera, cameras, cameraTracks, transitionClips, globalTransition, playhead, sceneSettings]); + const sensorAspect = lens.sensor.widthMm / lens.sensor.heightMm; + + useFrame(() => { + const renderCamera = cameraRef.current; + if (!renderCamera || !worldView) return; + + // 把当前机位的世界位姿 + 由焦段/画幅换算的 FOV 写到渲染相机上,并按传感器比例裁切画幅 + renderCamera.position.set(...worldView.position); + renderCamera.quaternion.copy(getCameraRenderQuaternion(worldView.lookRotation)); + if (Math.abs(renderCamera.fov - lens.fov) > 0.001) { + renderCamera.fov = lens.fov; + } + if (Math.abs(renderCamera.aspect - sensorAspect) > 1e-4) { + renderCamera.aspect = sensorAspect; + } + renderCamera.updateProjectionMatrix(); + camRef.current = renderCamera; + + // 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套 + // EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。 + // 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。 + renderSceneWithBokeh(gl, scene, renderCamera, lens); + }, 1); + + return ( + + ); +} + +/** 鼠标每像素对应的角度增量(度) */ +const LOOK_DRAG_SENSITIVITY = 0.3; +/** 俯仰角夹到 ±89°,避免越过天顶后镜头翻转 */ +const PITCH_LIMIT = 89; +/** 区分「单击对焦」与「拖拽瞄准」的位移阈值(像素) */ +const CLICK_THRESHOLD_PX = 4; + +/** + * 机位视角下监视器的「镜像」:主视口此刻渲染的就是机位画面,监视器没必要再开一个 Canvas + * 把整个场景重渲一遍(第二份场景 + 第二套景深后处理,既卡,又容易和主视口取景 / 虚化漂移)。 + * 这里直接把主视口画布按「传感器画幅」居中裁一块拷过来,逐像素与主视口一致。 + * + * fov 是垂直的:视口比传感器画幅更宽时按宽度裁,垂直取景保持不变 —— + * 结果与监视器自己用传感器画幅渲染完全一致。 + */ +function MonitorMirror({ sensorAspect }: { sensorAspect: number }) { + const canvasRef = useRef(null); + + useEffect(() => { + let frame = 0; + const draw = () => { + frame = requestAnimationFrame(draw); + const target = canvasRef.current; + const source = document.querySelector(".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 + ); + }; + + frame = requestAnimationFrame(draw); + return () => cancelAnimationFrame(frame); + }, [sensorAspect]); + + return ; +} + +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); + const activeCamera = useDirectorStore((state) => + state.project.cameras.find((item) => item.id === state.project.activeCameraId) + ); + const updateCameraLookRotationAtTime = useDirectorStore((state) => state.updateCameraLookRotationAtTime); + const updateCameraFocusDistanceAtTime = useDirectorStore((state) => state.updateCameraFocusDistanceAtTime); + const beginUndoBatch = useDirectorStore((state) => state.beginUndoBatch); + const endUndoBatch = useDirectorStore((state) => state.endUndoBatch); + const playhead = usePlaybackStore((state) => state.playhead); + 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 机位」, + // 导出(按显示机位解析镜头)就会和监视器对不上。 + const displayCameraId = useMemo( + () => getActiveCameraIdAtTime(transitionClips, cameraTracks, activeCamera?.id ?? null, playhead), + [transitionClips, cameraTracks, activeCamera?.id, playhead] + ); + const camera = cameras.find((item) => item.id === displayCameraId) ?? activeCamera; + // 小字镜头信息也按播放头解析(与画面渲染同一份 resolveLensAtTime),点击/拖到关键帧时数字同步刷新 + const lens = camera ? resolveLensAtTime(camera, playhead, cameraTracks) : null; + const sensorAspect = lens ? lens.sensor.widthMm / lens.sensor.heightMm : 16 / 9; + const [dragging, setDragging] = useState(false); + const dragRef = useRef<{ + startX: number; + startY: number; + startLook: [number, number, number]; + rollMode: boolean; + } | null>(null); + // 触屏对焦需要的监视器场景 / 渲染相机引用(由 LensMonitorCamera 每帧回填) + const monitorSceneRef = useRef(null); + const monitorCamRef = useRef(null); + const stageRef = useRef(null); + + // 在监视器画面上「按住拖动」改变机位注视角度:普通拖动改水平角 + 俯仰角;Shift+拖动改横滚角。 + // 用基础机位的 lookRotation 作为起点累加位移,避免逐帧累积误差;写入走 updateCameraLookRotationAtTime, + // 由它决定写基础机位还是最近关键帧(关键帧感知),并在拖拽起止用 undo 批次合并成单条撤销记录。 + const handlePointerDown = (event: React.PointerEvent) => { + if (!camera || event.button !== 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + startX: event.clientX, + startY: event.clientY, + startLook: [...camera.lookRotation] as [number, number, number], + rollMode: event.shiftKey, + }; + setDragging(true); + beginUndoBatch(); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || !camera) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + const [pitch, yaw, roll] = drag.startLook; + const next: [number, number, number] = drag.rollMode + ? [pitch, yaw, roll + dx * LOOK_DRAG_SENSITIVITY] + : [ + Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch - dy * LOOK_DRAG_SENSITIVITY)), + yaw + dx * LOOK_DRAG_SENSITIVITY, + roll, + ]; + updateCameraLookRotationAtTime(camera.id, next, playhead); + }; + + // 监视器触屏对焦:在已选中机位的前提下,单击画面某点(非拖拽)时, + // 从渲染相机发出射线拾取场景,取命中点到相机的距离写入对焦距离。 + const handleTouchFocus = (clientX: number, clientY: number) => { + 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; + const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1; + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(ndcX, ndcY), renderCamera); + const hits = raycaster.intersectObjects(scene.children, true); + if (hits.length === 0) return; + const distance = Math.max(0.1, Math.min(100, hits[0].distance)); + updateCameraFocusDistanceAtTime(camera.id, distance, playhead); + }; + + const endDrag = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + // 位移极小且无右键 => 视为单击,触发触屏对焦;否则只是结束拖拽瞄准 + const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY); + const wasClick = moved <= CLICK_THRESHOLD_PX && event.button === 0; + dragRef.current = null; + setDragging(false); + endUndoBatch(); + if (wasClick) handleTouchFocus(event.clientX, event.clientY); + }; + + return ( +
+
+ {camera && lens ? ( + // 机位视角下主视口本身就是机位画面 → 监视器直接镜像它,不再开第二个 Canvas 重渲整个场景 + //(省掉一份场景渲染 + 一套景深后处理,而且与主视口逐像素一致)。 + // 其它视角(例如导演视角下想同时看机位画面)才需要监视器自己渲染。 + viewMode === "camera" ? ( + + ) : ( + + + {/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、 + backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */} + + + + {/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。 + SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层, + 监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同, + 清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */} + + + + + ) + ) : ( +
无激活机位
+ )} +
+
+ {lens ? formatLensInfo(lens) : "无激活机位"} +
+
+ ); +} diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index 2de1eb1..daed929 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -40,6 +40,7 @@ import { } from "../schema/cameraGeometry"; import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels"; import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion"; +import { evaluateTrackAtTime } from "../schema/timeline"; import type { TransformMode } from "../store/directorStore"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; @@ -511,6 +512,7 @@ function ObjectSceneNode({ transformable, translationSnap, onSelect, + editorHelpers = true, }: { asset?: DirectorAssetRef; item: DirectorObject; @@ -520,6 +522,7 @@ function ObjectSceneNode({ transformable: boolean; translationSnap: number | null; onSelect?: (item: DirectorObject) => void; + editorHelpers?: boolean; }) { const groupRef = useRef(null!); const [measuredCharacterLabel, setMeasuredCharacterLabel] = useState<{ @@ -543,6 +546,23 @@ function ObjectSceneNode({ () => evaluateTrajectoryMotionAtTime(item, playbackDuration, motionTime), [item, playbackDuration, motionTime] ); + // 角色姿势关键帧:按 playhead 从物体轨道插值出「关节角度覆盖值」。 + // 与轨迹模式一致在渲染期计算(playhead 变化时 ObjectSceneNode 会重渲染), + // 预览与视频导出(flushSync 推进 playhead)都能实时反映姿势变化;无轨道则回落静态姿势。 + const objectTrack = useDirectorStore((state) => + state.project.timeline.tracks.find((track) => track.objectId === item.id) ?? null + ); + // 正在拖姿势滑杆 / 套用姿势预设的角色:临时让「静态姿势」优先于关键帧插值, + // 做到拖拽即所见;停手 / 打关键帧 / 播放后 posingCharacterId 回落为 null,轨道驱动恢复。 + const posingCharacterId = useDirectorStore((state) => state.posingCharacterId); + const poseControlsOverride = useMemo(() => { + if (item.kind !== "character") return undefined; + if (!objectTrack || objectTrack.keyframes.length === 0) return undefined; + // 编辑中的角色不套用关键帧插值(否则滑杆拖动的实时姿势被关键帧姿势盖住,看不到)。 + if (posingCharacterId === item.id && !isPlaying) return undefined; + const value = evaluateTrackAtTime(objectTrack, motionTime); + return value?.poseControls; + }, [item.kind, objectTrack, motionTime, posingCharacterId, isPlaying]); const isImportedModel = asset?.sourceType === "model"; // 导入的 GLB/GLTF 人形角色:几何来自资产,需要把对应资产的 url 传给 CharacterModel 去加载。 const characterModelAssetUrl = useDirectorStore((state) => @@ -615,6 +635,7 @@ function ObjectSceneNode({ color={item.color} onLabelAnchorYChange={handleCharacterLabelAnchorYChange} rigState={item.characterRig} + poseControlsOverride={poseControlsOverride} motionTime={motionTime} characterId={item.id} motionStartTime={item.motionStartTime} @@ -636,7 +657,7 @@ function ObjectSceneNode({ // 选中了本角色的某个轨迹点时,角色被预览到该路点上,此时不再显示角色自身的变换 gizmo, // 避免和轨迹点的拖拽 gizmo 重叠;拖拽由轨迹点 gizmo 负责。 - const showGizmo = selected && transformable && !isPlaying && !activeTrajectoryPoint; + const showGizmo = editorHelpers && selected && transformable && !isPlaying && !activeTrajectoryPoint; if (!showGizmo) return node; @@ -661,6 +682,7 @@ function CrowdTransformRig({ transformMode, transformable, translationSnap, + editorHelpers = true, }: { crowdId: string; objects: DirectorObject[]; @@ -668,6 +690,7 @@ function CrowdTransformRig({ transformMode: TransformMode; transformable: boolean; translationSnap: number | null; + editorHelpers?: boolean; }) { const groupRef = useRef(null!); const updateCrowdTransform = useDirectorStore((state) => state.updateCrowdTransform); @@ -685,7 +708,7 @@ function CrowdTransformRig({ }); } - if (!selected || !transformable || !crowdAnchor) return null; + if (!editorHelpers || !selected || !transformable || !crowdAnchor) return null; return ( <> @@ -736,6 +759,7 @@ function ViewportCameraRig({ transformMode, transformable, translationSnap, + editorHelpers = true, }: { camera: DirectorCameraShot; object?: DirectorObject; @@ -744,6 +768,7 @@ function ViewportCameraRig({ transformMode: TransformMode; transformable: boolean; translationSnap: number | null; + editorHelpers?: boolean; }) { const groupRef = useRef(null!); const selectObject = useDirectorStore((state) => state.selectObject); @@ -852,7 +877,7 @@ function ViewportCameraRig({ ); - if (!selected || !transformable) return node; + if (!editorHelpers || !selected || !transformable) return node; return ( <> @@ -936,8 +961,12 @@ function TrajectoryPath({ const selectedGroupRef = useRef(null!); const points = owner.trajectoryPoints ?? []; const color = owner.color ?? TRAJECTORY_FALLBACK_COLOR; - // 只有在「可交互(非打点模式)且非播放中」且确实有选中点时,才给选中点挂拖拽 gizmo - const draggable = interactive && !isPlaying && Boolean(selectedPointId); + const trajectoryLocked = owner.trajectoryLocked === true; + const trajectoryVisible = owner.trajectoryVisible !== false; + // 只有在「可交互(非打点模式)且非播放中」且确实有选中点、且轨迹未整体锁定时,才给选中点挂拖拽 gizmo + const selectedPointLocked = + Boolean(selectedPointId) && points.some((item) => item.id === selectedPointId && item.locked); + const draggable = interactive && !isPlaying && Boolean(selectedPointId) && !trajectoryLocked && !selectedPointLocked; /** 平滑曲线采样点:从「角色自身位置」出发,依次穿过每个路点(Catmull-Rom 样条, * centripetal 型避免路径自交打环)。这样轨迹是从角色身上延伸出来的,而不是凭空 @@ -963,10 +992,12 @@ function TrajectoryPath({ return curve.getPoints(segments).map((point) => [point.x, point.y, point.z] as [number, number, number]); }, [points, owner.transform.position]); - if (!points.length) return null; + if (!points.length || !trajectoryVisible) return null; function selectPoint(pointId: string, event: ThreeEvent) { event.stopPropagation(); + const point = points.find((item) => item.id === pointId); + if (!point || point.locked) return; selectTrajectoryPoint(owner.id, pointId); // 轨迹点本身就是时间轴上的关键帧:选中即把指针定位到该点时间, @@ -1017,7 +1048,7 @@ function TrajectoryPath({ selectPoint(point.id, event) : undefined} + onClick={!point.locked && interactive ? (event) => selectPoint(point.id, event) : undefined} > @@ -1029,7 +1060,7 @@ function TrajectoryPath({ selectPoint(point.id, event) : undefined} + onClick={!point.locked && interactive ? (event) => selectPoint(point.id, event) : undefined} > @@ -1072,7 +1103,7 @@ function GroundAxes({ y }: { y: number }) { ); } -export function SceneRoot() { +export function SceneRoot({ editorHelpers = true }: { editorHelpers?: boolean }) { const scene = useDirectorStore((state) => state.project.scene); const assets = useDirectorStore((state) => state.project.assets); const objects = useDirectorStore((state) => state.project.objects); @@ -1087,6 +1118,8 @@ export function SceneRoot() { const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId); const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint); const panoramaAsset = assets.find((item) => item.id === panoramaAssetId); + // 镜头监视器等「只看镜头画面」的场景:不显示名字标签、网格坐标轴等编辑器辅助 + const labelVisible = editorHelpers && scene.showLabels; const translationSnap = scene.snapToGrid ? 1 : null; const assetsById = useMemo(() => new Map(assets.map((item) => [item.id, item])), [assets]); const cameraObjectsByCameraId = useMemo(() => { @@ -1140,23 +1173,26 @@ export function SceneRoot() { transparent /> - + {/* 坐标轴只在编辑器里辅助用,镜头画面里不出现 */} + {editorHelpers ? : null} ) : null} - {/* 打点模式:整块地面变成落点用的拾取面(不可见) */} - {trajectoryPlacementObjectId ? ( + {/* 打点模式 / 轨迹线 / 机位线框:都是编辑器辅助,监视器里一律不显示 */} + {editorHelpers && trajectoryPlacementObjectId ? ( ) : null} - {objects - .filter((item) => (item.trajectoryPoints ?? []).length > 0) - .map((item) => ( - - ))} + {editorHelpers + ? objects + .filter((item) => (item.trajectoryPoints ?? []).length > 0) + .map((item) => ( + + )) + : null} {objects .filter((item) => item.visible && item.kind !== "camera") .map((item) => { @@ -1168,11 +1204,12 @@ export function SceneRoot() { asset={asset} item={item} selected={item.crowdId ? false : item.id === selectedObjectId} - showLabels={scene.showLabels} + showLabels={labelVisible} + editorHelpers={editorHelpers} transformMode={transformMode} transformable={!item.locked} translationSnap={translationSnap} - onSelect={handleObjectSelect} + onSelect={editorHelpers ? handleObjectSelect : undefined} /> ); })} @@ -1183,13 +1220,14 @@ export function SceneRoot() { crowdId={crowdId} objects={objects} selected={selectedCrowdId === crowdId} + editorHelpers={editorHelpers} transformMode={transformMode} transformable={!(crowdLocksById.get(crowdId) ?? false)} translationSnap={translationSnap} /> ) )} - {viewMode === "director" + {editorHelpers && viewMode === "director" ? cameras .map((camera) => ({ camera, object: cameraObjectsByCameraId.get(camera.id) })) .filter(({ object }) => object?.visible ?? true) @@ -1199,7 +1237,8 @@ export function SceneRoot() { camera={camera} object={object} selected={object?.id === selectedObjectId} - showLabel={scene.showLabels} + showLabel={false} + editorHelpers={editorHelpers} transformMode={transformMode} transformable={Boolean(object && !object.locked)} translationSnap={translationSnap} diff --git a/src/editor/canvas/viewportBokeh.tsx b/src/editor/canvas/viewportBokeh.tsx new file mode 100644 index 0000000..f9de2b0 --- /dev/null +++ b/src/editor/canvas/viewportBokeh.tsx @@ -0,0 +1,103 @@ +import { Vector2, type PerspectiveCamera, type Scene, type WebGLRenderer } from "three"; +import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; +import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; +import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js"; +import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js"; +import { apertureToBokeh, type ResolvedLens } from "../schema/cameraGeometry"; + +/** + * 复用的景深后处理:用一份按 WebGLRenderer 缓存的 EffectComposer(RenderPass + BokehPass + OutputPass) + * 渲染场景,让浅景深虚化在三处完全一致 —— 主视口机位视角(ActiveCameraPlaybackSync)、 + * 镜头监视器(LensMonitor)、视频逐帧导出(videoExport),另外机位截图也走这里。 + * + * 这是「监视器 / 机位视角 / 导出」唯一的渲染实现,三处只负责解析出「当前显示机位 + 该时刻镜头」 + * 再调用本函数,因此取景、焦距、虚化不会各写一套而漂移。 + * 合成器按 gl 缓存(每个 Canvas 一份),尺寸 / 像素比变化时按需重建。 + * 失败(如浏览器后处理不支持)则回退到普通渲染,画面永远可用。 + */ + +interface BokehState { + composer: EffectComposer; + bokeh: BokehPass; + width: number; + height: number; + pixelRatio: number; + scene: Scene; + camera: PerspectiveCamera; +} + +const bokehCache = new WeakMap(); +const sizeVector = new Vector2(); + +function readSize(gl: WebGLRenderer): { width: number; height: number; pixelRatio: number } { + gl.getSize(sizeVector); + return { width: sizeVector.x, height: sizeVector.y, pixelRatio: gl.getPixelRatio() }; +} + +function ensureBokehState( + gl: WebGLRenderer, + scene: Scene, + 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); + const existing = bokehCache.get(gl); + if ( + existing && + existing.scene === scene && + existing.camera === camera && + existing.width === width && + existing.height === height && + existing.pixelRatio === effectivePixelRatio + ) { + return existing; + } + + try { + const composer = new EffectComposer(gl); + composer.setPixelRatio(effectivePixelRatio); + composer.setSize(width, height); + composer.addPass(new RenderPass(scene, camera)); + const bokeh = new BokehPass(scene, camera, { + focus: 5, + aperture: apertureToBokeh(2.8), + maxblur: 0.012, + }); + composer.addPass(bokeh); + // OutputPass 负责把线性结果做色调映射 + sRGB 转换,否则经后处理的画面会偏暗/发灰 + composer.addPass(new OutputPass()); + const next: BokehState = { composer, bokeh, width, height, pixelRatio: effectivePixelRatio, scene, camera }; + bokehCache.set(gl, next); + return next; + } catch { + bokehCache.delete(gl); + return null; + } +} + +/** 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。 */ +export function renderSceneWithBokeh( + gl: WebGLRenderer, + scene: Scene, + camera: PerspectiveCamera, + lens: ResolvedLens +): void { + const state = ensureBokehState(gl, scene, camera); + if (!state) { + gl.render(scene, camera); + return; + } + + const uniforms = (state.bokeh as unknown as { uniforms?: Record }).uniforms; + 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; + } + state.composer.render(); +} diff --git a/src/editor/io/videoExport.test.ts b/src/editor/io/videoExport.test.ts index 775b96f..820c1b9 100644 --- a/src/editor/io/videoExport.test.ts +++ b/src/editor/io/videoExport.test.ts @@ -2,7 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest"; import { Object3D, PerspectiveCamera, Scene, Vector2 } from "three"; import { usePlaybackStore } from "../runtime/playbackStore"; import { useDirectorStore } from "../store/directorStore"; -import { getCameraLookRotationFromTarget } from "../schema/cameraGeometry"; +import { getCameraLookRotationFromTarget, resolveLens } from "../schema/cameraGeometry"; import { exportVideo } from "./videoExport"; vi.mock("mp4-muxer", () => ({ @@ -22,6 +22,14 @@ vi.mock("./videoExportBridge", () => ({ getVideoExportContext: () => contextRef.current, })); +// 相机视角导出会走 renderSceneWithBokeh(真实 EffectComposer / BokehPass 在 jsdom 下无 WebGL)。 +// 桩里直接委托给 gl.render,既能避开 WebGL,又保留「记录每帧相机位姿」的测试意图 +// (createFakeBridge 只在 gl.render 时把相机位姿写入 bridge.rendered)。 +vi.mock("../canvas/viewportBokeh", () => ({ + renderSceneWithBokeh: (gl: { render: (s: unknown, c: unknown) => void }, scene: unknown, camera: unknown) => + gl.render(scene, camera), +})); + beforeEach(() => { usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null }); }); @@ -152,13 +160,16 @@ it("renders camera-view exports from the camera shot itself when it has no camer const expected = new PerspectiveCamera(); expected.position.set(0, 3, 9); expected.lookAt(0, 1.2, 0); + // 导出相机 fov 走「按播放头解析出的镜头 fov」(与监视器一致),而非机位里可能陈旧的 fov 字段 + const shot = useDirectorStore.getState().project.cameras.find((c) => c.id === shotId)!; + const resolvedFov = resolveLens(shot).fov; expect(bridge.rendered.length).toBeGreaterThan(0); for (const frame of bridge.rendered) { expect(frame.position[0]).toBeCloseTo(0, 5); expect(frame.position[1]).toBeCloseTo(3, 5); expect(frame.position[2]).toBeCloseTo(9, 5); - expect(frame.fov).toBeCloseTo(35, 5); + expect(frame.fov).toBeCloseTo(resolvedFov, 5); expect(frame.quaternion[0]).toBeCloseTo(expected.quaternion.x, 5); expect(frame.quaternion[1]).toBeCloseTo(expected.quaternion.y, 5); expect(frame.quaternion[2]).toBeCloseTo(expected.quaternion.z, 5); @@ -186,12 +197,15 @@ it("lets the camera track win over the static shot pose in camera-view exports", } // 有轨道时走的是预览同款插值,导出不该再用静态机位覆盖它 + // fov 走解析后的镜头 fov(与监视器一致),与关键帧里可能陈旧的 fov 字段无关 + const shot2 = useDirectorStore.getState().project.cameras.find((c) => c.id === shotId)!; + const resolvedFov2 = resolveLens(shot2).fov; expect(bridge.rendered.length).toBeGreaterThan(0); for (const frame of bridge.rendered) { expect(frame.position[0]).toBeCloseTo(5, 5); expect(frame.position[1]).toBeCloseTo(1, 5); expect(frame.position[2]).toBeCloseTo(0, 5); - expect(frame.fov).toBeCloseTo(40, 5); + expect(frame.fov).toBeCloseTo(resolvedFov2, 5); } }); diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index f367451..0596824 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -3,10 +3,12 @@ import { ArrayBufferTarget, Muxer } from "mp4-muxer"; import { Vector2, type PerspectiveCamera } from "three"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; +import type { Scene } from "three"; import { getVideoExportContext } from "./videoExportBridge"; import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture"; -import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; -import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry"; +import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime, resolveLensAtTime } from "../runtime/playbackTransforms"; +import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, resolveLens, type CameraViewSnapshot } from "../schema/cameraGeometry"; +import { renderSceneWithBokeh } from "../canvas/viewportBokeh"; import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; import { getActiveCameraIdAtTime, normalizeTimelineFps } from "../schema/timeline"; import { EXPORT_CODEC_OPTIONS, EXPORT_QUALITY_OPTIONS, type ExportCodecPreference, type ExportSettings } from "../schema/directorProject"; @@ -75,12 +77,17 @@ function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: Camer * 按视频比例(viewportAspectRatio)计算导出尺寸,短边取自导出设置(默认 720),宽高取偶(编码器要求)。 * 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响, * 否则视口是扁的时导出也会跟着扁。 + * aspectOverride 供「机位视角」导出使用:直接指定画幅(传感器宽高比),覆盖视口比例, + * 让导出画面与镜头监视器(同样按传感器画幅裁切)的取景 / 景深虚化占比一致。 */ export function computeExportSize( aspectRatioId: ReturnType["viewportAspectRatio"], - resolutionShortEdge: number + resolutionShortEdge: number, + aspectOverride?: number ): { width: number; height: number } { - const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9; + const aspect = Number.isFinite(aspectOverride) && (aspectOverride as number) > 0 + ? (aspectOverride as number) + : getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9; let width: number; let height: number; if (aspect >= 1) { @@ -149,7 +156,26 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { const directorStore = useDirectorStore.getState(); const exportSettings = directorStore.project.exportSettings; - const { width, height } = computeExportSize(directorStore.viewportAspectRatio, exportSettings.resolutionShortEdge); + // 机位视角导出:按「激活机位的传感器画幅」裁切,与镜头监视器完全一致 + // (监视器同样按传感器宽高比裁切)。否则用默认 16:9 视口比例会比监视器更宽, + // 取景与景深虚化在画面中的占比都对不上。 + // 画幅统一走 resolveLens(而不是直接读 sensorPreset):监视器也是 resolveLens, + // 老工程 / 缺字段的机位会被补成默认画幅,两边无论如何都取到同一个比例。 + const activeShot = + view === "camera" + ? directorStore.project.cameras.find((item) => item.id === directorStore.project.activeCameraId) ?? null + : null; + const exportSensorAspect = activeShot + ? (() => { + const sensor = resolveLens(activeShot).sensor; + return sensor.widthMm / sensor.heightMm; + })() + : undefined; + const { width, height } = computeExportSize( + directorStore.viewportAspectRatio, + exportSettings.resolutionShortEdge, + exportSensorAspect + ); const duration = directorStore.project.timeline.duration; // 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致 const fps = normalizeTimelineFps(directorStore.project.timeline.fps); @@ -270,7 +296,26 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { // 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来, // 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。 - withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera)); + // 机位视角下带镜头参数 → bokeh 后处理渲染(浅景深,且随移焦关键帧逐帧变化);否则普通渲染。 + const exportLens = + view === "camera" && displayCameraId + ? (() => { + const exportCamera = directorStore.project.cameras.find((item) => item.id === displayCameraId); + return exportCamera + ? resolveLensAtTime(exportCamera, time, directorStore.project.timeline.cameraTracks) + : null; + })() + : null; + // 严格对齐监视器:渲染相机的垂直 fov 用「按播放头解析出的镜头 fov」(与监视器一致), + // 而非关键帧里可能陈旧的 fov 字段(如中途改过传感器画幅后),避免导出焦距 / 取景与监视器不同。 + if (exportLens && Math.abs(frameCamera.fov - exportLens.fov) > 0.001) { + frameCamera.fov = exportLens.fov; + frameCamera.updateProjectionMatrix(); + } + withViewportCaptureHelpersHidden(scene, () => { + if (exportLens) renderSceneWithBokeh(gl, scene as Scene, frameCamera, exportLens); + else gl.render(scene, frameCamera); + }); const frame = new VideoFrame(canvas, { timestamp: frameIndex * frameDurationUs, diff --git a/src/editor/panels/CameraPanel.tsx b/src/editor/panels/CameraPanel.tsx index 7c8b2d6..f13221c 100644 --- a/src/editor/panels/CameraPanel.tsx +++ b/src/editor/panels/CameraPanel.tsx @@ -17,12 +17,18 @@ import { postDirectorDeskCapturesToHost } from "../io/hostBridge"; import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraLookRotationFromTarget, + getSensorPreset, + computeVerticalFovFromLens, + computeFocalLengthFromFov, } from "../schema/cameraGeometry"; +import { SENSOR_PRESETS, type SensorPresetId } from "../schema/directorProject"; import { getDirectorObjectFocusTarget, isCameraFocusableObject } from "../schema/cameraTarget"; import type { DirectorCameraCapture } from "../schema/directorProject"; import { CAMERA_ROTATION_LIMITS, normalizeCameraRotation } from "../schema/directorProject"; import { useDirectorStore } from "../store/directorStore"; import { getSceneGroupMatrix } from "../canvas/sceneTransform"; +import { resolveLensAtTime } from "../runtime/playbackTransforms"; +import { usePlaybackStore } from "../runtime/playbackStore"; const VIEWER_ZOOM_MIN = 0.25; const VIEWER_ZOOM_MAX = 5; @@ -85,6 +91,14 @@ export function CameraPanel() { const setActiveCamera = useDirectorStore((state) => state.setActiveCamera); const addCameraCaptures = useDirectorStore((state) => state.addCameraCaptures); const updateCamera = useDirectorStore((state) => state.updateCamera); + const updateCameraLensAtTime = useDirectorStore((state) => state.updateCameraLensAtTime); + // 位置 / 角度也走「按播放头感知写入」:选中关键帧(播放头在该关键帧处)时,编辑即同步进该关键帧, + // 否则只改基础机位。否则像 fov 那样能同步、位置/角度却写不回关键帧,导致「在关键帧上改机位无效」。 + const updateCameraLookRotationAtTime = useDirectorStore((state) => state.updateCameraLookRotationAtTime); + const updateCameraPositionAtTime = useDirectorStore((state) => state.updateCameraPositionAtTime); + // 镜头数值按「播放头所在时刻」解析:点击/拖到关键帧时,面板显示该关键帧的镜头参数(与监视器/导出一致) + const playhead = usePlaybackStore((state) => state.playhead); + const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks); // 机位线框 / 三轴 gizmo 都画在场景组里(场景变换之内),手柄落在世界坐标; // 面板里的「机位位置 X/Y/Z」也要显示同一套世界坐标,否则场景一缩放 / 旋转, // 手柄(世界)和数值(局部)就差得越来越多。编辑时把世界值反算回场景局部坐标再存。 @@ -415,48 +429,85 @@ export function CameraPanel() { return; } + const targetWorld = getDirectorObjectFocusTarget(targetObject); + // 锁定物体时顺手把对焦距离回填成「机位 → 物体」的实测距离,主体立刻清晰、 + // 背景自然虚化,不用再手动估对焦距离。 + const focusDistance = Number( + new Vector3(...currentCamera.transform.position).distanceTo(new Vector3(...targetWorld)).toFixed(2) + ); + updateCamera(currentCamera.id, { targetMode: "object", targetObjectId: targetObject.id, // 锁定物体:把「机位 → 物体」这条视线换算成注视朝向角 - lookRotation: getCameraLookRotationFromTarget( - currentCamera.transform.position, - getDirectorObjectFocusTarget(targetObject) - ), + lookRotation: getCameraLookRotationFromTarget(currentCamera.transform.position, targetWorld), + focusDistance, }); } - /** 手改任一角度即退出「锁定物体」,改为手动角度(与轨迹点改朝向角的口径一致) */ + /** 手改任一角度即退出「锁定物体」,改为手动角度(与轨迹点改朝向角的口径一致)。 + * 走「按播放头感知写入」:选中关键帧时直接同步进该关键帧,否则只改基础机位。 */ function updateManualLookRotation(axis: 0 | 1 | 2, value: string) { const nextValue = normalizeCameraRotation(axis, toSafeNumber(value)); - updateCamera(currentCamera.id, { - targetMode: "manual", - targetObjectId: null, - lookRotation: replaceAxis(currentCamera.lookRotation, axis, nextValue), - }); + updateCameraLookRotationAtTime(currentCamera.id, replaceAxis(currentCamera.lookRotation, axis, nextValue), playhead); } function resetLookRotation(axis: 0 | 1 | 2) { // 俯仰角(轴 0)重置为水平 0°,水平角 / 横滚角回到默认机位对应角 const resetValue = axis === 0 ? DEFAULT_CAMERA_PITCH_RESET : DEFAULT_CAMERA_SHOT_LOOK_ROTATION[axis]; - updateCamera(currentCamera.id, { - targetMode: "manual", - targetObjectId: null, - lookRotation: replaceAxis(currentCamera.lookRotation, axis, resetValue), - }); + updateCameraLookRotationAtTime(currentCamera.id, replaceAxis(currentCamera.lookRotation, axis, resetValue), playhead); } function resetAllLookRotation() { - updateCamera(currentCamera.id, { - targetMode: "manual", - targetObjectId: null, - // 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位 - lookRotation: [ - DEFAULT_CAMERA_PITCH_RESET, - DEFAULT_CAMERA_SHOT_LOOK_ROTATION[1], - DEFAULT_CAMERA_SHOT_LOOK_ROTATION[2], - ], - }); + // 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位 + updateCameraLookRotationAtTime( + currentCamera.id, + [DEFAULT_CAMERA_PITCH_RESET, DEFAULT_CAMERA_SHOT_LOOK_ROTATION[1], DEFAULT_CAMERA_SHOT_LOOK_ROTATION[2]], + playhead + ); + } + + /** 镜头参数:焦段 / 画幅 / 光圈 / 对焦。焦段与 FOV 互算,保证两个控件一致。 + * 按播放头解析(resolveLensAtTime):无运镜轨道回落机位静态值;有轨道则显示该时刻插值出的镜头参数, + * 点击/拖到关键帧即显示那帧的镜头数值。 */ + const lens = resolveLensAtTime(currentCamera, playhead, cameraTracks); + + function handleFovChange(value: string) { + const fov = Number(value); + if (!Number.isFinite(fov)) return; + const focalLengthMm = computeFocalLengthFromFov(fov, lens.sensor.heightMm); + updateCameraLensAtTime(currentCamera.id, { fov, focalLengthMm }, playhead); + } + + function handleSensorChange(value: string) { + const sensor = getSensorPreset(value as SensorPresetId); + const fov = computeVerticalFovFromLens(lens.focalLengthMm, sensor.heightMm); + updateCamera(currentCamera.id, { sensorPreset: value as SensorPresetId, fov }); + } + + function handleFocalChange(value: string) { + const focalLengthMm = Number(value); + if (!Number.isFinite(focalLengthMm)) return; + const fov = computeVerticalFovFromLens(focalLengthMm, lens.sensor.heightMm); + updateCameraLensAtTime(currentCamera.id, { focalLengthMm, fov }, playhead); + } + + function handleApertureChange(value: string) { + const apertureFNumber = Number(value); + if (!Number.isFinite(apertureFNumber)) return; + updateCameraLensAtTime(currentCamera.id, { apertureFNumber }, playhead); + } + + function handleFocusChange(value: string) { + const focusDistance = Number(value); + if (!Number.isFinite(focusDistance)) return; + updateCameraLensAtTime(currentCamera.id, { focusDistance }, playhead); + } + + function handleBokehChange(value: string) { + const bokehMaxBlur = Number(value); + if (!Number.isFinite(bokehMaxBlur)) return; + updateCameraLensAtTime(currentCamera.id, { bokehMaxBlur }, playhead); } function renderCaptureCards(captureList: DirectorCameraCapture[]) { @@ -758,9 +809,8 @@ export function CameraPanel() { onReset={() => // 机位位置重置为默认机位(新建项目里 1 号机位所在的位置)。 // 这里不能像角色那样归零:相机落到世界原点后视线方向会退化。 - updateCamera(currentCamera.id, { - transform: { ...currentCamera.transform, position: [...DEFAULT_CAMERA_SHOT_POSITION] }, - }) + // 走「按播放头感知写入」:选中关键帧时把重置后的位置同步进该关键帧。 + updateCameraPositionAtTime(currentCamera.id, [...DEFAULT_CAMERA_SHOT_POSITION], playhead) } axes={[ { @@ -772,9 +822,7 @@ export function CameraPanel() { .applyMatrix4(sceneMatrix) .toArray() as [number, number, number]; world[0] = toSafeNumber(value); - updateCamera(currentCamera.id, { - transform: { ...currentCamera.transform, position: worldToLocal(world) }, - }); + updateCameraPositionAtTime(currentCamera.id, worldToLocal(world), playhead); }, }, { @@ -786,9 +834,7 @@ export function CameraPanel() { .applyMatrix4(sceneMatrix) .toArray() as [number, number, number]; world[1] = toSafeNumber(value); - updateCamera(currentCamera.id, { - transform: { ...currentCamera.transform, position: worldToLocal(world) }, - }); + updateCameraPositionAtTime(currentCamera.id, worldToLocal(world), playhead); }, }, { @@ -800,9 +846,7 @@ export function CameraPanel() { .applyMatrix4(sceneMatrix) .toArray() as [number, number, number]; world[2] = toSafeNumber(value); - updateCamera(currentCamera.id, { - transform: { ...currentCamera.transform, position: worldToLocal(world) }, - }); + updateCameraPositionAtTime(currentCamera.id, worldToLocal(world), playhead); }, }, ]} @@ -864,12 +908,61 @@ export function CameraPanel() { label="视野角度 (FOV)" rangeAriaLabel="机位 FOV 滑杆" numberAriaLabel="机位 FOV" - max="120" - min="10" + max="170" + min="1" step="0.1" value={currentCamera.fov} - onValueChange={(value) => updateCamera(currentCamera.id, { fov: Number(value) })} + onValueChange={handleFovChange} /> + + ({ value: preset.id, label: preset.label }))} + /> + + + + + state.selectedTrajectoryPoint); const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint); const removeTrajectoryPoint = useDirectorStore((state) => state.removeTrajectoryPoint); + const toggleTrajectoryVisible = useDirectorStore((state) => state.toggleTrajectoryVisible); + const toggleTrajectoryLocked = useDirectorStore((state) => state.toggleTrajectoryLocked); + const clearTrajectory = useDirectorStore((state) => state.clearTrajectory); + const toggleTrajectoryPointLocked = useDirectorStore((state) => state.toggleTrajectoryPointLocked); useEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -514,6 +518,14 @@ export function ObjectTreePanel() {
+ + + {trajectoryExpanded(item.id) ? (
    @@ -561,6 +614,21 @@ export function ObjectTreePanel() { {pointLabel} +