From 8645ea30076fc6faabb4e0dfabf1ed9234205529 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Fri, 18 Sep 2026 00:52:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BD=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/canvas/DirectorCanvas.test.tsx | 23 +- src/editor/canvas/DirectorCanvas.tsx | 69 ++- src/editor/canvas/ViewportToolbar.test.tsx | 25 +- src/editor/canvas/ViewportToolbar.tsx | 6 +- src/editor/canvas/sceneAxes.test.ts | 2 +- src/editor/canvas/sceneAxes.ts | 8 +- src/editor/canvas/sceneGround.ts | 6 + src/editor/io/videoExport.test.ts | 167 +++++++ src/editor/io/videoExport.ts | 66 ++- src/editor/panels/CameraPanel.tsx | 305 +++++++++--- src/editor/panels/ScenePanel.tsx | 2 +- src/editor/panels/TimelinePanel.test.tsx | 335 +++++++++++-- src/editor/panels/TimelinePanel.tsx | 521 ++++++++++++++------- src/editor/schema/directorProject.ts | 6 + src/editor/schema/timeline.ts | 33 ++ src/editor/schema/trajectoryMotion.ts | 26 +- src/editor/store/directorStore.ts | 75 ++- src/styles/index.css | 242 +++++++--- src/styles/index.css.test.ts | 14 +- 19 files changed, 1539 insertions(+), 392 deletions(-) diff --git a/src/editor/canvas/DirectorCanvas.test.tsx b/src/editor/canvas/DirectorCanvas.test.tsx index eaa72b9..504edde 100644 --- a/src/editor/canvas/DirectorCanvas.test.tsx +++ b/src/editor/canvas/DirectorCanvas.test.tsx @@ -159,30 +159,39 @@ vi.mock("@react-three/drei", async () => { Grid: ({ args, cellColor, + cellSize, cellThickness, fadeDistance, infiniteGrid, position, sectionColor, + sectionSize, + sectionThickness, side, }: { args?: [number, number]; cellColor?: string; + cellSize?: number; cellThickness?: number; fadeDistance?: number; infiniteGrid?: boolean; position?: [number, number, number]; sectionColor?: string; + sectionSize?: number; + sectionThickness?: number; side?: number; }) => (
@@ -292,13 +301,19 @@ it("does not render a full-viewport transform drag layer over the 3D viewport", expect(screen.queryByRole("application", { name: "3D视口缩放拖拽层" })).not.toBeInTheDocument(); }); -it("renders only the dark major viewport grid lines on the same footprint as the ground", () => { +it("renders a fine viewport grid with thicker, darker major lines every 5 cells on the same footprint as the ground", () => { render(); const grid = screen.getByTestId("viewport-grid"); - expect(grid).toHaveAttribute("data-cell-thickness", "0"); expect(grid).toHaveAttribute("data-position", "[0,0.002,0]"); - expect(grid).toHaveAttribute("data-section-color", "#2A4065"); + expect(grid).toHaveAttribute("data-cell-size", "1"); + expect(grid).toHaveAttribute("data-section-size", "5"); + // 每 5 格一条更粗的分割线 + expect(grid).toHaveAttribute("data-section-thickness", "1.4"); + // 细分割线比主分割线更细 + expect(grid).toHaveAttribute("data-cell-thickness", "0.5"); + // 主分割线颜色更浅(中性灰,突出 RGB 坐标轴) + expect(grid).toHaveAttribute("data-section-color", "#6b7280"); expect(grid).toHaveAttribute("data-fade-distance", "80"); // 网格不能是"无限网格":默认尺寸(1×1 × (1+fadeDistance))比地面小得多, // 边缘不重合就会被看成另一层地面。这里必须和地面同尺寸。 @@ -450,7 +465,7 @@ it("renders the native 3D viewport gizmo in an overlay canvas above the aspect m expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-margin", "[0,0]"); expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute( "data-axis-colors", - "[\"#E56C5B\",\"#6CDB7A\",\"#7AA7FF\"]" + "[\"#FF4D4F\",\"#52C41A\",\"#1890FF\"]" ); expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-disabled", "true"); expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-scale", "25"); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index 02a068a..22263e1 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -24,7 +24,7 @@ import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes"; import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig"; import { SceneRoot } from "./SceneRoot"; import { AXIS_COLORS } from "./sceneAxes"; -import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround"; +import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE, GRID_CELL_SIZE, GRID_SECTION_CELLS } from "./sceneGround"; import { getSceneGroupTransform } from "./sceneTransform"; import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture"; import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver"; @@ -74,6 +74,8 @@ const CAPTURE_LABEL_FONT_SIZE = 12; const CAPTURE_LABEL_HORIZONTAL_PADDING = 10; const CAPTURE_LABEL_VERTICAL_PADDING = 6; const CAPTURE_LABEL_BORDER_RADIUS = 999; +/** 截图标签相对角色头顶的额外抬高量,让标签浮在头部上方、不遮住模型 */ +const CAPTURE_LABEL_Y_OFFSET = 0.4; const CAPTURE_LABEL_PANEL_RGB_FALLBACK = "26 26 26"; const CAPTURE_LABEL_TEXT_RGB_FALLBACK = "255 255 255"; const GIZMO_AXIS_HIT_TARGETS: Array<{ @@ -240,9 +242,10 @@ function createSceneMatrix(scene: SceneSettings) { } function getCharacterCaptureLabelY(item: DirectorObject) { - return item.characterRig?.rigType === "ue4-mannequin" + const base = item.characterRig?.rigType === "ue4-mannequin" ? getUE4GroundedLabelY(item.bodyType) : getGroundedLabelY(item.bodyType); + return base + CAPTURE_LABEL_Y_OFFSET; } export function getViewportCaptureLabels(captureScene?: Object3D) { @@ -503,7 +506,7 @@ function CanvasCaptureBridge({ const originalQuaternion = workingCamera.quaternion.clone(); const originalFov = workingCamera.fov; - const snapshot = (label: string) => { + const snapshot = (label: string, metaTarget?: [number, number, number]) => { withViewportCaptureHelpersHidden(scene, () => { gl.render(scene, workingCamera); }); @@ -518,12 +521,35 @@ function CanvasCaptureBridge({ cameraId: cameraId ?? (viewMode === "camera" ? activeCamera?.id ?? null : null), fov: workingCamera.fov, position: [workingCamera.position.x, workingCamera.position.y, workingCamera.position.z], - target: [target.x, target.y, target.z], + target: metaTarget ?? [target.x, target.y, target.z], }), }; }; if (preset === "current") { + // 机位面板触发的「当前机位」截图:渲染的是该机位自己的视角, + // 而不是编辑器此刻所在的导演视角(导演视角通常更高、更远), + // 否则用户在机位面板生成的预览会和切到相机视角看到的不一致。 + if (source === "camera-panel" && cameraId) { + const captureCamera = useDirectorStore + .getState() + .project.cameras.find((item) => item.id === cameraId); + if (captureCamera) { + const view = getCameraViewSnapshotFromShot(captureCamera); + const savedPosition = workingCamera.position.clone(); + const savedQuaternion = workingCamera.quaternion.clone(); + const savedFov = workingCamera.fov; + applySnapshotToCamera(workingCamera, view); + try { + return [snapshot("当前机位", [...view.target])]; + } finally { + workingCamera.position.copy(savedPosition); + workingCamera.quaternion.copy(savedQuaternion); + workingCamera.fov = savedFov; + workingCamera.updateProjectionMatrix(); + } + } + } return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角")]; } @@ -561,6 +587,32 @@ function CanvasCaptureBridge({ return null; } +/** 机位视角下屏蔽 Ctrl/Cmd + 滚轮,避免浏览器把整页放大缩小。 + * 相机模式下没有 OrbitControls 接管滚轮,浏览器默认的 ctrl+滚轮缩放页面会直接触发, + * 监听 canvas 的 wheel 事件(非 passive)并在按下 ctrl/meta 时 preventDefault 即可拦截。 */ +function ViewportWheelGuard({ enabled }: { enabled: boolean }) { + const { gl } = useThree(); + + useEffect(() => { + if (!enabled) { + return; + } + const canvas = gl.domElement; + if (typeof canvas?.addEventListener !== "function") { + return; + } + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + } + }; + canvas.addEventListener("wheel", handleWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", handleWheel); + }, [enabled, gl]); + + return null; +} + /** 把渲染三件套注册给视频导出逻辑(逐帧离线渲染用),模式仿 CanvasCaptureBridge */ function VideoExportBridge() { const { camera, gl, scene } = useThree(); @@ -854,10 +906,14 @@ export function DirectorCanvas() { @@ -915,6 +971,7 @@ export function DirectorCanvas() { viewportAspectRatio={viewportAspectRatio} viewMode={viewMode} /> + diff --git a/src/editor/canvas/ViewportToolbar.test.tsx b/src/editor/canvas/ViewportToolbar.test.tsx index 5166306..78ed1ed 100644 --- a/src/editor/canvas/ViewportToolbar.test.tsx +++ b/src/editor/canvas/ViewportToolbar.test.tsx @@ -128,7 +128,7 @@ it("uses the fullscreen button to collapse the side panels instead of entering b ); }); -it("creates a new camera before storing viewport capsule screenshots from director view", async () => { +it("stores viewport capsule screenshots on the active camera without creating a new one from director view", async () => { const user = userEvent.setup(); const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); const snapshot = { @@ -142,7 +142,7 @@ it("creates a new camera before storing viewport capsule screenshots from direct dataUrl: "data:image/png;base64,current-camera", meta: { mode: "camera" as const, - cameraId: "cam_2", + cameraId: "cam_1", fov: 64, position: [3, 2, 1] as [number, number, number], target: [0, 1, -2] as [number, number, number], @@ -156,30 +156,25 @@ it("creates a new camera before storing viewport capsule screenshots from direct await user.click(screen.getByRole("button", { name: "当前视角截图" })); await waitFor(() => { - expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_2" }); + expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_1" }); }); const state = useDirectorStore.getState(); - const originalCamera = state.project.cameras[0]; - const newCamera = state.project.cameras[1]; + const activeCamera = state.project.cameras[0]; expect(anchorClick).not.toHaveBeenCalled(); expect(state.viewMode).toBe("camera"); - expect(state.project.activeCameraId).toBe("cam_2"); - expect(state.selectedObjectId).toBe("cam_object_2"); - expect(originalCamera?.captures).toEqual([]); - expect(newCamera?.fov).toBe(64); - expect(newCamera?.transform.position).toEqual(getCameraRigPositionFromViewSnapshot(snapshot)); - expect(getCameraViewSnapshotFromShot(newCamera)).toEqual(snapshot); - expect(newCamera?.captures).toEqual([ + expect(state.project.cameras).toHaveLength(1); + expect(state.project.activeCameraId).toBe("cam_1"); + expect(activeCamera?.captures).toEqual([ { - id: "cam_2-capture-01", + id: "cam_1-capture-01", index: 1, - name: "机位02-截图01", + name: "机位01-截图01", dataUrl: "data:image/png;base64,current-camera", }, ]); - expect(newCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera"); + expect(activeCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera"); }); it("stores viewport capsule screenshots in the current camera while already in camera view", async () => { diff --git a/src/editor/canvas/ViewportToolbar.tsx b/src/editor/canvas/ViewportToolbar.tsx index d726d2f..b6fd0f1 100644 --- a/src/editor/canvas/ViewportToolbar.tsx +++ b/src/editor/canvas/ViewportToolbar.tsx @@ -345,8 +345,8 @@ export function ViewportToolbar({ async function handleCapture(preset: "current" | "four" | "twelve") { try { - const targetCameraId = - viewMode === "director" ? addCameraShot(getViewportCameraSnapshot?.()) : activeCameraId; + // 复用当前选中的机位承接截图,避免每次点截图都新增一个机位(四方位 / 十二方位尤其明显) + const targetCameraId = activeCameraId ?? addCameraShot(getViewportCameraSnapshot?.()); setViewMode("camera"); await waitForNextAnimationFrame(); @@ -507,8 +507,8 @@ export function ViewportToolbar({ }, { label: "模型库", icon: Boxes, onClick: toggleModelLibrary }, { label: "添加机位", icon: Video, onClick: addCameraFromViewport }, - { label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") }, { label: "选择画幅比例", icon: Ratio, onClick: toggleAspectRatioPanel }, + { label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") }, { label: "四方位截图", icon: Grid2X2, onClick: () => void handleCapture("four") }, { label: "十二方位截图", icon: Grid3X3, onClick: () => void handleCapture("twelve") }, { label: "全屏", icon: Expand, onClick: toggleViewportPanelsCollapsed }, diff --git a/src/editor/canvas/sceneAxes.test.ts b/src/editor/canvas/sceneAxes.test.ts index aa0e0a9..5422bf7 100644 --- a/src/editor/canvas/sceneAxes.test.ts +++ b/src/editor/canvas/sceneAxes.test.ts @@ -3,7 +3,7 @@ import { AXIS_COLORS, getGroundAxisLines, GROUND_AXIS_LENGTH } from "./sceneAxes import { GROUND_PLANE_SIZE } from "./sceneGround"; it("keeps the axis color convention shared with the viewport gizmo", () => { - expect(AXIS_COLORS).toEqual({ x: "#E56C5B", y: "#6CDB7A", z: "#7AA7FF" }); + expect(AXIS_COLORS).toEqual({ x: "#FF4D4F", y: "#52C41A", z: "#1890FF" }); }); it("stretches each ground axis to both edges of the ground", () => { diff --git a/src/editor/canvas/sceneAxes.ts b/src/editor/canvas/sceneAxes.ts index 436b187..a1376f8 100644 --- a/src/editor/canvas/sceneAxes.ts +++ b/src/editor/canvas/sceneAxes.ts @@ -1,13 +1,13 @@ import { GROUND_PLANE_SIZE } from "./sceneGround"; /** - * 轴向颜色约定:X 红 / Y 绿 / Z 蓝。 + * 轴向颜色约定:X 红 / Y 绿 / Z 蓝(标准 RGB)。 * 视口右上角的坐标控件(全景球)和地面坐标轴共用这一份,避免两处颜色漂移。 */ export const AXIS_COLORS = { - x: "#E56C5B", - y: "#6CDB7A", - z: "#7AA7FF", + x: "#FF4D4F", + y: "#52C41A", + z: "#1890FF", } as const; /** 地面坐标轴的单侧长度:从原点铺到地面边缘,两侧合起来正好贯穿整块地面。 */ diff --git a/src/editor/canvas/sceneGround.ts b/src/editor/canvas/sceneGround.ts index 59fa740..d5b1c23 100644 --- a/src/editor/canvas/sceneGround.ts +++ b/src/editor/canvas/sceneGround.ts @@ -12,6 +12,12 @@ import { DoubleSide } from "three"; */ export const GROUND_PLANE_SIZE = 100; +/** 视口网格单格尺寸(世界单位)。以网格中心为起点向四周铺开。 */ +export const GRID_CELL_SIZE = 1; + +/** 每隔多少格绘制一条更粗、更深的分割线(主分割线)。 */ +export const GRID_SECTION_CELLS = 5; + /** 网格相对地面平面的抬升,避免两者共面时 z-fighting。 */ export const GROUND_GRID_ELEVATION = 0.002; diff --git a/src/editor/io/videoExport.test.ts b/src/editor/io/videoExport.test.ts index 07ca252..2f4b733 100644 --- a/src/editor/io/videoExport.test.ts +++ b/src/editor/io/videoExport.test.ts @@ -64,6 +64,173 @@ it("keeps the playhead where the export found it", async () => { expect(usePlaybackStore.getState().playhead).toBe(2.5); }); +/** 逐帧导出在 jsdom 里需要的几个全局桩(视口画布 / WebCodecs 都没有) */ +function stubVideoCodecs() { + const VideoEncoderStub = class { + static isConfigSupported = () => Promise.resolve({ supported: true }); + output = () => {}; + error = () => {}; + encodeQueueSize = 0; + configure() {} + encode() {} + flush() { + return Promise.resolve(); + } + close() {} + }; + const VideoFrameStub = class { + close() {} + }; + vi.stubGlobal("VideoEncoder", VideoEncoderStub); + vi.stubGlobal("VideoFrame", VideoFrameStub); + vi.stubGlobal("URL", { + ...URL, + createObjectURL: () => "blob:fake", + revokeObjectURL: () => {}, + }); +} + +/** 造一套假的渲染上下文,并记录「每一帧渲染瞬间」相机在哪儿、fov 多少 */ +function createFakeBridge() { + const scene = new Scene(); + const camera = new PerspectiveCamera(); + const rendered: Array<{ + position: [number, number, number]; + quaternion: [number, number, number, number]; + fov: number; + }> = []; + const gl = { + domElement: document.createElement("canvas"), + render: vi.fn(() => { + rendered.push({ + position: [camera.position.x, camera.position.y, camera.position.z], + quaternion: [camera.quaternion.x, camera.quaternion.y, camera.quaternion.z, camera.quaternion.w], + fov: camera.fov, + }); + }), + getSize: (size: Vector2) => size.set(300, 150), + getPixelRatio: () => 1, + setPixelRatio: () => {}, + setSize: () => {}, + }; + + contextRef.current = { gl, scene, camera }; + + return { camera, gl, scene, rendered }; +} + +/** 给默认机位换一个明显区别于导演视角的位姿(更高 / 更远 / 更窄) */ +function placeCameraShotAt(position: [number, number, number], target: [number, number, number], fov: number) { + const store = useDirectorStore.getState(); + const shot = store.project.cameras[0]; + store.updateCamera(shot.id, { + transform: { position, rotation: [0, 0, 0], scale: [1, 1, 1] }, + target, + fov, + }); + store.setActiveCamera(shot.id); + // 短时长,缩小导出帧数,加速测试 + store.setTimelineDuration(0.1); + + return shot.id; +} + +it("renders camera-view exports from the camera shot itself when it has no camera track", async () => { + const shotId = placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35); + const bridge = createFakeBridge(); + stubVideoCodecs(); + + try { + await exportVideo("camera"); + } finally { + vi.unstubAllGlobals(); + } + + // 没有运镜轨道时 applyActiveCameraViewAtTime 不会动相机,导出必须自己把机位视角写进去, + // 否则画面取决于「此刻 R3F 默认相机是谁」,换相机没及时落地就渲成导演视角(更高、更远)。 + const expected = new PerspectiveCamera(); + expected.position.set(0, 3, 9); + expected.lookAt(0, 1.2, 0); + + 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.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); + expect(frame.quaternion[3]).toBeCloseTo(expected.quaternion.w, 5); + } + // 机位自己仍在,没被导出改掉 + expect(useDirectorStore.getState().project.activeCameraId).toBe(shotId); +}); + +it("lets the camera track win over the static shot pose in camera-view exports", async () => { + const shotId = placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35); + // 给它加一条运镜轨道:0s 处机位在 [5, 1, 0],和机位自身的 [0, 3, 9] 明显不同 + useDirectorStore.getState().addCameraKeyframe(shotId, 0, { + position: [5, 1, 0], + target: [0, 1, 0], + fov: 40, + }); + const bridge = createFakeBridge(); + stubVideoCodecs(); + + try { + await exportVideo("camera"); + } finally { + vi.unstubAllGlobals(); + } + + // 有轨道时走的是预览同款插值,导出不该再用静态机位覆盖它 + 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); + } +}); + +it("does not move the render camera to the shot when exporting the director view", async () => { + placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35); + const bridge = createFakeBridge(); + stubVideoCodecs(); + + try { + await exportVideo("director"); + } finally { + vi.unstubAllGlobals(); + } + + // 上帝视角导出就该用导演相机,不能被机位自身的视角带跑 + expect(bridge.rendered.length).toBeGreaterThan(0); + expect(bridge.rendered.every((frame) => frame.position.every((value) => value === 0))).toBe(true); +}); + +it("puts the render camera back where it was after a camera-view export", async () => { + placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35); + const bridge = createFakeBridge(); + bridge.camera.position.set(1, 2, 3); + bridge.camera.lookAt(0, 0, 0); + bridge.camera.fov = 60; + const savedQuaternion = bridge.camera.quaternion.clone(); + stubVideoCodecs(); + + try { + await exportVideo("camera"); + } finally { + vi.unstubAllGlobals(); + } + + // 导出期间为了「所见即所得」改写了相机,结束必须还原,否则切回导演视角会变成机位的视角 + expect(bridge.camera.position.toArray()).toEqual([1, 2, 3]); + expect(bridge.camera.fov).toBe(60); + expect(bridge.camera.quaternion.equals(savedQuaternion)).toBe(true); +}); + it("hides editor helpers (gizmo / grid / axes) while rendering exported frames, then restores them", async () => { // 模拟场景里有一个「导出时隐藏」的辅助物体(也就是选中对象拖拽 gizmo 的那三条红绿蓝轴) const scene = new Scene(); diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index d19a5a3..98f52de 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -1,11 +1,12 @@ import { flushSync } from "react-dom"; import { ArrayBufferTarget, Muxer } from "mp4-muxer"; -import { Vector2 } from "three"; +import { Vector2, type PerspectiveCamera } from "three"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; import { getVideoExportContext } from "./videoExportBridge"; import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture"; import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; +import { getCameraViewSnapshotFromShot, type CameraViewSnapshot } from "../schema/cameraGeometry"; import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; import { normalizeTimelineFps } from "../schema/timeline"; @@ -55,6 +56,23 @@ function waitNextFrame(): Promise { ); } +/** + * 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。 + * + * 机位视角下 applyActiveCameraViewAtTime 只在有运镜轨道时才覆盖相机(无轨道直接 return false), + * 也就是说无轨道时导出画面完全取决于「此刻 R3F 的默认相机是谁」。默认相机本来应该是 + * 机位视角挂载的那颗 drei 相机,但换相机要等 React/R3F 走一轮,稍有差池就会渲成 + * 导演视角(默认更高、更远)—— 所以这里不再赌它,直接按机位自身的视角写死。 + */ +function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: CameraViewSnapshot) { + camera.position.set(view.position[0], view.position[1], view.position[2]); + camera.lookAt(view.target[0], view.target[1], view.target[2]); + if (Math.abs(camera.fov - view.fov) > 0.001) { + camera.fov = view.fov; + } + camera.updateProjectionMatrix(); +} + /** * 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。 * 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响, @@ -131,6 +149,10 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { gl.getSize(previousSize); const previousPixelRatio = gl.getPixelRatio(); const previousAspect = camera.aspect; + // 兜底改写相机会动到位姿,导出结束要还回去(切回导演视角后不该变成机位的视角) + const previousCameraPosition = camera.position.clone(); + const previousCameraQuaternion = camera.quaternion.clone(); + const previousCameraFov = camera.fov; const muxer = new Muxer({ target: new ArrayBufferTarget(), @@ -155,9 +177,19 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { camera.aspect = width / height; camera.updateProjectionMatrix(); - // 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera), - // 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用 - await new Promise((resolve) => window.setTimeout(resolve, 0)); + // 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera)。 + // 必须等一整帧:换相机要 React 重渲染 + R3F 换默认相机 + VideoExportBridge 重新注册, + // 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。 + await waitNextFrame(); + + // 切完视角再取当前机位:setViewMode("camera") 会在 activeCameraId 为空时补上第一个机位, + // 所以不能用上面的 directorStore 快照(那时可能还是 null)。 + const exportState = useDirectorStore.getState(); + const activeCameraShot = + view === "camera" + ? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null + : null; + const activeCameraView = activeCameraShot ? getCameraViewSnapshotFromShot(activeCameraShot) : null; const frameDurationUs = Math.round(1_000_000 / fps); try { @@ -182,15 +214,22 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { usePlaybackStore.getState().editingObjectId, { objects: directorStore.project.objects, duration } ); - applyActiveCameraViewAtTime({ + // 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机 + const appliedCameraTrack = applyActiveCameraViewAtTime({ camera: frameCamera, cameraTracks: directorStore.project.timeline.cameraTracks, - activeCameraId: directorStore.project.activeCameraId, + activeCameraId: exportState.project.activeCameraId, playhead: time, viewMode: view, editingObjectId: usePlaybackStore.getState().editingObjectId, }); + // 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机, + // 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。 + if (!appliedCameraTrack && activeCameraView) { + applyCameraSnapshotToFrameCamera(frameCamera, activeCameraView); + } + // 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来, // 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。 withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera)); @@ -222,9 +261,24 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { canvas.classList.remove("is-video-exporting"); gl.setPixelRatio(previousPixelRatio); gl.setSize(previousSize.x, previousSize.y, false); + + // 导出期间相机可能换过(切视角 → drei 挂载机位相机),两颗都按导出前的样子还原: + // - aspect 两颗都要还,否则切回导演视角后画面比例还停在导出的 16:9; + // - 位姿只在「导出用的就是导出前那颗相机」时还,否则等于把导演相机的位姿 + // 写到机位相机上,机位视角就会被带歪。 const restoreCamera = getVideoExportContext()?.camera ?? camera; restoreCamera.aspect = previousAspect; restoreCamera.updateProjectionMatrix(); + camera.aspect = previousAspect; + camera.updateProjectionMatrix(); + + if (restoreCamera === camera) { + restoreCamera.position.copy(previousCameraPosition); + restoreCamera.quaternion.copy(previousCameraQuaternion); + restoreCamera.fov = previousCameraFov; + restoreCamera.updateProjectionMatrix(); + } + directorStore.setViewMode(previousViewMode); usePlaybackStore.getState().setPlayhead(previousPlayhead); } diff --git a/src/editor/panels/CameraPanel.tsx b/src/editor/panels/CameraPanel.tsx index 6a41a05..d51245a 100644 --- a/src/editor/panels/CameraPanel.tsx +++ b/src/editor/panels/CameraPanel.tsx @@ -1,4 +1,4 @@ -import { Camera, Download, Eye, Images, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react"; +import { Camera, ChevronLeft, ChevronRight, Download, Eye, Images, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -20,6 +20,9 @@ import { useDirectorStore } from "../store/directorStore"; const VIEWER_ZOOM_MIN = 0.25; const VIEWER_ZOOM_MAX = 5; const VIEWER_ZOOM_STEP = 0.25; +/** 可拖动预览窗口的最小尺寸(手动调节时不允许小于该值) */ +const VIEWER_DIALOG_MIN_WIDTH = 360; +const VIEWER_DIALOG_MIN_HEIGHT = 300; /** 机位重置值:默认机位的位置 / 注视点(与新建项目里 1 号机位一致) */ const DEFAULT_CAMERA_SHOT_POSITION = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.position; const DEFAULT_CAMERA_SHOT_TARGET = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.target; @@ -42,6 +45,23 @@ export function CameraPanel() { originX: number; originY: number; } | null>(null); + const [viewerDialogOffset, setViewerDialogOffset] = useState({ x: 0, y: 0 }); + const [viewerDialogDragging, setViewerDialogDragging] = useState(false); + const viewerDialogDragStateRef = useRef<{ + startX: number; + startY: number; + originX: number; + originY: number; + } | null>(null); + const [viewerDialogSize, setViewerDialogSize] = useState<{ width: number; height: number } | null>(null); + const [viewerDialogResizing, setViewerDialogResizing] = useState(false); + const viewerDialogResizeStateRef = useRef<{ + startX: number; + startY: number; + originWidth: number; + originHeight: number; + } | null>(null); + const viewerDialogRef = useRef(null); const camera = useDirectorStore((state) => state.project.cameras.find((item) => item.id === state.project.activeCameraId) ); @@ -75,6 +95,12 @@ export function CameraPanel() { setViewerOffset({ x: 0, y: 0 }); setViewerDragging(false); viewerDragStateRef.current = null; + setViewerDialogOffset({ x: 0, y: 0 }); + setViewerDialogDragging(false); + viewerDialogDragStateRef.current = null; + setViewerDialogSize(null); + setViewerDialogResizing(false); + viewerDialogResizeStateRef.current = null; return; } @@ -127,6 +153,121 @@ export function CameraPanel() { }; }, [viewerDragging]); + useEffect(() => { + if (!viewerDialogDragging) { + return; + } + + function handleMouseMove(event: MouseEvent) { + const dragState = viewerDialogDragStateRef.current; + if (!dragState) { + return; + } + + setViewerDialogOffset({ + x: dragState.originX + event.clientX - dragState.startX, + y: dragState.originY + event.clientY - dragState.startY, + }); + } + + function handleMouseUp() { + setViewerDialogDragging(false); + viewerDialogDragStateRef.current = null; + } + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [viewerDialogDragging]); + + function handleViewerDialogMouseDown(event: React.MouseEvent) { + if (event.button !== 0) { + return; + } + // 标题栏上的工具按钮(放大/缩小/下载/关闭/前后切换)不应触发拖动 + const target = event.target as HTMLElement; + if (target.closest(".camera-capture-viewer-toolbar")) { + return; + } + event.preventDefault(); + viewerDialogDragStateRef.current = { + startX: event.clientX, + startY: event.clientY, + originX: viewerDialogOffset.x, + originY: viewerDialogOffset.y, + }; + setViewerDialogDragging(true); + } + + useEffect(() => { + if (!viewerDialogResizing) { + return; + } + + function handleMouseMove(event: MouseEvent) { + const resizeState = viewerDialogResizeStateRef.current; + if (!resizeState) { + return; + } + + const maxWidth = Math.max(VIEWER_DIALOG_MIN_WIDTH, window.innerWidth - 32); + const maxHeight = Math.max(VIEWER_DIALOG_MIN_HEIGHT, window.innerHeight - 32); + const width = Math.min(maxWidth, Math.max(VIEWER_DIALOG_MIN_WIDTH, resizeState.originWidth + event.clientX - resizeState.startX)); + const height = Math.min(maxHeight, Math.max(VIEWER_DIALOG_MIN_HEIGHT, resizeState.originHeight + event.clientY - resizeState.startY)); + + setViewerDialogSize({ width, height }); + } + + function handleMouseUp() { + setViewerDialogResizing(false); + viewerDialogResizeStateRef.current = null; + } + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [viewerDialogResizing]); + + function handleViewerDialogResizeMouseDown(event: React.MouseEvent) { + if (event.button !== 0) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const el = viewerDialogRef.current; + if (!el) { + return; + } + const rect = el.getBoundingClientRect(); + viewerDialogResizeStateRef.current = { + startX: event.clientX, + startY: event.clientY, + originWidth: rect.width, + originHeight: rect.height, + }; + setViewerDialogResizing(true); + } + + function handleViewerNavigate(capture: DirectorCameraCapture | null) { + if (!capture) { + return; + } + setViewerCapture(capture); + // 切换截图时重置缩放与平移,避免上一张的缩放状态带到下一张 + setViewerScale(1); + setViewerOffset({ x: 0, y: 0 }); + setViewerDragging(false); + viewerDragStateRef.current = null; + } + const clampViewerScale = useCallback((value: number) => { return Math.min(VIEWER_ZOOM_MAX, Math.max(VIEWER_ZOOM_MIN, value)); }, []); @@ -408,71 +549,121 @@ export function CameraPanel() { .filter(Boolean) .join(" "); + // 在所有机位的截图列表中前后切换(按总览里的分组顺序排列) + const allCaptures = cameraCaptureGroups.flatMap((group) => group.captures); + const currentCaptureIndex = allCaptures.findIndex((item) => item.id === viewerCapture.id); + const prevCapture = currentCaptureIndex > 0 ? allCaptures[currentCaptureIndex - 1] : null; + const nextCapture = currentCaptureIndex >= 0 && currentCaptureIndex < allCaptures.length - 1 ? allCaptures[currentCaptureIndex + 1] : null; + // 用 portal 挂到 body:侧栏卡片带 backdrop-filter,会成为 fixed 元素的包含块, - // 直接挂在面板里的话这个「position: fixed; inset: 0」的查看器会被缩在侧栏内并被裁掉, - // 看起来就不是弹出层了。 + // 直接挂在面板里的话这个查看器会被缩在侧栏内并被裁掉,看起来就不是弹出层了。 if (typeof document === "undefined") return null; return createPortal( -
+ <> + , + , document.body ); } diff --git a/src/editor/panels/ScenePanel.tsx b/src/editor/panels/ScenePanel.tsx index 82bab71..cf1ce27 100644 --- a/src/editor/panels/ScenePanel.tsx +++ b/src/editor/panels/ScenePanel.tsx @@ -302,7 +302,7 @@ export function ScenePanel() { /> 角色标签
-
+
item.kind === "character")!; } +/** 加一个非角色物体(几何体)用于「物体轨道」相关测试,避免角色被渲染成两条轨道 */ +function addPropObject() { + useDirectorStore.getState().addGeometryPrimitive("box"); + const objects = useDirectorStore.getState().project.objects; + return objects[objects.length - 1]; +} + /** * jsdom 不做真实布局(所有 getBoundingClientRect 都是 0): * - 轨道拖拽排序要靠行几何算落点 → 按 data-track-key 造一份给定布局; @@ -205,7 +212,7 @@ it("disables add-keyframe button when no object is selected", () => { }); it("adds an object keyframe for the selected object at the playhead", () => { - const objectId = useDirectorStore.getState().project.objects[0].id; + const objectId = addPropObject().id; useDirectorStore.getState().selectObject(objectId); usePlaybackStore.getState().setPlayhead(2); @@ -232,7 +239,7 @@ it("adds a camera keyframe when a manual camera is selected", () => { }); it("deletes a selected keyframe", () => { - const objectId = useDirectorStore.getState().project.objects[0].id; + const objectId = addPropObject().id; useDirectorStore.getState().selectObject(objectId); usePlaybackStore.getState().setPlayhead(1); render(); @@ -245,20 +252,21 @@ it("deletes a selected keyframe", () => { expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]); }); -it("deletes a keyframe via the per-keyframe × button", () => { - const objectId = useDirectorStore.getState().project.objects[0].id; +it("deletes a keyframe after selecting it", () => { + const objectId = addPropObject().id; useDirectorStore.getState().selectObject(objectId); usePlaybackStore.getState().setPlayhead(1); render(); fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); - fireEvent.click(screen.getByRole("button", { name: "删除关键帧 1.0s" })); + fireEvent.click(screen.getByRole("button", { name: "关键帧 1.0s" })); + fireEvent.click(screen.getByRole("button", { name: "删除选中关键帧" })); expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]); }); it("clears all keyframes via the clear-all button", () => { - const objectId = useDirectorStore.getState().project.objects[0].id; + const objectId = addPropObject().id; useDirectorStore.getState().selectObject(objectId); render(); fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); @@ -317,6 +325,35 @@ it("adds a character track with motion and trajectory sub-tracks", () => { expect(screen.getByRole("button", { name: "轨迹关键帧 5.0s" })).toBeInTheDocument(); }); +it("renders a single track for a character, never a duplicate object track", () => { + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + // 即便角色带有一条 transform 关键帧轨道(历史数据 / 误加),也不应再渲染成「物体轨道」 + useDirectorStore.getState().addKeyframe(character.id, 1, { + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }); + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + + const { container } = render(); + + // 只有一条「角色轨道」,且不存在以该角色 id 命名的「物体轨道」 + expect(container.querySelectorAll(".timeline-character-track")).toHaveLength(1); + expect(container.querySelector(`.timeline-track[data-track-key="object:${character.id}"]`)).toBeNull(); +}); + +it("does not create a transform track when adding a keyframe to a selected character", () => { + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + useDirectorStore.getState().selectObject(character.id); + usePlaybackStore.getState().setPlayhead(1); + + render(); + fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); + + const track = useDirectorStore.getState().project.timeline.tracks.find((t) => t.objectId === character.id); + expect(track).toBeUndefined(); +}); + it("shows the character motion kind on the motion sub-track", () => { const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; useDirectorStore.getState().setMotion(character.id, "walk"); @@ -344,13 +381,71 @@ it("selects a trajectory point and seeks the playhead when its timeline keyframe }); }); -it("deletes a trajectory point from its timeline keyframe", () => { +it("clears the trajectory keyframe selection when clicking the track background", () => { + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + const { container } = render(); + const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" }); + fireEvent.click(keyframe); + expect(keyframe).toHaveClass("is-selected"); + expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull(); + + // 点轨道空白处:选中态取消(角色本身的选择不受影响) + fireEvent.pointerDown(container.querySelector(".timeline-grid-body")!, { + button: 0, + pointerId: 1, + }); + + expect(useDirectorStore.getState().selectedTrajectoryPoint).toBeNull(); + expect(screen.getByRole("button", { name: "轨迹关键帧 2.5s" })).not.toHaveClass("is-selected"); + expect(useDirectorStore.getState().selectedObjectId).toBe(character.id); +}); + +it("moves focus off the trajectory keyframe when clicking elsewhere", () => { const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); render(); - fireEvent.click(screen.getByRole("button", { name: "删除轨迹关键帧 5.0s" })); + const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" }); + fireEvent.click(keyframe); + keyframe.focus(); + expect(document.activeElement).toBe(keyframe); + + fireEvent.pointerDown(document.body, { button: 0, pointerId: 1 }); + + // 焦点从已取消选中的关键帧上移走,焦点框不再留在它身上 + expect(document.activeElement).not.toBe(keyframe); +}); + +it("keeps the trajectory keyframe selected when clicking the keyframe itself", () => { + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + render(); + const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" }); + fireEvent.click(keyframe); + + // 再点自己:那是选中 / 拖它改时间,不该被当成「点到别处」清掉 + fireEvent.pointerDown(keyframe, { button: 0, pointerId: 1 }); + + expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull(); + expect(keyframe).toHaveClass("is-selected"); +}); + +it("deletes a selected trajectory point with the Delete key", () => { + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + render(); + // 轨迹点没有独立删除按钮:选中后按 Delete 删除 + const lastKeyframe = screen.getByRole("button", { name: "轨迹关键帧 5.0s" }); + fireEvent.click(lastKeyframe); + fireEvent.keyDown(lastKeyframe, { key: "Delete" }); const points = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!.trajectoryPoints; expect(points).toHaveLength(1); @@ -383,8 +478,11 @@ it("keeps the track order untouched when a drop would not change it", () => { /** 渲染一个「物体轨道 + 角色轨道」的时间轴,并给两行造好纵向布局 */ function renderTwoTrackTimeline() { - const character = getDefaultCharacter(); - useDirectorStore.getState().selectObject(character.id); + // 用「几何体」造物体轨道:角色只应有角色轨道,不应再被渲染成一条物体轨道 + useDirectorStore.getState().addGeometryPrimitive("box"); + const objects = useDirectorStore.getState().project.objects; + const object = objects[objects.length - 1]; + useDirectorStore.getState().selectObject(object.id); usePlaybackStore.getState().setPlayhead(1); const rendered = render(); @@ -392,14 +490,16 @@ function renderTwoTrackTimeline() { const container = rendered.container; const objectRow = container.querySelector(".timeline-grid-body > .timeline-track")!; + const character = getDefaultCharacter(); const characterRow = container.querySelector(".timeline-character-track")!; stubTrackRowLayout({ - [`object:${character.id}`]: { top: 0, height: 28 }, + [`object:${object.id}`]: { top: 0, height: 28 }, [`character:${character.id}`]: { top: 28, height: 84 }, }); return { + object, character, container, objectRow, @@ -416,8 +516,8 @@ function beginTrackDrag(row: HTMLElement, clientY = 10) { } it("reorders tracks by dragging a whole track row", () => { - const { character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline(); - const objectTrackKey = `object:${character.id}`; + const { object, character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline(); + const objectTrackKey = `object:${object.id}`; const characterTrackKey = `character:${character.id}`; // 默认顺序:物体轨道在前,角色轨道在后 @@ -480,8 +580,8 @@ it("keeps repeating pointermove at the same drop position idempotent", () => { }); it("does not start a track drag from the collapse button", () => { - const { character, objectRow, panel } = renderTwoTrackTimeline(); - const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${character.name})` }); + const { object, objectRow, panel } = renderTwoTrackTimeline(); + const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${object.name})` }); fireEvent.pointerDown(collapseButton, { button: 0, pointerId: 1, clientX: 10, clientY: 10 }); fireEvent.pointerMove(collapseButton, { pointerId: 1, clientX: 10, clientY: 90 }); @@ -497,40 +597,120 @@ it("renders a trajectory bar whose right handle shows the walk duration", () => const { container } = render(); - // 2 个轨迹点在 5s 时间轴上均分 → 走到 5s - const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` }); + // 2 个轨迹点在 5s 时间轴上均分 → 走到 5s;停留结束默认就是 5s + const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` }); expect(handle).toHaveAttribute("aria-valuenow", "5"); expect(container.querySelector(".timeline-trajectory-bar")?.textContent).toBe("5.0s"); - // 条宽度 = 走完时间占整条时间轴的比例 + // 条宽度 = 停留结束占整条时间轴的比例 expect(container.querySelector(".timeline-trajectory-bar")?.style.width).toBe("100%"); }); -it("drags the duration handle to rescale every trajectory point", () => { +it("drags the duration handle to change the trajectory hold duration", () => { const character = getDefaultCharacter(); useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + // 最后一点落在 3s,停留结束仍默认到 5s,留出 2s 停留段 + useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]); render(); // 5s 时间轴对应 500px 轨道 → 1px = 0.01s stubTrackRowLayout({}, 500); - const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` }); - fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 400 }); + const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500 }); // 拖轨迹时冻结全站毛玻璃,避免背后 WebGL 画布反复重算模糊 expect(document.body).toHaveClass("is-timeline-dragging"); - // 往左拖 100px → 时长 5s → 4s,两个点按比例缩到 2s / 4s - fireEvent.pointerMove(handle, { pointerId: 1, clientX: 300 }); - fireEvent.pointerUp(handle, { pointerId: 1, clientX: 300 }); + // 把手在条右端(5s / 100%);往左拖 100px = -1s → 停留结束 4s,各轨迹点不动 + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 400 }); const points = getTrajectoryPoints(character.id); - expect(points[0].time).toBeCloseTo(2, 5); - expect(points[1].time).toBeCloseTo(4, 5); + expect(points[0].time).toBeCloseTo(1.5, 5); + expect(points[1].time).toBeCloseTo(3, 5); + const obj = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(obj.trajectoryEndTime).toBeCloseTo(4, 5); expect(document.body).not.toHaveClass("is-timeline-dragging"); // 拖滑块不应被当成「轨道拖动排序」 expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]); }); +it("drags the left trim handle to delay the clip start without moving the points", () => { + const character = getDefaultCharacter(); + const objectId = character.id; + useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]); + // 两个点落在 1.5s / 3s,片段起点默认 0s,左边留出可修剪的空间 + useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!; + // 左右两端各有一条修剪把手 + expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2); + + const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 0 }); + // 往右拖 100px = +1s → 起点推后到 1s(角色先原地等 1s),两个轨迹点留在原地 + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 100 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 100 }); + + const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!; + expect(obj.trajectoryStartTime).toBeCloseTo(1, 5); + const points = getTrajectoryPoints(objectId); + expect(points[0].time).toBeCloseTo(1.5, 5); + expect(points[1].time).toBeCloseTo(3, 5); + // 停留结束不受影响,仍是时间轴末尾 + expect(obj.trajectoryEndTime).toBeUndefined(); + // 拖把手不应被当成「轨道拖动排序」 + expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]); +}); + +it("clamps the trim-start handle so it cannot overrun the first trajectory point", () => { + const character = getDefaultCharacter(); + const objectId = character.id; + useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]); + useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]); + + render(); + stubTrackRowLayout({}, 500); + + const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 0 }); + // 一路拖过第一个点也不让它越过去:最多到 1.45s(第一个点前留一个最小间隔) + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 400 }); + + const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!; + expect(obj.trajectoryStartTime).toBeCloseTo(1.45, 5); + const points = getTrajectoryPoints(objectId); + expect(points[0].time).toBeCloseTo(1.5, 5); + expect(points[1].time).toBeCloseTo(3, 5); +}); + +it("nudges the trim-start handle with the keyboard", () => { + const character = getDefaultCharacter(); + const objectId = character.id; + useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]); + useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]); + useDirectorStore.getState().setTrajectoryClipTrimStart(objectId, 1); + + render(); + + const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + + const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!; + // 1s → 0.9s,各轨迹点不动 + expect(obj.trajectoryStartTime).toBeCloseTo(0.9, 5); + const points = getTrajectoryPoints(objectId); + expect(points[0].time).toBeCloseTo(1.5, 5); + expect(points[1].time).toBeCloseTo(3, 5); +}); + it("drags a single trajectory keyframe to change only its own time", () => { const character = getDefaultCharacter(); useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); @@ -553,20 +733,23 @@ it("drags a single trajectory keyframe to change only its own time", () => { expect(screen.getByRole("button", { name: "轨迹关键帧 3.0s" })).toBeInTheDocument(); }); -it("nudges the trajectory duration with the keyboard", () => { +it("nudges the trajectory hold duration with the keyboard", () => { const character = getDefaultCharacter(); useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]); render(); - const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` }); + const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` }); fireEvent.keyDown(handle, { key: "ArrowLeft" }); const points = getTrajectoryPoints(character.id); - // 时长 5s → 4.9s,各点等比缩放 - expect(points[1].time).toBeCloseTo(4.9, 5); - expect(points[0].time).toBeCloseTo(2.45, 5); + // 停留结束 5s → 4.9s,各轨迹点不动 + expect(points[1].time).toBeCloseTo(3, 5); + expect(points[0].time).toBeCloseTo(1.5, 5); + const obj = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(obj.trajectoryEndTime).toBeCloseTo(4.9, 5); }); it("collapses and expands a character track", () => { @@ -579,7 +762,7 @@ it("collapses and expands a character track", () => { fireEvent.click(screen.getByRole("button", { name: `折叠角色轨道(${character.name})` })); - // 折叠后只剩抬头行,动作 / 轨迹子轨道都收起来 + // 折叠后只剩抬头行,动作 / 轨迹子轨道都收起来(轨迹条在抬头行里,轨迹子轨道也折叠) expect(container.querySelectorAll(".timeline-subtrack")).toHaveLength(0); expect(container.querySelector(".timeline-character-track")).toHaveClass("is-collapsed"); expect(container.querySelector(".timeline-character-head-lane")?.textContent).toContain("1 个轨迹点"); @@ -595,20 +778,22 @@ it("collapses and expands a character track", () => { }); it("gives every track a collapse toggle, even when it has no sub-tracks", () => { - const character = getDefaultCharacter(); - useDirectorStore.getState().selectObject(character.id); + useDirectorStore.getState().addGeometryPrimitive("box"); + const objects = useDirectorStore.getState().project.objects; + const object = objects[objects.length - 1]; + useDirectorStore.getState().selectObject(object.id); usePlaybackStore.getState().setPlayhead(1); const { container } = render(); fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ })); // 物体轨道没有子轨道,同样带折叠开关 - fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${character.name})` })); + fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${object.name})` })); expect(container.querySelector(".timeline-grid-body > .timeline-track")).toHaveClass("is-collapsed"); expect(container.querySelector(".timeline-track-summary")?.textContent).toBe("1 个关键帧"); expect(screen.queryByRole("button", { name: "关键帧 1.0s" })).not.toBeInTheDocument(); - expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`object:${character.id}`]); + expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`object:${object.id}`]); }); it("gives the camera track a collapse toggle as well", () => { @@ -700,6 +885,64 @@ it("drags the camera clip handle to rescale the whole camera move", () => { expect(document.body).not.toHaveClass("is-timeline-dragging"); }); +it("drags the camera clip left handle to trim the start while the last keyframe stays put", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-clip-bar-camera")!; + // 左右两端各有一条修剪把手 + expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2); + + const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 }); + // 往右拖 100px = +1s → 运镜推迟到 2s 才开始,末帧仍停在 4s + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 }); + + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBeCloseTo(2, 5); + expect(times[1]).toBeCloseTo(4, 5); +}); + +it("clamps the camera trim-start handle so it cannot overrun the last keyframe", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + render(); + stubTrackRowLayout({}, 500); + + const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 }); + // 一路拖到末帧之后也不越过去:最多到 3.95s(末帧前留一个最小间隔) + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 500 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 500 }); + + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBeCloseTo(3.95, 5); + expect(times[1]).toBeCloseTo(4, 5); +}); + +it("nudges the camera clip start with the arrow keys", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + render(); + + const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` }); + fireEvent.keyDown(handle, { key: "ArrowRight" }); + + const times = getCameraKeyframeTimes(camera.id); + // 起点 1s → 1.1s,末帧不动 + expect(times[0]).toBeCloseTo(1.1, 5); + expect(times[1]).toBeCloseTo(4, 5); +}); + it("nudges the camera clip duration with the arrow keys", () => { const camera = useDirectorStore.getState().project.cameras[0]; addCameraKeyframeAt(camera.id, 1); @@ -764,9 +1007,10 @@ it("drags the trajectory bar body to move the whole walk later", () => { const { container } = render(); stubTrackRowLayout({}, 500); - const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!; expect(bar.style.left).toBe("0%"); - expect(bar.style.width).toBe("60%"); + // 停留结束默认到时间轴末尾(5s),所以整条条铺满 100%,最后一个点(3s)只是途经点 + expect(bar.style.width).toBe("100%"); // 往右拖 100px = +1s:整段刚性平移,两个点一起 +1s fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); @@ -787,7 +1031,7 @@ it("keeps the trajectory clip inside the timeline when dragged to the end", () = const { container } = render(); stubTrackRowLayout({}, 500); - const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!; fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); // 往右拖 1000px 远超时间轴:末点顶到 5s 就不再挪 fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 }); @@ -806,7 +1050,7 @@ it("does not move a trajectory that already fills the whole timeline", () => { const { container } = render(); stubTrackRowLayout({}, 500); - const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!; expect(bar.style.width).toBe("100%"); fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); @@ -869,7 +1113,7 @@ it("selects the trajectory clip when it is grabbed, and clears it on an empty ar const { container } = render(); stubTrackRowLayout({}, 500); - const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + const bar = container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")!; expect(bar).not.toHaveClass("is-selected"); fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); @@ -883,7 +1127,7 @@ it("selects the trajectory clip when it is grabbed, and clears it on an empty ar clientX: 5, }); - expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected"); + expect(container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected"); }); it("selects the clip when one of its own keyframes is grabbed", () => { @@ -899,7 +1143,7 @@ it("selects the clip when one of its own keyframes is grabbed", () => { fireEvent.pointerDown(keyframe, { button: 0, pointerId: 1, clientX: 150 }); fireEvent.pointerUp(keyframe, { pointerId: 1, clientX: 150 }); - expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).toHaveClass("is-selected"); + expect(container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")).toHaveClass("is-selected"); }); it("selects the camera clip both when its body is grabbed and when its keyframe is grabbed", () => { @@ -1126,8 +1370,9 @@ it("nudges the playhead with the ruler keyboard controls", () => { }); it("positions the playhead on the ruler and labels every track with its object", () => { - const objectId = useDirectorStore.getState().project.objects[0].id; - const objectName = useDirectorStore.getState().project.objects[0].name; + const propObject = addPropObject(); + const objectId = propObject.id; + const objectName = propObject.name; useDirectorStore.getState().selectObject(objectId); const { container } = render(); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index d205b93..8f84779 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -40,12 +40,15 @@ import { moveCameraKeyframeTime, normalizeTimelineDuration, normalizeTimelineFps, + scaleCameraKeyframeStart, scaleCameraKeyframeTimes, type CameraKeyframeValue, type KeyframeTransformValue, } from "../schema/timeline"; import type { MotionKind } from "../schema/directorProject"; import { + TRAJECTORY_POINT_MIN_GAP, + getTrajectoryClipEndTime, getTrajectoryPointTimes, getTrajectoryStartTime, moveTrajectoryPointTime, @@ -318,6 +321,8 @@ export function TimelinePanel() { const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint); const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes); const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart); + const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart); + const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd); const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder); const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks); const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder); @@ -368,15 +373,18 @@ export function TimelinePanel() { * - point:拖单个关键帧,只改这一帧的时间 * - duration:拖条右端把手,整段时长按比例缩放 * - clip:拖条本体,整段左右平移(可以脱离 0s 往后放) + * - trimStart:拖条左端把手,只挪片段起点(各轨迹点原地不动) */ const timeDragRef = useRef<{ pointerId: number; owner: TimelineTimeDragOwner; - kind: "point" | "duration" | "clip"; + kind: "point" | "duration" | "clip" | "trimStart"; index: number; startTimes: number[]; /** 片段左端在按下时的位置(平移基准) */ startEdge: number; + /** 片段右端在按下时的位置(轨迹最后一点停留结束,作为拖关键帧的上界) */ + endEdge: number; startClientX: number; secondsPerPixel: number; } | null>(null); @@ -439,6 +447,30 @@ export function TimelinePanel() { }; }, [exportMenuOpen]); + // 轨迹关键帧没有独立的删除按钮,选中态(.is-selected)就是「按 Delete 会删掉它」的唯一提示, + // 所以点到别处(轨道空白、别的轨道、面板其它区域,甚至时间轴之外)就必须取消选中并移走焦点, + // 否则选中高亮和焦点框会一直留在那颗关键帧上,看起来像还能按 Delete 删它。 + useEffect(() => { + if (!selectedTrajectoryPoint) return; + + function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) { + // 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间 + if (event.target instanceof Element && event.target.closest(".timeline-keyframe-trajectory")) return; + + selectTrajectoryPoint(null, null); + + // 焦点还停在那颗关键帧上时一并移走,避免焦点框留在一个已经没选中的元素上 + const active = document.activeElement; + if (active instanceof HTMLElement && active.classList.contains("timeline-keyframe-trajectory")) { + active.blur(); + } + } + + document.addEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown); + + return () => document.removeEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown); + }, [selectedTrajectoryPoint, selectTrajectoryPoint]); + // 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃) useEffect(() => { if (!settingsOpen) return; @@ -484,7 +516,9 @@ export function TimelinePanel() { if (selectionIsManualCamera) { if (!selectedShot || selectedShot.targetMode !== "manual") return; addCameraKeyframe(selectedShot.id, playhead, snapshotShot()); - } else if (selectedObject) { + } else if (selectedObject && selectedObject.kind !== "character") { + // 角色由轨迹(trajectory)驱动,不占 transform 关键帧轨道, + // 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。 addKeyframe(selectedObject.id, playhead, snapshotObjectTransform()); } } @@ -511,6 +545,10 @@ export function TimelinePanel() { } } function handleDeleteSelected() { + if (selectedTrajectoryPoint) { + removeTrajectoryPoint(selectedTrajectoryPoint.objectId, selectedTrajectoryPoint.pointId); + return; + } if (!selectedKeyframe) return; if (selectedKeyframe.kind === "object") { removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId); @@ -655,8 +693,15 @@ export function TimelinePanel() { // 轨道顺序:默认「物体轨道 → 相机运镜轨道 → 人物轨道」,用户可以上移 / 下移调整。 // 视觉顺序靠 flex order 实现,DOM 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。 + // 角色由轨迹驱动,只走「角色轨道」;其 transform 关键帧不单独占一条「物体轨道」, + // 否则同一个角色会在时间轴上出现两条。 + const nonCharacterObjectIds = new Set( + project.objects.filter((item) => item.kind !== "character").map((item) => item.id) + ); const defaultTrackKeys = [ - ...timeline.tracks.map((track) => getTimelineTrackKey("object", track.objectId)), + ...timeline.tracks + .filter((track) => nonCharacterObjectIds.has(track.objectId)) + .map((track) => getTimelineTrackKey("object", track.objectId)), ...timeline.cameraTracks.map((track) => getTimelineTrackKey("camera", track.cameraId)), ...characterObjects.map((item) => getTimelineTrackKey("character", item.id)), ]; @@ -788,38 +833,40 @@ export function TimelinePanel() { return selectedClip === getTimelineClipKey(kind, id); } - /** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */ + /** 拖整段时长(条右端把手 / 键盘微调): + * - 运镜:末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定; + * - 人物轨迹:末点不再等于时间结束位置,这条把手改的是「停留时长」(角色走完最后一点后 + * 原地停留到这一刻),所以只写 trajectoryEndTime,不动各轨迹点的时间。 */ function scaleClipDuration( owner: TimelineTimeDragOwner, times: number[], startEdge: number, deltaSeconds: number ) { - const targetEnd = (times[times.length - 1] ?? startEdge) + deltaSeconds; + const targetEnd = + (owner.kind === "trajectory" ? startEdge : times[times.length - 1] ?? startEdge) + deltaSeconds; if (owner.kind === "trajectory") { - setTrajectoryPointTimes( - owner.objectId, - scaleTrajectoryPointTimes(times, targetEnd, duration, startEdge) - ); + setTrajectoryClipEnd(owner.objectId, targetEnd); return; } setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration)); } - /** 拖单个关键帧:只改它自己的时间(被前后邻居夹住) */ + /** 拖单个关键帧:只改它自己的时间(被前后邻居夹住;轨迹最后一点还受停留结束时间约束) */ function moveClipKeyframe( owner: TimelineTimeDragOwner, times: number[], index: number, startEdge: number, - deltaSeconds: number + deltaSeconds: number, + endEdge = duration ) { const targetTime = (times[index] ?? 0) + deltaSeconds; if (owner.kind === "trajectory") { - const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge); + const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge, endEdge); updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] }); return; } @@ -852,6 +899,29 @@ export function TimelinePanel() { setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift)); } + /** + * 修剪起点(条左端把手),和右端把手对称: + * - 人物轨迹:只挪片段起点 —— 角色在这之前原地等待,各轨迹点与停留结束时间留在原地, + * 所以「等待多久」变了,走位本身的节奏没变; + * - 运镜:末帧固定不动,首帧推到新位置,其余关键帧按比例缩放 —— 和右端把手(首帧固定、 + * 末帧伸缩)正好反过来,整段运镜的节奏保持不变。 + */ + function trimClipStart( + owner: TimelineTimeDragOwner, + times: number[], + startEdge: number, + deltaSeconds: number + ) { + if (owner.kind === "camera") { + setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeStart(times, startEdge + deltaSeconds, duration)); + return; + } + + if (owner.kind !== "trajectory") return; + + setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds); + } + /** * 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」, * 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。 @@ -859,10 +929,11 @@ export function TimelinePanel() { function beginTimeDrag( event: ReactPointerEvent, owner: TimelineTimeDragOwner, - kind: "point" | "duration" | "clip", + kind: "point" | "duration" | "clip" | "trimStart", index: number, times: number[], - startEdge = 0 + startEdge = 0, + endEdge = duration ) { if (event.button !== 0) return; if (isTrackDragBlockedTarget(event.target)) return; @@ -884,6 +955,7 @@ export function TimelinePanel() { index, startTimes: [...times], startEdge, + endEdge, startClientX: event.clientX, secondsPerPixel: duration / laneWidth, }; @@ -917,7 +989,12 @@ export function TimelinePanel() { return; } - moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime); + if (drag.kind === "trimStart") { + trimClipStart(drag.owner, drag.startTimes, drag.startEdge, deltaTime); + return; + } + + moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime, drag.endEdge); } function handleTimeDragEnd(event: ReactPointerEvent) { @@ -937,14 +1014,15 @@ export function TimelinePanel() { /** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */ function getTimeDragProps( owner: TimelineTimeDragOwner, - kind: "point" | "duration" | "clip", + kind: "point" | "duration" | "clip" | "trimStart", index: number, times: number[], - startEdge = 0 + startEdge = 0, + endEdge = duration ) { return { onPointerDown: (event: ReactPointerEvent) => - beginTimeDrag(event, owner, kind, index, times, startEdge), + beginTimeDrag(event, owner, kind, index, times, startEdge, endEdge), onPointerMove: handleTimeDragMove, onPointerUp: handleTimeDragEnd, onPointerCancel: handleTimeDragEnd, @@ -966,6 +1044,21 @@ export function TimelinePanel() { scaleClipDuration(owner, times, startEdge, delta); } + /** 修剪起点的把手同样支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */ + function handleTrimStartKeyDown( + event: ReactKeyboardEvent, + owner: TimelineTimeDragOwner, + times: number[], + startEdge: number + ) { + const step = event.shiftKey ? 0.5 : 0.1; + const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0; + if (delta === 0) return; + + event.preventDefault(); + trimClipStart(owner, times, startEdge, delta); + } + function handleTrackPointerMove(event: ReactPointerEvent) { const gesture = dragGestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) return; @@ -1110,14 +1203,27 @@ export function TimelinePanel() { action(); } - // 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。 - // 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。 + // 焦点在时间轴上时: + // - 空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放); + // - Delete / Backspace 删除选中的轨迹点(角色轨迹点没有独立删除按钮,选中后按删除键移除)。 + // 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的行为重复触发。 function handlePanelKeyDown(event: ReactKeyboardEvent) { - if (event.key !== " " && event.key !== "Spacebar") return; const target = event.target as HTMLElement | null; - if (target && target.closest("button, input, textarea, select, [contenteditable='true']")) { + const insideNativeControl = !!( + target && target.closest("button, input, textarea, select, [contenteditable='true']") + ); + + if (event.key === "Delete" || event.key === "Backspace") { + if (insideNativeControl) return; + if (selectedTrajectoryPoint) { + event.preventDefault(); + handleDeleteSelected(); + } return; } + + if (event.key !== " " && event.key !== "Spacebar") return; + if (insideNativeControl) return; event.preventDefault(); togglePlaying(); } @@ -1387,7 +1493,9 @@ export function TimelinePanel() { {/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板, 这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
- {timeline.tracks.map((track) => { + {timeline.tracks + .filter((track) => project.objects.find((o) => o.id === track.objectId)?.kind !== "character") + .map((track) => { const obj = project.objects.find((o) => o.id === track.objectId); const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box; const trackName = obj?.name ?? track.objectId; @@ -1432,17 +1540,6 @@ export function TimelinePanel() { onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)} >
); }) @@ -1467,10 +1564,15 @@ export function TimelinePanel() { const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0; const clipWidthPercent = duration > 0 ? (clipDuration / duration) * 100 : 0; const clipOwner: TimelineTimeDragOwner = { kind: "camera", cameraId: track.cameraId }; + // 运镜关键帧连线:在「机位」子轨道上把各关键帧用折线连起来(视图坐标 0~100 对应整条时间轴宽度, + // 50 = 轨道垂直中点),直观呈现运镜过程 + const cameraConnectorPoints = cameraTimes + .map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`) + .join(" "); return (
-
- {renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)} -
+
+ + )}
); })} @@ -1580,11 +1716,18 @@ export function TimelinePanel() { const trackKey = getTimelineTrackKey("character", character.id); const collapsed = timelineCollapsedTracks.includes(trackKey); const dragProps = getTrackDragProps(trackKey); - // 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)与每个轨迹点的关键帧时间 + // 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)、每个轨迹点的关键帧时间, + // 以及片段结束时间(角色走完最后一点后原地停留到这一刻,即「停留时长」)。 + // 默认结束时间 = 时间轴末尾,所以最后一个点落在时间结束处;一旦设置 trajectoryEndTime, + // 最后一个点只是路径中途的途经点,条右边缘到结束时间之间都是停留段。 const trajectoryStart = getTrajectoryStartTime(character, duration); const trajectoryTimes = getTrajectoryPointTimes(trajectoryPoints, duration, trajectoryStart); - const trajectoryEndTime = - trajectoryTimes.length > 0 ? trajectoryTimes[trajectoryTimes.length - 1] : trajectoryStart; + const trajectoryEndTime = getTrajectoryClipEndTime(character, duration, trajectoryStart); + // 轨迹点连线:在「轨迹」轨道上把各轨迹点用折线连起来(视图坐标 0~100 对应整条时间轴宽度, + // 50 = 轨道垂直中点),直观呈现走位路径(最后一个点只是途经点,后面是停留段) + const trajectoryConnectorPoints = [trajectoryStart, ...trajectoryTimes] + .map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`) + .join(" "); const trajectoryLeftPercent = duration > 0 ? (trajectoryStart / duration) * 100 : 0; const trajectoryWidthPercent = duration > 0 ? Math.min(100, ((trajectoryEndTime - trajectoryStart) / duration) * 100) : 0; @@ -1613,12 +1756,71 @@ export function TimelinePanel() { {character.name} {trajectoryPoints.length}
- {/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */} + {/* 分组抬头行:展开时显示轨迹片段条,折叠时显示一行摘要 */}
{collapsed ? ( {motionLabel} · {trajectoryPoints.length} 个轨迹点 + ) : trajectoryTimes.length > 0 && duration > 0 ? ( +
+ {formatTime(trajectoryEndTime)} + {/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */} + + handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart) + } + {...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)} + /> + {/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */} + + handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime) + } + {...getTimeDragProps( + trajectoryOwner, + "duration", + trajectoryTimes.length - 1, + trajectoryTimes, + trajectoryEndTime + )} + /> +
) : null}
@@ -1636,57 +1838,25 @@ export function TimelinePanel() { - {/* 人物轨迹:一条「轨迹条」= 角色从起点走到最后一个轨迹点所需的时长。 - 条本体可左右拖动整段平移(脱离 0s,前面那段时间角色原地等待), - 右端把手拖动改整段时长,条上的关键帧可单独拖动改时间 */} + {/* 人物轨迹轨道:一条「轨迹」子轨道,显示各轨迹点关键帧(小菱形), + 并用折线把点连起来呈现走位路径。点可单独左右拖动改时间,选中后按 Delete 删除 */}
轨迹
{trajectoryTimes.length > 0 && duration > 0 ? ( <> - - + + {trajectoryPoints.map((point, index) => { const pointTime = trajectoryTimes[index] ?? 0; const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100; @@ -1704,27 +1874,20 @@ export function TimelinePanel() { role="button" tabIndex={0} aria-label={`轨迹关键帧 ${formatTime(pointTime)}`} - title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}(左右拖动调整时间)`} + title={`第 ${index + 1} 个轨迹点 · ${formatTime( + pointTime + )}(左右拖动调整时间,选中后按 Delete 删除)`} onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)} {...getTimeDragProps( { kind: "trajectory", objectId: character.id, pointId: point.id }, "point", index, - trajectoryTimes + trajectoryTimes, + trajectoryStart, + trajectoryEndTime )} >
); })} diff --git a/src/editor/schema/directorProject.ts b/src/editor/schema/directorProject.ts index e98bbc3..fa7d737 100644 --- a/src/editor/schema/directorProject.ts +++ b/src/editor/schema/directorProject.ts @@ -232,6 +232,12 @@ export interface DirectorObject { * 大于 0 表示角色先在起点原地等待,到这一刻才沿轨迹移动(时间轴上可拖动整条轨迹条平移)。 */ trajectoryStartTime?: number; + /** + * 人物轨迹片段的结束时间(秒):角色走完最后一个轨迹点后,原地停留到这一刻。 + * 缺省 / 为 0 时沿用旧语义(结束时间 = 最后一个轨迹点的时间,最后一个点即时间结束处); + * 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。 + */ + trajectoryEndTime?: number; } export interface DirectorCameraCapture { diff --git a/src/editor/schema/timeline.ts b/src/editor/schema/timeline.ts index 6aaf6c2..326c2f1 100644 --- a/src/editor/schema/timeline.ts +++ b/src/editor/schema/timeline.ts @@ -362,6 +362,39 @@ export function scaleCameraKeyframeTimes(times: number[], newEnd: number, durati return scaled; } +/** + * 拖动运镜片段左端把手(修剪起点):与右端把手完全对称 —— 末帧固定不动, + * 首帧落到 newStart,其余关键帧按同一比例缩放,各帧之间的相对节奏不变。 + * newStart 夹在「0 ~ 末帧 - 最小跨度」之间。 + */ +export function scaleCameraKeyframeStart(times: number[], newStart: number, duration: number): number[] { + const count = times.length; + if (count < 2) return times; + + const end = times[count - 1]; + const start = times[0]; + const currentSpan = end - start; + if (!Number.isFinite(currentSpan) || currentSpan <= 0) return times; + + // 缩放上界:末帧不动,首帧最多贴到「末帧 - 最小跨度」处,保证各帧还能依次排开 + const minSpan = CAMERA_KEYFRAME_MIN_GAP * (count - 1); + const upper = Math.max(Math.min(end - minSpan, duration), 0); + const nextStart = Math.min(Math.max(newStart, 0), upper); + if (Math.abs(nextStart - start) < 1e-6) return times; + + const ratio = (end - nextStart) / currentSpan; + const scaled = times.map((value, index) => { + if (index === count - 1) return end; + const next = end - (end - value) * ratio; + // 这一帧之后还剩 count-1-index 帧,各自要留够最小间隔 + const ceiling = end - CAMERA_KEYFRAME_MIN_GAP * (count - 1 - index); + return Math.max(Math.min(next, ceiling), nextStart); + }); + scaled[0] = nextStart; + + return scaled; +} + /** * 一次性覆盖整条运镜轨道的所有关键帧时间(按索引对应,最后统一按时间重排)。 * 拖动把手 / 拖动关键帧都走这里,避免逐个更新时反复排序导致索引错位。 diff --git a/src/editor/schema/trajectoryMotion.ts b/src/editor/schema/trajectoryMotion.ts index 3479ec6..e5c0f94 100644 --- a/src/editor/schema/trajectoryMotion.ts +++ b/src/editor/schema/trajectoryMotion.ts @@ -93,7 +93,8 @@ export function moveTrajectoryPointTime( index: number, time: number, duration: number, - startTime = 0 + startTime = 0, + endTime = duration ): number[] { if (index < 0 || index >= times.length) return times; @@ -103,7 +104,7 @@ export function moveTrajectoryPointTime( ? Math.max(Number.isFinite(startTime) ? startTime : 0, 0) + TRAJECTORY_POINT_MIN_GAP : times[index - 1] + TRAJECTORY_POINT_MIN_GAP; const upper = isLast - ? Math.max(duration, lower) + ? Math.max(Math.min(Number.isFinite(endTime) ? endTime : duration, duration), lower) : times[index + 1] - TRAJECTORY_POINT_MIN_GAP; const next = [...times]; next[index] = Math.min(Math.max(time, lower), Math.max(lower, upper)); @@ -111,6 +112,27 @@ export function moveTrajectoryPointTime( return next; } +/** + * 人物轨迹片段的结束时间(秒):角色走完最后一个轨迹点后原地停留到这一刻。 + * 缺省(未设置 trajectoryEndTime)时取时间轴总时长(即最后一个点落在时间轴末尾,无停留段); + * 一旦显式设置,返回该值,并夹在「最后一个轨迹点的时间 ~ 时间轴末尾」之间, + * 保证最后一个点永远落在停留段起点之前(不再等于时间结束位置)。 + */ +export function getTrajectoryClipEndTime( + owner: DirectorObject, + duration = Number.POSITIVE_INFINITY, + startTime = 0 +): number { + const points = owner.trajectoryPoints ?? []; + const times = getTrajectoryPointTimes(points, duration, startTime); + const lastPointTime = times.length > 0 ? times[times.length - 1] : startTime; + const safeDuration = Number.isFinite(duration) ? duration : 0; + const stored = owner.trajectoryEndTime; + const rawEnd = typeof stored === "number" && Number.isFinite(stored) ? stored : safeDuration; + + return Math.min(Math.max(rawEnd, lastPointTime), Math.max(safeDuration, lastPointTime)); +} + /** * 拖动整段轨迹的时长(条右端把手):末点落到 newEnd,其余点按同一比例缩放, * 保持各点之间的相对节奏,片段起点固定不动。newEnd 夹在「起点 + 最小跨度 ~ 时间轴末尾」之间。 diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index 1199d70..4b2231b 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -53,7 +53,12 @@ import { type CameraKeyframeValue, type Timeline, } from "../schema/timeline"; -import { getTrajectoryStartTime, shiftTrajectoryClip } from "../schema/trajectoryMotion"; +import { + TRAJECTORY_POINT_MIN_GAP, + getTrajectoryPointTimes, + getTrajectoryStartTime, + shiftTrajectoryClip, +} from "../schema/trajectoryMotion"; export type TransformMode = "translate" | "rotate" | "scale"; @@ -159,8 +164,13 @@ export interface DirectorActions { setTrajectoryPointTimes: (objectId: string, times: number[]) => void; /** 整体平移人物轨迹片段(拖轨迹条本体):片段起点 + 所有轨迹点时间同步平移 */ setTrajectoryClipStart: (objectId: string, startTime: number) => void; + /** 修剪人物轨迹片段起点(拖轨迹条左端把手):只挪片段起点,轨迹点与停留结束时间都不动 */ + setTrajectoryClipTrimStart: (objectId: string, startTime: number) => void; + /** 设置人物轨迹片段的结束时间(停留时长):角色走完最后一点后原地停留到这一刻,拖条右端把手时写入 */ + setTrajectoryClipEnd: (objectId: string, endTime: number) => void; removeTrajectoryPoint: (objectId: string, pointId: string) => void; - selectTrajectoryPoint: (objectId: string, pointId: string) => void; + /** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */ + selectTrajectoryPoint: (objectId: string | null, pointId: string | null) => void; updateScene: (patch: Partial) => void; removePanoramaAsset: () => void; removeImportedAsset: (assetId: string) => void; @@ -1763,21 +1773,75 @@ export const useDirectorStore = create((set, get) => { // 片段刚性平移:起点夹在时间轴内,所有轨迹点跟着走同样的量。 // 点的时间在这里落成显式值,避免默认推导把片段「压缩」而不是「平移」。 + const currentStart = getTrajectoryStartTime(item, state.project.timeline.duration); const { startTime: nextStart, times } = shiftTrajectoryClip( points, state.project.timeline.duration, - getTrajectoryStartTime(item, state.project.timeline.duration), + currentStart, startTime ); + const delta = nextStart - currentStart; + // 停留结束时间(trajectoryEndTime)同样随片段刚性平移,保持与轨迹点的相对关系 + const nextEndTime = + typeof item.trajectoryEndTime === "number" + ? Math.min(Math.max(item.trajectoryEndTime + delta, 0), state.project.timeline.duration) + : item.trajectoryEndTime; return { ...item, trajectoryStartTime: nextStart, + trajectoryEndTime: nextEndTime, trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })), }; }), }, })), + setTrajectoryClipTrimStart: (objectId, startTime) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, objectId, (item) => { + const points = item.trajectoryPoints ?? []; + if (points.length === 0) return item; + + const duration = state.project.timeline.duration; + const currentStart = getTrajectoryStartTime(item, duration); + const times = getTrajectoryPointTimes(points, duration, currentStart); + const firstPointTime = times[0] ?? 0; + // 起点最多挪到「第一个轨迹点再往前一个最小间隔」处 —— 不能越过第一个点, + // 否则那一列点就落在片段起点之前,和「起点之前原地等待」的语义冲突。 + const upper = Math.max(firstPointTime - TRAJECTORY_POINT_MIN_GAP, 0); + const nextStart = Math.min(Math.max(startTime, 0), upper); + + return { + ...item, + trajectoryStartTime: nextStart, + // 把点的时间落成显式值:这些点不该跟着起点走,否则就退化成整段平移而不是修剪起点。 + // (没有显式 time 的点会按「起点 → 时间轴末尾」重新推导,改起点会连点一起拖走。) + trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })), + }; + }), + }, + })), + setTrajectoryClipEnd: (objectId, endTime) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, objectId, (item) => { + const points = item.trajectoryPoints ?? []; + if (points.length === 0) return item; + + const duration = state.project.timeline.duration; + const times = getTrajectoryPointTimes(points, duration, getTrajectoryStartTime(item, duration)); + const lastPointTime = times.length > 0 ? times[times.length - 1] : 0; + const nextEnd = Math.min(Math.max(endTime, lastPointTime), duration); + + return { ...item, trajectoryEndTime: nextEnd }; + }), + }, + })), removeTrajectoryPoint: (objectId, pointId) => commitMutation((state) => ({ ...state, @@ -1795,6 +1859,11 @@ export const useDirectorStore = create((set, get) => { })), selectTrajectoryPoint: (objectId, pointId) => commitUiMutation((state) => { + // 取消选中:只清掉「轨迹点」这一层,不动角色本身的选择,避免点一下空白就把对象也取消选中 + if (!objectId || !pointId) { + return state.selectedTrajectoryPoint ? { ...state, selectedTrajectoryPoint: null } : state; + } + const target = state.project.objects.find((item) => item.id === objectId); if (!target?.trajectoryPoints?.some((point) => point.id === pointId)) return state; diff --git a/src/styles/index.css b/src/styles/index.css index 6e17e10..56224a6 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -1408,6 +1408,11 @@ textarea:focus-visible { white-space: nowrap; } +/* 长标签(如「截图/录制时显示网格」)独占一整行,避免与相邻开关项重叠 */ +.scene-switch-row .inspector-toggle-row.toggle-wide { + grid-column: 1 / -1; +} + .inspector-action-button { width: 100%; height: 40px; @@ -3177,26 +3182,75 @@ textarea:focus-visible { background: rgb(var(--surface-hover-rgb) / 0.72); } -.camera-capture-viewer { +.camera-capture-viewer-backdrop { position: fixed; inset: 0; z-index: 100000; - display: flex; - align-items: center; - justify-content: center; - background: rgb(0 0 0 / 0.9); + background: rgb(0 0 0 / 0.6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } +/* 可拖动的浮动对话框:默认居中,拖动标题栏时通过 transform 偏移移动 */ +.camera-capture-viewer { + position: fixed; + top: 50%; + left: 50%; + z-index: 100001; + display: flex; + flex-direction: column; + width: min(90vw, 920px); + height: min(85vh, 760px); + min-width: 360px; + min-height: 300px; + transform: translate(-50%, -50%); + border: 1px solid rgb(255 255 255 / 0.12); + border-radius: 12px; + background: rgb(var(--surface-rgb) / 0.98); + box-shadow: 0 28px 70px rgb(0 0 0 / 0.55); + overflow: hidden; +} + +.camera-capture-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex: 0 0 auto; + padding: 10px 14px; + border-bottom: 1px solid rgb(255 255 255 / 0.08); + cursor: grab; + user-select: none; +} + +.camera-capture-viewer-header.is-dragging { + cursor: grabbing; +} + +.camera-capture-viewer-title { + flex: 1 1 auto; + min-width: 0; + font-size: 13px; + color: rgb(var(--text-rgb)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.camera-capture-viewer-counter { + flex: 0 0 auto; + margin-left: 10px; + font-size: 12px; + color: rgb(var(--text-dim-rgb)); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + .camera-capture-viewer-toolbar { - position: absolute; - top: 16px; - right: 16px; display: flex; align-items: center; gap: 8px; - z-index: 10; + flex: 0 0 auto; } .camera-capture-viewer-tool { @@ -3240,17 +3294,56 @@ textarea:focus-visible { background: rgb(255 255 255 / 0.2); } +.camera-capture-viewer-tool:disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; + background: rgb(255 255 255 / 0.06); +} + +/* 右下角拖动调节尺寸的手柄,悬浮在标题栏 / 工具栏之外,不会遮挡图片 */ +.camera-capture-viewer-resize { + position: absolute; + right: 0; + bottom: 0; + width: 18px; + height: 18px; + z-index: 5; + cursor: nwse-resize; + border-bottom-right-radius: 12px; + background: linear-gradient( + 135deg, + transparent 0, + transparent 50%, + rgb(255 255 255 / 0.28) 50%, + rgb(255 255 255 / 0.28) 62%, + transparent 62%, + transparent 74%, + rgb(255 255 255 / 0.28) 74%, + rgb(255 255 255 / 0.28) 86%, + transparent 86% + ); +} + +.camera-capture-viewer-resize.is-resizing { + cursor: nwse-resize; +} + .camera-capture-viewer-stage { display: grid; place-items: center; + flex: 1 1 auto; + min-height: 0; width: 100%; - height: 100%; + padding: 16px; + background: #000000; + overflow: hidden; } .camera-capture-viewer-image { display: block; - max-width: 80vw; - max-height: 80vh; + max-width: 100%; + max-height: 100%; user-select: none; -webkit-user-drag: none; border-radius: 8px; @@ -4127,15 +4220,18 @@ textarea:focus-visible { } /* ---- 轨道拖拽排序:整行都是拖拽单元 ---- */ .timeline-track, -.timeline-character-track { +.timeline-character-track, +.timeline-camera-track { cursor: grab; } .timeline-track:active, -.timeline-character-track:active { +.timeline-character-track:active, +.timeline-camera-track:active { cursor: grabbing; } .timeline-track.is-dragging, -.timeline-character-track.is-dragging { +.timeline-character-track.is-dragging, +.timeline-camera-track.is-dragging { opacity: 0.42; } /* 拖拽落点线:独立的一条线,只靠 transform 上下移动。 @@ -4179,7 +4275,8 @@ body.is-timeline-dragging * { -webkit-backdrop-filter: none !important; } /* 折叠后的轨道:车道内容换成一句摘要,网格线淡化表示「这里被折起来了」 */ -.timeline-track.is-collapsed .timeline-track-lane { +.timeline-track.is-collapsed .timeline-track-lane, +.timeline-camera-track.is-collapsed .timeline-track-lane { background-image: none; } .timeline-track-summary { @@ -4214,6 +4311,10 @@ body.is-timeline-dragging * { .timeline-character-track { background: rgb(var(--accent-rgb) / 0.05); } +/* 机位(运镜)轨道:和角色轨道一样,整组(头部 + 机位子轨道)用一个容器包起来,便于整体拖拽排序、折叠 */ +.timeline-camera-track { + background: rgb(var(--accent-rgb) / 0.05); +} .timeline-subtrack { display: grid; grid-template-columns: var(--timeline-gutter-width) minmax(0, 1fr); @@ -4234,16 +4335,26 @@ body.is-timeline-dragging * { font-size: 11px; font-weight: 600; } -/* 分组抬头行:展开时轨道留空只作分组抬头,折叠时显示一行摘要 */ +/* 分组抬头行:展开时轨道留空只作分组抬头,折叠时显示一行摘要。 + 头部车道兼作「轨迹条」的容器,需要成为绝对定位的基准,且去掉左右内边距, + 否则轨迹条 / 关键帧按百分比定位会相对网格错开 8px */ .timeline-character-head-lane { + position: relative; display: flex; align-items: center; - padding: 0 8px; + padding: 0; border-bottom-color: rgb(var(--border-rgb) / 0.22); } +/* 机位子轨道的车道:与轨迹子轨道同理,承载折线 + 关键帧,铺满整条、与网格对齐 */ +.timeline-camera-keyframe-lane { + padding: 0; +} .timeline-character-track.is-collapsed { background: rgb(var(--accent-rgb) / 0.03); } +.timeline-camera-track.is-collapsed { + background: rgb(var(--accent-rgb) / 0.03); +} .timeline-motion-lane { display: flex; align-items: center; @@ -4297,26 +4408,37 @@ body.is-timeline-dragging * { transform: translateY(-50%); pointer-events: none; } -.timeline-trajectory-handle { +/* 轨迹片段条的左右修剪把手:贴在条内侧两端,整条高度可抓。 + 平时只露一条淡淡的分隔色,选中 / hover 时才加重成明确的把手。 */ +.timeline-trajectory-trim { position: absolute; - top: -3px; - right: -5px; - bottom: -3px; - width: 9px; - border-radius: 3px; - background: rgb(var(--accent-rgb)); - box-shadow: 0 0 0 1px rgb(var(--panel-rgb) / 0.85); + top: 0; + bottom: 0; + width: 6px; cursor: ew-resize; touch-action: none; + background: rgb(var(--accent-rgb) / 0.45); + transition: background-color 0.14s ease; } -.timeline-trajectory-handle:hover, -.timeline-trajectory-handle:focus-visible { - box-shadow: - 0 0 0 1px rgb(var(--panel-rgb) / 0.9), - 0 0 0 4px rgb(var(--accent-rgb) / 0.25); +.timeline-trajectory-trim.is-pre { + left: 0; + border-radius: 4px 0 0 4px; +} +.timeline-trajectory-trim.is-post { + right: 0; + border-radius: 0 4px 4px 0; +} +.timeline-trajectory-trim:hover, +.timeline-trajectory-trim:focus-visible { + background: rgb(var(--accent-rgb)); outline: none; } -/* 运镜片段条:和人物轨迹条同一套「片段条 + 右端把手」交互(首帧固定、拖把手缩放整段时长), +/* 选中态:两端把手站出来,明确「左右都能拖」 */ +.timeline-trajectory-bar.is-selected .timeline-trajectory-trim { + background: rgb(var(--accent-rgb)); + box-shadow: inset 0 0 0 1px rgb(var(--panel-rgb) / 0.55); +} +/* 运镜片段条:和人物轨迹条同一套「片段条 + 左右两端把手」交互(拖左端修剪起点、拖右端修剪终点), 只是区间是「第一个运镜关键帧 → 最后一个关键帧」,配色换成运镜绿以区分 */ .timeline-clip-bar-camera { border-color: rgb(18 184 134 / 0.42); @@ -4325,14 +4447,33 @@ body.is-timeline-dragging * { .timeline-clip-bar-camera .timeline-trajectory-bar-label { color: #12b886; } -.timeline-clip-bar-camera .timeline-trajectory-handle { +/* 运镜条的把手跟着运镜绿走,hover / 聚焦 / 选中时加重 */ +.timeline-clip-bar-camera .timeline-trajectory-trim { + background: rgb(18 184 134 / 0.45); +} +.timeline-clip-bar-camera .timeline-trajectory-trim:hover, +.timeline-clip-bar-camera .timeline-trajectory-trim:focus-visible, +.timeline-clip-bar-camera.is-selected .timeline-trajectory-trim { background: #12b886; } -.timeline-clip-bar-camera .timeline-trajectory-handle:hover, -.timeline-clip-bar-camera .timeline-trajectory-handle:focus-visible { - box-shadow: - 0 0 0 1px rgb(var(--panel-rgb) / 0.9), - 0 0 0 4px rgb(18 184 134 / 0.25); +/* 轨迹条内的连线:把各轨迹点用直线连起来,直观呈现走位路径(viewBox 0~100 映射条内百分比, + preserveAspectRatio=none 让它随条拉伸铺满;non-scaling-stroke 保证线宽不随缩放变形) */ +.timeline-trajectory-connector { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} +.timeline-trajectory-connector polyline { + fill: none; + stroke: rgb(var(--accent-rgb)); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + opacity: 0.6; } /* 片段选中态:底色 + 边框 + 外描边一起加重(轨迹条走强调色,运镜条走它自己的绿)。 必须排在「未选中」规则之后:同优先级下靠源序决定,选中态才盖得住。 */ @@ -4386,7 +4527,7 @@ body.is-timeline-dragging * { } .timeline-keyframe-dot { position: absolute; - inset: 4px; + inset: 5px; border-radius: 2px; background: #4f8ef7; box-shadow: 0 0 0 1px rgb(var(--panel-rgb) / 0.75); @@ -4420,29 +4561,6 @@ body.is-timeline-dragging * { 0 0 0 1px rgb(var(--panel-rgb) / 0.9), 0 0 0 3px rgb(242 169 0 / 0.28); } -.timeline-keyframe-delete { - position: absolute; - top: -4px; - right: -4px; - width: 14px; - height: 14px; - padding: 0; - border: none; - border-radius: 50%; - background: #e0524d; - color: #fff; - font-size: 11px; - line-height: 12px; - text-align: center; - cursor: pointer; - opacity: 0; - transition: opacity 0.12s; -} -.timeline-keyframe:hover .timeline-keyframe-delete, -.timeline-keyframe.is-selected .timeline-keyframe-delete, -.timeline-keyframe:focus-visible .timeline-keyframe-delete { - opacity: 1; -} /* ===== 全屏画布 + 悬浮卡片 ===== 画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。 diff --git a/src/styles/index.css.test.ts b/src/styles/index.css.test.ts index 164384e..11cc101 100644 --- a/src/styles/index.css.test.ts +++ b/src/styles/index.css.test.ts @@ -127,13 +127,19 @@ it("matches the provided right inspector layout dimensions and field styling", ( expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*button\.camera-capture-action\s*\{[\s\S]*?color:\s*rgb\(255 255 255 \/ 0\.82\);/); expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*button\.camera-capture-action:hover,\s*[\r\n]+\s*\.right-sidebar\s*\.right-inspector\s*button\.camera-capture-action:focus-visible\s*\{[\s\S]*?color:\s*rgb\(255 255 255\);/); expect(css).toMatch(/\.camera-capture-name\s*\{[\s\S]*?font-size:\s*12px;[\s\S]*?line-height:\s*17px;[\s\S]*?white-space:\s*nowrap;[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;/); - expect(css).toMatch(/\.camera-capture-viewer\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?inset:\s*0;[\s\S]*?z-index:\s*100000;[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*center;[\s\S]*?background:\s*rgb\(0 0 0 \/ 0\.9\);[\s\S]*?backdrop-filter:\s*blur\(4px\);/); - expect(css).toMatch(/\.camera-capture-viewer-toolbar\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?top:\s*16px;[\s\S]*?right:\s*16px;[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*8px;[\s\S]*?z-index:\s*10;/); + expect(css).toMatch(/\.camera-capture-viewer-backdrop\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?inset:\s*0;[\s\S]*?z-index:\s*100000;[\s\S]*?background:\s*rgb\(0 0 0 \/ 0\.6\);[\s\S]*?backdrop-filter:\s*blur\(4px\);/); + expect(css).toMatch(/\.camera-capture-viewer\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?z-index:\s*100001;[\s\S]*?display:\s*flex;[\s\S]*?flex-direction:\s*column;[\s\S]*?width:\s*min\(90vw,\s*920px\);[\s\S]*?min-width:\s*360px;[\s\S]*?min-height:\s*300px;[\s\S]*?border-radius:\s*12px;[\s\S]*?background:\s*rgb\(var\(--surface-rgb\) \/ 0\.98\);/); + expect(css).toMatch(/\.camera-capture-viewer-header\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*space-between;[\s\S]*?cursor:\s*grab;[\s\S]*?user-select:\s*none;/); + expect(css).toMatch(/\.camera-capture-viewer-header\.is-dragging\s*\{[\s\S]*?cursor:\s*grabbing;/); + expect(css).toMatch(/\.camera-capture-viewer-title\s*\{[\s\S]*?color:\s*rgb\(var\(--text-rgb\)\);[\s\S]*?white-space:\s*nowrap;[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;/); + expect(css).toMatch(/\.camera-capture-viewer-counter\s*\{[\s\S]*?color:\s*rgb\(var\(--text-dim-rgb\)\);[\s\S]*?font-variant-numeric:\s*tabular-nums;/); + expect(css).toMatch(/\.camera-capture-viewer-resize\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?right:\s*0;[\s\S]*?bottom:\s*0;[\s\S]*?width:\s*18px;[\s\S]*?height:\s*18px;[\s\S]*?z-index:\s*5;[\s\S]*?cursor:\s*nwse-resize;/); + expect(css).toMatch(/\.camera-capture-viewer-toolbar\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?gap:\s*8px;/); expect(css).toMatch(/\.camera-capture-viewer-tool\s*\{[\s\S]*?width:\s*40px;[\s\S]*?height:\s*40px;[\s\S]*?border:\s*1px solid rgb\(255 255 255 \/ 0\.1\);[\s\S]*?border-radius:\s*8px;[\s\S]*?background:\s*rgb\(255 255 255 \/ 0\.1\);/); expect(css).toMatch(/\.right-inspector\s*button\.camera-capture-viewer-tool\s*\{[\s\S]*?color:\s*rgb\(255 255 255 \/ 0\.9\);/); expect(css).toMatch(/\.right-inspector\s*button\.camera-capture-viewer-tool:hover,\s*[\r\n]+\s*\.right-inspector\s*button\.camera-capture-viewer-tool:focus-visible\s*\{[\s\S]*?color:\s*rgb\(255 255 255\);/); - expect(css).toMatch(/\.camera-capture-viewer-stage\s*\{[\s\S]*?display:\s*grid;[\s\S]*?place-items:\s*center;[\s\S]*?width:\s*100%;[\s\S]*?height:\s*100%;/); - expect(css).toMatch(/\.camera-capture-viewer-image\s*\{[\s\S]*?max-width:\s*80vw;[\s\S]*?max-height:\s*80vh;[\s\S]*?user-select:\s*none;[\s\S]*?border-radius:\s*8px;[\s\S]*?object-fit:\s*contain;[\s\S]*?transition:\s*transform 0\.2s ease;/); + expect(css).toMatch(/\.camera-capture-viewer-stage\s*\{[\s\S]*?display:\s*grid;[\s\S]*?place-items:\s*center;[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;[\s\S]*?width:\s*100%;[\s\S]*?background:\s*#000000;/); + expect(css).toMatch(/\.camera-capture-viewer-image\s*\{[\s\S]*?max-width:\s*100%;[\s\S]*?max-height:\s*100%;[\s\S]*?user-select:\s*none;[\s\S]*?border-radius:\s*8px;[\s\S]*?object-fit:\s*contain;[\s\S]*?transition:\s*transform 0\.2s ease;/); expect(css).toMatch(/\.camera-capture-viewer-image\.is-zoomed\s*\{[\s\S]*?cursor:\s*grab;/); expect(css).toMatch(/\.camera-capture-viewer-image\.is-dragging\s*\{[\s\S]*?cursor:\s*grabbing;[\s\S]*?transition:\s*none;/); expect(css).not.toContain(".panorama-preview-card");