feat(timeline): 支持拖拽调整时长与运镜/轨迹关键帧时间
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
46
src/editor/io/videoExport.test.ts
Normal file
46
src/editor/io/videoExport.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
Reference in New Issue
Block a user