diff --git a/src/editor/canvas/DirectorCanvas.test.tsx b/src/editor/canvas/DirectorCanvas.test.tsx index fc29929..eaa72b9 100644 --- a/src/editor/canvas/DirectorCanvas.test.tsx +++ b/src/editor/canvas/DirectorCanvas.test.tsx @@ -380,7 +380,9 @@ it("shares the scene transform with the ground plane so the grid never becomes a render(); const grid = screen.getByTestId("viewport-grid"); - const sceneGroup = grid.closest("group"); + // 网格被包在「导出时隐藏」的 group 里,再外层才是与地面共享变换的场景 group + const gridWrapper = grid.closest("group"); + const sceneGroup = gridWrapper?.parentElement?.closest("group"); // 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间 expect(sceneGroup).not.toBeNull(); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index 9656e70..02a068a 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -26,6 +26,7 @@ import { SceneRoot } from "./SceneRoot"; import { AXIS_COLORS } from "./sceneAxes"; import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround"; import { getSceneGroupTransform } from "./sceneTransform"; +import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture"; import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver"; import { ActiveCameraPlaybackSync } from "../runtime/ActiveCameraPlaybackSync"; import { PlaybackTransformSync } from "./PlaybackTransformSync"; @@ -69,7 +70,6 @@ const RIGHT_PANEL_WIDTH = 300; const GIZMO_EDGE_PADDING = 20; /** 悬浮卡片比面板自身宽度多占的留白(左/右各 --floating-inset,再加同宽间距) */ const FLOATING_PANEL_GAP = 32; -const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture"; const CAPTURE_LABEL_FONT_SIZE = 12; const CAPTURE_LABEL_HORIZONTAL_PADDING = 10; const CAPTURE_LABEL_VERTICAL_PADDING = 6; @@ -245,7 +245,7 @@ function getCharacterCaptureLabelY(item: DirectorObject) { : getGroundedLabelY(item.bodyType); } -function getViewportCaptureLabels() { +export function getViewportCaptureLabels(captureScene?: Object3D) { const { project: { objects, scene }, } = useDirectorStore.getState(); @@ -253,14 +253,15 @@ function getViewportCaptureLabels() { if (!scene.showLabels) return []; const sceneMatrix = createSceneMatrix(scene); + // 角色在场景里的真实世界坐标可能已被播放头 / 人物轨迹挪动,先刷新一遍矩阵,保证读到的位置是「当前这帧」的 + if (captureScene && typeof captureScene.updateMatrixWorld === "function") captureScene.updateMatrixWorld(true); return objects .filter((item) => item.kind === "character" && item.visible) .map((item): ViewportCaptureLabel => { - const objectMatrix = createTransformMatrix(item.transform); - const worldPosition = new Vector3(0, getCharacterCaptureLabelY(item), 0) - .applyMatrix4(objectMatrix) - .applyMatrix4(sceneMatrix); + // 优先用角色在场景里的真实世界坐标(播放头移动 / 人物轨迹会让它偏离静态 transform), + // 这样截图上标签才贴着角色头部,而不是落在它摆放时的 0 点坐标。 + const worldPosition = findCharacterWorldPosition(captureScene, item) ?? computeCharacterLabelWorldPosition(item, sceneMatrix); return { text: item.name, @@ -269,6 +270,28 @@ function getViewportCaptureLabels() { }); } +function computeCharacterLabelWorldPosition(item: DirectorObject, sceneMatrix: Matrix4): Vector3 { + const objectMatrix = createTransformMatrix(item.transform); + return new Vector3(0, getCharacterCaptureLabelY(item), 0) + .applyMatrix4(objectMatrix) + .applyMatrix4(sceneMatrix); +} + +/** 去场景里找这个角色的根 group(userData.directorObjectId === item.id),取它当前的世界坐标 + 标签高度 */ +function findCharacterWorldPosition(captureScene: Object3D | undefined, item: DirectorObject): Vector3 | null { + if (!captureScene) return null; + + let found: Object3D | undefined; + captureScene.traverse((object) => { + if (!found && object.userData?.directorObjectId === item.id) found = object; + }); + if (!found) return null; + + const position = found.getWorldPosition(new Vector3()); + position.y += getCharacterCaptureLabelY(item); + return position; +} + function getCssRgbVariable(name: string, fallback: string) { if (typeof window === "undefined") return fallback; @@ -434,25 +457,6 @@ function captureViewportCanvas( return cropCanvas.toDataURL("image/png"); } -function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) { - const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = []; - - scene.traverse((object) => { - if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) { - hiddenObjects.push({ object, visible: object.visible }); - object.visible = false; - } - }); - - try { - render(); - } finally { - hiddenObjects.forEach(({ object, visible }) => { - object.visible = visible; - }); - } -} - function CanvasCaptureBridge({ activeCamera, bottomPadding, @@ -507,7 +511,7 @@ function CanvasCaptureBridge({ label, dataUrl: captureViewportCanvas(gl.domElement, viewportAspectRatio, bottomPadding, safeAreaInsets, { camera: workingCamera, - labels: getViewportCaptureLabels(), + labels: getViewportCaptureLabels(scene), }), meta: buildScreenshotMeta({ mode: viewMode, @@ -844,15 +848,19 @@ export function DirectorCanvas() { // 网格和地面一样属于「场景内容」:共用同一套场景变换 + 同一尺寸, // 变换不同会让两者错位,尺寸不同会让边缘不重合 —— 两种都会看成"两层地面"。 - + {/* 导出时(截图 / 视频)是否把网格藏起来由 captureShowGrid 决定: + 默认 false → 打上 hideFromViewportCapture,导出前统一藏起;勾选后不标记,网格留在画面里。 + 用一层 group 承载标记,避免依赖 自身是否转发 userData。 */} + + + ) : null} {viewMode === "director" ? ( @@ -916,11 +924,6 @@ export function DirectorCanvas() { - {trajectoryPlacementObjectId ? ( -
- 双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出 -
- ) : null} + {/* 轨迹打点提示:独立浮层,显示在快捷工具栏「上方」,不嵌进工具栏里 */} + {trajectoryPlacementObjectId ? ( +
+ 双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出 +
+ ) : null} ); diff --git a/src/editor/canvas/SceneRoot.test.tsx b/src/editor/canvas/SceneRoot.test.tsx index bc19386..d5ee0e0 100644 --- a/src/editor/canvas/SceneRoot.test.tsx +++ b/src/editor/canvas/SceneRoot.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { readFileSync } from "node:fs"; import { Box3, DoubleSide, Vector3 } from "three"; import { afterEach, beforeEach, vi } from "vitest"; import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry"; @@ -274,6 +275,16 @@ it("uses the requested dark ground surface color", () => { expect(groundMaterial?.outerHTML.toLowerCase()).toContain("polygonoffsetunits=\"1\""); }); +it("paints the ground with the ground color from the scene settings", () => { + useDirectorStore.getState().updateScene({ groundColor: "#204060" }); + + const { container } = render(); + + // 地面颜色和天空颜色一样是工程设置:面板上改了颜色,视口里的地面材质就得跟着变 + expect(container.querySelector('meshbasicmaterial[color="#204060"]')).toBeInTheDocument(); + expect(container.querySelector('meshbasicmaterial[color="#303640"]')).not.toBeInTheDocument(); +}); + it("keeps the ground visible when the view drops below the ground plane", () => { const { container } = render(); const groundMaterial = container.querySelector('meshbasicmaterial[color="#303640"]'); @@ -680,6 +691,21 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints", expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2); }); +it("keeps the trajectory marker material writing depth so an opaque ground cannot hide it", () => { + // 这条约束落在材质参数上(jsdom 里 / 只是普通 DOM 节点, + // depthWrite 这种 unknown 布尔属性不会落到 DOM 上,跑不出来),所以按源码契约断言。 + const source = readFileSync("src/editor/canvas/SceneRoot.tsx", "utf8"); + const markerMaterialLine = source + .split("\n") + .find((line) => line.includes(" { useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]); // 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点 diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index 1ab5b00..c17e2d5 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -41,6 +41,7 @@ import { getEffectiveGroundOpacity } from "./panoramaMath"; import { getGroundAxisLines } from "./sceneAxes"; import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround"; import { getSceneGroupTransform } from "./sceneTransform"; +import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture"; import { getCrowdAnchorTransform } from "../store/directorStore"; export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath"; @@ -55,7 +56,6 @@ const VIEWPORT_CAMERA_LINE_OPACITY = 0.92; const VIEWPORT_CAMERA_HIT_PADDING = 0.06; const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1); const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0); -const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture"; const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE]; const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [ 0.4 * VIEWPORT_CAMERA_VISUAL_SCALE, @@ -967,14 +967,17 @@ function TrajectoryPath({ - {/* 实心小球:选中用高亮色,否则用角色色 */} + {/* 实心小球:选中用高亮色,否则用角色色。 + 这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质, + 即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染; + 小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */} selectPoint(point.id, event) : undefined} > - + ); @@ -1073,7 +1076,7 @@ export function SceneRoot() { = {}): SceneSettings { @@ -11,8 +11,10 @@ function createScene(patch: Partial = {}): SceneSettings { panoramaYaw: 0, panoramaRadius: 60, showLabels: true, + captureShowGrid: false, snapToGrid: false, showGround: true, + groundColor: DEFAULT_GROUND_COLOR, groundOpacity: 0.4, groundHeight: 0, ...patch, diff --git a/src/editor/canvas/viewportCapture.test.ts b/src/editor/canvas/viewportCapture.test.ts new file mode 100644 index 0000000..2e335ab --- /dev/null +++ b/src/editor/canvas/viewportCapture.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from "vitest"; +import { Group, Object3D } from "three"; +import { + HIDE_FROM_VIEWPORT_CAPTURE_KEY, + hideViewportCaptureHelpers, + withViewportCaptureHelpersHidden, +} from "./viewportCapture"; + +function makeFlaggedObject() { + const object = new Object3D(); + object.userData[HIDE_FROM_VIEWPORT_CAPTURE_KEY] = true; + return object; +} + +it("hides every flagged helper and restores the original visibility afterwards", () => { + const root = new Group(); + const flagged = makeFlaggedObject(); + const nestedFlagged = makeFlaggedObject(); + const kept = new Object3D(); + const nestedKept = new Object3D(); + flagged.add(nestedFlagged, nestedKept); + root.add(flagged, kept); + // 先藏起来一个,验证 restore 还原的是「进入前的可见性」而不是无脑设 true + flagged.visible = false; + + const restore = hideViewportCaptureHelpers(root); + + expect(flagged.visible).toBe(false); // 本来就隐藏,hider 不该改它 + expect(nestedFlagged.visible).toBe(false); + expect(kept.visible).toBe(true); + expect(nestedKept.visible).toBe(true); + + restore(); + + expect(flagged.visible).toBe(false); // 还原回 hider 进入前的状态 + expect(nestedFlagged.visible).toBe(true); + expect(kept.visible).toBe(true); + expect(nestedKept.visible).toBe(true); +}); + +it("hides helpers only for the render call and always restores, even when render throws", () => { + const root = new Group(); + const flagged = makeFlaggedObject(); + root.add(flagged); + const seenDuringRender: boolean[] = []; + + expect(() => + withViewportCaptureHelpersHidden(root, () => { + seenDuringRender.push(flagged.visible); + throw new Error("render blew up"); + }) + ).toThrow("render blew up"); + + expect(seenDuringRender).toEqual([false]); + expect(flagged.visible).toBe(true); +}); diff --git a/src/editor/canvas/viewportCapture.ts b/src/editor/canvas/viewportCapture.ts new file mode 100644 index 0000000..b227970 --- /dev/null +++ b/src/editor/canvas/viewportCapture.ts @@ -0,0 +1,44 @@ +import type { Object3D } from "three"; + +/** + * 截图 / 视频导出时要从画面里去掉的编辑器辅助元素。 + * + * 约定:凡是「只在编辑器里给用户看」的东西 —— 视口网格、地面 X/Z 坐标轴、人物轨迹与路点、 + * 机位线框与拾取盒、拖拽 gizmo(那三条红绿蓝轴)、名字标签……都在 userData 里打上这个标记。 + * + * 截图与视频导出必须在渲染前统一把它们隐藏、渲完立刻恢复,并且**共用这一份实现**: + * 两边各写一套时,很容易出现「截图干净、导出的视频里却带着 gizmo 红绿蓝轴」这类不一致。 + */ +export const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture"; + +/** + * 隐藏场景里所有带标记的辅助对象,返回「恢复原样」的函数。 + * + * 适合需要跨多帧保持隐藏的场合(如实时录屏兜底路径,整段录制期间都不能露出来)。 + */ +export function hideViewportCaptureHelpers(scene: Object3D): () => void { + const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = []; + + scene.traverse((object) => { + if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) { + hiddenObjects.push({ object, visible: object.visible }); + object.visible = false; + } + }); + + return () => { + hiddenObjects.forEach(({ object, visible }) => { + object.visible = visible; + }); + }; +} + +/** 藏起辅助元素后渲染一帧;无论渲染成功还是抛错,都把可见性恢复原状。 */ +export function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) { + const restore = hideViewportCaptureHelpers(scene); + try { + render(); + } finally { + restore(); + } +} diff --git a/src/editor/canvas/viewportCaptureLabels.test.ts b/src/editor/canvas/viewportCaptureLabels.test.ts new file mode 100644 index 0000000..045574c --- /dev/null +++ b/src/editor/canvas/viewportCaptureLabels.test.ts @@ -0,0 +1,68 @@ +import { Group, Scene } from "three"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { DirectorObject } from "../schema/directorProject"; +import { createDefaultDirectorProject, useDirectorStore } from "../store/directorStore"; +import { getViewportCaptureLabels } from "./DirectorCanvas"; + +describe("getViewportCaptureLabels", () => { + beforeEach(() => { + useDirectorStore.getState().replaceProject(createDefaultDirectorProject()); + }); + + it("places each label at the character's real scene position, not its static transform (0,0,0)", () => { + const project = createDefaultDirectorProject(); + const characters = project.objects.filter((o): o is DirectorObject => o.kind === "character"); + expect(characters.length).toBeGreaterThan(0); + + // 把所有角色静态 transform 摆到原点,但在场景里把它们挪到别处 —— + // 截图标签必须贴着「场景里真实的位置」,而不是落回 0 点坐标。 + const scene = new Scene(); + const nameToPosition = new Map(); + characters.forEach((item, index) => { + const group = new Group(); + group.userData.directorObjectId = item.id; + const position: [number, number, number] = [10 + index, 0.5, 20 + index]; + group.position.set(position[0], position[1], position[2]); + scene.add(group); + nameToPosition.set(item.name, position); + + item.transform.position = [0, 0, 0]; + }); + + useDirectorStore.getState().replaceProject(project); + + const labels = getViewportCaptureLabels(scene); + expect(labels.length).toBe(characters.length); + + for (const label of labels) { + const expected = nameToPosition.get(label.text); + expect(expected).toBeTruthy(); + // 用 x / z 验证:若误用静态 transform(原点),这里会是 0 + expect(label.worldPosition.x).toBeCloseTo(expected![0], 5); + expect(label.worldPosition.z).toBeCloseTo(expected![2], 5); + // y 在角色头部高度(基础 y + 标签高度),应高于场景里摆放的 y + expect(label.worldPosition.y).toBeGreaterThan(expected![1]); + } + }); + + it("returns nothing when scene.showLabels is off", () => { + const project = createDefaultDirectorProject(); + project.scene.showLabels = false; + useDirectorStore.getState().replaceProject(project); + + expect(getViewportCaptureLabels(new Scene())).toEqual([]); + }); +}); + +describe("captureShowGrid setting", () => { + beforeEach(() => { + useDirectorStore.getState().replaceProject(createDefaultDirectorProject()); + }); + + it("defaults to false (grid hidden during capture) and toggles", () => { + expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(false); + + useDirectorStore.getState().updateScene({ captureShowGrid: true }); + expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(true); + }); +}); diff --git a/src/editor/io/videoExport.test.ts b/src/editor/io/videoExport.test.ts index 8d9ae3d..07ca252 100644 --- a/src/editor/io/videoExport.test.ts +++ b/src/editor/io/videoExport.test.ts @@ -1,7 +1,26 @@ -import { beforeEach, expect, it } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; +import { Object3D, PerspectiveCamera, Scene, Vector2 } from "three"; import { usePlaybackStore } from "../runtime/playbackStore"; +import { useDirectorStore } from "../store/directorStore"; import { exportVideo } from "./videoExport"; +vi.mock("mp4-muxer", () => ({ + Muxer: class { + target = { buffer: new ArrayBuffer(8) }; + addVideoChunk() {} + finalize() {} + }, + ArrayBufferTarget: class {}, +})); + +const contextRef = vi.hoisted(() => ({ + current: null as null | { gl: any; scene: Scene; camera: PerspectiveCamera }, +})); + +vi.mock("./videoExportBridge", () => ({ + getVideoExportContext: () => contextRef.current, +})); + beforeEach(() => { usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null }); }); @@ -44,3 +63,68 @@ it("keeps the playhead where the export found it", async () => { expect(usePlaybackStore.getState().playhead).toBe(2.5); }); + +it("hides editor helpers (gizmo / grid / axes) while rendering exported frames, then restores them", async () => { + // 模拟场景里有一个「导出时隐藏」的辅助物体(也就是选中对象拖拽 gizmo 的那三条红绿蓝轴) + const scene = new Scene(); + const helper = new Object3D(); + helper.userData.hideFromViewportCapture = true; + const solidObject = new Object3D(); + scene.add(helper, solidObject); + + const visibilityDuringRender: boolean[] = []; + const gl = { + domElement: document.createElement("canvas"), + render: vi.fn(() => { + // 在渲染这一帧的瞬间记录辅助物体的可见性 + visibilityDuringRender.push(helper.visible); + }), + getSize: (size: Vector2) => size.set(300, 150), + getPixelRatio: () => 1, + setPixelRatio: () => {}, + setSize: () => {}, + }; + const camera = new PerspectiveCamera(); + contextRef.current = { gl, scene, camera }; + + // 短时长,缩小导出帧数,加速测试 + useDirectorStore.getState().setTimelineDuration(0.1); + + // jsdom 没有 WebCodecs,这里把逐帧导出需要的几个全局桩好 + 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: () => {}, + }); + + try { + await exportVideo("director"); + } finally { + vi.unstubAllGlobals(); + } + + expect(gl.render).toHaveBeenCalled(); + // 每一帧渲染时,辅助物体都必须藏起来,否则导出的视频里会带着 gizmo 红绿蓝轴 + expect(visibilityDuringRender.length).toBeGreaterThan(0); + expect(visibilityDuringRender.every((v) => v === false)).toBe(true); + // 导出结束后原样恢复 + expect(helper.visible).toBe(true); + expect(solidObject.visible).toBe(true); +}); diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index f528aba..d19a5a3 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -4,6 +4,7 @@ import { Vector2 } 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 { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; import { normalizeTimelineFps } from "../schema/timeline"; @@ -190,7 +191,9 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { editingObjectId: usePlaybackStore.getState().editingObjectId, }); - gl.render(scene, frameCamera); + // 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来, + // 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。 + withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera)); const frame = new VideoFrame(canvas, { timestamp: frameIndex * frameDurationUs, @@ -250,12 +253,20 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise {}; + // 切视角 + 复位指针(时间轴本身就是单次播放,到末尾会自己停住) directorStore.setViewMode(view); usePlaybackStore.getState().setPlayhead(0); const stopped = new Promise((resolve) => { - recorder.onstop = () => resolve(); + recorder.onstop = () => { + restoreCaptureHelpers(); + resolve(); + }; }); recorder.start(); diff --git a/src/editor/panels/ScenePanel.test.tsx b/src/editor/panels/ScenePanel.test.tsx index a7e35f5..db75b5c 100644 --- a/src/editor/panels/ScenePanel.test.tsx +++ b/src/editor/panels/ScenePanel.test.tsx @@ -49,7 +49,7 @@ it("lays scene switches out in one row and only toggles from the checkbox", asyn const checkbox = screen.getByLabelText("角色标签"); expect(switchRow).toBeInTheDocument(); - expect(switchRow?.querySelectorAll(".inspector-toggle-row")).toHaveLength(3); + expect(switchRow?.querySelectorAll(".inspector-toggle-row")).toHaveLength(4); expect(checkbox).toBeChecked(); await user.click(labelText); @@ -79,6 +79,8 @@ it("updates scene transform, panorama, and ground controls", async () => { await user.type(screen.getByLabelText("全景球半径"), "90"); await user.click(screen.getByLabelText("角色标签")); await user.click(screen.getByLabelText("网格吸附")); + await user.clear(screen.getByLabelText("地面颜色 HEX")); + await user.type(screen.getByLabelText("地面颜色 HEX"), "#204060"); await user.clear(screen.getByLabelText("地面透明度")); await user.type(screen.getByLabelText("地面透明度"), "0.65"); await user.clear(screen.getByLabelText("地面高度")); @@ -93,6 +95,7 @@ it("updates scene transform, panorama, and ground controls", async () => { expect(scene.panoramaRadius).toBe(90); expect(scene.showLabels).toBe(false); expect(scene.snapToGrid).toBe(true); + expect(scene.groundColor).toBe("#204060"); expect(scene.groundOpacity).toBe(0.65); expect(scene.groundHeight).toBe(1.2); }); @@ -277,15 +280,17 @@ it("updates panorama yaw and ground height from both sliders and numeric inputs" expect(screen.getByLabelText("地面高度")).toHaveValue(-1.5); }); -it("hides ground opacity and height controls when ground is disabled", async () => { +it("hides ground color, opacity, and height controls when ground is disabled", async () => { const user = userEvent.setup(); render(); + expect(screen.getByLabelText("地面颜色")).toBeInTheDocument(); expect(screen.getByLabelText("地面透明度")).toBeInTheDocument(); expect(screen.getByLabelText("地面高度")).toBeInTheDocument(); await user.click(screen.getByLabelText("地面")); + expect(screen.queryByLabelText("地面颜色")).not.toBeInTheDocument(); expect(screen.queryByLabelText("地面透明度")).not.toBeInTheDocument(); expect(screen.queryByLabelText("地面高度")).not.toBeInTheDocument(); }); diff --git a/src/editor/panels/ScenePanel.tsx b/src/editor/panels/ScenePanel.tsx index 4262d6f..82bab71 100644 --- a/src/editor/panels/ScenePanel.tsx +++ b/src/editor/panels/ScenePanel.tsx @@ -302,6 +302,15 @@ export function ScenePanel() { /> 角色标签 +
+ updateScene({ captureShowGrid: event.target.checked })} + /> + 截图/录制时显示网格 +
{scene.showGround ? ( + {/* 和「天空颜色」同款控件:取色块 + HEX 输入框,两个入口都写同一个字段 */} + updateScene({ groundColor: value })} + onHexChange={(value) => updateScene({ groundColor: value })} + /> { + const { container } = render(); + const panel = container.querySelector("section.timeline-panel") as HTMLElement; + + // 焦点在时间轴上(例如刻度尺 / 面板本身)→ 空格:暂停 → 播放 → 暂停 + fireEvent.keyDown(panel, { key: " " }); + expect(usePlaybackStore.getState().isPlaying).toBe(true); + + fireEvent.keyDown(panel, { key: " " }); + expect(usePlaybackStore.getState().isPlaying).toBe(false); +}); + +it("toggles playback with Space when focus is on the toolbar (timeline-toolbar), not only on the ruler", () => { + const { container } = render(); + const toolbar = container.querySelector(".timeline-toolbar") as HTMLElement; + + expect(toolbar).toBeTruthy(); + // 点工具栏空白处后焦点落在工具栏区 → 空格切换播放 / 暂停 + fireEvent.keyDown(toolbar, { key: " " }); + expect(usePlaybackStore.getState().isPlaying).toBe(true); + + fireEvent.keyDown(toolbar, { key: " " }); + expect(usePlaybackStore.getState().isPlaying).toBe(false); +}); + +it("toggles playback with Space when focus is on a track (the grid body), not only on the ruler", () => { + const { container } = render(); + const gridBody = container.querySelector(".timeline-grid-body") as HTMLElement; + + expect(gridBody).toBeTruthy(); + // 点轨道空白处后焦点落在轨道区 → 空格切换播放 / 暂停 + fireEvent.keyDown(gridBody, { key: " " }); + expect(usePlaybackStore.getState().isPlaying).toBe(true); + + fireEvent.keyDown(gridBody, { key: " " }); + expect(usePlaybackStore.getState().isPlaying).toBe(false); +}); + +it("does not double-toggle when Space is pressed on a native button inside the timeline", () => { + // 播放按钮自身会在松开空格时触发点击(由浏览器处理),面板层应跳过、不要重复切换 + usePlaybackStore.setState({ isPlaying: true }); + + const { container } = render(); + const playButton = screen.getByRole("button", { name: "暂停" }); + + fireEvent.keyDown(playButton, { key: " " }); + // 焦点在 button 上,面板层的空格处理让位给按钮,播放状态不应被这次按键改变 + expect(usePlaybackStore.getState().isPlaying).toBe(true); +}); + it("shows the playhead as a frame number next to the time", () => { usePlaybackStore.getState().setPlayhead(1.5); @@ -810,6 +860,78 @@ it("keeps the camera clip inside the timeline when dragged past the end", () => expect(times[1]).toBeCloseTo(5, 5); }); +it("selects the trajectory clip when it is grabbed, and clears it on an empty area click", () => { + 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]); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + expect(bar).not.toHaveClass("is-selected"); + + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + expect(bar).toHaveClass("is-selected"); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 100 }); + + // 点轨道空白处=取消选中(片段自己会 stopPropagation,所以刚才那一下不会被清掉) + fireEvent.pointerDown(container.querySelector(".timeline-grid-body")!, { + button: 0, + pointerId: 2, + clientX: 5, + }); + + expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected"); +}); + +it("selects the clip when one of its own keyframes is grabbed", () => { + 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]); + + const { container } = render(); + + // 按住关键帧(真实点击的第一步)就把整段片段标成选中:帧和条是同一个编辑对象 + const keyframe = screen.getByRole("button", { name: "轨迹关键帧 1.5s" }); + 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"); +}); + +it("selects the camera clip both when its body is grabbed and when its keyframe is grabbed", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 3); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-clip-bar-camera")!; + expect(bar).not.toHaveClass("is-selected"); + + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + expect(bar).toHaveClass("is-selected"); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 100 }); + + // 点空白处取消选中后,再按住关键帧同样能把片段带亮 + fireEvent.pointerDown(container.querySelector(".timeline-grid-body")!, { + button: 0, + pointerId: 2, + clientX: 5, + }); + expect(container.querySelector(".timeline-clip-bar-camera")).not.toHaveClass("is-selected"); + + const keyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" }); + fireEvent.pointerDown(keyframe, { button: 0, pointerId: 3, clientX: 100 }); + fireEvent.pointerUp(keyframe, { pointerId: 3, clientX: 100 }); + + expect(container.querySelector(".timeline-clip-bar-camera")).toHaveClass("is-selected"); +}); + it("jumps to the first and last frame from the transport controls", () => { usePlaybackStore.getState().setPlayhead(3); render(); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index 81c6fd5..d205b93 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -70,6 +70,11 @@ type TimelineTimeDragOwner = | { kind: "trajectory"; objectId: string; pointId: string } | { kind: "camera"; cameraId: string }; +/** 时间轴片段(人物轨迹条 / 运镜片段条)的稳定标识 */ +function getTimelineClipKey(kind: "trajectory" | "camera", id: string) { + return `${kind}:${id}`; +} + function formatTime(t: number) { return `${t.toFixed(1)}s`; } @@ -335,6 +340,8 @@ export function TimelinePanel() { const editableDuration = project.timeline.duration ?? duration; const [selectedKeyframe, setSelectedKeyframe] = useState(null); + /** 选中的片段(轨迹条 / 运镜条):点条本体或条上的关键帧都会选中它 */ + const [selectedClip, setSelectedClip] = useState(null); const [isRecording, setIsRecording] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); // 设置弹窗:fps / loop 先放草稿里改,点「完成」才写回工程(不点则原样丢弃) @@ -758,6 +765,29 @@ export function TimelinePanel() { }; } + /** + * 点轨道空白处 = 取消片段选中。 + * 片段 / 关键帧自己的手势里会 stopPropagation,正常情况冒泡不到这里; + * 但手势可能因为尺寸未就绪等原因提前返回(那时还没走到 stopPropagation), + * 所以这里再按事件来源兜一层,避免把刚选中的片段立刻清掉。 + */ + function handleTimelineGridPointerDown(event: ReactPointerEvent) { + if (event.target instanceof Element && event.target.closest(".timeline-trajectory-bar, .timeline-keyframe")) { + return; + } + + setSelectedClip(null); + } + + /** + * 片段是否处于选中态。只有一个来源:`selectedClip` —— + * 任何一次片段手势(拖条本体 / 拖把手 / 拖条上的关键帧)都会把这条片段标成选中, + * 点轨道空白处取消。不跟关键帧 / 轨迹点选中做联动,是为了让「点空白处取消选中」始终有效。 + */ + function isClipSelected(kind: "trajectory" | "camera", id: string) { + return selectedClip === getTimelineClipKey(kind, id); + } + /** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */ function scaleClipDuration( owner: TimelineTimeDragOwner, @@ -837,6 +867,9 @@ export function TimelinePanel() { if (event.button !== 0) return; if (isTrackDragBlockedTarget(event.target)) return; + // 任何一次片段手势(拖条本体 / 拖把手 / 拖关键帧)都先把这段片段标成选中 + setSelectedClip(getTimelineClipKey(owner.kind, owner.kind === "trajectory" ? owner.objectId : owner.cameraId)); + const lane = event.currentTarget.closest(".timeline-track-lane"); const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0; if (laneWidth <= 0 || duration <= 0) return; @@ -1077,6 +1110,18 @@ export function TimelinePanel() { action(); } + // 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。 + // 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。 + 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']")) { + return; + } + event.preventDefault(); + togglePlaying(); + } + const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject; const addKeyframeTitle = selectionIsManualCamera ? canAddCameraKeyframe @@ -1091,8 +1136,11 @@ export function TimelinePanel() { className={`timeline-panel${draggingTrackKey ? " is-track-dragging" : ""}`} aria-label="时间轴" ref={panelRef} + onKeyDown={handlePanelKeyDown} > -
+ {/* tabIndex 让工具栏区可聚焦:点工具栏空白处后焦点落进面板, + 这样「焦点在 timeline-toolbar 时空格切换播放/暂停」才生效(否则焦点在面板外,空格够不到面板级键盘处理) */} +
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
-
+ {/* 点轨道空白处(不含片段与关键帧)=取消片段选中 */} + {/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板, + 这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */} +
{timeline.tracks.map((track) => { const obj = project.objects.find((o) => o.id === track.objectId); const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box; @@ -1442,7 +1493,9 @@ export function TimelinePanel() { 拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */} {cameraTimes.length >= 2 && duration > 0 ? (
0 && duration > 0 ? ( <>
(); @@ -875,6 +876,8 @@ it("replaceProject migrates a legacy v1 project into a v2 project with a timelin fps: 30, loop: false, }); + // 旧场景里没有 groundColor:迁移补默认地面色,否则地面会落到材质默认白 + expect(project.scene.groundColor).toBe(DEFAULT_GROUND_COLOR); }); it("addKeyframe inserts a keyframe on the object's track and returns its id", () => { diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index e8d9eb5..1199d70 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -1,6 +1,11 @@ import { create } from "zustand"; import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets"; -import { GEOMETRY_PRIMITIVE_OPTIONS, normalizeTrajectoryRotation, normalizeTransformRotation } from "../schema/directorProject"; +import { + DEFAULT_GROUND_COLOR, + GEOMETRY_PRIMITIVE_OPTIONS, + normalizeTrajectoryRotation, + normalizeTransformRotation, +} from "../schema/directorProject"; import type { DirectorAssetRef, DirectorAssetSource, @@ -245,8 +250,10 @@ const DEFAULT_SCENE: SceneSettings = { panoramaYaw: 0, panoramaRadius: 60, showLabels: true, + captureShowGrid: false, snapToGrid: false, showGround: true, + groundColor: DEFAULT_GROUND_COLOR, groundOpacity: 0.4, groundHeight: 0, }; @@ -477,8 +484,31 @@ function withTimeline(project: DirectorProject): DirectorProject { }; } +/** + * 旧工程里还没有「地面颜色」这一项(当时是写死的深灰):补上默认值, + * 否则加载后地面会落到材质默认色(白),和天空色一起看非常刺眼。 + */ +function withSceneDefaults(project: DirectorProject): DirectorProject { + const scene = project.scene; + if (!scene) return project; + + const nextScene = { ...scene }; + if (!(typeof scene.groundColor === "string" && scene.groundColor)) { + nextScene.groundColor = DEFAULT_GROUND_COLOR; + } + // 旧工程没有「导出时是否带网格」这一项:补默认值(false = 导出时藏起网格) + if (typeof nextScene.captureShowGrid !== "boolean") { + nextScene.captureShowGrid = false; + } + + if (nextScene.groundColor === scene.groundColor && nextScene.captureShowGrid === scene.captureShowGrid) { + return project; + } + return { ...project, scene: nextScene }; +} + function migrateDirectorProject(project: DirectorProject): DirectorProject { - const withTimelineProject = withTimeline(project); + const withTimelineProject = withSceneDefaults(withTimeline(project)); return { ...withTimelineProject, objects: withTimelineProject.objects.map((object) => { diff --git a/src/styles/index.css b/src/styles/index.css index 822ff47..6e17e10 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -4334,6 +4334,18 @@ body.is-timeline-dragging * { 0 0 0 1px rgb(var(--panel-rgb) / 0.9), 0 0 0 4px rgb(18 184 134 / 0.25); } +/* 片段选中态:底色 + 边框 + 外描边一起加重(轨迹条走强调色,运镜条走它自己的绿)。 + 必须排在「未选中」规则之后:同优先级下靠源序决定,选中态才盖得住。 */ +.timeline-trajectory-bar.is-selected { + border-color: rgb(var(--accent-rgb) / 0.85); + background: rgb(var(--accent-rgb) / 0.2); + box-shadow: 0 0 0 1px rgb(var(--accent-rgb) / 0.45); +} +.timeline-clip-bar-camera.is-selected { + border-color: rgb(18 184 134 / 0.85); + background: rgb(18 184 134 / 0.2); + box-shadow: 0 0 0 1px rgb(18 184 134 / 0.45); +} .timeline-playhead-layer { position: absolute; top: 0; @@ -4624,7 +4636,10 @@ body.is-timeline-dragging * { .viewport-placement-hint { position: absolute; left: 50%; - bottom: calc(40px + var(--viewport-toolbar-height, 46px) + 12px); + /* 紧贴在快捷工具栏「上方」独立显示,与工具栏留一道明显空隙,避免看起来像嵌在工具栏里。 + 同时加上 --timeline-panel-height:录制弹出时间轴时工具条会整体抬上去, + 提示也要跟着一起上移,否则会被留在原地、盖在时间轴上。 */ + bottom: calc(40px + var(--timeline-panel-height, 0px) + var(--viewport-toolbar-height, 46px) + 22px); z-index: 35; padding: 6px 14px; border: 1px solid rgb(var(--border-rgb) / 0.42); diff --git a/src/styles/index.css.test.ts b/src/styles/index.css.test.ts index b7173a4..164384e 100644 --- a/src/styles/index.css.test.ts +++ b/src/styles/index.css.test.ts @@ -283,6 +283,17 @@ it("shows icon names as a floating label on viewport toolbar hover and focus", ( expect(css).toMatch(/\.viewport-toolbar-button:hover\s*\.viewport-toolbar-label,\s*[\r\n]+\s*\.viewport-toolbar-button:focus-visible\s*\.viewport-toolbar-label\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?visibility:\s*visible;/); }); +it("declares the selected clip style after the default clip style", () => { + const css = readFileSync("src/styles/index.css", "utf8"); + const baseIndex = css.indexOf(".timeline-clip-bar-camera {"); + const selectedIndex = css.indexOf(".timeline-clip-bar-camera.is-selected"); + + expect(baseIndex).toBeGreaterThan(-1); + expect(selectedIndex).toBeGreaterThan(-1); + // 两条规则同优先级,靠源序决定胜负:选中态必须写在默认态之后才盖得住 + expect(selectedIndex).toBeGreaterThan(baseIndex); +}); + it("keeps the color swatch background from leaking at its rounded corners", () => { const css = readFileSync("src/styles/index.css", "utf8"); const swatchRule = css.match(/\.inspector-color-swatch\s*\{(?[\s\S]*?)\}/)?.groups?.body ?? "";