feat(timeline): 支持拖拽调整时长与运镜/轨迹关键帧时间

This commit is contained in:
2026-09-17 17:30:22 +08:00
parent bab92c05b8
commit 8d67f17a84
21 changed files with 2556 additions and 194 deletions

View File

@@ -1,4 +1,5 @@
import {
applyExternalViewSnapshot,
applySnapshotToControls,
DEFAULT_DIRECTOR_VIEW_SNAPSHOT,
getViewportGizmoHitButtonStyle,
@@ -43,6 +44,27 @@ it("flushes leftover orbit inertia before writing the reset view back", () => {
expect(camera.position.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position);
});
it("writes an external view change on the spot instead of deferring it to the next render", () => {
// 机位视角下没有 OrbitControls:写不进去,交给切回导演视角时的副作用补写
expect(applyExternalViewSnapshot(null, DEFAULT_DIRECTOR_VIEW_SNAPSHOT)).toBe(false);
const dampingStateDuringUpdate: boolean[] = [];
const controls = {
enableDamping: true,
target: new Vector3(9, 9, 9),
object: new PerspectiveCamera(),
update: () => dampingStateDuringUpdate.push(controls.enableDamping),
};
expect(applyExternalViewSnapshot(controls, DEFAULT_DIRECTOR_VIEW_SNAPSHOT)).toBe(true);
// 当场就把残余惯性结算掉并落到目标视角(惯性滑行中点重置视角不会再被推走)
expect(dampingStateDuringUpdate).toEqual([false, false]);
expect(controls.target.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target);
expect(controls.object.position.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position);
expect(controls.enableDamping).toBe(true);
});
it("keeps the viewport grid visible when a panorama background is active", () => {
expect(shouldRenderViewportGrid(true, false)).toBe(true);
});

View File

@@ -166,6 +166,20 @@ function applySnapshotToCamera(camera: ThreePerspectiveCamera, snapshot: CameraS
camera.updateMatrixWorld();
}
/**
* 外部改视角(重置视角 / 坐标控件切轴)时「当场」结算残余惯性并把视角写回相机与 OrbitControls。
* 返回是否真的写回去了 —— 机位视角下没有 OrbitControls,只能等切回导演视角时由副作用补写。
*/
export function applyExternalViewSnapshot(
controls: { enableDamping: boolean; target: Vector3; update: () => void; object: unknown } | null,
snapshot: CameraShotSnapshot
): boolean {
if (!controls) return false;
applySnapshotToControls(controls, controls.object as ThreePerspectiveCamera, snapshot);
return true;
}
/**
* 把视角快照写回相机 + OrbitControls(重置视角、坐标控件切轴、切回导演视角都走这里)。
*
@@ -747,13 +761,28 @@ export function DirectorCanvas() {
return viewportCameraSnapshotRef.current;
}
/** 记录/广播导演视角快照;source 决定 DirectorViewCameraSync 要不要把它写回相机 */
/**
* 记录/广播导演视角快照;source 决定这次变化是谁产生的。
*
* 外部改视角(重置视角 / 坐标控件切轴)时**当场写回**相机 + OrbitControls,
* 不能只丢给 DirectorViewCameraSync 的 layout effect。惯性滑行时有两道闸门会把它吞掉:
* 1. 快照可能因为「和当前视角足够接近」被 React 合并掉 → 状态没变 → 副作用不会重跑;
* 2. OrbitControls 每帧都抛 change,把来源标成 "controls",effect 里那道
* 「用户自己在操作就别写回」的闸门会顺手拦掉这次外部请求。
* 两者都表现为「点了重置视角没有反应、镜头继续滑」。当场结算残余惯性最稳;
* 随后 effect 再跑到也只是同值重写(幂等)。
*/
function updateDirectorViewSnapshot(
snapshot: CameraShotSnapshot,
source: ViewportSnapshotSource = "external"
) {
viewportCameraSnapshotRef.current = snapshot;
viewportSnapshotSourceRef.current = source;
if (source === "external") {
applyExternalViewSnapshot(controlsRef.current, snapshot);
}
setDirectorViewSnapshot((currentSnapshot) =>
areCameraSnapshotsClose(currentSnapshot, snapshot) ? currentSnapshot : snapshot
);

View File

@@ -30,7 +30,7 @@ import {
VIEWPORT_CAMERA_VISUAL_SCALE,
} from "../schema/cameraGeometry";
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTime } from "../schema/trajectoryMotion";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion";
import type { TransformMode } from "../store/directorStore";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
@@ -920,7 +920,7 @@ function TrajectoryPath({
if (pointIndex < 0) return;
setPlaying(false);
setPlayhead(getTrajectoryPointTime(pointIndex, points.length, playbackDuration));
setPlayhead(getTrajectoryPointTimes(points, playbackDuration)[pointIndex] ?? 0);
}
/**

View File

@@ -0,0 +1,46 @@
import { beforeEach, expect, it } from "vitest";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo } from "./videoExport";
beforeEach(() => {
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null });
});
/**
* jsdom 里既没有视口画布也没有 WebCodecs,导出会立刻失败 ——
* 但这里要测的正是「导出期间 / 导出之后」的播放状态收口,与导出是否成功无关。
*/
it("turns the playback loop off while exporting and puts it back afterwards", async () => {
usePlaybackStore.getState().setLoop(true);
const loopHistory: boolean[] = [];
const unsubscribe = usePlaybackStore.subscribe((state) => loopHistory.push(state.loop));
await expect(exportVideo("director")).rejects.toThrow();
unsubscribe();
// 导出一开始就关掉循环:导出永远只播一遍,不会录到回卷的一段
expect(loopHistory[0]).toBe(false);
// 导出结束后循环设置原样还回去
expect(usePlaybackStore.getState().loop).toBe(true);
});
it("stops playback after an export instead of resuming an endless loop", async () => {
// 导出前正在播放,而且开着循环 —— 这就是「播完一直循环播放」的现场
usePlaybackStore.getState().setLoop(true);
usePlaybackStore.getState().setPlaying(true);
await expect(exportVideo("director")).rejects.toThrow();
const state = usePlaybackStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.loop).toBe(true);
});
it("keeps the playhead where the export found it", async () => {
usePlaybackStore.getState().setPlayhead(2.5);
await expect(exportVideo("director")).rejects.toThrow();
expect(usePlaybackStore.getState().playhead).toBe(2.5);
});

View File

@@ -6,10 +6,10 @@ import { usePlaybackStore } from "../runtime/playbackStore";
import { getVideoExportContext } from "./videoExportBridge";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { normalizeTimelineFps } from "../schema/timeline";
export type VideoExportView = "director" | "camera";
const EXPORT_FPS = 30;
/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */
const EXPORT_SHORT_EDGE = 720;
@@ -75,20 +75,20 @@ export function computeExportSize(
return { width: width + (width % 2), height: height + (height % 2) };
}
async function pickVideoEncoderConfig(width: number, height: number) {
async function pickVideoEncoderConfig(width: number, height: number, fps: number) {
const candidates = [
{ codec: "avc1.640028" }, // High 4.0
{ codec: "avc1.4d0028" }, // Main 4.0
{ codec: "avc1.42001f" }, // Baseline 3.1
];
const bitrate = Math.round(width * height * EXPORT_FPS * 0.15); // ≈6Mbps @720p30
const bitrate = Math.round(width * height * fps * 0.15); // ≈6Mbps @720p30
for (const { codec } of candidates) {
const config: VideoEncoderConfig = {
codec,
width,
height,
bitrate,
framerate: EXPORT_FPS,
framerate: fps,
};
try {
const support = await VideoEncoder.isConfigSupported(config);
@@ -115,15 +115,17 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
const directorStore = useDirectorStore.getState();
const { width, height } = computeExportSize(directorStore.viewportAspectRatio);
const duration = directorStore.project.timeline.duration;
const totalFrames = Math.max(1, Math.round(duration * EXPORT_FPS));
// 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致
const fps = normalizeTimelineFps(directorStore.project.timeline.fps);
const totalFrames = Math.max(1, Math.round(duration * fps));
const encoderConfig = await pickVideoEncoderConfig(width, height);
const encoderConfig = await pickVideoEncoderConfig(width, height, fps);
if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge");
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)
// 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)。
// 播放状态不在这里恢复:exportVideo 统一收口成「导出后停住」。
const previousViewMode = directorStore.viewMode;
const previousPlayhead = usePlaybackStore.getState().playhead;
const previousPlaying = usePlaybackStore.getState().isPlaying;
const previousSize = new Vector2();
gl.getSize(previousSize);
const previousPixelRatio = gl.getPixelRatio();
@@ -156,11 +158,11 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const frameDurationUs = Math.round(1_000_000 / EXPORT_FPS);
const frameDurationUs = Math.round(1_000_000 / fps);
try {
for (let frameIndex = 0; frameIndex <= totalFrames; frameIndex += 1) {
if (encodeError) throw encodeError;
const time = Math.min(frameIndex / EXPORT_FPS, duration);
const time = Math.min(frameIndex / fps, duration);
// 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例
const frameCamera = getVideoExportContext()?.camera ?? camera;
frameCamera.aspect = width / height;
@@ -194,7 +196,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
timestamp: frameIndex * frameDurationUs,
duration: frameDurationUs,
});
encoder.encode(frame, { keyFrame: frameIndex % (EXPORT_FPS * 2) === 0 });
encoder.encode(frame, { keyFrame: frameIndex % (fps * 2) === 0 });
frame.close();
// 背压:编码队列太深时等一等,防止内存堆积
@@ -221,7 +223,6 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
restoreCamera.aspect = previousAspect;
restoreCamera.updateProjectionMatrix();
directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlaying(previousPlaying);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}
}
@@ -298,11 +299,25 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
/**
* 导出视频:优先 WebCodecs 逐帧快速导出(MP4,固定分辨率,不受视口布局影响);
* 浏览器不支持时回退 MediaRecorder 实时录屏(WebM/MP4,耗时 = 视频时长)。
*
* 不管哪条路径,导出期间都强制关掉「播放循环」——导出永远是「一次性播完」:
* 否则录屏兜底路径会录到回卷的一段,逐帧路径也会把「正在循环播放」的状态带回视口。
* 导出结束时一律停住(不恢复导出前的播放状态,否则开着循环就会一直播下去),
* 只把指针放回导出前的位置,循环设置原样还回去。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
if (typeof VideoEncoder !== "undefined") {
await exportVideoFrameByFrame(view);
return;
const previousLoop = usePlaybackStore.getState().loop;
usePlaybackStore.getState().setLoop(false);
try {
if (typeof VideoEncoder !== "undefined") {
await exportVideoFrameByFrame(view);
return;
}
await exportVideoWithMediaRecorder(view);
} finally {
const playback = usePlaybackStore.getState();
playback.setLoop(previousLoop);
playback.setPlaying(false);
}
await exportVideoWithMediaRecorder(view);
}

View File

@@ -68,6 +68,20 @@ it("uses the provided right inspector layout for role properties", () => {
expect(screen.getByLabelText("角色颜色")).toHaveClass("inspector-color-swatch");
});
it("paints the color swatch with the current color so its rounded corners never leak", async () => {
const user = userEvent.setup();
render(<CharacterPanel />);
const swatch = screen.getByLabelText("角色颜色") as HTMLInputElement;
await user.clear(screen.getByLabelText("角色颜色 HEX"));
await user.type(screen.getByLabelText("角色颜色 HEX"), "#123456");
// 元素底色跟着当前颜色走,而不是固定的强调蓝(蓝底会在圆角处透出来)
expect(swatch.style.background).not.toBe("");
expect(swatch.style.background).not.toContain("3, 150, 255");
});
it("marks the pose adjustment section for the compact character inspector layout", async () => {
const user = userEvent.setup();
render(<CharacterPanel />);

View File

@@ -648,6 +648,8 @@ export function InspectorColorField({
className="inspector-color-swatch"
type="color"
value={value}
// 元素底色跟着当前颜色走:内层取色块的圆角边缘是半透明的,底色一致时圆角处才看不出接缝
style={{ background: value }}
onChange={(event) => onColorChange(event.currentTarget.value)}
onBlur={endInteraction}
onFocus={beginInteraction}

View File

@@ -9,22 +9,38 @@ import {
getTimelineRulerTicks,
reorderTimelineTrackKeys,
resolveTimelineTrackOrder,
TIMELINE_FPS,
TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND,
TimelinePanel,
} from "./TimelinePanel";
import { DEFAULT_TIMELINE_FPS } from "../schema/timeline";
function getDefaultCharacter() {
return useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
}
/**
* jsdom 不做真实布局(所有 getBoundingClientRect 都是 0),
* 轨道拖拽要靠行几何算落点,这里按 data-track-key 造一份给定布局。
* jsdom 不做真实布局(所有 getBoundingClientRect 都是 0):
* - 轨道拖拽排序要靠行几何算落点 → 按 data-track-key 造一份给定布局;
* - 轨迹时间滑块要靠轨道宽度把像素换成秒 → 统一给轨道车道一个固定宽度。
*/
function stubTrackRowLayout(layout: Record<string, { top: number; height: number }>) {
function stubTrackRowLayout(layout: Record<string, { top: number; height: number }>, laneWidth = 500) {
const original = Element.prototype.getBoundingClientRect;
return vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
if (this instanceof HTMLElement && this.classList.contains("timeline-track-lane")) {
return {
x: 0,
y: 0,
top: 0,
bottom: 28,
left: 0,
right: laneWidth,
width: laneWidth,
height: 28,
toJSON: () => ({}),
} as DOMRect;
}
const key = this instanceof HTMLElement ? this.dataset.trackKey : undefined;
const entry = key ? layout[key] : undefined;
if (!entry) return original.call(this);
@@ -44,6 +60,10 @@ function stubTrackRowLayout(layout: Record<string, { top: number; height: number
});
}
function getTrajectoryPoints(objectId: string) {
return useDirectorStore.getState().project.objects.find((item) => item.id === objectId)?.trajectoryPoints ?? [];
}
// 导出是浏览器能力(WebCodecs / MediaRecorder),单测里只关心调用的是哪个视角
const mockExportVideo = vi.hoisted(() => vi.fn());
@@ -83,11 +103,15 @@ it("shows the playhead as a frame number next to the time", () => {
expect(container.querySelector(".timeline-time-readout")?.textContent).toBe("F45/1.5s");
});
it("gets the frame number from the exported frame rate", () => {
it("gets the frame number from the frame rate in the settings", () => {
expect(getTimelineFrame(0)).toBe(0);
expect(getTimelineFrame(1)).toBe(TIMELINE_FPS);
expect(getTimelineFrame(0.5)).toBe(TIMELINE_FPS / 2);
expect(getTimelineFrame(1)).toBe(DEFAULT_TIMELINE_FPS);
expect(getTimelineFrame(0.5)).toBe(DEFAULT_TIMELINE_FPS / 2);
expect(getTimelineFrame(-3)).toBe(0);
// 帧率可配置:60fps 下同一时刻的帧号翻倍;非法帧率回落到默认值
expect(getTimelineFrame(1, 60)).toBe(60);
expect(getTimelineFrame(0.5, 60)).toBe(30);
expect(getTimelineFrame(1, 0)).toBe(DEFAULT_TIMELINE_FPS);
});
it("draws 0.1s minor ticks between the major ticks", () => {
@@ -416,6 +440,85 @@ it("does not start a track drag from the collapse button", () => {
expect(panel).not.toHaveClass("is-track-dragging");
});
it("renders a trajectory bar whose right handle shows the walk duration", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
const { container } = render(<TimelinePanel />);
// 2 个轨迹点在 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", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
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 });
// 拖轨迹时冻结全站毛玻璃,避免背后 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 });
const points = getTrajectoryPoints(character.id);
expect(points[0].time).toBeCloseTo(2, 5);
expect(points[1].time).toBeCloseTo(4, 5);
expect(document.body).not.toHaveClass("is-timeline-dragging");
// 拖滑块不应被当成「轨道拖动排序」
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
});
it("drags a single trajectory keyframe to change only its own time", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const firstKeyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 250 });
// 往右拖 50px → 该点 2.5s → 3s
fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 300 });
fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 300 });
const points = getTrajectoryPoints(character.id);
expect(points[0].time).toBeCloseTo(3, 5);
// 其它点没被拖动过,仍按均匀铺开推导
expect(points[1].time).toBeUndefined();
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
expect(screen.getByRole("button", { name: "轨迹关键帧 3.0s" })).toBeInTheDocument();
});
it("nudges the trajectory duration with the keyboard", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
render(<TimelinePanel />);
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);
});
it("collapses and expands a character track", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
@@ -478,6 +581,235 @@ it("gives the camera track a collapse toggle as well", () => {
expect(screen.queryByRole("button", { name: "运镜关键帧 1.0s" })).not.toBeInTheDocument();
});
/** 给默认机位挑几条运镜关键帧(time 会被 store 夹进时间轴) */
function addCameraKeyframeAt(cameraId: string, time: number) {
useDirectorStore.getState().addCameraKeyframe(cameraId, time, {
position: [time, 0, 0],
target: [0, 0, 0],
fov: 50,
});
}
function getCameraKeyframeTimes(cameraId: string) {
const track = useDirectorStore
.getState()
.project.timeline.cameraTracks.find((item) => item.cameraId === cameraId)!;
return track.keyframes.map((keyframe) => keyframe.time);
}
it("renders a camera clip bar spanning the first and last camera keyframes", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
addCameraKeyframeAt(camera.id, 4);
const { container } = render(<TimelinePanel />);
const bar = container.querySelector<HTMLElement>(".timeline-clip-bar-camera")!;
// 1s → 4s 的运镜:条从 20% 开始、宽 60%(5s 时间轴)
expect(bar.style.left).toBe("20%");
expect(bar.style.width).toBe("60%");
// 条上写的是「这段运镜的时长」
expect(bar.querySelector(".timeline-trajectory-bar-label")?.textContent).toBe("3.0s");
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` });
expect(handle).toHaveAttribute("aria-valuenow", "4");
expect(handle).toHaveAttribute("aria-valuemax", "5");
});
it("does not draw a camera clip bar for a single camera keyframe", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
const { container } = render(<TimelinePanel />);
expect(container.querySelector(".timeline-clip-bar-camera")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "运镜关键帧 1.0s" })).toBeInTheDocument();
});
it("drags the camera clip handle to rescale the whole camera move", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
addCameraKeyframeAt(camera.id, 4);
render(<TimelinePanel />);
// 5s 时间轴对应 500px 轨道 → 1px = 0.01s
stubTrackRowLayout({}, 500);
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` });
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 480 });
// 拖条时同样冻结全站毛玻璃
expect(document.body).toHaveClass("is-timeline-dragging");
// 往左拖 100px → 运镜从 1s→4s 缩短到 1s→3s,首帧固定不动
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 380 });
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 380 });
const times = getCameraKeyframeTimes(camera.id);
expect(times[0]).toBe(1);
expect(times[1]).toBeCloseTo(3, 5);
expect(document.body).not.toHaveClass("is-timeline-dragging");
});
it("nudges the camera clip duration 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: "ArrowLeft" });
// 末帧 4s → 3.9s,中间没有别的帧,首帧仍在 1s
const times = getCameraKeyframeTimes(camera.id);
expect(times[0]).toBe(1);
expect(times[1]).toBeCloseTo(3.9, 5);
});
it("drags a single camera keyframe to change only its own time", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
addCameraKeyframeAt(camera.id, 4);
render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const firstKeyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" });
fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 100 });
// 往右拖 50px → 该帧 1s → 1.5s,末帧不受影响
fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 150 });
fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 150 });
const times = getCameraKeyframeTimes(camera.id);
expect(times[0]).toBeCloseTo(1.5, 5);
expect(times[1]).toBe(4);
});
it("keeps the camera track ordered when a clipped keyframe is dragged past its neighbour", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
addCameraKeyframeAt(camera.id, 4);
render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const firstKeyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" });
fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 100 });
// 想把它拖到末帧后面:被夹在末帧之前,时间仍然递增
fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 900 });
fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 900 });
const times = getCameraKeyframeTimes(camera.id);
expect(times).toHaveLength(2);
expect(times[0]).toBeLessThan(times[1]);
});
it("drags the trajectory bar body to move the whole walk later", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
// 先把走位缩短到 0s → 3s,右边才留出可挪的空间
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
const { container } = render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
expect(bar.style.left).toBe("0%");
expect(bar.style.width).toBe("60%");
// 往右拖 100px = +1s:整段刚性平移,两个点一起 +1s
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 });
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 });
const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
expect(updated.trajectoryStartTime).toBe(1);
expect(updated.trajectoryPoints!.map((point) => point.time)).toEqual([2.5, 4]);
});
it("keeps the trajectory clip inside the timeline when dragged to the end", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
const { container } = render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
// 往右拖 1000px 远超时间轴:末点顶到 5s 就不再挪
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 });
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 1100 });
const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
expect(updated.trajectoryStartTime).toBeCloseTo(2, 5);
expect(updated.trajectoryPoints!.map((point) => point.time)).toEqual([3.5, 5]);
});
it("does not move a trajectory that already fills the whole timeline", () => {
const character = getDefaultCharacter();
// 单点默认铺开 = 0s → 5s,正好占满整条时间轴
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
const { container } = render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
expect(bar.style.width).toBe("100%");
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 });
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 });
// 没有余量:不会挪走,也不会把走位压缩成两秒半
const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
expect(updated.trajectoryStartTime).toBeUndefined();
expect(updated.trajectoryPoints![0].time).toBeUndefined();
});
it("drags the camera clip bar to move the whole camera move later", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
addCameraKeyframeAt(camera.id, 3);
const { container } = render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-clip-bar-camera")!;
expect(bar.style.left).toBe("20%");
expect(bar.style.width).toBe("40%");
// 往右拖 100px = +1s:两个关键帧一起 +1s
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 });
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 });
const times = getCameraKeyframeTimes(camera.id);
expect(times[0]).toBeCloseTo(2, 5);
expect(times[1]).toBeCloseTo(4, 5);
});
it("keeps the camera clip inside the timeline when dragged past the end", () => {
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")!;
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 });
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 1100 });
// 末帧顶到 5s 为止,首帧跟着挪到 2s(整段长度不变)
const times = getCameraKeyframeTimes(camera.id);
expect(times[0]).toBeCloseTo(2, 5);
expect(times[1]).toBeCloseTo(5, 5);
});
it("jumps to the first and last frame from the transport controls", () => {
usePlaybackStore.getState().setPlayhead(3);
render(<TimelinePanel />);
@@ -489,6 +821,169 @@ it("jumps to the first and last frame from the transport controls", () => {
expect(usePlaybackStore.getState().playhead).toBe(5);
});
it("shows the frame readout with the frame rate from the settings", () => {
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60 });
usePlaybackStore.getState().setPlayhead(1.5);
const { container } = render(<TimelinePanel />);
// 1.5s × 60fps = 90 帧(默认 30fps 时是 45)
expect(container.querySelector(".timeline-time-readout")?.textContent).toBe("F90/1.5s");
});
it("drags the duration handle to lengthen the timeline", () => {
render(<TimelinePanel />);
const handle = screen.getByRole("slider", { name: "拖动调整总时长" });
const twoSeconds = TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND * 2;
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 200 });
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 + twoSeconds });
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 + twoSeconds });
expect(useDirectorStore.getState().project.timeline.duration).toBe(7);
});
it("drags the duration handle left to shorten the timeline", () => {
render(<TimelinePanel />);
const handle = screen.getByRole("slider", { name: "拖动调整总时长" });
const twoSeconds = TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND * 2;
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 200 });
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 - twoSeconds });
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 - twoSeconds });
expect(useDirectorStore.getState().project.timeline.duration).toBe(3);
});
it("never drags the timeline below the minimum duration", () => {
render(<TimelinePanel />);
const handle = screen.getByRole("slider", { name: "拖动调整总时长" });
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 400 });
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 0 });
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 0 });
expect(useDirectorStore.getState().project.timeline.duration).toBe(0.1);
});
it("nudges the duration by 0.1s from the drag handle keyboard", () => {
render(<TimelinePanel />);
const handle = screen.getByRole("slider", { name: "拖动调整总时长" });
fireEvent.keyDown(handle, { key: "ArrowRight" });
expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.1, 5);
expect(handle).toHaveAttribute("aria-valuenow", "5.1");
fireEvent.keyDown(handle, { key: "ArrowLeft" });
expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5, 5);
});
it("steps the duration by 0.1s with the up / down spinner buttons", () => {
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "总时长增加 0.1s" }));
expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.1, 5);
// 连续点两下:以最新的工程时长继续累加,而不是等着 playbackStore 同步
fireEvent.click(screen.getByRole("button", { name: "总时长增加 0.1s" }));
expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.2, 5);
fireEvent.click(screen.getByRole("button", { name: "总时长减少 0.1s" }));
expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.1, 5);
});
it("opens the timeline settings with the current frame rate and loop state", () => {
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 24, loop: true });
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
const dialog = screen.getByRole("dialog", { name: "时间轴设置" });
expect(within(dialog).getByLabelText("每秒帧数")).toHaveValue(24);
expect(within(dialog).getByLabelText("播放循环")).toBeChecked();
});
it("only saves and applies the settings after pressing 完成", () => {
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
const dialog = screen.getByRole("dialog", { name: "时间轴设置" });
fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "60" } });
fireEvent.click(within(dialog).getByLabelText("播放循环"));
// 还没点「完成」:工程里的设置不变
expect(useDirectorStore.getState().project.timeline.fps).toBe(30);
expect(useDirectorStore.getState().project.timeline.loop).toBe(false);
fireEvent.click(within(dialog).getByRole("button", { name: "完成" }));
const timeline = useDirectorStore.getState().project.timeline;
expect(timeline.fps).toBe(60);
expect(timeline.loop).toBe(true);
expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument();
});
it("discards the edited settings when the dialog is dismissed without 完成", () => {
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
const dialog = screen.getByRole("dialog", { name: "时间轴设置" });
fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "120" } });
// 点遮罩关掉 = 取消
fireEvent.click(screen.getByTestId("timeline-settings-overlay"));
expect(useDirectorStore.getState().project.timeline.fps).toBe(30);
expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument();
// 再次打开时草稿值重新从工程读取,不会残留上次没保存的 120
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
expect(screen.getByLabelText("每秒帧数")).toHaveValue(30);
});
it("closes the settings dialog from the top-right icon button without applying", () => {
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
const dialog = screen.getByRole("dialog", { name: "时间轴设置" });
// 关闭按钮在标题行右侧
expect(within(dialog.querySelector(".timeline-settings-head") as HTMLElement).getByRole("button", {
name: "关闭设置",
})).toBeInTheDocument();
fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "50" } });
fireEvent.click(within(dialog).getByRole("button", { name: "关闭设置" }));
expect(useDirectorStore.getState().project.timeline.fps).toBe(30);
expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument();
});
it("clamps an out-of-range frame rate when the settings are applied", () => {
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
const dialog = screen.getByRole("dialog", { name: "时间轴设置" });
fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "999" } });
fireEvent.click(within(dialog).getByRole("button", { name: "完成" }));
expect(useDirectorStore.getState().project.timeline.fps).toBe(120);
});
it("closes the settings dialog with Escape without applying", () => {
render(<TimelinePanel />);
fireEvent.click(screen.getByRole("button", { name: "时间轴设置" }));
const dialog = screen.getByRole("dialog", { name: "时间轴设置" });
fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "50" } });
fireEvent.keyDown(document, { key: "Escape" });
expect(useDirectorStore.getState().project.timeline.fps).toBe(30);
expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument();
});
it("nudges the playhead with the ruler keyboard controls", () => {
usePlaybackStore.getState().setPlayhead(2);
render(<TimelinePanel />);

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
@@ -9,6 +10,7 @@ import {
Camera,
ChevronDown,
ChevronRight,
ChevronUp,
DiamondPlus,
Eraser,
Film,
@@ -17,16 +19,38 @@ import {
PersonStanding,
Play,
RefreshCw,
Settings2,
SkipBack,
SkipForward,
Square,
Trash2,
Video,
X,
type LucideIcon,
} from "lucide-react";
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
import {
CAMERA_KEYFRAME_MIN_GAP,
DEFAULT_TIMELINE_FPS,
MAX_TIMELINE_DURATION,
MAX_TIMELINE_FPS,
MIN_TIMELINE_DURATION,
MIN_TIMELINE_FPS,
TIMELINE_DURATION_STEP,
getCameraClipSpan,
moveCameraKeyframeTime,
normalizeTimelineDuration,
normalizeTimelineFps,
scaleCameraKeyframeTimes,
type CameraKeyframeValue,
type KeyframeTransformValue,
} from "../schema/timeline";
import type { MotionKind } from "../schema/directorProject";
import { getTrajectoryPointTime } from "../schema/trajectoryMotion";
import {
getTrajectoryPointTimes,
getTrajectoryStartTime,
moveTrajectoryPointTime,
scaleTrajectoryPointTimes,
} from "../schema/trajectoryMotion";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo, type VideoExportView } from "../io/videoExport";
@@ -37,6 +61,15 @@ interface SelectedKeyframe {
keyframeId: string;
}
/**
* 时间滑块手势的数据主人:
* - trajectory:人物轨迹(拖单个点走 updateTrajectoryPoint,按点 id 定位)
* - camera:运镜片段(拖把手 / 拖关键帧都整条覆盖时间,按索引对应)
*/
type TimelineTimeDragOwner =
| { kind: "trajectory"; objectId: string; pointId: string }
| { kind: "camera"; cameraId: string };
function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
@@ -56,13 +89,22 @@ export const TIMELINE_RULER_MINOR_STEP = 0.1;
/** 细刻度数量上限:再密就糊成一条色带、没有读数意义,此时干脆不画 */
const TIMELINE_RULER_MINOR_MAX_COUNT = 200;
/** 时间轴帧率:读数里的帧号按它换算,与视频导出帧率保持一致 */
export const TIMELINE_FPS = 30;
/** 把秒换算成帧号(四舍五入到最近的一帧) */
export function getTimelineFrame(time: number): number {
/** 把秒换算成帧号(四舍五入到最近的一帧);帧率来自工程设置,默认 30fps */
export function getTimelineFrame(time: number, fps: number = DEFAULT_TIMELINE_FPS): number {
const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0;
return Math.round(safeTime * TIMELINE_FPS);
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_TIMELINE_FPS;
return Math.round(safeTime * safeFps);
}
/** 拖动总时长时的灵敏度:横向 25px = 1s,拖起来不会太窜 */
export const TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND = 25;
/**
* 把秒数对齐到 0.1s 刻度。
* 顺带消掉浮点尾巴:5 + 0.1 会得到 5.0999999999999996,直接塞进 number 输入框会显示出这一长串小数。
*/
export function alignTimelineDuration(seconds: number): number {
return Number(seconds.toFixed(2));
}
export interface TimelineRulerTick {
@@ -261,16 +303,21 @@ export function TimelinePanel() {
const addCameraKeyframe = useDirectorStore((state) => state.addCameraKeyframe);
const removeCameraKeyframe = useDirectorStore((state) => state.removeCameraKeyframe);
const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue);
const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes);
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
const setTimelineDuration = useDirectorStore((state) => state.setTimelineDuration);
const clearAllKeyframes = useDirectorStore((state) => state.clearAllKeyframes);
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
const removeTrajectoryPoint = useDirectorStore((state) => state.removeTrajectoryPoint);
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes);
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
const toggleTimelineTrackCollapsed = useDirectorStore((state) => state.toggleTimelineTrackCollapsed);
const setTimelinePlaybackSettings = useDirectorStore((state) => state.setTimelinePlaybackSettings);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const playhead = usePlaybackStore((state) => state.playhead);
@@ -280,9 +327,22 @@ export function TimelinePanel() {
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
// 帧率 / 循环来自工程设置(旧工程在 store 迁移时补默认值)
const fps = project.timeline.fps ?? DEFAULT_TIMELINE_FPS;
const loop = project.timeline.loop === true;
// 时长编辑以工程里的值为准:playbackStore 那份是给播放用的副本,靠帧驱动同步、会慢一拍,
// 连续拖 / 连续点微调按钮时会把上一次的结果吞掉。
const editableDuration = project.timeline.duration ?? duration;
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// 设置弹窗:fps / loop 先放草稿里改,点「完成」才写回工程(不点则原样丢弃)
const [settingsOpen, setSettingsOpen] = useState(false);
const [draftFps, setDraftFps] = useState(fps);
const [draftLoop, setDraftLoop] = useState(loop);
/** 拖动总时长:按下时记一次起始时长与指针位置,移动中只做线性换算 */
const durationDragRef = useRef<{ pointerId: number; startClientX: number; startDuration: number } | null>(null);
// 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次;
// 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。
const [draggingTrackKey, setDraggingTrackKey] = useState<string | null>(null);
@@ -296,6 +356,23 @@ export function TimelinePanel() {
startX: number;
startY: number;
} | null>(null);
/**
* 正在拖时间滑块(人物轨迹条 / 运镜片段条共用):
* - point:拖单个关键帧,只改这一帧的时间
* - duration:拖条右端把手,整段时长按比例缩放
* - clip:拖条本体,整段左右平移(可以脱离 0s 往后放)
*/
const timeDragRef = useRef<{
pointerId: number;
owner: TimelineTimeDragOwner;
kind: "point" | "duration" | "clip";
index: number;
startTimes: number[];
/** 片段左端在按下时的位置(平移基准) */
startEdge: number;
startClientX: number;
secondsPerPixel: number;
} | null>(null);
/** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */
const trackRectsRef = useRef(new Map<string, TrackRowRect>());
const trackLayoutOriginRef = useRef(0);
@@ -355,6 +432,18 @@ export function TimelinePanel() {
};
}, [exportMenuOpen]);
// 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃)
useEffect(() => {
if (!settingsOpen) return;
function closeSettingsOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") setSettingsOpen(false);
}
document.addEventListener("keydown", closeSettingsOnEscape);
return () => document.removeEventListener("keydown", closeSettingsOnEscape);
}, [settingsOpen]);
const timeline = project.timeline;
// 选中目标解析:物体 or 相机
@@ -431,6 +520,72 @@ export function TimelinePanel() {
selectTrajectoryPoint(objectId, pointId);
}
/** 总时长微调(上下按钮 / 键盘左右键):按 0.1s 刻度走,统一交给 store 夹取 */
function nudgeDuration(offsetSeconds: number) {
setTimelineDuration(normalizeTimelineDuration(alignTimelineDuration(editableDuration + offsetSeconds)));
}
/**
* 总时长拖动把手(「总时长」左边的双向箭头):左右拖动减 / 加总时长。
* 灵敏度固定(25px = 1s)并对齐到 0.1s,避免拖一下跳一大截。
*/
function handleDurationDragMove(event: ReactPointerEvent<HTMLElement>) {
const drag = durationDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const deltaSeconds = (event.clientX - drag.startClientX) / TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND;
setTimelineDuration(normalizeTimelineDuration(alignTimelineDuration(drag.startDuration + deltaSeconds)));
}
function handleDurationDragEnd(event: ReactPointerEvent<HTMLElement>) {
const drag = durationDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
durationDragRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
}
}
function getDurationDragProps() {
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
// 这次手势归总时长把手,不要被下面的轨道拖拽抢走
event.stopPropagation();
durationDragRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startDuration: editableDuration,
};
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// 同上
}
},
onPointerMove: handleDurationDragMove,
onPointerUp: handleDurationDragEnd,
onPointerCancel: handleDurationDragEnd,
};
}
function openSettings() {
// 每次打开都从当前工程值重新生成草稿,避免上次没点「完成」的残留值
setDraftFps(fps);
setDraftLoop(loop);
setSettingsOpen(true);
}
/** 「完成」才保存并应用:帧率 / 循环一次性写回工程,弹窗随之关闭 */
function confirmSettings() {
setTimelinePlaybackSettings({ fps: normalizeTimelineFps(draftFps), loop: draftLoop });
setSettingsOpen(false);
}
function handleClearAll() {
if (timeline.tracks.length === 0 && timeline.cameraTracks.length === 0) return;
if (window.confirm("确定清空所有关键帧吗?此操作可用撤销恢复。")) {
@@ -481,8 +636,8 @@ export function TimelinePanel() {
),
[rulerMinorTicks, rulerTicks]
);
const playheadFrame = getTimelineFrame(playhead);
const totalFrames = getTimelineFrame(duration);
const playheadFrame = getTimelineFrame(playhead, fps);
const totalFrames = getTimelineFrame(duration, fps);
const playheadPercent = duration === 0 ? 0 : Math.min(100, Math.max(0, (playhead / duration) * 100));
const hasKeyframes = timeline.tracks.length > 0 || timeline.cameraTracks.length > 0;
// 相邻刻度间距(百分比)交给 CSS:轨道底色用它画贯穿网格线,与刻度尺天然对齐
@@ -603,6 +758,181 @@ export function TimelinePanel() {
};
}
/** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */
function scaleClipDuration(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
const targetEnd = (times[times.length - 1] ?? startEdge) + deltaSeconds;
if (owner.kind === "trajectory") {
setTrajectoryPointTimes(
owner.objectId,
scaleTrajectoryPointTimes(times, targetEnd, duration, startEdge)
);
return;
}
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
}
/** 拖单个关键帧:只改它自己的时间(被前后邻居夹住) */
function moveClipKeyframe(
owner: TimelineTimeDragOwner,
times: number[],
index: number,
startEdge: number,
deltaSeconds: number
) {
const targetTime = (times[index] ?? 0) + deltaSeconds;
if (owner.kind === "trajectory") {
const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge);
updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] });
return;
}
setCameraKeyframeTimes(owner.cameraId, moveCameraKeyframeTime(times, index, targetTime, duration));
}
/**
* 拖条本体:整段片段左右平移,可以脱离 0s 往后放(前面那段时间角色原地等待 / 相机保持首帧机位)。
* 先把位移夹住(片段两端都不越出时间轴),再整体平移 —— 片段刚性移动,各关键帧的节奏不变。
*/
function shiftClip(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
const endEdge = times.length > 0 ? times[times.length - 1] : startEdge;
const minShift = -startEdge;
const maxShift = Math.max(duration - endEdge, minShift);
const shift = Math.min(Math.max(deltaSeconds, minShift), maxShift);
// 顶到头(例如片段已经占满整条时间轴)时什么都不写,避免留下没有变化的一步撤销
if (shift === 0) return;
if (owner.kind === "trajectory") {
setTrajectoryClipStart(owner.objectId, startEdge + shift);
return;
}
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
}
/**
* 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」,
* 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。
*/
function beginTimeDrag(
event: ReactPointerEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
kind: "point" | "duration" | "clip",
index: number,
times: number[],
startEdge = 0
) {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
const lane = event.currentTarget.closest(".timeline-track-lane");
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
if (laneWidth <= 0 || duration <= 0) return;
// 这次手势归时间滑块,轨道行的「拖动排序」不要来抢
event.stopPropagation();
timeDragRef.current = {
pointerId: event.pointerId,
owner,
kind,
index,
startTimes: [...times],
startEdge,
startClientX: event.clientX,
secondsPerPixel: duration / laneWidth,
};
// 拖动时冻结全站毛玻璃:条 / 关键帧每次重绘都会让背后 WebGL 画布重算一次模糊
document.body.classList.add("is-timeline-dragging");
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
}
}
function handleTimeDragMove(event: ReactPointerEvent<HTMLElement>) {
const drag = timeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const deltaTime = (event.clientX - drag.startClientX) * drag.secondsPerPixel;
if (drag.kind === "duration") {
scaleClipDuration(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
return;
}
if (drag.kind === "clip") {
shiftClip(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
return;
}
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime);
}
function handleTimeDragEnd(event: ReactPointerEvent<HTMLElement>) {
const drag = timeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
timeDragRef.current = null;
document.body.classList.remove("is-timeline-dragging");
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// 同上,忽略不支持的环境
}
}
/** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */
function getTimeDragProps(
owner: TimelineTimeDragOwner,
kind: "point" | "duration" | "clip",
index: number,
times: number[],
startEdge = 0
) {
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) =>
beginTimeDrag(event, owner, kind, index, times, startEdge),
onPointerMove: handleTimeDragMove,
onPointerUp: handleTimeDragEnd,
onPointerCancel: handleTimeDragEnd,
};
}
/** 时长把手支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
function handleTimeHandleKeyDown(
event: ReactKeyboardEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
times: number[],
startEdge = 0
) {
const step = event.shiftKey ? 0.5 : 0.1;
const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
if (delta === 0 || times.length === 0) return;
event.preventDefault();
scaleClipDuration(owner, times, startEdge, delta);
}
function handleTrackPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
@@ -767,7 +1097,7 @@ export function TimelinePanel() {
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
<span
className="timeline-time-readout"
title={`当前第 ${playheadFrame} 帧 / 共 ${totalFrames} 帧(${TIMELINE_FPS}fps)`}
title={`当前第 ${playheadFrame} 帧 / 共 ${totalFrames} 帧(${fps}fps)`}
>
<span className="timeline-time-readout-frame">F{playheadFrame}</span>
<span className="timeline-time-readout-divider" aria-hidden="true">
@@ -775,20 +1105,63 @@ export function TimelinePanel() {
</span>
<span className="timeline-time-readout-clock">{formatReadoutTime(playhead)}</span>
</span>
<label className="timeline-duration-chip" title="时间轴总时长(秒)">
<MoveHorizontal aria-hidden="true" size={14} strokeWidth={1.8} />
<div className="timeline-duration-chip" title="时间轴总时长(秒):左右拖动箭头调整,或用上下按钮 ±0.1s">
{/* 拖动把手:按住左右拖动即可减 / 加总时长(也可以聚焦后用左右方向键) */}
<span
className="timeline-duration-drag-handle"
role="slider"
tabIndex={0}
aria-label="拖动调整总时长"
aria-valuemin={MIN_TIMELINE_DURATION}
aria-valuemax={MAX_TIMELINE_DURATION}
aria-valuenow={Number(editableDuration.toFixed(2))}
aria-valuetext={formatTime(editableDuration)}
title="按住左右拖动调整总时长(← / → 每次 0.1s)"
onKeyDown={(event) => {
const delta =
event.key === "ArrowLeft" ? -TIMELINE_DURATION_STEP : event.key === "ArrowRight" ? TIMELINE_DURATION_STEP : 0;
if (delta === 0) return;
event.preventDefault();
nudgeDuration(delta);
}}
{...getDurationDragProps()}
>
<MoveHorizontal aria-hidden="true" size={14} strokeWidth={1.8} />
</span>
<span className="timeline-duration-caption">总时长</span>
<input
className="timeline-duration-input"
aria-label="时间轴总时长"
type="number"
min={0.1}
step={0.5}
value={duration}
min={MIN_TIMELINE_DURATION}
max={MAX_TIMELINE_DURATION}
step={TIMELINE_DURATION_STEP}
value={editableDuration}
onChange={(event) => setTimelineDuration(Number(event.target.value))}
/>
<span aria-hidden="true">s</span>
</label>
{/* 上下微调:以 0.1s 为刻度增减总时长 */}
<span className="timeline-duration-spinner">
<button
className="timeline-duration-spinner-button"
type="button"
aria-label={`总时长增加 ${TIMELINE_DURATION_STEP}s`}
title={`+${TIMELINE_DURATION_STEP}s`}
onClick={() => nudgeDuration(TIMELINE_DURATION_STEP)}
>
<ChevronUp aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
<button
className="timeline-duration-spinner-button"
type="button"
aria-label={`总时长减少 ${TIMELINE_DURATION_STEP}s`}
title={`-${TIMELINE_DURATION_STEP}s`}
onClick={() => nudgeDuration(-TIMELINE_DURATION_STEP)}
>
<ChevronDown aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
</span>
</div>
</div>
<div className="timeline-toolbar-group timeline-toolbar-center">
@@ -862,6 +1235,18 @@ export function TimelinePanel() {
</div>
<div className="timeline-toolbar-group timeline-toolbar-end">
<button
className="timeline-icon-button"
type="button"
aria-haspopup="dialog"
aria-expanded={settingsOpen}
aria-label="时间轴设置"
title="时间轴设置:每秒帧数 / 播放循环"
onClick={openSettings}
>
<Settings2 aria-hidden="true" size={15} strokeWidth={1.8} />
</button>
{hasKeyframes ? (
<button className="timeline-action-button" type="button" onClick={handleClearAll}>
<Eraser aria-hidden="true" size={14} strokeWidth={1.8} />
@@ -1022,6 +1407,15 @@ export function TimelinePanel() {
const trackKey = getTimelineTrackKey("camera", track.cameraId);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
// 运镜片段:第一个关键帧 → 最后一个关键帧,这段区间就是「这一段运镜」的时长
const cameraTimes = track.keyframes.map((keyframe) => keyframe.time);
const clipSpan = getCameraClipSpan(track.keyframes);
const clipStart = clipSpan?.start ?? 0;
const clipEnd = clipSpan?.end ?? 0;
const clipDuration = clipEnd - clipStart;
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
const clipWidthPercent = duration > 0 ? (clipDuration / duration) * 100 : 0;
const clipOwner: TimelineTimeDragOwner = { kind: "camera", cameraId: track.cameraId };
return (
<div
@@ -1043,37 +1437,79 @@ export function TimelinePanel() {
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : (
track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<>
{/* 运镜片段条:拖条本体可整段左右平移(前面那段时间相机保持首帧机位),
拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
{cameraTimes.length >= 2 && duration > 0 ? (
<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)}`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
className="timeline-trajectory-bar timeline-clip-bar-camera"
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动右端把手改时长`}
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
>
<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);
}}
>
×
</button>
<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);
}}
>
×
</button>
</div>
);
})}
</>
)}
</div>
</div>
@@ -1091,6 +1527,20 @@ export function TimelinePanel() {
const trackKey = getTimelineTrackKey("character", character.id);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
// 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)与每个轨迹点的关键帧时间
const trajectoryStart = getTrajectoryStartTime(character, duration);
const trajectoryTimes = getTrajectoryPointTimes(trajectoryPoints, duration, trajectoryStart);
const trajectoryEndTime =
trajectoryTimes.length > 0 ? trajectoryTimes[trajectoryTimes.length - 1] : trajectoryStart;
const trajectoryLeftPercent = duration > 0 ? (trajectoryStart / duration) * 100 : 0;
const trajectoryWidthPercent =
duration > 0 ? Math.min(100, ((trajectoryEndTime - trajectoryStart) / duration) * 100) : 0;
// 条右端把手 / 键盘微调改的是「整段轨迹时长」,不针对某个点,pointId 留空
const trajectoryOwner: TimelineTimeDragOwner = {
kind: "trajectory",
objectId: character.id,
pointId: "",
};
return (
<div
@@ -1133,46 +1583,98 @@ export function TimelinePanel() {
</div>
</div>
{/* 人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,末点落在时间轴结束时刻 */}
{/* 人物轨迹:一条「轨迹条」= 角色从起点走到最后一个轨迹点所需的时长。
条本体可左右拖动整段平移(脱离 0s,前面那段时间角色原地等待),
右端把手拖动改整段时长,条上的关键帧可单独拖动改时间 */}
<div
className="timeline-subtrack"
title="人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,最后一个轨迹点落在时间轴结束时刻"
title="人物轨迹:拖动条本体可整段左右平移(起点之前角色原地等待);拖动右端把手调整整段时长;拖动关键帧调整该点时间"
>
<div className="timeline-subtrack-label">轨迹</div>
<div className="timeline-track-lane">
{trajectoryPoints.map((point, index) => {
const pointTime = getTrajectoryPointTime(index, trajectoryPoints.length, duration);
const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100;
const isSelected =
selectedTrajectoryPoint?.objectId === character.id &&
selectedTrajectoryPoint.pointId === point.id;
return (
<div className="timeline-track-lane timeline-trajectory-lane">
{trajectoryTimes.length > 0 && duration > 0 ? (
<>
<div
key={point.id}
className={`timeline-keyframe timeline-keyframe-trajectory${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`轨迹关键帧 ${formatTime(pointTime)}`}
title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}`}
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
className="timeline-trajectory-bar"
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime
)}(时长 ${formatTime(trajectoryEndTime - trajectoryStart)}):拖动条本体整段平移,拖动右端把手改时长`}
{...getTimeDragProps(
trajectoryOwner,
"clip",
0,
trajectoryTimes,
trajectoryStart
)}
>
<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>
<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>
);
})}
{trajectoryPoints.map((point, index) => {
const pointTime = trajectoryTimes[index] ?? 0;
const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100;
const isSelected =
selectedTrajectoryPoint?.objectId === character.id &&
selectedTrajectoryPoint.pointId === point.id;
return (
<div
key={point.id}
className={`timeline-keyframe timeline-keyframe-trajectory${
isSelected ? " is-selected" : ""
}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`轨迹关键帧 ${formatTime(pointTime)}`}
title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}(左右拖动调整时间)`}
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
{...getTimeDragProps(
{ kind: "trajectory", objectId: character.id, pointId: point.id },
"point",
index,
trajectoryTimes
)}
>
<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>
);
})}
</>
) : null}
</div>
</div>
</>
@@ -1192,6 +1694,79 @@ export function TimelinePanel() {
</div>
</div>
</div>
{/* 时间轴设置:帧率 / 循环先改草稿,点「完成」才写回工程并生效。
用 portal 挂到 body:时间轴面板带 backdrop-filter,会成为 fixed 元素的包含块,
直接挂在面板里会被缩在面板内并被裁掉。 */}
{settingsOpen && typeof document !== "undefined"
? createPortal(
<div
className="timeline-settings-overlay"
data-testid="timeline-settings-overlay"
onClick={() => setSettingsOpen(false)}
>
<div
className="timeline-settings-dialog"
role="dialog"
aria-modal="true"
aria-label="时间轴设置"
onClick={(event) => event.stopPropagation()}
>
<div className="timeline-settings-head">
<h3 className="timeline-settings-title">时间轴设置</h3>
{/* 右上角关闭按钮:等同取消,草稿值不写回工程 */}
<button
className="timeline-settings-close"
type="button"
aria-label="关闭设置"
title="关闭(不保存设置)"
onClick={() => setSettingsOpen(false)}
>
<X aria-hidden="true" size={14} strokeWidth={2} />
</button>
</div>
<label className="timeline-settings-field">
<span className="timeline-settings-field-label">每秒帧数</span>
<span className="timeline-settings-field-control">
<input
className="timeline-settings-input"
aria-label="每秒帧数"
type="number"
min={MIN_TIMELINE_FPS}
max={MAX_TIMELINE_FPS}
step={1}
value={draftFps}
onChange={(event) => setDraftFps(Number(event.target.value))}
/>
<span className="timeline-settings-unit">fps</span>
</span>
</label>
<label className="timeline-settings-toggle">
<input
type="checkbox"
aria-label="播放循环"
checked={draftLoop}
onChange={(event) => setDraftLoop(event.target.checked)}
/>
<span>播放循环(播到末尾自动回到起点接着播)</span>
</label>
<p className="timeline-settings-hint">
帧率决定时间读数与导出视频的帧数;点「完成」后才保存并应用。
</p>
<div className="timeline-settings-actions">
<button className="timeline-settings-confirm" type="button" onClick={confirmSettings}>
完成
</button>
</div>
</div>
</div>,
document.body
)
: null}
</section>
);
}

View File

@@ -7,19 +7,25 @@ import { usePlaybackStore } from "./playbackStore";
/**
* 挂在 R3F <Canvas> 内部:
* 1. 每帧推进 playhead(delta 是秒,钳制最大步长防止切 tab 后大跳)
* 2. 把 directorStore 的 timeline.duration 同步到 playbackStore
* 2. 把 directorStore 的 timeline.duration / loop 同步到 playbackStore
*/
export function PlaybackFrameDriver() {
const advance = usePlaybackStore((state) => state.advance);
const timelineDuration = useDirectorStore(
(state) => state.project.timeline?.duration ?? DEFAULT_TIMELINE_DURATION
);
const timelineLoop = useDirectorStore((state) => state.project.timeline?.loop === true);
const setDuration = usePlaybackStore((state) => state.setDuration);
const setLoop = usePlaybackStore((state) => state.setLoop);
useEffect(() => {
setDuration(timelineDuration);
}, [timelineDuration, setDuration]);
useEffect(() => {
setLoop(timelineLoop);
}, [timelineLoop, setLoop]);
useFrame((_, delta) => {
advance(Math.min(delta, 0.1));
});

View File

@@ -1,8 +1,8 @@
import { beforeEach, it, expect } from "vitest";
import { usePlaybackStore } from "./playbackStore";
import { usePlaybackStore, wrapPlayhead } from "./playbackStore";
beforeEach(() => {
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, editingObjectId: null });
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null });
});
it("togglePlaying flips isPlaying", () => {
@@ -57,3 +57,51 @@ it("setDuration keeps a positive minimum", () => {
usePlaybackStore.getState().setDuration(0);
expect(usePlaybackStore.getState().duration).toBeGreaterThanOrEqual(0.1);
});
it("advance loops back to the start when loop is enabled", () => {
usePlaybackStore.getState().setLoop(true);
usePlaybackStore.getState().setPlaying(true);
usePlaybackStore.getState().setPlayhead(4.8);
usePlaybackStore.getState().advance(0.5);
const state = usePlaybackStore.getState();
// 越过末尾的 0.3s 折回起点,播放不中断
expect(state.playhead).toBeCloseTo(0.3, 5);
expect(state.isPlaying).toBe(true);
});
it("keeps looping after several turns around the timeline", () => {
usePlaybackStore.getState().setLoop(true);
usePlaybackStore.getState().setPlaying(true);
for (let index = 0; index < 12; index += 1) {
usePlaybackStore.getState().advance(1);
}
const state = usePlaybackStore.getState();
expect(state.isPlaying).toBe(true);
expect(state.playhead).toBeGreaterThanOrEqual(0);
expect(state.playhead).toBeLessThan(5);
});
it("stops at the end again once loop is turned off", () => {
usePlaybackStore.getState().setLoop(true);
usePlaybackStore.getState().setPlaying(true);
usePlaybackStore.getState().setPlayhead(4.8);
usePlaybackStore.getState().advance(0.5);
expect(usePlaybackStore.getState().isPlaying).toBe(true);
usePlaybackStore.getState().setLoop(false);
usePlaybackStore.getState().setPlayhead(4.8);
usePlaybackStore.getState().advance(0.5);
const state = usePlaybackStore.getState();
expect(state.playhead).toBe(5);
expect(state.isPlaying).toBe(false);
});
it("wraps a playhead across the timeline, tolerating a zero duration", () => {
expect(wrapPlayhead(5.3, 5)).toBeCloseTo(0.3, 5);
expect(wrapPlayhead(10, 5)).toBe(0);
expect(wrapPlayhead(0.3, 0)).toBe(0);
});

View File

@@ -5,18 +5,21 @@ export interface PlaybackState {
isPlaying: boolean;
playhead: number; // 秒
duration: number; // 秒,与 timeline.duration 保持同步
/** 播放到末尾是否循环回起点(与 timeline.loop 保持同步) */
loop: boolean;
/** 正在被 gizmo 拖拽的对象 id(DirectorObject.id 或 DirectorCameraShot.id)。
* PlaybackTransformSync 会跳过它,避免把 gizmo 改的值立刻覆盖回去。 */
editingObjectId: string | null;
setPlaying: (playing: boolean) => void;
/** 播放 / 暂停切换:暂停后再次播放会从当前位置继续;已经在末尾时从头重播(单次播放)。 */
/** 播放 / 暂停切换:暂停后再次播放会从当前位置继续;已经在末尾时从头重播。 */
togglePlaying: () => void;
/** 停止:终止播放并回到起点(playhead = 0)。 */
stop: () => void;
setPlayhead: (time: number) => void;
setDuration: (duration: number) => void;
setLoop: (loop: boolean) => void;
setEditingObjectId: (id: string | null) => void;
/** 供 useFrame 调用:推进 playhead;到末尾时停住(单次播放,不循环回 0)。 */
/** 供 useFrame 调用:推进 playhead;到末尾时按 loop 决定「循环回起点」还是「停住」。 */
advance: (deltaSeconds: number) => void;
}
@@ -24,16 +27,24 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
/** 循环播放时把越过末尾的时间折回区间内(保留溢出的部分,让节奏保持稳定) */
export function wrapPlayhead(next: number, duration: number): number {
if (!(duration > 0)) return 0;
const wrapped = next % duration;
return wrapped < 0 ? wrapped + duration : wrapped;
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
isPlaying: false,
playhead: 0,
duration: DEFAULT_TIMELINE_DURATION,
loop: false,
editingObjectId: null,
setPlaying: (isPlaying) => set({ isPlaying }),
togglePlaying: () =>
set((state) => {
if (state.isPlaying) return { isPlaying: false };
// 单次播放:播到末尾后再点播放,从头重播,而不是停在末尾没反应
// 播到末尾后再点播放,从头重播,而不是停在末尾没反应
return {
isPlaying: true,
playhead: state.playhead >= state.duration ? 0 : state.playhead,
@@ -44,13 +55,15 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
setPlayhead: (playhead) =>
set((state) => ({ playhead: clamp(playhead, 0, state.duration), editingObjectId: null })),
setDuration: (duration) => set({ duration: Math.max(0.1, duration) }),
setLoop: (loop) => set({ loop }),
setEditingObjectId: (editingObjectId) => set({ editingObjectId }),
advance: (deltaSeconds) => {
if (!get().isPlaying) return;
set((state) => {
const next = state.playhead + deltaSeconds;
if (next >= state.duration) {
// 单次播放:到末尾停在末帧并结束播放(不再循环回 0)
// 循环播放:回到起点接着播;否则停在末帧并结束播放
if (state.loop) return { playhead: wrapPlayhead(next, state.duration) };
return { playhead: state.duration, isPlaying: false };
}
return { playhead: next };

View File

@@ -104,6 +104,11 @@ export interface DirectorTrajectoryPoint {
id: string;
position: [number, number, number];
rotation: [number, number, number];
/**
* 该路点的关键帧时间(秒,0 < time ≤ 时间轴时长)。
* 缺省表示「还没被拖动过」,按轨迹点序号在时间轴上均匀铺开;拖动滑块 / 关键帧后写入实际值。
*/
time?: number;
}
/** 当前选中的轨迹点:轨迹点存在角色身上,所以要同时记所属对象与点的 id。 */
@@ -211,6 +216,11 @@ export interface DirectorObject {
characterRig?: CharacterRigState;
/** 角色身上的运动轨迹点:按创建顺序连成路径,画布上以标记 + 连线呈现。 */
trajectoryPoints?: DirectorTrajectoryPoint[];
/**
* 人物轨迹片段的起始时间(秒):0 / 缺省 = 从 0s 就开始走位;
* 大于 0 表示角色先在起点原地等待,到这一刻才沿轨迹移动(时间轴上可拖动整条轨迹条平移)。
*/
trajectoryStartTime?: number;
}
export interface DirectorCameraCapture {

View File

@@ -12,6 +12,20 @@ import {
removeCameraKeyframe,
updateCameraKeyframeTime,
updateCameraKeyframeValue,
CAMERA_KEYFRAME_MIN_GAP,
DEFAULT_TIMELINE_FPS,
MAX_TIMELINE_DURATION,
MAX_TIMELINE_FPS,
MIN_TIMELINE_DURATION,
MIN_TIMELINE_FPS,
getCameraClipSpan,
moveCameraKeyframeTime,
normalizeTimelineDuration,
normalizeTimelineFps,
scaleCameraKeyframeTimes,
setCameraKeyframeTimes,
updateTimelineDuration,
updateTimelinePlaybackSettings,
} from "./timeline";
const V0 = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
@@ -19,8 +33,14 @@ const V1 = { position: [10, 0, 0] as [number, number, number], rotation: [0, 0,
const CAM_A = { position: [0, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 50 };
const CAM_B = { position: [10, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 70 };
it("creates a default empty timeline with both track kinds", () => {
expect(createDefaultTimeline()).toEqual({ duration: DEFAULT_TIMELINE_DURATION, tracks: [], cameraTracks: [] });
it("creates a default empty timeline with both track kinds and playback settings", () => {
expect(createDefaultTimeline()).toEqual({
duration: DEFAULT_TIMELINE_DURATION,
tracks: [],
cameraTracks: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
});
});
// ---------- 物体插值 ----------
@@ -119,3 +139,117 @@ it("clamps a camera keyframe time to duration", () => {
const timeline = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 999, CAM_A, "ckf_1");
expect(timeline.cameraTracks[0].keyframes[0].time).toBe(DEFAULT_TIMELINE_DURATION);
});
// ---------- 运镜片段(首尾关键帧之间的「一段运镜」) ----------
it("reads a camera clip span from the first and last keyframes", () => {
expect(getCameraClipSpan([{ time: 1 }, { time: 2.5 }, { time: 4 }])).toEqual({ start: 1, end: 4 });
expect(getCameraClipSpan([{ time: 2 }])).toEqual({ start: 2, end: 2 });
expect(getCameraClipSpan([])).toBeNull();
});
it("moves a single camera keyframe between its neighbours", () => {
const times = [1, 2, 4];
expect(moveCameraKeyframeTime(times, 1, 3, 5)).toEqual([1, 3, 4]);
// 撞到前一个关键帧 → 至少留最小间隔
expect(moveCameraKeyframeTime(times, 1, 0.2, 5)).toEqual([1, 1 + CAMERA_KEYFRAME_MIN_GAP, 4]);
// 撞到后一个关键帧
expect(moveCameraKeyframeTime(times, 1, 9, 5)).toEqual([1, 4 - CAMERA_KEYFRAME_MIN_GAP, 4]);
// 越界 / 找不到时原样返回
expect(moveCameraKeyframeTime(times, 9, 3, 5)).toBe(times);
});
it("lets the first camera keyframe sit at 0 and the last one go to the timeline end", () => {
// 第一个关键帧可以落在 0s(相机一开始就摆好机位),上限被后一个关键帧挡住
expect(moveCameraKeyframeTime([1, 4], 0, 0, 5)).toEqual([0, 4]);
// 最后一个关键帧最多拖到时间轴末尾
expect(moveCameraKeyframeTime([1, 4], 1, 99, 5)).toEqual([1, 5]);
});
it("scales the whole camera move from the end handle while pinning the first keyframe", () => {
// 1s → 4s 的运镜,末帧拉到 7s:整段放大一倍,首帧仍在 1s
expect(scaleCameraKeyframeTimes([1, 2.5, 4], 7, 10)).toEqual([1, 4, 7]);
// 末帧只能拖到时间轴末尾:整段被压到 5s,中间帧按同一比例跟着走
const squeezed = scaleCameraKeyframeTimes([1, 2.5, 4], 99, 5);
expect(squeezed[0]).toBe(1);
expect(squeezed[1]).toBeCloseTo(3, 6);
expect(squeezed[2]).toBe(5);
});
it("keeps every camera keyframe ordered when the clip is squeezed", () => {
const scaled = scaleCameraKeyframeTimes([1, 2.5, 4], 0, 5);
expect(scaled[0]).toBe(1);
// 缩到最小跨度:各帧之间仍然严格递增,且不越过起点
expect(scaled[1]).toBeCloseTo(1 + CAMERA_KEYFRAME_MIN_GAP, 6);
expect(scaled[2]).toBeCloseTo(1 + CAMERA_KEYFRAME_MIN_GAP * 2, 6);
});
it("ignores a clip that has nothing to scale", () => {
const single = [2];
expect(scaleCameraKeyframeTimes(single, 3, 5)).toBe(single);
});
it("overwrites all camera keyframe times at once and keeps them sorted", () => {
let timeline = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 1, CAM_A, "ckf_1");
timeline = upsertCameraKeyframe(timeline, "cam_1", 4, CAM_B, "ckf_2");
timeline = setCameraKeyframeTimes(timeline, "cam_1", [2, 3]);
expect(timeline.cameraTracks[0].keyframes.map((k) => [k.id, k.time])).toEqual([
["ckf_1", 2],
["ckf_2", 3],
]);
});
it("clamps batched camera keyframe times into the timeline and leaves other tracks alone", () => {
let timeline = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 1, CAM_A, "ckf_1");
timeline = upsertCameraKeyframe(timeline, "cam_2", 1, CAM_A, "ckf_3");
timeline = setCameraKeyframeTimes(timeline, "cam_1", [99]);
expect(timeline.cameraTracks[0].keyframes[0].time).toBe(DEFAULT_TIMELINE_DURATION);
expect(timeline.cameraTracks[1].keyframes[0].time).toBe(1);
});
// ---------- 播放设置(时长 / 帧率 / 循环) ----------
it("normalizes timeline duration into the editable range", () => {
expect(normalizeTimelineDuration(12)).toBe(12);
expect(normalizeTimelineDuration(0)).toBe(MIN_TIMELINE_DURATION);
expect(normalizeTimelineDuration(-5)).toBe(MIN_TIMELINE_DURATION);
expect(normalizeTimelineDuration(1e9)).toBe(MAX_TIMELINE_DURATION);
// NaN / 非数字一律回落到下限,绝不把 NaN 写进时间轴
expect(normalizeTimelineDuration(Number.NaN)).toBe(MIN_TIMELINE_DURATION);
expect(normalizeTimelineDuration("8")).toBe(MIN_TIMELINE_DURATION);
});
it("normalizes the frame rate to an integer inside the allowed range", () => {
expect(normalizeTimelineFps(60)).toBe(60);
expect(normalizeTimelineFps(29.6)).toBe(30);
expect(normalizeTimelineFps(0)).toBe(MIN_TIMELINE_FPS);
expect(normalizeTimelineFps(999)).toBe(MAX_TIMELINE_FPS);
expect(normalizeTimelineFps(undefined)).toBe(DEFAULT_TIMELINE_FPS);
expect(normalizeTimelineFps(Number.NaN)).toBe(DEFAULT_TIMELINE_FPS);
});
it("writes the timeline duration with the same clamping", () => {
const timeline = createDefaultTimeline();
expect(updateTimelineDuration(timeline, 12).duration).toBe(12);
expect(updateTimelineDuration(timeline, Number.NaN).duration).toBe(MIN_TIMELINE_DURATION);
});
it("applies playback settings only for the fields being passed", () => {
const timeline = createDefaultTimeline();
const withFps = updateTimelinePlaybackSettings(timeline, { fps: 60 });
expect(withFps.fps).toBe(60);
expect(withFps.loop).toBe(false);
const withLoop = updateTimelinePlaybackSettings(withFps, { loop: true });
expect(withLoop.loop).toBe(true);
expect(withLoop.fps).toBe(60);
// 非法帧率写进来也会被夹到合法范围,loop 只认严格的 true
const patched = updateTimelinePlaybackSettings(timeline, { fps: 999, loop: undefined });
expect(patched.fps).toBe(MAX_TIMELINE_FPS);
expect(patched.loop).toBe(false);
});

View File

@@ -40,13 +40,66 @@ export interface Timeline {
duration: number; // 秒
tracks: TimelineTrack[];
cameraTracks: CameraTimelineTrack[];
/** 每秒帧数:读数里的帧号与视频导出帧率都按它换算 */
fps: number;
/** 播放到末尾是否循环回起点 */
loop: boolean;
}
export const DEFAULT_TIMELINE_DURATION = 5;
/** 时长下限(秒):再短就没有可编辑的区间了 */
export const MIN_TIMELINE_DURATION = 0.1;
/** 时长上限(秒):1 小时,够用且能挡住误输入 */
export const MAX_TIMELINE_DURATION = 3600;
/** 默认帧率:与视频导出的常用帧率一致 */
export const DEFAULT_TIMELINE_FPS = 30;
export const MIN_TIMELINE_FPS = 1;
export const MAX_TIMELINE_FPS = 120;
/** 时长微调刻度(秒):上下按钮与键盘方向键都按它走 */
export const TIMELINE_DURATION_STEP = 0.1;
const KEYFRAME_TIME_TOLERANCE = 0.001;
/** 把任意输入夹成合法的时长(秒):非法值回落到下限,避免 NaN 把整条时间轴搞坏 */
export function normalizeTimelineDuration(value: unknown): number {
const seconds = typeof value === "number" && Number.isFinite(value) ? value : MIN_TIMELINE_DURATION;
return Math.min(Math.max(seconds, MIN_TIMELINE_DURATION), MAX_TIMELINE_DURATION);
}
/** 把任意输入夹成合法的帧率(取整) */
export function normalizeTimelineFps(value: unknown): number {
const fps = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : DEFAULT_TIMELINE_FPS;
return Math.min(Math.max(fps, MIN_TIMELINE_FPS), MAX_TIMELINE_FPS);
}
export function createDefaultTimeline(): Timeline {
return { duration: DEFAULT_TIMELINE_DURATION, tracks: [], cameraTracks: [] };
return {
duration: DEFAULT_TIMELINE_DURATION,
tracks: [],
cameraTracks: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
};
}
/** 写入总时长(统一做夹取,拖动 / 输入框 / 微调按钮共用) */
export function updateTimelineDuration(timeline: Timeline, seconds: number): Timeline {
return { ...timeline, duration: normalizeTimelineDuration(seconds) };
}
/**
* 写入播放设置(帧率 / 循环)。设置弹窗只在点「完成」时调用一次,
* 所以没传的字段保持原值,不会把用户没改的那一项覆盖掉。
*/
export function updateTimelinePlaybackSettings(
timeline: Timeline,
patch: { fps?: number; loop?: boolean }
): Timeline {
return {
...timeline,
fps: patch.fps === undefined ? timeline.fps : normalizeTimelineFps(patch.fps),
loop: patch.loop === undefined ? timeline.loop : patch.loop === true,
};
}
export function createKeyframeId(): string {
@@ -247,3 +300,86 @@ export function updateCameraKeyframeValue(timeline: Timeline, cameraId: string,
),
};
}
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
export const CAMERA_KEYFRAME_MIN_GAP = 0.05;
/**
* 一次运镜占用的时间区间:第一个关键帧是「运镜开始」,最后一个是「运镜结束」,
* 两者之差就是这段运镜的时长。没有关键帧时返回 null。
*/
export function getCameraClipSpan(keyframes: { time: number }[]): { start: number; end: number } | null {
if (keyframes.length === 0) return null;
return { start: keyframes[0].time, end: keyframes[keyframes.length - 1].time };
}
/**
* 把某个运镜关键帧拖到新时间:夹在前后邻居之间(各留最小间隔)。
* 与轨迹点不同的是,第一个关键帧的下界是 0——相机完全可以在 0s 就摆好第一个机位;
* 最后一个关键帧的上界是时间轴末尾。
*/
export function moveCameraKeyframeTime(times: number[], index: number, time: number, duration: number): number[] {
if (index < 0 || index >= times.length) return times;
const isLast = index === times.length - 1;
const lower = index === 0 ? 0 : times[index - 1] + CAMERA_KEYFRAME_MIN_GAP;
const upper = isLast ? Math.max(duration, lower) : times[index + 1] - CAMERA_KEYFRAME_MIN_GAP;
const next = [...times];
next[index] = Math.min(Math.max(time, lower), Math.max(lower, upper));
return next;
}
/**
* 拖动运镜片段右端把手:末帧落到 newEnd,其余关键帧按同一比例缩放,
* 第一个关键帧保持不动——这就是「整体缩放这段运镜的时间」,各帧之间的相对节奏不变。
* newEnd 夹在「起点 + 最小跨度 ~ 时间轴末尾」之间。
*/
export function scaleCameraKeyframeTimes(times: number[], newEnd: number, duration: number): number[] {
const count = times.length;
if (count < 2) return times;
const start = times[0];
const currentEnd = times[count - 1];
const currentSpan = currentEnd - start;
if (!Number.isFinite(currentSpan) || currentSpan <= 0) return times;
// 缩放下界:所有关键帧还得能依次排开
const minSpan = CAMERA_KEYFRAME_MIN_GAP * (count - 1);
const lower = start + minSpan;
const end = Math.min(Math.max(newEnd, lower), Math.max(lower, duration));
if (Math.abs(end - currentEnd) < 1e-6) return times;
const ratio = (end - start) / currentSpan;
const scaled = times.map((value, index) => {
if (index === 0) return start;
const next = start + (value - start) * ratio;
return Math.min(Math.max(next, start + CAMERA_KEYFRAME_MIN_GAP * index), end);
});
scaled[count - 1] = end;
return scaled;
}
/**
* 一次性覆盖整条运镜轨道的所有关键帧时间(按索引对应,最后统一按时间重排)。
* 拖动把手 / 拖动关键帧都走这里,避免逐个更新时反复排序导致索引错位。
*/
export function setCameraKeyframeTimes(timeline: Timeline, cameraId: string, times: number[]): Timeline {
return {
...timeline,
cameraTracks: timeline.cameraTracks.map((track) => {
if (track.cameraId !== cameraId) return track;
const keyframes = track.keyframes.map((keyframe, index) => {
const time = times[index];
return typeof time === "number" && Number.isFinite(time)
? { ...keyframe, time: clampTime(time, timeline.duration) }
: keyframe;
});
return { ...track, keyframes: sortKeyframes(keyframes) };
}),
};
}

View File

@@ -1,15 +1,21 @@
import { Euler, Quaternion } from "three";
import { expect, it } from "vitest";
import { degreesToRadians, radiansToDegrees } from "./directorProject";
import type { DirectorObject } from "./directorProject";
import type { DirectorObject, DirectorTrajectoryPoint } from "./directorProject";
import {
evaluateTrajectoryMotionAtTime,
getTrajectoryPointTime,
getTrajectoryEndTime,
getTrajectoryPointTimes,
getTrajectoryStartTime,
hasTrajectoryMotion,
moveTrajectoryPointTime,
resolveTrajectorySegment,
scaleTrajectoryPointTimes,
shiftTrajectoryClip,
} from "./trajectoryMotion";
function createCharacter(
trajectoryPoints: Array<{ id: string; position: [number, number, number]; rotation: [number, number, number] }>,
trajectoryPoints: DirectorTrajectoryPoint[],
transform?: Partial<DirectorObject["transform"]>
): DirectorObject {
return {
@@ -27,6 +33,23 @@ function createCharacter(
};
}
/** 带片段起点的角色(轨迹条被拖到后面时就是这种数据) */
function createShiftedCharacter(trajectoryPoints: DirectorTrajectoryPoint[], startTime: number): DirectorObject {
const character = createCharacter(trajectoryPoints, { position: [1, 0, -1], rotation: [0, 30, 0] });
character.trajectoryStartTime = startTime;
return character;
}
function createPoint(
id: string,
position: [number, number, number],
rotation: [number, number, number] = [0, 0, 0],
time?: number
): DirectorTrajectoryPoint {
return { id, position, rotation, time };
}
/** 两个「度数欧拉角」表示的姿态之间的夹角(度)。同一姿态可能有多种等价欧拉表示,所以比较姿态本身。 */
function orientationDeltaDegrees(a: [number, number, number], b: [number, number, number]) {
const qa = new Quaternion().setFromEuler(
@@ -39,26 +62,70 @@ function orientationDeltaDegrees(a: [number, number, number], b: [number, number
return radiansToDegrees(qa.angleTo(qb));
}
it("spreads trajectory points evenly and lands the last one on the timeline end", () => {
// 2 个点 → 2.5s / 5s;3 个点 → 2s / 4s / 6s
expect([0, 1].map((index) => getTrajectoryPointTime(index, 2, 5))).toEqual([2.5, 5]);
expect([0, 1, 2].map((index) => getTrajectoryPointTime(index, 3, 6))).toEqual([2, 4, 6]);
expect(getTrajectoryPointTime(0, 0, 5)).toBe(0);
expect(getTrajectoryPointTime(0, 2, 0)).toBe(0);
it("falls back to an even spread when the points have no explicit time", () => {
const points = [createPoint("tp_1", [1, 0, 0]), createPoint("tp_2", [2, 0, 0])];
expect(getTrajectoryPointTimes(points, 5)).toEqual([2.5, 5]);
const three = [createPoint("a", [1, 0, 0]), createPoint("b", [2, 0, 0]), createPoint("c", [3, 0, 0])];
expect(getTrajectoryPointTimes(three, 6)).toEqual([2, 4, 6]);
expect(getTrajectoryEndTime(three, 6)).toBe(6);
expect(getTrajectoryPointTimes([], 5)).toEqual([]);
expect(getTrajectoryEndTime([], 5)).toBe(0);
expect(getTrajectoryPointTimes(points, 0)).toEqual([0, 0]);
});
it("prefers the explicit time of a dragged point and keeps the rest on the fallback", () => {
const points = [
createPoint("tp_1", [1, 0, 0]),
createPoint("tp_2", [2, 0, 0], [0, 0, 0], 4),
createPoint("tp_3", [3, 0, 0]),
];
// 第 2 个点被拖到 4s,其余仍均匀铺开
expect(getTrajectoryPointTimes(points, 6)).toEqual([2, 4, 6]);
});
it("fixes messy stored times so they never go backwards or leave the timeline", () => {
// 逆序 / 越界的脏数据 → 修正成不倒退
const messy = getTrajectoryPointTimes(
[
createPoint("tp_1", [1, 0, 0], [0, 0, 0], 2),
createPoint("tp_2", [2, 0, 0], [0, 0, 0], 1),
createPoint("tp_3", [3, 0, 0], [0, 0, 0], 4),
],
10
);
expect(messy[0]).toBeCloseTo(2, 5);
expect(messy[1]).toBeCloseTo(2.05, 5);
expect(messy[2]).toBeCloseTo(4, 5);
// 所有点都贴在时间轴末尾的退化数据 → 允许重合,但绝不越界
const degenerate = getTrajectoryPointTimes(
[
createPoint("tp_1", [1, 0, 0], [0, 0, 0], 5),
createPoint("tp_2", [2, 0, 0], [0, 0, 0], 5),
createPoint("tp_3", [3, 0, 0], [0, 0, 0], 5),
],
5
);
degenerate.forEach((time) => {
expect(time).toBeGreaterThanOrEqual(0);
expect(time).toBeLessThanOrEqual(5);
});
});
it("hasTrajectoryMotion only accepts characters that actually own trajectory points", () => {
expect(hasTrajectoryMotion(createCharacter([]), 5)).toBe(false);
expect(hasTrajectoryMotion(createCharacter([{ id: "tp_1", position: [1, 0, 0], rotation: [0, 0, 0] }]), 5)).toBe(true);
expect(hasTrajectoryMotion(createCharacter([{ id: "tp_1", position: [1, 0, 0], rotation: [0, 0, 0] }]), 0)).toBe(false);
expect(hasTrajectoryMotion(createCharacter([createPoint("tp_1", [1, 0, 0])]), 5)).toBe(true);
expect(hasTrajectoryMotion(createCharacter([createPoint("tp_1", [1, 0, 0])]), 0)).toBe(false);
});
it("starts walking from the character's own position and rotation at 0s", () => {
it("walks from the character's own position and rotation at 0s", () => {
const character = createCharacter(
[
{ id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
{ id: "tp_2", position: [8, 0, 4], rotation: [0, 180, 0] },
],
[createPoint("tp_1", [4, 0, 0], [0, 90, 0]), createPoint("tp_2", [8, 0, 4], [0, 180, 0])],
{ position: [1, 0, -1], rotation: [0, 30, 0] }
);
@@ -69,15 +136,16 @@ it("starts walking from the character's own position and rotation at 0s", () =>
expect(sample?.rotation[1]).toBeCloseTo(30, 6);
});
it("passes exactly through every trajectory point at its keyframe time", () => {
const character = createCharacter([
{ id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
{ id: "tp_2", position: [8, 0, 4], rotation: [30, 180, 20] },
]);
const duration = 5;
it("passes exactly through every trajectory point at its own keyframe time", () => {
const points = [
createPoint("tp_1", [4, 0, 0], [0, 90, 0], 1.5),
createPoint("tp_2", [8, 0, 4], [30, 180, 20], 4),
];
const character = createCharacter(points);
const times = getTrajectoryPointTimes(points, 5);
character.trajectoryPoints!.forEach((point, index) => {
const sample = evaluateTrajectoryMotionAtTime(character, duration, getTrajectoryPointTime(index, 2, duration));
points.forEach((point, index) => {
const sample = evaluateTrajectoryMotionAtTime(character, 5, times[index]);
expect(sample?.position[0]).toBeCloseTo(point.position[0], 5);
expect(sample?.position[1]).toBeCloseTo(point.position[1], 5);
@@ -87,11 +155,9 @@ it("passes exactly through every trajectory point at its keyframe time", () => {
});
});
it("ends the walk on the last trajectory point at the timeline end", () => {
const character = createCharacter([
{ id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
{ id: "tp_2", position: [8, 0, 4], rotation: [0, 180, 0] },
]);
it("keeps the character on the last point after the track has finished", () => {
const points = [createPoint("tp_1", [4, 0, 0], [0, 90, 0], 2), createPoint("tp_2", [8, 0, 4], [0, 180, 0], 3)];
const character = createCharacter(points);
const sample = evaluateTrajectoryMotionAtTime(character, 5, 5);
@@ -101,10 +167,8 @@ it("ends the walk on the last trajectory point at the timeline end", () => {
});
it("rolls the three angles smoothly between two trajectory points", () => {
const character = createCharacter([
{ id: "tp_1", position: [4, 0, 0], rotation: [0, 90, 0] },
{ id: "tp_2", position: [8, 0, 0], rotation: [0, 180, 0] },
]);
const points = [createPoint("tp_1", [4, 0, 0], [0, 90, 0]), createPoint("tp_2", [8, 0, 0], [0, 180, 0])];
const character = createCharacter(points);
// 5s 时间轴上第一个点落在 2.5s:半段(1.25s)应是起始朝向 → 90° 的中点 45°
const sample = evaluateTrajectoryMotionAtTime(character, 5, 1.25);
@@ -112,23 +176,21 @@ it("rolls the three angles smoothly between two trajectory points", () => {
expect(sample?.rotation[0]).toBeCloseTo(0, 4);
expect(sample?.rotation[1]).toBeCloseTo(45, 4);
expect(sample?.rotation[2]).toBeCloseTo(0, 4);
// 位置也走在起点与第一个点之间(直线段中点)
expect(sample?.position[0]).toBeCloseTo(2, 5);
});
it("rolls pitch and roll together with the horizontal angle instead of snapping", () => {
const character = createCharacter([{ id: "tp_1", position: [4, 0, 0], rotation: [60, 0, 0] }]);
const character = createCharacter([createPoint("tp_1", [4, 0, 0], [60, 0, 0])]);
const midway = evaluateTrajectoryMotionAtTime(character, 4, 2)!;
const halfwayDelta = orientationDeltaDegrees(midway.rotation, [0, 0, 0]);
// 段中是「起始姿态 → 轨迹点姿态」的中间态,既不是起点也不是终点
expect(halfwayDelta).toBeGreaterThan(5);
expect(halfwayDelta).toBeLessThan(55);
});
it("keeps a single trajectory point as the whole walk", () => {
const character = createCharacter([{ id: "tp_1", position: [6, 0, 0], rotation: [0, 270, 0] }]);
const character = createCharacter([createPoint("tp_1", [6, 0, 0], [0, 270, 0])]);
expect(evaluateTrajectoryMotionAtTime(character, 4, 0)?.position[0]).toBeCloseTo(0, 5);
expect(evaluateTrajectoryMotionAtTime(character, 4, 4)?.position[0]).toBeCloseTo(6, 5);
@@ -141,7 +203,155 @@ it("keeps a single trajectory point as the whole walk", () => {
it("returns null when there is nothing to walk or the duration is unusable", () => {
expect(evaluateTrajectoryMotionAtTime(createCharacter([]), 5, 1)).toBeNull();
expect(
evaluateTrajectoryMotionAtTime(createCharacter([{ id: "tp_1", position: [1, 0, 0], rotation: [0, 0, 0] }]), 0, 1)
).toBeNull();
expect(evaluateTrajectoryMotionAtTime(createCharacter([createPoint("tp_1", [1, 0, 0])]), 0, 1)).toBeNull();
});
it("resolves the segment a time falls into so each keyframe is hit exactly", () => {
expect(resolveTrajectorySegment([2.5, 5], 0)).toEqual({ segment: 0, local: 0 });
expect(resolveTrajectorySegment([2.5, 5], 1.25)).toEqual({ segment: 0, local: 0.5 });
// 正好落在关键帧上:归到下一段的起点,样条参数仍然精确命中该控制点
expect(resolveTrajectorySegment([2.5, 5], 2.5)).toEqual({ segment: 0, local: 1 });
expect(resolveTrajectorySegment([2.5, 5], 3.75)).toEqual({ segment: 1, local: 0.5 });
expect(resolveTrajectorySegment([2.5, 5], 5)).toEqual({ segment: 1, local: 1 });
expect(resolveTrajectorySegment([], 1)).toEqual({ segment: 0, local: 0 });
});
it("moves a single trajectory point between its neighbours", () => {
const times = [1, 2, 3];
expect(moveTrajectoryPointTime(times, 1, 1.5, 5)).toEqual([1, 1.5, 3]);
// 夹在前后邻居之间(各留最小间隔)
expect(moveTrajectoryPointTime(times, 1, 99, 5)[1]).toBeCloseTo(3 - 0.05, 5);
expect(moveTrajectoryPointTime(times, 1, -99, 5)[1]).toBeCloseTo(1.05, 5);
// 第一个点不能跑到 0s 之前;最后一个点可以顶到时间轴末尾
expect(moveTrajectoryPointTime(times, 0, -5, 5)[0]).toBeCloseTo(0.05, 5);
expect(moveTrajectoryPointTime(times, 2, 99, 5)[2]).toBe(5);
// 越界原样返回
expect(moveTrajectoryPointTime(times, 9, 1, 5)).toBe(times);
});
it("scales every point proportionally when dragging the duration handle", () => {
const times = [1, 2, 4];
// 时长从 4s 拉到 2s → 各点等比缩到一半
expect(scaleTrajectoryPointTimes(times, 2, 10)).toEqual([0.5, 1, 2]);
// 拉到 8s → 等比放大两倍
expect(scaleTrajectoryPointTimes(times, 8, 10)).toEqual([2, 4, 8]);
// 不能短到让各点挤在一起(下界 = 点数 × 最小间隔),也不能长过时间轴末尾
expect(scaleTrajectoryPointTimes(times, 0, 10)[2]).toBeCloseTo(0.15, 5);
expect(scaleTrajectoryPointTimes(times, 99, 6)[2]).toBe(6);
// 没变化时原样返回同一个引用
expect(scaleTrajectoryPointTimes(times, 4, 10)).toBe(times);
expect(scaleTrajectoryPointTimes([], 3, 10)).toEqual([]);
});
// ---------- 轨迹片段整体平移(拖条本体) ----------
it("reads the clip start, defaulting to zero for old projects", () => {
const points = [createPoint("tp_1", [1, 0, 0])];
expect(getTrajectoryStartTime(createCharacter(points))).toBe(0);
expect(getTrajectoryStartTime(createShiftedCharacter(points, 2))).toBe(2);
// 脏数据(负数 / NaN / 越过时间轴末尾)一律收敛
expect(getTrajectoryStartTime(createShiftedCharacter(points, -3))).toBe(0);
expect(getTrajectoryStartTime(createShiftedCharacter(points, Number.NaN))).toBe(0);
expect(getTrajectoryStartTime(createShiftedCharacter(points, 99), 5)).toBe(5);
});
it("spreads the fallback times over the shifted clip, keeping the end on the timeline end", () => {
const points = [createPoint("tp_1", [1, 0, 0]), createPoint("tp_2", [2, 0, 0])];
// 片段从 2s 开始:两个点平分剩下的 3s
expect(getTrajectoryPointTimes(points, 5, 2)).toEqual([3.5, 5]);
expect(getTrajectoryEndTime(points, 5, 2)).toBe(5);
// 起点为 0 时与旧行为一致
expect(getTrajectoryPointTimes(points, 5, 0)).toEqual([2.5, 5]);
// 起点贴着末尾:点只能挤在末尾,但绝不越界
const squeezed = getTrajectoryPointTimes(points, 5, 5);
expect(squeezed[1]).toBeLessThanOrEqual(5);
expect(squeezed[0]).toBeLessThanOrEqual(squeezed[1]);
});
it("keeps the first trajectory point behind the clip start when dragging it", () => {
const times = [3.5, 5];
// 想拖到片段起点之前 → 夹在起点 + 最小间隔
expect(moveTrajectoryPointTime(times, 0, 1, 5, 2)[0]).toBeCloseTo(2.05, 5);
// 起点为 0 时仍是原来的下界
expect(moveTrajectoryPointTime([2.5, 5], 0, -5, 5, 0)[0]).toBeCloseTo(0.05, 5);
});
it("scales the clip around its start instead of around 0", () => {
// 片段 2s → 5s(时长 3s),末帧拖到 3.5s → 整段压缩成 2s → 3.5s
const scaled = scaleTrajectoryPointTimes([3.5, 5], 3.5, 5, 2);
// 第 1 个点原本在片段中点(2 → 3.5 的中点 = 2.75)
expect(scaled[0]).toBeCloseTo(2.75, 5);
expect(scaled[1]).toBeCloseTo(3.5, 5);
});
it("shifts a whole clip rigidly and keeps both ends inside the timeline", () => {
// 0s → 3s 的片段(走 3s),时间轴 5s:往右最多挪 2s
const points = [
createPoint("tp_1", [1, 0, 0], [0, 0, 0], 1.5),
createPoint("tp_2", [2, 0, 0], [0, 0, 0], 3),
];
// 整段往后挪 2s:各点一起 +2s,节奏不变
const moved = shiftTrajectoryClip(points, 5, 0, 2);
expect(moved.startTime).toBe(2);
expect(moved.times[0]).toBeCloseTo(3.5, 5);
expect(moved.times[1]).toBeCloseTo(5, 5);
// 往回拖:起点夹到 0,回到原位(此时点时间已经是挪过之后的 3.5s / 5s)
const movedPoints = [
createPoint("tp_1", [1, 0, 0], [0, 0, 0], 3.5),
createPoint("tp_2", [2, 0, 0], [0, 0, 0], 5),
];
const back = shiftTrajectoryClip(movedPoints, 5, 2, -99);
expect(back.startTime).toBe(0);
expect(back.times[0]).toBeCloseTo(1.5, 5);
expect(back.times[1]).toBeCloseTo(3, 5);
// 往后拖过头:末点顶到时间轴末尾就不再多挪(片段长度不变)
const clamped = shiftTrajectoryClip(points, 5, 0, 99);
expect(clamped.startTime).toBeCloseTo(2, 5);
expect(clamped.times[1]).toBeCloseTo(5, 5);
});
it("cannot shift a clip that already fills the whole timeline", () => {
// 默认铺开的轨迹 = 0s → 5s,整条时间轴都是它,没有余量可挪
const points = [createPoint("tp_1", [1, 0, 0]), createPoint("tp_2", [2, 0, 0])];
const moved = shiftTrajectoryClip(points, 5, 0, 2);
expect(moved.startTime).toBe(0);
expect(moved.times).toEqual([2.5, 5]);
});
it("leaves a clip with no points untouched when shifting", () => {
expect(shiftTrajectoryClip([], 5, 0, 2)).toEqual({ startTime: 0, times: [] });
});
it("waits at the start pose until the shifted clip begins", () => {
const points = [createPoint("tp_1", [6, 0, 0], [0, 90, 0], 5)];
const character = createShiftedCharacter(points, 2);
// 片段开始之前:停在起始位置与朝向(原地等待)
const waiting = evaluateTrajectoryMotionAtTime(character, 5, 1)!;
expect(waiting.position[0]).toBeCloseTo(1, 5);
expect(waiting.position[2]).toBeCloseTo(-1, 5);
expect(orientationDeltaDegrees(waiting.rotation, [0, 30, 0])).toBeCloseTo(0, 3);
// 起点那一刻也是起始姿态
expect(evaluateTrajectoryMotionAtTime(character, 5, 2)!.position[0]).toBeCloseTo(1, 5);
// 走到关键帧时间(5s)时精确落在轨迹点上
const arrived = evaluateTrajectoryMotionAtTime(character, 5, 5)!;
expect(arrived.position[0]).toBeCloseTo(6, 5);
expect(orientationDeltaDegrees(arrived.rotation, [0, 90, 0])).toBeCloseTo(0, 3);
});
it("stops driving the character once the clip start reaches the timeline end", () => {
const points = [createPoint("tp_1", [1, 0, 0])];
expect(hasTrajectoryMotion(createShiftedCharacter(points, 4), 5)).toBe(true);
expect(hasTrajectoryMotion(createShiftedCharacter(points, 5), 5)).toBe(false);
});

View File

@@ -1,6 +1,6 @@
import { CatmullRomCurve3, Euler, Quaternion, Vector3 } from "three";
import { degreesToRadians, radiansToDegrees } from "./directorProject";
import type { DirectorObject } from "./directorProject";
import type { DirectorObject, DirectorTrajectoryPoint } from "./directorProject";
import type { TransformTuple } from "./timeline";
/**
@@ -13,23 +13,165 @@ export interface TrajectoryMotionSample {
rotation: TransformTuple;
}
/**
* 轨迹点的关键帧时间(pointIndex 从 0 开始)。
*
* N 个轨迹点把时间轴等分成 N 段:第 i 个点(1-based)落在 duration * i / N,
* 于是最后一个轨迹点正好落在时间轴结束时刻。0s 不是轨迹点,而是角色自身的起始位置 / 起始朝向。
*/
export function getTrajectoryPointTime(pointIndex: number, pointCount: number, duration: number): number {
if (pointCount <= 0 || !Number.isFinite(duration) || duration <= 0) return 0;
/** 相邻轨迹点之间允许的最小时间间隔(秒):保证时间严格递增,拖到底也不会重叠 */
export const TRAJECTORY_POINT_MIN_GAP = 0.05;
const safeIndex = Math.min(Math.max(pointIndex, 0), pointCount - 1);
return (duration * (safeIndex + 1)) / pointCount;
/**
* 轨迹片段的起始时间(秒):0 表示从 0s 就开始走位;
* 拖到后面(比如 2s)表示角色先在起点原地等待,到这一刻才开始沿轨迹移动。
*/
export function getTrajectoryStartTime(owner: DirectorObject, duration = Number.POSITIVE_INFINITY): number {
const raw = owner.trajectoryStartTime;
const start = typeof raw === "number" && Number.isFinite(raw) ? Math.max(raw, 0) : 0;
const upper = Number.isFinite(duration) ? Math.max(duration, 0) : Number.POSITIVE_INFINITY;
return Math.min(start, upper);
}
/** 轨迹点是否有时长可用的采样:有点且时间轴时长有效才参与驱动。 */
/**
* 解析出每个轨迹点的关键帧时间(秒)。
*
* - 点上有显式 time(用户拖动过滑块 / 关键帧)就用它;
* - 没有的(旧工程、刚打的点)按「均匀铺满片段」推导:start + (duration - start) * (i + 1) / N,
* 于是最后一个点正好落在时间轴结束时刻;
* - 片段起点(默认 0s)不是轨迹点,而是角色自身的起始位置与起始朝向(起点之前角色原地等待);
* - 最后统一做一次单调递增修正,防止非法数据把时间轴搞乱。
*/
export function getTrajectoryPointTimes(
points: DirectorTrajectoryPoint[],
duration: number,
startTime = 0
): number[] {
const count = points.length;
if (count === 0) return [];
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0) return points.map(() => 0);
const gap = Math.min(TRAJECTORY_POINT_MIN_GAP, safeDuration / (count + 1));
// 片段起点也要夹进时间轴:起点 + 每个点至少一个最小间隔,剩下的才用来铺开
const start = Math.min(
Math.max(Number.isFinite(startTime) ? startTime : 0, 0),
Math.max(safeDuration - gap * count, 0)
);
const times: number[] = [];
let previous = start;
points.forEach((point, index) => {
const explicit =
typeof point.time === "number" && Number.isFinite(point.time)
? point.time
: start + ((safeDuration - start) * (index + 1)) / count;
// 先保证「不往前一个点之前」,再夹进时间轴;两头都满足不了(脏数据全挤在末尾)时
// 退让为允许重合,但时间绝不越界。
const next = Math.max(Math.min(Math.max(explicit, previous + gap), safeDuration), previous);
times.push(next);
previous = next;
});
return times;
}
/** 轨迹走完的时间 = 最后一个轨迹点的时间;没有轨迹点时是片段起点 */
export function getTrajectoryEndTime(
points: DirectorTrajectoryPoint[],
duration: number,
startTime = 0
): number {
const times = getTrajectoryPointTimes(points, duration, startTime);
return times.length > 0 ? times[times.length - 1] : startTime;
}
/**
* 把某个轨迹点拖到新时间:夹在前后邻居之间(各自留最小间隔),
* 第一个点的下界是片段起点之后,最后一个点的上界是时间轴末尾。
* 返回新的完整时间表;越界 / 找不到时原样返回。
*/
export function moveTrajectoryPointTime(
times: number[],
index: number,
time: number,
duration: number,
startTime = 0
): number[] {
if (index < 0 || index >= times.length) return times;
const isLast = index === times.length - 1;
const lower =
index === 0
? 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)
: times[index + 1] - TRAJECTORY_POINT_MIN_GAP;
const next = [...times];
next[index] = Math.min(Math.max(time, lower), Math.max(lower, upper));
return next;
}
/**
* 拖动整段轨迹的时长(条右端把手):末点落到 newEnd,其余点按同一比例缩放,
* 保持各点之间的相对节奏,片段起点固定不动。newEnd 夹在「起点 + 最小跨度 ~ 时间轴末尾」之间。
*/
export function scaleTrajectoryPointTimes(
times: number[],
newEnd: number,
duration: number,
startTime = 0
): number[] {
const count = times.length;
if (count === 0) return times;
const start = Math.min(Math.max(Number.isFinite(startTime) ? startTime : 0, 0), Math.max(duration, 0));
const currentEnd = times[count - 1];
const currentSpan = currentEnd - start;
if (!Number.isFinite(currentSpan) || currentSpan <= 0) return times;
// 缩放下界:只要所有点还能依次排在起点之后即可
const lower = start + TRAJECTORY_POINT_MIN_GAP * count;
const end = Math.min(Math.max(newEnd, lower), Math.max(lower, duration));
if (Math.abs(end - currentEnd) < 1e-6) return times;
const ratio = (end - start) / currentSpan;
const scaled = times.map((value, index) => {
const next = start + (value - start) * ratio;
return Math.min(Math.max(next, start + TRAJECTORY_POINT_MIN_GAP * (index + 1)), end);
});
scaled[count - 1] = end;
return scaled;
}
/**
* 整体平移轨迹片段(拖条本体):起点夹到 [0, duration - 片段长度] 之间,
* 所有轨迹点跟着平移同样的量 —— 片段刚性移动,各点节奏不变。
*/
export function shiftTrajectoryClip(
points: DirectorTrajectoryPoint[],
duration: number,
currentStart: number,
nextStart: number
): { startTime: number; times: number[] } {
const times = getTrajectoryPointTimes(points, duration, currentStart);
if (times.length === 0) return { startTime: Math.max(currentStart, 0), times };
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
const span = Math.max(times[times.length - 1] - currentStart, 0);
const requested = Number.isFinite(nextStart) ? nextStart : currentStart;
const startTime = Math.min(Math.max(requested, 0), Math.max(safeDuration - span, 0));
const delta = startTime - currentStart;
return { startTime, times: times.map((time) => time + delta) };
}
/** 轨迹点是否有时长可用的采样:有点、时长有效、且片段起点还没越过时间轴末尾才参与驱动。 */
export function hasTrajectoryMotion(owner: DirectorObject, duration = Number.POSITIVE_INFINITY): boolean {
if ((owner.trajectoryPoints ?? []).length === 0) return false;
return Number.isFinite(duration) ? duration > 0 : true;
if (!Number.isFinite(duration)) return true;
return duration > 0 && getTrajectoryStartTime(owner, duration) < duration;
}
const _qa = new Quaternion();
@@ -77,15 +219,44 @@ export function createTrajectoryCurve(curvePoints: Vector3[]): CatmullRomCurve3
return new CatmullRomCurve3(curvePoints, false, "centripetal");
}
/**
* 定位某一时刻落在哪一段轨迹上。
* 段的边界是 [片段起点, t0, t1, ...],段 k 从 bounds[k] 走到 bounds[k+1],local 是段内比例 [0,1]。
*/
export function resolveTrajectorySegment(
times: number[],
time: number,
startTime = 0
): { segment: number; local: number } {
const count = times.length;
if (count === 0) return { segment: 0, local: 0 };
let segment = count - 1;
for (let index = 0; index < count; index += 1) {
if (time <= times[index]) {
segment = index;
break;
}
}
const start = segment === 0 ? Math.max(startTime, 0) : times[segment - 1];
const end = times[segment];
const span = end - start;
const local = span <= 0 ? 1 : Math.min(1, Math.max(0, (time - start) / span));
return { segment, local };
}
/**
* 求某一时刻角色沿轨迹的位置与朝向:
* - 0s:角色自身的起始位置与起始朝向(transform);
* - 第 i 个轨迹点(1-based):duration * i / N,朝向 = 该点设定值;
* - 片段起点之前(片段被拖到后面时):角色停在自身起始位置与起始朝向,原地等待;
* - 片段起点:角色自身的起始位置与起始朝向(transform);
* - 第 i 个轨迹点:它自己的关键帧时间(可被拖动调整),朝向 = 该点设定值;
* - 段内:位置沿样条从上一个关键位置平滑推进到下一个,三个角度用四元数 slerp 平滑滚动;
* - 最后一个轨迹点落在时间轴结束时刻(duration)。
* - 最后一个点走完之后:停在最后一个轨迹点上。
*
* 时间 → 样条参数是等比例映射:第 i 个点恰好落在参数 i/N 上,所以关键点会被精确命中,
* 不会因为曲线长度不同而提前/滞后。
* 时间 → 样条参数按「段」映射:第 k 段的参数区间正好是 [k/N, (k+1)/N],
* 所以每个关键点都会被精确命中,不会因为曲线长度不同而提前 / 滞后。
*/
export function evaluateTrajectoryMotionAtTime(
owner: DirectorObject,
@@ -95,29 +266,36 @@ export function evaluateTrajectoryMotionAtTime(
const points = owner.trajectoryPoints ?? [];
if (points.length === 0 || !Number.isFinite(duration) || duration <= 0) return null;
const startTime = getTrajectoryStartTime(owner, duration);
// 还没到片段起点:角色停在起始位置与朝向等着(拖到后面 = 推迟出发)
if (time < startTime) {
return {
position: [owner.transform.position[0], owner.transform.position[1], owner.transform.position[2]],
rotation: [owner.transform.rotation[0], owner.transform.rotation[1], owner.transform.rotation[2]],
};
}
const curve = createTrajectoryCurve(getTrajectoryCurvePoints(owner));
if (!curve) return null;
const ratio = Math.min(1, Math.max(0, time / duration));
const position = curve.getPoint(ratio, _point);
const times = getTrajectoryPointTimes(points, duration, startTime);
const { segment, local } = resolveTrajectorySegment(times, time, startTime);
const curveRatio = (segment + local) / times.length;
const position = curve.getPoint(curveRatio, _point);
// 朝向关键帧:[角色起始朝向, ...每个轨迹点的朝向] 共 N+1 个,对应 N 段
const segmentCount = points.length;
const scaled = ratio * segmentCount;
const segmentIndex = Math.min(segmentCount - 1, Math.max(0, Math.floor(scaled)));
const localT = Math.min(1, Math.max(0, scaled - segmentIndex));
const fromRotation: TransformTuple =
segmentIndex === 0
segment === 0
? [owner.transform.rotation[0], owner.transform.rotation[1], owner.transform.rotation[2]]
: [
points[segmentIndex - 1].rotation[0],
points[segmentIndex - 1].rotation[1],
points[segmentIndex - 1].rotation[2],
points[segment - 1].rotation[0],
points[segment - 1].rotation[1],
points[segment - 1].rotation[2],
];
const toRotation = points[segmentIndex].rotation;
const toRotation = points[segment].rotation;
return {
position: [position.x, position.y, position.z],
rotation: slerpRotationDegrees(fromRotation, toRotation, localT),
rotation: slerpRotationDegrees(fromRotation, toRotation, local),
};
}

View File

@@ -844,7 +844,13 @@ it("groups repeated transform updates into one undo step while batching", () =>
it("createDefaultDirectorProject ships a default timeline and version 2", () => {
const project = createDefaultDirectorProject();
expect(project.version).toBe(2);
expect(project.timeline).toEqual({ duration: 5, tracks: [], cameraTracks: [] });
expect(project.timeline).toEqual({
duration: 5,
tracks: [],
cameraTracks: [],
fps: 30,
loop: false,
});
});
it("replaceProject migrates a legacy v1 project into a v2 project with a timeline", () => {
@@ -862,7 +868,13 @@ it("replaceProject migrates a legacy v1 project into a v2 project with a timelin
const project = useDirectorStore.getState().project;
expect(project.version).toBe(2);
expect(project.timeline).toEqual({ duration: 5, tracks: [], cameraTracks: [] });
expect(project.timeline).toEqual({
duration: 5,
tracks: [],
cameraTracks: [],
fps: 30,
loop: false,
});
});
it("addKeyframe inserts a keyframe on the object's track and returns its id", () => {
@@ -936,6 +948,51 @@ it("setTimelineDuration updates the timeline duration", () => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
useDirectorStore.getState().setTimelineDuration(12);
expect(useDirectorStore.getState().project.timeline.duration).toBe(12);
// 非法值会被夹到下限,不会把 NaN 写进时间轴
useDirectorStore.getState().setTimelineDuration(Number.NaN);
expect(useDirectorStore.getState().project.timeline.duration).toBe(0.1);
});
it("setTrajectoryClipStart shifts the whole trajectory clip in time", () => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
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]);
// 先缩短走位到 0s → 3s,右边留出可挪的空间
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
useDirectorStore.getState().setTrajectoryClipStart(character.id, 2);
const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
expect(updated.trajectoryStartTime).toBe(2);
expect(updated.trajectoryPoints!.map((point) => point.time)).toEqual([3.5, 5]);
});
it("setTrajectoryClipStart clamps the clip inside the timeline", () => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [2]);
useDirectorStore.getState().setTrajectoryClipStart(character.id, 99);
const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
// 片段长 2s、时间轴 5s → 起点最多挪到 3s
expect(updated.trajectoryStartTime).toBe(3);
expect(updated.trajectoryPoints![0].time).toBe(5);
});
it("setTimelinePlaybackSettings stores the frame rate and loop flag", () => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60, loop: true });
const timeline = useDirectorStore.getState().project.timeline;
expect(timeline.fps).toBe(60);
expect(timeline.loop).toBe(true);
// 只传其中一个字段时,另一个保持原值
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 24 });
expect(useDirectorStore.getState().project.timeline.fps).toBe(24);
expect(useDirectorStore.getState().project.timeline.loop).toBe(true);
});
it("clearAllKeyframes empties both object and camera tracks", () => {

View File

@@ -37,11 +37,18 @@ import {
removeCameraKeyframe as removeTimelineCameraKeyframe,
updateCameraKeyframeTime as updateTimelineCameraKeyframeTime,
updateCameraKeyframeValue as updateTimelineCameraKeyframeValue,
setCameraKeyframeTimes as setTimelineCameraKeyframeTimes,
updateTimelineDuration,
updateTimelinePlaybackSettings,
normalizeTimelineDuration,
normalizeTimelineFps,
DEFAULT_TIMELINE_DURATION,
createKeyframeId,
type KeyframeTransformValue,
type CameraKeyframeValue,
type Timeline,
} from "../schema/timeline";
import { getTrajectoryStartTime, shiftTrajectoryClip } from "../schema/trajectoryMotion";
export type TransformMode = "translate" | "rotate" | "scale";
@@ -141,8 +148,12 @@ export interface DirectorActions {
updateTrajectoryPoint: (
objectId: string,
pointId: string,
patch: Partial<Pick<DirectorTrajectoryPoint, "position" | "rotation">>
patch: Partial<Pick<DirectorTrajectoryPoint, "position" | "rotation" | "time">>
) => void;
/** 一次性覆盖整条轨迹的所有关键帧时间(拖动「轨迹时长」把手时按比例缩放用) */
setTrajectoryPointTimes: (objectId: string, times: number[]) => void;
/** 整体平移人物轨迹片段(拖轨迹条本体):片段起点 + 所有轨迹点时间同步平移 */
setTrajectoryClipStart: (objectId: string, startTime: number) => void;
removeTrajectoryPoint: (objectId: string, pointId: string) => void;
selectTrajectoryPoint: (objectId: string, pointId: string) => void;
updateScene: (patch: Partial<SceneSettings>) => void;
@@ -203,9 +214,13 @@ export interface DirectorActions {
removeCameraKeyframe: (cameraId: string, keyframeId: string) => void;
updateCameraKeyframeTime: (cameraId: string, keyframeId: string, time: number) => void;
updateCameraKeyframeValue: (cameraId: string, keyframeId: string, value: CameraKeyframeValue) => void;
/** 一次性覆盖整条运镜的所有关键帧时间(拖动「运镜时长」把手按比例缩放用) */
setCameraKeyframeTimes: (cameraId: string, times: number[]) => void;
applyCameraKeyframeToShot: (cameraId: string, value: CameraKeyframeValue) => void;
// 时长
setTimelineDuration: (seconds: number) => void;
/** 时间轴播放设置(每秒帧数 / 是否循环):设置弹窗里点「完成」时一次性写入 */
setTimelinePlaybackSettings: (patch: { fps?: number; loop?: boolean }) => void;
clearAllKeyframes: () => void;
beginUndoBatch: () => void;
endUndoBatch: () => void;
@@ -443,13 +458,21 @@ function withPersistedLocalAssets(project: DirectorProject, includePersistedLoca
function withTimeline(project: DirectorProject): DirectorProject {
const timeline = project.timeline ?? createDefaultTimeline();
// 旧工程里没有 fps / loop:补默认值(30fps、不循环),duration 也顺手夹一次
const duration =
typeof timeline.duration === "number" && Number.isFinite(timeline.duration)
? timeline.duration
: DEFAULT_TIMELINE_DURATION;
return {
...project,
version: 2,
timeline: {
duration: timeline.duration,
duration: normalizeTimelineDuration(duration),
tracks: timeline.tracks ?? [],
cameraTracks: timeline.cameraTracks ?? [],
fps: normalizeTimelineFps(timeline.fps),
loop: timeline.loop === true,
},
};
}
@@ -1674,12 +1697,57 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
normalizeTrajectoryRotation(2, patch.rotation[2]),
] as [number, number, number])
: point.rotation,
// 关键帧时间:拖到哪儿就是哪儿(合法性由拖动时的夹取保证)
time:
typeof patch.time === "number" && Number.isFinite(patch.time)
? patch.time
: point.time,
}
: point
),
})),
},
})),
setTrajectoryPointTimes: (objectId, times) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
objects: updateObjectById(state.project.objects, objectId, (item) => ({
...item,
trajectoryPoints: (item.trajectoryPoints ?? []).map((point, index) => {
const time = times[index];
return typeof time === "number" && Number.isFinite(time) ? { ...point, time } : point;
}),
})),
},
})),
setTrajectoryClipStart: (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 { startTime: nextStart, times } = shiftTrajectoryClip(
points,
state.project.timeline.duration,
getTrajectoryStartTime(item, state.project.timeline.duration),
startTime
);
return {
...item,
trajectoryStartTime: nextStart,
trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })),
};
}),
},
})),
removeTrajectoryPoint: (objectId, pointId) =>
commitMutation((state) => ({
...state,
@@ -2568,6 +2636,14 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
timeline: updateTimelineCameraKeyframeValue(state.project.timeline, cameraId, keyframeId, value),
},
})),
setCameraKeyframeTimes: (cameraId, times) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
timeline: setTimelineCameraKeyframeTimes(state.project.timeline, cameraId, times),
},
})),
applyCameraKeyframeToShot: (cameraId, value) => {
// 只改 position/target/fov,rotation/scale 保留 shot 原值(相机朝向由 target 决定)
const camera = get().project.cameras.find((item) => item.id === cameraId);
@@ -2583,7 +2659,15 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
...state,
project: {
...state.project,
timeline: { ...state.project.timeline, duration: Math.max(0.1, seconds) },
timeline: updateTimelineDuration(state.project.timeline, seconds),
},
})),
setTimelinePlaybackSettings: (patch) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
timeline: updateTimelinePlaybackSettings(state.project.timeline, patch),
},
})),
clearAllKeyframes: () =>

View File

@@ -1216,7 +1216,10 @@ textarea:focus-visible {
border: 0;
border-radius: 8px;
padding: 0;
background: rgb(var(--accent-rgb));
/* 底色必须是中性色:真正显示颜色的是内层取色块(::-webkit-color-swatch),
它在圆角处的抗锯齿边缘是半透明的,用强调蓝当底色时圆角会透出一圈蓝边
(没聚焦时看着像残留的焦点环)。这里和旁边的 HEX 输入框用同一个字段底色。 */
background: rgb(var(--field-rgb));
}
.inspector-color-swatch:focus,
@@ -3499,7 +3502,8 @@ textarea:focus-visible {
比原来宽一些,给每行的「上移 / 下移 / 折叠」按钮留出位置 */
--timeline-gutter-width: 166px;
--timeline-ruler-height: 22px;
--timeline-row-height: 28px;
/* 每条轨道(含角色子轨道)的行高:比原来更宽松,关键帧 / 片段条更好点 */
--timeline-row-height: 32px;
display: flex;
flex-direction: column;
gap: 10px;
@@ -3567,7 +3571,7 @@ textarea:focus-visible {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
height: 28px;
padding: 0 8px;
border: 1px solid rgb(var(--border-rgb) / 0.35);
border-radius: 8px;
@@ -3576,7 +3580,6 @@ textarea:focus-visible {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: ew-resize;
}
/* 「总时长」文案:和读数分开展示,让总时间一眼可辨 */
.timeline-duration-caption {
@@ -3616,6 +3619,203 @@ textarea:focus-visible {
margin: 0;
appearance: none;
}
/* 总时长拖动把手(双向箭头):按住左右拖动减 / 加总时长 */
.timeline-duration-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 22px;
border-radius: 4px;
cursor: ew-resize;
touch-action: none;
}
.timeline-duration-drag-handle:hover,
.timeline-duration-drag-handle:focus-visible {
color: rgb(var(--text-rgb));
background: rgb(var(--text-rgb) / 0.08);
outline: none;
}
/* 上下微调按钮:0.1s 一格 */
.timeline-duration-spinner {
display: inline-flex;
flex-direction: column;
gap: 1px;
margin-left: -2px;
}
.timeline-duration-spinner-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 12px;
padding: 0;
border: 0;
border-radius: 3px;
color: rgb(var(--text-dim-rgb));
background: transparent;
cursor: pointer;
}
.timeline-duration-spinner-button:hover,
.timeline-duration-spinner-button:focus-visible {
color: rgb(var(--text-rgb));
background: rgb(var(--text-rgb) / 0.1);
outline: none;
}
.timeline-duration-spinner-button svg {
color: inherit;
}
/* 时间轴设置弹窗:遮罩 + 卡片。草稿值只在点「完成」时才写回工程 */
.timeline-settings-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
background: rgb(0 0 0 / 0.42);
}
.timeline-settings-dialog {
display: grid;
gap: 14px;
width: 280px;
padding: 18px;
border: 1px solid rgb(var(--border-rgb) / 0.4);
border-radius: 16px;
background: rgb(var(--panel-rgb) / 0.96);
box-shadow: 0 18px 48px rgb(0 0 0 / 0.35);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
/* 弹窗里的输入框要能选中文本(时间轴面板整体禁用了选中) */
user-select: text;
-webkit-user-select: text;
}
.timeline-settings-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.timeline-settings-title {
margin: 0;
color: rgb(var(--text-rgb));
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
/* 右上角图形关闭按钮(等同取消) */
.timeline-settings-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 6px;
color: rgb(var(--text-dim-rgb));
background: transparent;
cursor: pointer;
transition:
color 0.12s ease,
background-color 0.12s ease;
}
.timeline-settings-close:hover,
.timeline-settings-close:focus-visible {
color: rgb(var(--text-rgb));
background: rgb(var(--text-rgb) / 0.1);
outline: none;
}
.timeline-settings-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.timeline-settings-field-label {
color: rgb(var(--text-muted-rgb));
font-size: 12px;
line-height: 17px;
}
.timeline-settings-field-control {
display: inline-flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 8px;
border: 1px solid rgb(var(--border-rgb) / 0.35);
border-radius: 8px;
background: rgb(var(--field-rgb));
}
.timeline-settings-field-control:focus-within {
border-color: rgb(var(--accent-rgb) / 0.6);
}
.timeline-settings-input {
width: 40px;
border: 0;
padding: 0;
color: rgb(var(--text-rgb));
background: transparent;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
outline: none;
appearance: textfield;
-moz-appearance: textfield;
}
.timeline-settings-input:focus,
.timeline-settings-input:focus-visible {
outline: none;
box-shadow: none;
}
.timeline-settings-input::-webkit-outer-spin-button,
.timeline-settings-input::-webkit-inner-spin-button {
margin: 0;
appearance: none;
}
.timeline-settings-unit {
color: rgb(var(--text-dim-rgb));
font-size: 11px;
}
.timeline-settings-toggle {
display: flex;
align-items: center;
gap: 8px;
color: rgb(var(--text-muted-rgb));
font-size: 12px;
line-height: 17px;
cursor: pointer;
}
.timeline-settings-toggle input {
accent-color: rgb(var(--accent-rgb));
}
.timeline-settings-hint {
margin: 0;
color: rgb(var(--text-dim-rgb));
font-size: 11px;
line-height: 16px;
}
.timeline-settings-actions {
display: flex;
justify-content: flex-end;
}
.timeline-settings-confirm {
height: 32px;
padding: 0 16px;
border: 0;
border-radius: 8px;
color: rgb(255 255 255);
background: rgb(var(--accent-rgb));
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.timeline-settings-confirm:hover,
.timeline-settings-confirm:focus-visible {
background: rgb(var(--accent-rgb) / 0.88);
outline: none;
}
.timeline-transport {
display: inline-flex;
align-items: center;
@@ -4065,6 +4265,75 @@ body.is-timeline-dragging * {
.timeline-motion-chip.is-disabled {
opacity: 0.55;
}
/* 轨迹条 / 运镜片段条:像剪辑软件里的片段。
拖条本体 = 整段左右平移(可脱离 0s),拖右端把手 = 调整整段时长 */
.timeline-trajectory-bar {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
border: 1px solid rgb(var(--accent-rgb) / 0.4);
border-radius: 5px;
background: rgb(var(--accent-rgb) / 0.12);
/* 条本体的手势是「整段平移」,不是所在轨道的拖动排序(手势里已 stopPropagation) */
cursor: grab;
touch-action: none;
}
.timeline-trajectory-bar:active {
cursor: grabbing;
}
.timeline-trajectory-bar-label {
position: absolute;
top: 50%;
left: 7px;
max-width: calc(100% - 18px);
overflow: hidden;
color: rgb(var(--accent-rgb));
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
transform: translateY(-50%);
pointer-events: none;
}
.timeline-trajectory-handle {
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);
cursor: ew-resize;
touch-action: none;
}
.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);
outline: none;
}
/* 运镜片段条:和人物轨迹条同一套「片段条 + 右端把手」交互(首帧固定、拖把手缩放整段时长),
只是区间是「第一个运镜关键帧 → 最后一个关键帧」,配色换成运镜绿以区分 */
.timeline-clip-bar-camera {
border-color: rgb(18 184 134 / 0.42);
background: rgb(18 184 134 / 0.12);
}
.timeline-clip-bar-camera .timeline-trajectory-bar-label {
color: #12b886;
}
.timeline-clip-bar-camera .timeline-trajectory-handle {
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);
}
.timeline-playhead-layer {
position: absolute;
top: 0;
@@ -4121,7 +4390,15 @@ body.is-timeline-dragging * {
.timeline-keyframe-camera .timeline-keyframe-dot {
background: #12b886;
}
/* 人物轨迹关键帧:用紫色和物体 / 运镜关键帧区分(选中态仍走下面的 .is-selected 规则) */
/* 运镜关键帧:左右拖动即调整该帧的时间(和轨迹关键帧一致的手势) */
.timeline-keyframe-camera {
cursor: ew-resize;
}
/* 人物轨迹关键帧:用紫色和物体 / 运镜关键帧区分(选中态仍走下面的 .is-selected 规则),
左右拖动即调整该轨迹点的时间 */
.timeline-keyframe-trajectory {
cursor: ew-resize;
}
.timeline-keyframe-trajectory .timeline-keyframe-dot {
background: #a06ef5;
}

View File

@@ -283,6 +283,17 @@ it("shows icon names as a floating label on viewport toolbar hover and focus", (
expect(css).toMatch(/\.viewport-toolbar-button:hover\s*\.viewport-toolbar-label,\s*[\r\n]+\s*\.viewport-toolbar-button:focus-visible\s*\.viewport-toolbar-label\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?visibility:\s*visible;/);
});
it("keeps the color swatch background from leaking at its rounded corners", () => {
const css = readFileSync("src/styles/index.css", "utf8");
const swatchRule = css.match(/\.inspector-color-swatch\s*\{(?<body>[\s\S]*?)\}/)?.groups?.body ?? "";
// 圆角靠元素自身的 clip 收口;底色不能是强调蓝 ——
// 内层取色块在圆角处的抗锯齿边缘是半透明的,用蓝底会在没聚焦时透出一圈蓝边。
expect(swatchRule).toContain("overflow: hidden;");
expect(swatchRule).toContain("border-radius: 8px;");
expect(swatchRule).not.toContain("rgb(var(--accent-rgb))");
});
it("renders viewport object labels without an outline stroke", () => {
const css = readFileSync("src/styles/index.css", "utf8");
const labelRule = css.match(/\.role-label\s*\{(?<body>[\s\S]*?)\}/)?.groups?.body ?? "";