feat: 优化时间轴

This commit is contained in:
2026-09-18 00:52:28 +08:00
parent e66e3374bf
commit 8645ea3007
19 changed files with 1539 additions and 392 deletions

View File

@@ -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;
}) => (
<div
data-args={JSON.stringify(args)}
data-cell-color={cellColor}
data-cell-size={String(cellSize)}
data-cell-thickness={String(cellThickness)}
data-fade-distance={String(fadeDistance)}
data-infinite-grid={String(Boolean(infiniteGrid))}
data-position={JSON.stringify(position)}
data-section-color={sectionColor}
data-section-size={String(sectionSize)}
data-section-thickness={String(sectionThickness)}
data-side={String(side)}
data-testid="viewport-grid"
/>
@@ -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(<App />);
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");

View File

@@ -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() {
<group userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: !sceneSettings.captureShowGrid }}>
<Grid
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
cellThickness={0}
cellSize={GRID_CELL_SIZE}
sectionSize={GRID_CELL_SIZE * GRID_SECTION_CELLS}
cellThickness={0.5}
sectionThickness={1.4}
cellColor="#33373f"
sectionColor="#6b7280"
fadeDistance={80}
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
sectionColor="#2A4065"
side={GROUND_SURFACE_SIDE}
/>
</group>
@@ -915,6 +971,7 @@ export function DirectorCanvas() {
viewportAspectRatio={viewportAspectRatio}
viewMode={viewMode}
/>
<ViewportWheelGuard enabled={viewMode === "camera"} />
<PlaybackFrameDriver />
<VideoExportBridge />
<PlaybackTransformSync />

View File

@@ -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 () => {

View File

@@ -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 },

View File

@@ -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", () => {

View File

@@ -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;
/** 地面坐标轴的单侧长度:从原点铺到地面边缘,两侧合起来正好贯穿整块地面。 */

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<void> {
);
}
/**
* 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。
*
* 机位视角下 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<void> {
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<void> {
camera.aspect = width / height;
camera.updateProjectionMatrix();
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera),
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
await new Promise<void>((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<void> {
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<void> {
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);
}

View File

@@ -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<HTMLDivElement | null>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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(
<div
aria-label="相机截图查看器"
aria-modal="true"
className="camera-capture-viewer"
role="dialog"
onClick={closeViewer}
>
<>
<div className="camera-capture-viewer-backdrop" aria-hidden="true" onClick={closeViewer} />
<div
aria-label="相机截图查看器工具栏"
className="camera-capture-viewer-toolbar"
role="toolbar"
onClick={(event) => event.stopPropagation()}
ref={viewerDialogRef}
aria-label="相机截图查看器"
aria-modal="true"
className="camera-capture-viewer"
role="dialog"
style={{
width: viewerDialogSize ? `${viewerDialogSize.width}px` : undefined,
height: viewerDialogSize ? `${viewerDialogSize.height}px` : undefined,
transform: `translate(calc(-50% + ${viewerDialogOffset.x}px), calc(-50% + ${viewerDialogOffset.y}px))`,
}}
onClick={closeViewer}
>
<button
aria-label="放大图片"
className="camera-capture-viewer-tool"
type="button"
onClick={() => handleViewerZoom("in")}
>
<ZoomIn aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="缩小图片"
className="camera-capture-viewer-tool"
type="button"
onClick={() => handleViewerZoom("out")}
>
<ZoomOut aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="下载图片"
className="camera-capture-viewer-tool"
type="button"
onClick={() => downloadDataUrl(viewerCapture.dataUrl, `${viewerCapture.name}.png`)}
>
<Download aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="关闭相机截图查看器"
className="camera-capture-viewer-tool camera-capture-viewer-close"
type="button"
onClick={closeViewer}
>
<X aria-hidden="true" size={18} strokeWidth={2} />
</button>
</div>
<div className="camera-capture-viewer-stage">
<img
className={viewerImageClassName}
alt={`${viewerCapture.name} 查看大图`}
src={viewerCapture.dataUrl}
style={{ transform: `translate(${viewerOffset.x}px, ${viewerOffset.y}px) scale(${viewerScale})` }}
<div
aria-label="相机截图查看器标题栏"
className={`camera-capture-viewer-header${viewerDialogDragging ? " is-dragging" : ""}`}
role="presentation"
onMouseDown={handleViewerDialogMouseDown}
onClick={(event) => event.stopPropagation()}
>
<span className="camera-capture-viewer-title">{viewerCapture.name}</span>
<span className="camera-capture-viewer-counter">
{allCaptures.length > 0 ? `${currentCaptureIndex + 1} / ${allCaptures.length}` : ""}
</span>
<div
aria-label="相机截图查看器工具栏"
className="camera-capture-viewer-toolbar"
role="toolbar"
onClick={(event) => event.stopPropagation()}
>
<button
aria-label="上一张截图"
className="camera-capture-viewer-tool"
type="button"
disabled={!prevCapture}
onClick={() => handleViewerNavigate(prevCapture)}
>
<ChevronLeft aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="下一张截图"
className="camera-capture-viewer-tool"
type="button"
disabled={!nextCapture}
onClick={() => handleViewerNavigate(nextCapture)}
>
<ChevronRight aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="放大图片"
className="camera-capture-viewer-tool"
type="button"
onClick={() => handleViewerZoom("in")}
>
<ZoomIn aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="缩小图片"
className="camera-capture-viewer-tool"
type="button"
onClick={() => handleViewerZoom("out")}
>
<ZoomOut aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="下载图片"
className="camera-capture-viewer-tool"
type="button"
onClick={() => downloadDataUrl(viewerCapture.dataUrl, `${viewerCapture.name}.png`)}
>
<Download aria-hidden="true" size={18} strokeWidth={2} />
</button>
<button
aria-label="关闭相机截图查看器"
className="camera-capture-viewer-tool camera-capture-viewer-close"
type="button"
onClick={closeViewer}
>
<X aria-hidden="true" size={18} strokeWidth={2} />
</button>
</div>
</div>
<div className="camera-capture-viewer-stage">
<img
className={viewerImageClassName}
alt={`${viewerCapture.name} 查看大图`}
src={viewerCapture.dataUrl}
style={{ transform: `translate(${viewerOffset.x}px, ${viewerOffset.y}px) scale(${viewerScale})` }}
onClick={(event) => event.stopPropagation()}
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
draggable={false}
/>
</div>
<div
aria-hidden="true"
className={`camera-capture-viewer-resize${viewerDialogResizing ? " is-resizing" : ""}`}
onMouseDown={handleViewerDialogResizeMouseDown}
onClick={(event) => event.stopPropagation()}
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
draggable={false}
/>
</div>
</div>,
</>,
document.body
);
}

View File

@@ -302,7 +302,7 @@ export function ScenePanel() {
/>
<span>角色标签</span>
</div>
<div className="inspector-toggle-row">
<div className="inspector-toggle-row toggle-wide">
<input
aria-label="截图/录制时显示网格"
checked={scene.captureShowGrid}

View File

@@ -18,6 +18,13 @@ function getDefaultCharacter() {
return useDirectorStore.getState().project.objects.find((item) => 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(<TimelinePanel />);
@@ -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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
// 只有一条「角色轨道」,且不存在以该角色 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(<TimelinePanel />);
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(<TimelinePanel />);
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<HTMLElement>(".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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
// 轨迹点没有独立删除按钮:选中后按 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(<TimelinePanel />);
@@ -392,14 +490,16 @@ function renderTwoTrackTimeline() {
const container = rendered.container;
const objectRow = container.querySelector<HTMLElement>(".timeline-grid-body > .timeline-track")!;
const character = getDefaultCharacter();
const characterRow = container.querySelector<HTMLElement>(".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(<TimelinePanel />);
// 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<HTMLElement>(".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(<TimelinePanel />);
// 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(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".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(<TimelinePanel />);
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(<TimelinePanel />);
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(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
const bar = container.querySelector<HTMLElement>(".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(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
const bar = container.querySelector<HTMLElement>(".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(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
const bar = container.querySelector<HTMLElement>(".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(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
const bar = container.querySelector<HTMLElement>(".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(<TimelinePanel />);

View File

@@ -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<HTMLElement>,
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<HTMLElement>) {
@@ -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<HTMLElement>) =>
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<HTMLElement>,
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<HTMLDivElement>) {
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<HTMLElement>) {
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 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
<div className="timeline-grid-body" tabIndex={0} onPointerDown={handleTimelineGridPointerDown}>
{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)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
>
×
</button>
</div>
);
})
@@ -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 (
<div
className={`timeline-track timeline-track-camera${collapsed ? " is-collapsed" : ""}${
className={`timeline-camera-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.cameraId}
@@ -1478,93 +1580,127 @@ export function TimelinePanel() {
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
<div className="timeline-track timeline-track-camera">
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
</div>
{/* 运镜轨道头部车道:展开时放「运镜片段条」,折叠时放一行摘要(与角色轨道头部一致) */}
<div className="timeline-track-lane">
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : cameraTimes.length >= 2 && duration > 0 ? (
<div
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与终点`}
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
{/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜起点`}
aria-valuemin={0}
aria-valuemax={Number(
Math.max(
clipEnd - CAMERA_KEYFRAME_MIN_GAP * Math.max(cameraTimes.length - 1, 0),
0
).toFixed(2)
)}
aria-valuenow={Number(clipStart.toFixed(2))}
aria-valuetext={formatTime(clipStart)}
title={`拖动修剪运镜起点(${formatTime(
clipStart
)} 开始这段运镜,末帧不动、其余关键帧按比例缩放),或用左右方向键微调`}
onKeyDown={(event) => handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)}
{...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)}
/>
{/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜时长`}
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
aria-valuemax={Math.max(duration, clipEnd)}
aria-valuenow={Number(clipEnd.toFixed(2))}
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
}
{...getTimeDragProps(
clipOwner,
"duration",
cameraTimes.length - 1,
cameraTimes,
clipStart
)}
/>
</div>
) : null}
</div>
</div>
<div className="timeline-track-lane">
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : (
<>
{/* 运镜片段条:拖条本体可整段左右平移(前面那段时间相机保持首帧机位),
拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
{cameraTimes.length >= 2 && duration > 0 ? (
<div
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动右端把手改时长`}
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
<span
className="timeline-trajectory-handle"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜时长`}
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
aria-valuemax={Math.max(duration, clipEnd)}
aria-valuenow={Number(clipEnd.toFixed(2))}
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
}
{...getTimeDragProps(
clipOwner,
"duration",
cameraTimes.length - 1,
cameraTimes,
clipStart
)}
/>
</div>
) : null}
{track.keyframes.map((kf, index) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${
isSelected ? " is-selected" : ""
}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
{collapsed ? null : (
<>
{/* 机位子轨道:运镜关键帧单独一条轨道,并用折线把相邻关键帧连起来呈现运镜过程 */}
<div
className="timeline-subtrack"
title="机位关键帧:每个运镜关键帧,可单独左右拖动改时间,选中后点 × 或按 Delete 删除。折线连接相邻关键帧呈现运镜过程"
>
<div className="timeline-subtrack-label">关键帧</div>
<div className="timeline-track-lane timeline-camera-keyframe-lane">
{cameraTimes.length > 0 && duration > 0 ? (
<>
{/* 折线连接各运镜关键帧,呈现运镜过程 */}
<svg
className="timeline-trajectory-connector"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
×
</button>
</div>
);
})}
</>
)}
</div>
<polyline points={cameraConnectorPoints} />
</svg>
{track.keyframes.map((kf, index) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${
isSelected ? " is-selected" : ""
}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
</div>
);
})}
</>
) : null}
</div>
</div>
</>
)}
</div>
);
})}
@@ -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() {
<span className="timeline-track-name">{character.name}</span>
<span className="timeline-track-count">{trajectoryPoints.length}</span>
</div>
{/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
{/* 分组抬头行:展开时显示轨迹片段条,折叠时显示一行摘要 */}
<div className="timeline-track-lane timeline-character-head-lane">
{collapsed ? (
<span className="timeline-track-summary">
{motionLabel} · {trajectoryPoints.length} 个轨迹点
</span>
) : trajectoryTimes.length > 0 && duration > 0 ? (
<div
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime
)}(时长 ${formatTime(
trajectoryEndTime - trajectoryStart
)},结尾停留到 ${formatTime(
trajectoryEndTime
)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与停留终点`}
{...getTimeDragProps(trajectoryOwner, "clip", 0, trajectoryTimes, trajectoryStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
{/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹起点`}
aria-valuemin={0}
aria-valuemax={Number(
Math.max((trajectoryTimes[0] ?? 0) - TRAJECTORY_POINT_MIN_GAP, 0).toFixed(2)
)}
aria-valuenow={Number(trajectoryStart.toFixed(2))}
aria-valuetext={formatTime(trajectoryStart)}
title={`拖动修剪轨迹起点(${formatTime(
trajectoryStart
)} 之前角色原地等待),或用左右方向键微调`}
onKeyDown={(event) =>
handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
}
{...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)}
/>
{/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹停留时长`}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
aria-valuetext={formatTime(trajectoryEndTime)}
title="拖动调整轨迹停留时长(角色走完最后一点后原地停留到这一刻),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime)
}
{...getTimeDragProps(
trajectoryOwner,
"duration",
trajectoryTimes.length - 1,
trajectoryTimes,
trajectoryEndTime
)}
/>
</div>
) : null}
</div>
</div>
@@ -1636,57 +1838,25 @@ export function TimelinePanel() {
</div>
</div>
{/* 人物轨迹:一条「轨迹条」= 角色从起点走到最后一个轨迹点所需的时长。
条本体可左右拖动整段平移(脱离 0s,前面那段时间角色原地等待),
右端把手拖动改整段时长,条上的关键帧可单独拖动改时间 */}
{/* 人物轨迹轨道:一条「轨迹」子轨道,显示各轨迹点关键帧(小菱形),
并用折线把点连起来呈现走位路径。点可单独左右拖动改时间,选中后按 Delete 删除 */}
<div
className="timeline-subtrack"
title="人物轨迹:拖动条本体可整段左右平移(起点之前角色原地等待);拖动右端把手调整整段时长;拖动关键帧调整该点时间"
title="人物轨迹:每个轨迹点一条关键帧,可单独左右拖动改时间,选中后按 Delete 删除"
>
<div className="timeline-subtrack-label">轨迹</div>
<div className="timeline-track-lane timeline-trajectory-lane">
{trajectoryTimes.length > 0 && duration > 0 ? (
<>
<div
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime
)}(时长 ${formatTime(trajectoryEndTime - trajectoryStart)}):拖动条本体整段平移,拖动右端把手改时长`}
{...getTimeDragProps(
trajectoryOwner,
"clip",
0,
trajectoryTimes,
trajectoryStart
)}
{/* 折线连接各轨迹点,呈现走位路径(最后一个点只是途经点) */}
<svg
className="timeline-trajectory-connector"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
<span
className="timeline-trajectory-handle"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹时长`}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
aria-valuetext={formatTime(trajectoryEndTime)}
title="拖动调整轨迹时长(所有轨迹点按比例缩放),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
}
{...getTimeDragProps(
trajectoryOwner,
"duration",
trajectoryTimes.length - 1,
trajectoryTimes,
trajectoryStart
)}
/>
</div>
<polyline points={trajectoryConnectorPoints} />
</svg>
{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
)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除轨迹关键帧 ${formatTime(pointTime)}`}
onClick={(event) => {
event.stopPropagation();
removeTrajectoryPoint(character.id, point.id);
}}
>
×
</button>
</div>
);
})}

View File

@@ -232,6 +232,12 @@ export interface DirectorObject {
* 大于 0 表示角色先在起点原地等待,到这一刻才沿轨迹移动(时间轴上可拖动整条轨迹条平移)。
*/
trajectoryStartTime?: number;
/**
* 人物轨迹片段的结束时间(秒):角色走完最后一个轨迹点后,原地停留到这一刻。
* 缺省 / 为 0 时沿用旧语义(结束时间 = 最后一个轨迹点的时间,最后一个点即时间结束处);
* 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。
*/
trajectoryEndTime?: number;
}
export interface DirectorCameraCapture {

View File

@@ -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;
}
/**
* 一次性覆盖整条运镜轨道的所有关键帧时间(按索引对应,最后统一按时间重排)。
* 拖动把手 / 拖动关键帧都走这里,避免逐个更新时反复排序导致索引错位。

View File

@@ -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 夹在「起点 + 最小跨度 ~ 时间轴末尾」之间。

View File

@@ -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<SceneSettings>) => void;
removePanoramaAsset: () => void;
removeImportedAsset: (assetId: string) => void;
@@ -1763,21 +1773,75 @@ export const useDirectorStore = create<DirectorStore>((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<DirectorStore>((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;

View File

@@ -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;
}
/* ===== 全屏画布 + 悬浮卡片 =====
画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。

View File

@@ -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");