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 ?? "";