diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx
index d294637..a7e55f5 100644
--- a/src/editor/panels/TimelinePanel.test.tsx
+++ b/src/editor/panels/TimelinePanel.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, it, expect, vi } from "vitest";
import { act, render, screen, fireEvent, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { useDirectorStore, createDefaultDirectorProject } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import {
@@ -14,6 +15,7 @@ import {
} from "./TimelinePanel";
import { DEFAULT_TIMELINE_FPS } from "../schema/timeline";
import { TrajectoryPointPanel } from "./TrajectoryPointPanel";
+import { CameraPanel } from "./CameraPanel";
function getDefaultCharacter() {
return useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
@@ -780,6 +782,28 @@ it("drags a single trajectory keyframe to change only its own time", () => {
expect(screen.getByRole("button", { name: "轨迹关键帧 3.0s" })).toBeInTheDocument();
});
+it("snaps a dragged trajectory keyframe to the nearest whole frame", () => {
+ const character = getDefaultCharacter();
+ useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
+ useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
+ useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
+
+ render();
+ stubTrackRowLayout({}, 500);
+
+ // 第一个轨迹点落在 1.5s(= 第 45 帧,30fps)。只把它拖约 0.02s(2px)到 1.52s 这个非整帧位置,
+ // 应被吸附到最近的整帧(第 46 帧 ≈ 1.5333s)。
+ const firstKeyframe = screen.getByRole("button", { name: "轨迹关键帧 1.5s" });
+ fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 150 });
+ fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 152 });
+ fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 152 });
+
+ const points = getTrajectoryPoints(character.id);
+ expect(points[0].time).toBeCloseTo(46 / 30, 5);
+ // 离播放头 / 其它片段边缘都很远,所以这次吸附来自整帧网格而非其它基准
+ expect(points[1].time).toBeCloseTo(3, 5);
+});
+
it("nudges the trajectory hold duration with the keyboard", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
@@ -1234,6 +1258,80 @@ it("jumps to the first and last frame from the transport controls", () => {
expect(usePlaybackStore.getState().playhead).toBe(5);
});
+it("edits a selected camera keyframe's look rotation and commits it back to the keyframe", async () => {
+ const cameraObject = useDirectorStore.getState().project.objects.find((o) => o.kind === "camera")!;
+ useDirectorStore.getState().selectObject(cameraObject.id);
+ const cameraId = cameraObject.linkedCameraId!;
+ useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
+ position: [0, 1.5, 5],
+ lookRotation: [0, 0, 0],
+ fov: 50,
+ });
+ useDirectorStore.getState().addCameraKeyframe(cameraId, 4, {
+ position: [0, 1.5, 5],
+ lookRotation: [0, 90, 0],
+ fov: 50,
+ });
+
+ const user = userEvent.setup();
+ render();
+ render();
+
+ const firstKeyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" });
+ await user.click(firstKeyframe);
+
+ const yaw = screen.getByLabelText("机位水平角");
+ await user.clear(yaw);
+ await user.type(yaw, "45");
+
+ await user.click(screen.getByRole("button", { name: "更新选中关键帧" }));
+
+ const track = useDirectorStore
+ .getState()
+ .project.timeline.cameraTracks.find((t) => t.cameraId === cameraId)!;
+ const kf = track.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!;
+ // 期望:在关键帧上改的水平角被写回该关键帧
+ expect(kf.value.lookRotation[1]).toBeCloseTo(45, 5);
+});
+
+it("commits a camera keyframe edit even without a selected camera object (camera view mode)", async () => {
+ const cameraObject = useDirectorStore.getState().project.objects.find((o) => o.kind === "camera")!;
+ const cameraId = cameraObject.linkedCameraId!;
+ useDirectorStore.getState().selectObject(null);
+ useDirectorStore.setState({ viewMode: "camera" });
+ useDirectorStore.getState().setActiveCamera(cameraId);
+ useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
+ position: [0, 1.5, 5],
+ lookRotation: [0, 0, 0],
+ fov: 50,
+ });
+ useDirectorStore.getState().addCameraKeyframe(cameraId, 4, {
+ position: [0, 1.5, 5],
+ lookRotation: [0, 90, 0],
+ fov: 50,
+ });
+
+ const user = userEvent.setup();
+ render();
+ render();
+
+ const firstKeyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" });
+ await user.click(firstKeyframe);
+
+ const yaw = screen.getByLabelText("机位水平角");
+ await user.clear(yaw);
+ await user.type(yaw, "45");
+
+ await user.click(screen.getByRole("button", { name: "更新选中关键帧" }));
+
+ const track = useDirectorStore
+ .getState()
+ .project.timeline.cameraTracks.find((t) => t.cameraId === cameraId)!;
+ const kf = track.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!;
+ // 没选中机位物体时 selectedShot 为空,原本提交会读错机位而写不回;修复后应写回该关键帧
+ expect(kf.value.lookRotation[1]).toBeCloseTo(45, 5);
+});
+
it("shows the frame readout with the frame rate from the settings", () => {
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60 });
usePlaybackStore.getState().setPlayhead(1.5);
diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx
index 07d5989..aa3f653 100644
--- a/src/editor/panels/TimelinePanel.tsx
+++ b/src/editor/panels/TimelinePanel.tsx
@@ -46,7 +46,7 @@ import {
type CameraKeyframeValue,
type KeyframeTransformValue,
} from "../schema/timeline";
-import type { MotionKind } from "../schema/directorProject";
+import type { MotionClip, MotionKind } from "../schema/directorProject";
import {
TRAJECTORY_POINT_MIN_GAP,
getMotionEndTime,
@@ -207,6 +207,8 @@ type TimelineTrackKind = "object" | "camera" | "character";
/** 轨道拖拽的启动阈值(px):小于它当作普通点击,避免抢走关键帧的选中点击 */
const TRACK_DRAG_THRESHOLD_PX = 4;
+/** 时间滑块(片段 / 关键帧)吸附阈值(px):拖动的边缘进入其它片段边缘 / 播放头这么近就吸上去 */
+const SNAP_THRESHOLD_PX = 8;
/** 轨道行的纵向几何(拖拽开始时量一次就缓存,移动中只查表) */
interface TrackRowRect {
@@ -318,6 +320,7 @@ export function TimelinePanel() {
const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue);
const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes);
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
+ const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
const setTimelineDuration = useDirectorStore((state) => state.setTimelineDuration);
const clearAllKeyframes = useDirectorStore((state) => state.clearAllKeyframes);
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
@@ -375,6 +378,8 @@ export function TimelinePanel() {
const [draggingTrackKey, setDraggingTrackKey] = useState(null);
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
const dropIndicatorRef = useRef(null);
+ /** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */
+ const snapIndicatorRef = useRef(null);
/** 当前这一次指针手势(按下 → 超过阈值才算真的开始拖拽,否则当普通点击放行) */
const dragGestureRef = useRef<{
pointerId: number;
@@ -402,6 +407,8 @@ export function TimelinePanel() {
endEdge: number;
startClientX: number;
secondsPerPixel: number;
+ /** 拖拽开始时该角色的全部动作片段(用于「拖动换位」:依据原始布局做确定性重排) */
+ startClips?: MotionClip[];
} | null>(null);
/** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */
const trackRectsRef = useRef(new Map());
@@ -553,6 +560,9 @@ export function TimelinePanel() {
setPlaying(false);
setPlayhead(time);
applyCameraKeyframeToShot(cameraId, value);
+ // 把该机位设为当前编辑机位:机位面板(CameraPanel)始终按 activeCameraId 编辑,
+ // 否则在「机位视角」或当前激活机位与选中物体不一致时,编辑的是别的机位,改动写不回这个关键帧。
+ setActiveCamera(cameraId);
setSelectedKeyframe({ kind: "camera", targetId: cameraId, keyframeId });
}
@@ -560,8 +570,17 @@ export function TimelinePanel() {
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object" && selectedObject) {
updateKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotObjectTransform());
- } else if (selectedKeyframe.kind === "camera" && selectedShot) {
- updateCameraKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotShot());
+ } else if (selectedKeyframe.kind === "camera") {
+ // 直接读「这个关键帧所属机位」的当前机位值写回,而不是 selectedShot
+ // (selectedShot 是「选中物体关联的机位」,在机位视角 / 激活机位与选中物体不一致时会对错机位,导致改了角度却写不回关键帧)。
+ const camera = project.cameras.find((c) => c.id === selectedKeyframe.targetId);
+ if (camera) {
+ updateCameraKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, {
+ position: [...camera.transform.position] as [number, number, number],
+ lookRotation: [...camera.lookRotation] as [number, number, number],
+ fov: camera.fov,
+ });
+ }
}
}
function handleDeleteSelected() {
@@ -831,17 +850,28 @@ export function TimelinePanel() {
}
/**
- * 点轨道空白处 = 取消片段选中。
- * 片段 / 关键帧自己的手势里会 stopPropagation,正常情况冒泡不到这里;
+ * 点轨道空白处 = 取消所有已选中的片段 / 关键帧 / 轨迹点 / 动作片段。
+ * 片段 / 关键帧 / 各类手柄自己的手势里会 stopPropagation,正常情况冒泡不到这里;
* 但手势可能因为尺寸未就绪等原因提前返回(那时还没走到 stopPropagation),
- * 所以这里再按事件来源兜一层,避免把刚选中的片段立刻清掉。
+ * 所以这里再按事件来源兜一层,避免把刚选中的条目立刻清掉。
*/
function handleTimelineGridPointerDown(event: ReactPointerEvent) {
- if (event.target instanceof Element && event.target.closest(".timeline-trajectory-bar, .timeline-keyframe")) {
+ // 点在交互元素(片段条 / 关键帧 / 拖拽把手 / 删除按钮 / 添加按钮 / 折叠开关)上不算空白处,
+ // 那些手势自己会处理选中,这里不能清掉它们。
+ if (
+ event.target instanceof Element &&
+ event.target.closest(
+ ".timeline-trajectory-bar, .timeline-keyframe, .timeline-trajectory-trim, .timeline-clip-delete, .timeline-add-clip-button, .timeline-track-icon-button"
+ )
+ ) {
return;
}
+ // 点轨道空白处:取消所有已选中项,让右侧编辑面板回到未选中态。
setSelectedClip(null);
+ setSelectedKeyframe(null);
+ selectTrajectoryPoint(null, null);
+ selectMotionClip(null);
}
/**
@@ -915,7 +945,8 @@ export function TimelinePanel() {
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
- deltaSeconds: number
+ deltaSeconds: number,
+ originalClips?: MotionClip[]
) {
const endEdge = times.length > 0 ? times[times.length - 1] : startEdge;
const minShift = -startEdge;
@@ -940,7 +971,8 @@ export function TimelinePanel() {
owner.clipId,
startEdge + shift,
times[times.length - 1] + shift,
- times[0]
+ times[0],
+ originalClips
);
return;
}
@@ -1026,6 +1058,11 @@ export function TimelinePanel() {
endEdge,
startClientX: event.clientX,
secondsPerPixel: duration / laneWidth,
+ // 拖动作片段本体时记录按下时刻的全部片段,供「拖动换位」使用
+ startClips:
+ owner.kind === "motionClip"
+ ? (project.objects.find((o) => o.id === owner.objectId)?.characterRig?.motionClips ?? []).map((c) => ({ ...c }))
+ : undefined,
};
// 拖动时冻结全站毛玻璃:条 / 关键帧每次重绘都会让背后 WebGL 画布重算一次模糊
@@ -1045,24 +1082,27 @@ export function TimelinePanel() {
event.preventDefault();
event.stopPropagation();
- const deltaTime = (event.clientX - drag.startClientX) * drag.secondsPerPixel;
+ const rawDelta = (event.clientX - drag.startClientX) * drag.secondsPerPixel;
+ // 吸附:把要移动的边缘吸到其它片段边缘 / 播放头(磁性对齐)
+ const { delta, snappedTime } = computeSnappedDelta(drag, rawDelta);
+ showSnapIndicator(snappedTime);
if (drag.kind === "duration") {
- scaleClipDuration(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
+ scaleClipDuration(drag.owner, drag.startTimes, drag.startEdge, delta);
return;
}
if (drag.kind === "clip") {
- shiftClip(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
+ shiftClip(drag.owner, drag.startTimes, drag.startEdge, delta, drag.startClips);
return;
}
if (drag.kind === "trimStart") {
- trimClipStart(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
+ trimClipStart(drag.owner, drag.startTimes, drag.startEdge, delta);
return;
}
- moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime, drag.endEdge);
+ moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, delta, drag.endEdge);
}
function handleTimeDragEnd(event: ReactPointerEvent) {
@@ -1070,6 +1110,7 @@ export function TimelinePanel() {
if (!drag || drag.pointerId !== event.pointerId) return;
timeDragRef.current = null;
+ hideSnapIndicator();
document.body.classList.remove("is-timeline-dragging");
try {
@@ -1097,6 +1138,138 @@ export function TimelinePanel() {
};
}
+ type TimeDragGesture = NonNullable;
+
+ /**
+ * 收集拖拽时可吸附的目标时刻:同轨道(同角色 / 同相机)其它片段的边缘 + 播放头。
+ * 过滤掉被拖元素自身当前的边缘 / 关键帧时刻,避免「吸回原处」导致无法小幅移动。
+ */
+ function buildSnapTargets(drag: TimeDragGesture): number[] {
+ const targets: number[] = [playhead];
+ const { owner } = drag;
+ if (owner.kind === "motionClip") {
+ const obj = project.objects.find((o) => o.id === owner.objectId);
+ for (const c of obj?.characterRig?.motionClips ?? []) {
+ if (c.id === owner.clipId) continue;
+ targets.push(c.start);
+ if (Number.isFinite(c.end)) targets.push(c.end);
+ }
+ } else if (owner.kind === "trajectory") {
+ const track = project.timeline.tracks.find((t) => t.objectId === owner.objectId);
+ for (const k of track?.keyframes ?? []) {
+ if (k.id === owner.pointId) continue;
+ targets.push(k.time);
+ }
+ } else if (owner.kind === "camera") {
+ const track = project.timeline.cameraTracks.find((t) => t.cameraId === owner.cameraId);
+ for (const k of track?.keyframes ?? []) targets.push(k.time);
+ } else if (owner.kind === "motion") {
+ const obj = project.objects.find((o) => o.id === owner.objectId);
+ if (obj) {
+ const start = getMotionStartTime(obj, duration);
+ const end = getMotionEndTime(obj, duration, start);
+ targets.push(start, end);
+ }
+ }
+
+ const selfTimes: number[] = [];
+ if (drag.kind === "clip") selfTimes.push(drag.startEdge, drag.endEdge);
+ else if (drag.kind === "trimStart") selfTimes.push(drag.startEdge);
+ else if (drag.kind === "duration") selfTimes.push(drag.endEdge);
+ else if (drag.kind === "point") {
+ const t = drag.startTimes[drag.index];
+ if (typeof t === "number") selfTimes.push(t);
+ }
+ return targets.filter((tt) => !selfTimes.some((s) => Math.abs(tt - s) < 1e-6));
+ }
+
+ /**
+ * 收集「网格类」吸附目标:整帧边界(k/fps)+ 刻度网格主刻度线(与车道里画出的竖网格线一致)。
+ * 这是一组「静态基准线」,和动态的同轨片段边缘 / 播放头互补:离后者近时优先吸到后者
+ * (保留原有磁性对齐,可对齐到非整帧位置),否则吸到最近的整帧 / 刻度线。
+ */
+ function buildGridSnapTargets(): number[] {
+ const targets: number[] = [];
+ // 刻度网格:主刻度线(与车道背景的竖网格线一致),便于吸附对齐到可见网格
+ for (const tick of getTimelineRulerTicks(duration)) {
+ targets.push(tick.value);
+ }
+ // 整帧:每帧边界(k/fps),视频按帧导出,关键帧落在整帧上更自然
+ const frameCount = Math.floor(duration * fps + 1e-6);
+ for (let k = 0; k <= frameCount; k += 1) {
+ const tick = k / fps;
+ if (tick <= duration + 1e-6) targets.push(tick);
+ }
+ return targets;
+ }
+
+ /**
+ * 把候选时刻吸附到最近的目标(在阈值内),否则原样返回。
+ */
+ function snapTimeToTargets(candidate: number, targets: number[], thresholdSec: number): number {
+ let best = candidate;
+ let bestDist = thresholdSec;
+ for (const t of targets) {
+ const d = Math.abs(candidate - t);
+ if (d < bestDist) {
+ bestDist = d;
+ best = t;
+ }
+ }
+ return best;
+ }
+
+ /**
+ * 根据拖拽手势与原始位移算出「吸附后的位移」以及被吸附到的时刻(未吸附则为 null)。
+ * 条本体 / 左端把手吸附起点;右端把手吸附终点;关键帧吸附其自身时刻。
+ */
+ function computeSnappedDelta(drag: TimeDragGesture, rawDelta: number): { delta: number; snappedTime: number | null } {
+ const thresholdSec = SNAP_THRESHOLD_PX * drag.secondsPerPixel;
+ let candidate: number;
+ let base: number;
+ if (drag.kind === "clip" || drag.kind === "trimStart") {
+ candidate = drag.startEdge + rawDelta;
+ base = drag.startEdge;
+ } else if (drag.kind === "duration") {
+ candidate = drag.endEdge + rawDelta;
+ base = drag.endEdge;
+ } else {
+ const t = drag.startTimes[drag.index] ?? 0;
+ candidate = t + rawDelta;
+ base = t;
+ }
+ // 第一阶段:吸附到同轨其它片段边缘 / 播放头(保留原有磁性对齐,可对齐到非整帧位置)。
+ const primarySnapped = snapTimeToTargets(candidate, buildSnapTargets(drag), thresholdSec);
+ let snapped = primarySnapped;
+ let didSnap = Math.abs(snapped - candidate) > 1e-6;
+ // 第二阶段:未吸到片段边缘 / 播放头时,再吸附到整帧 / 刻度网格(对齐到可见网格与帧边界)。
+ if (!didSnap) {
+ const gridSnapped = snapTimeToTargets(candidate, buildGridSnapTargets(), thresholdSec);
+ if (Math.abs(gridSnapped - candidate) > 1e-6) {
+ snapped = gridSnapped;
+ didSnap = true;
+ }
+ }
+ return { delta: snapped - base, snappedTime: didSnap ? snapped : null };
+ }
+
+ /** 显示 / 隐藏吸附参考线(直接改样式,不触发 React 重绘) */
+ function showSnapIndicator(time: number | null) {
+ const el = snapIndicatorRef.current;
+ if (!el) return;
+ if (time === null || duration <= 0) {
+ el.classList.remove("is-visible");
+ return;
+ }
+ const percent = Math.min(100, Math.max(0, (time / duration) * 100));
+ el.style.left = `${percent}%`;
+ el.classList.add("is-visible");
+ }
+
+ function hideSnapIndicator() {
+ snapIndicatorRef.current?.classList.remove("is-visible");
+ }
+
/** 时长把手支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
function handleTimeHandleKeyDown(
event: ReactKeyboardEvent,
@@ -2076,6 +2249,8 @@ export function TimelinePanel() {
+ {/* 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示 */}
+
diff --git a/src/editor/runtime/CharacterModel.test.tsx b/src/editor/runtime/CharacterModel.test.tsx
index ce10481..dd17f21 100644
--- a/src/editor/runtime/CharacterModel.test.tsx
+++ b/src/editor/runtime/CharacterModel.test.tsx
@@ -3,14 +3,14 @@ import { vi } from "vitest";
import { CharacterModel } from "./CharacterModel";
vi.mock("./UE4MannequinModel", () => ({
- UE4MannequinModel: ({ bodyType }: { bodyType?: string }) => (
-
+ UE4MannequinModel: ({ bodyType, rigState }: { bodyType?: string; rigState?: { motion?: { kind?: string } } }) => (
+
),
}));
vi.mock("./PrimitiveMannequin", () => ({
- PrimitiveMannequin: ({ bodyType }: { bodyType?: string }) => (
-
+ PrimitiveMannequin: ({ bodyType, rigState }: { bodyType?: string; rigState?: { motion?: { kind?: string } } }) => (
+
),
}));
@@ -47,3 +47,64 @@ it("keeps the procedural mannequin fallback for non-UE4 rigs", () => {
expect(screen.getByTestId("mock-procedural-mannequin")).toHaveAttribute("data-body-type", "chibi");
expect(screen.queryByTestId("mock-ue4-mannequin")).not.toBeInTheDocument();
});
+
+it("holds the character static when the clip sequence has no clip at the current time", () => {
+ const { rerender } = render(
+
+ );
+
+ // t=3 落在两个片段之间的空隙 → 静止(none),而非沿用默认 walk
+ expect(screen.getByTestId("mock-ue4-mannequin")).toHaveAttribute("data-motion-kind", "none");
+
+ // t=6 落在 run 片段内 → 使用 run
+ rerender(
+
+ );
+ expect(screen.getByTestId("mock-ue4-mannequin")).toHaveAttribute("data-motion-kind", "run");
+});
+
+it("keeps the legacy single-motion behavior when no clip sequence is provided", () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId("mock-ue4-mannequin")).toHaveAttribute("data-motion-kind", "walk");
+});
diff --git a/src/editor/runtime/CharacterModel.tsx b/src/editor/runtime/CharacterModel.tsx
index 780bc71..55b25c3 100644
--- a/src/editor/runtime/CharacterModel.tsx
+++ b/src/editor/runtime/CharacterModel.tsx
@@ -1,5 +1,5 @@
import { Component, type ReactNode } from "react";
-import type { CharacterRigState, MotionClip } from "../schema/directorProject";
+import type { CharacterMotionState, CharacterRigState, MotionClip } from "../schema/directorProject";
import { PrimitiveMannequin } from "./PrimitiveMannequin";
import { UE4MannequinModel } from "./UE4MannequinModel";
import { GltfCharacterModel } from "./GltfCharacterModel";
@@ -47,13 +47,20 @@ class CharacterModelBoundary extends Component<
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId, motionStartTime, motionEndTime, modelAssetUrl, motionClips, duration }: CharacterModelProps) {
// 动作序列:按当前播放时间取出应生效的片段(后段覆盖前段),用它覆盖单一 motion 字段。
- // 无片段 / 时间不在任何片段内 → 回退到旧的单一段落(rigState.motion + motionStartTime/EndTime)。
+ // 处于时间轴片段序列模式、但当前时间没有任何片段覆盖时 → 人物保持静止(none),而非沿用默认动作。
+ // 仅在「未启用片段序列」的 legacy 模式下,才回退到旧的单一段落(rigState.motion + motionStartTime/EndTime)。
+ const usingSequence = Array.isArray(motionClips);
const activeClip = getActiveMotionClipAtTime(motionClips, motionTime ?? 0, duration);
- const resolvedRigState: CharacterRigState | undefined = activeClip && rigState
- ? { ...rigState, motion: activeClip }
- : rigState;
- const resolvedStart = activeClip ? activeClip.start : motionStartTime;
- const resolvedEnd = activeClip ? resolveMotionClipEnd(activeClip, duration) : motionEndTime;
+ const staticInSequence = usingSequence && !activeClip;
+ const STATIC_MOTION: CharacterMotionState = { kind: "none", speed: 1, amplitude: 1, enabled: true };
+ const resolvedRigState: CharacterRigState | undefined =
+ activeClip && rigState
+ ? { ...rigState, motion: activeClip }
+ : staticInSequence && rigState
+ ? { ...rigState, motion: STATIC_MOTION }
+ : rigState;
+ const resolvedStart = activeClip ? activeClip.start : staticInSequence ? motionTime : motionStartTime;
+ const resolvedEnd = activeClip ? resolveMotionClipEnd(activeClip, duration) : staticInSequence ? motionTime : motionEndTime;
const fallback = ;
diff --git a/src/editor/store/directorStore.motionClip.test.ts b/src/editor/store/directorStore.motionClip.test.ts
index 3a4606d..ac135b9 100644
--- a/src/editor/store/directorStore.motionClip.test.ts
+++ b/src/editor/store/directorStore.motionClip.test.ts
@@ -15,7 +15,7 @@ beforeEach(() => {
});
});
-describe("动作片段拖动不重叠(可交换位置)", () => {
+describe("动作片段拖动不重叠(重叠时紧贴到就近一侧)", () => {
function setupTwoClips() {
const s = useDirectorStore.getState();
// 给足够长的时间轴,避免片段被默认较短的时长截断,干扰重叠推挤逻辑的验证
@@ -27,32 +27,206 @@ describe("动作片段拖动不重叠(可交换位置)", () => {
return { a: clips[0].id, b: clips[1].id };
}
- it("整段右移穿过另一段:两者不重叠,被压片段被推到左侧", () => {
+ it("整段右移穿过另一段:被拖片段紧贴放在被压片段之后(首尾相接、不重叠)", () => {
const { a, b } = setupTwoClips();
useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 6, 11, 0);
const clipA = getClips("char_default_a").find((c) => c.id === a)!;
const clipB = getClips("char_default_a").find((c) => c.id === b)!;
- expect(clipA.start).toBe(6);
- expect(clipB.end).toBeLessThanOrEqual(clipA.start);
- expect(clipB.start).toBeGreaterThanOrEqual(0);
+ expect(clipA.start).toBe(clipB.end);
+ expect(clipA.end).toBeLessThanOrEqual(30);
});
- it("整段左移穿过另一段:被压片段被推到右侧", () => {
+ it("整段左移穿过另一段:被拖片段贴到被压片段就近一侧、不重叠", () => {
const { a, b } = setupTwoClips();
useDirectorStore.getState().updateMotionClipTime("char_default_a", b, 0, 4, 5);
const clipA = getClips("char_default_a").find((c) => c.id === a)!;
const clipB = getClips("char_default_a").find((c) => c.id === b)!;
- expect(clipB.start).toBe(0);
- expect(clipA.start).toBeGreaterThanOrEqual(clipB.end);
+ // 自身左端没有空间,于是贴到 A 之后;两段不重叠。
+ expect(clipA.end).toBeLessThanOrEqual(clipB.start);
});
- it("右端把手扩张进入另一段:被压片段被挤到右侧", () => {
+ it("右端把手扩张进入另一段:整段被推到被压片段之后", () => {
const { a, b } = setupTwoClips();
- // 拖动 A 的右端把手从 5 扩张到 8(进入 B[5,10]),起点不变 → 推 B 到右侧
+ // 拖动 A 的右端把手从 5 扩张到 8(进入 B[5,10]),与 B 重叠 → 整段贴到 B 之后。
useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 0, 8, 0);
const clipA = getClips("char_default_a").find((c) => c.id === a)!;
const clipB = getClips("char_default_a").find((c) => c.id === b)!;
- expect(clipA.end).toBe(8);
- expect(clipB.start).toBeGreaterThanOrEqual(clipA.end);
+ expect(clipA.start).toBe(clipB.end);
+ expect(clipA.end).toBeLessThanOrEqual(30);
+ });
+
+ it("轻微重叠:被拖片段贴到就近一侧、不与任何片段重叠", () => {
+ const { a, b } = setupTwoClips();
+ // A[0,5] 右移到 [2,7],与 B[5,10] 轻微重叠 → 贴到 B 之前(回到 [0,5])。
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 2, 7, 0);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ expect(clipA.start).toBe(0);
+ expect(clipA.end).toBeLessThanOrEqual(clipB.start);
+ });
+
+ it("有限片段拖入「到末尾」的无限长片段:贴到其之前、不重叠", () => {
+ const s = useDirectorStore.getState();
+ s.setTimelineDuration(30);
+ // A 有限 [0,5],B 是「到末尾」无限长 [5,∞)(默认 addMotionClip 的终点就是这个)
+ s.addMotionClip("char_default_a", { kind: "walk", start: 0, end: 5 });
+ s.addMotionClip("char_default_a", { kind: "walk", start: 5 });
+ const clips = getClips("char_default_a");
+ const a = clips[0].id;
+ const b = clips[1].id;
+ // 把 A 右拖到 [8,13],压在无限长 B 上 → 应贴回 B 之前 [0,5],而不是被吸到末尾重叠
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 8, 13, 0);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ expect(clipA.end).toBeLessThanOrEqual(clipB.start);
+ expect(clipA.start).toBe(0);
+ });
+
+ it("无限长片段拖入有限片段:只能贴到被压片段之后、不重叠", () => {
+ const s = useDirectorStore.getState();
+ s.setTimelineDuration(30);
+ // A 有限 [0,5],B 无限长 [5,∞)
+ s.addMotionClip("char_default_a", { kind: "walk", start: 0, end: 5 });
+ s.addMotionClip("char_default_a", { kind: "walk", start: 5 });
+ const clips = getClips("char_default_a");
+ const a = clips[0].id;
+ const b = clips[1].id;
+ // 把无限长的 B 左拖到起点 2(变成 [2,∞)),压在 A 上 → 应贴到 A 之后 [5,∞)
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", b, 2, Number.POSITIVE_INFINITY, 5);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ expect(clipB.start).toBe(clipA.end);
+ expect(clipB.end).toBe(Number.POSITIVE_INFINITY);
+ });
+
+ it("多段相邻:被拖片段穿过中间两段仍落稳、全程不重叠", () => {
+ const s = useDirectorStore.getState();
+ s.setTimelineDuration(30);
+ s.addMotionClip("char_default_a", { kind: "walk", start: 0, end: 5 });
+ s.addMotionClip("char_default_a", { kind: "walk", start: 5, end: 10 });
+ s.addMotionClip("char_default_a", { kind: "walk", start: 10, end: 15 });
+ const clips = getClips("char_default_a");
+ const a = clips[0].id;
+ // 把开头这段拖到 [7,12],同时压住中段与末段 → 迭代解析后应贴到末段之后 [15,20]
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 7, 12, 0);
+ const all = getClips("char_default_a");
+ for (let i = 0; i < all.length; i += 1) {
+ for (let j = i + 1; j < all.length; j += 1) {
+ const x = all[i];
+ const y = all[j];
+ const overlap = x.start < y.end && y.start < x.end;
+ expect(overlap).toBe(false);
+ }
+ }
+ const clipA = all.find((c) => c.id === a)!;
+ expect(clipA.start).toBe(15);
+ });
+});
+
+describe("动作片段拖动换位(越过邻居即交换位置、可重排)", () => {
+ function setupConnected(durations: Array<[number, number]>) {
+ const s = useDirectorStore.getState();
+ s.setTimelineDuration(30);
+ const ids: string[] = [];
+ for (const [start, end] of durations) {
+ s.addMotionClip("char_default_a", { kind: "walk", start, end });
+ const clips = getClips("char_default_a");
+ ids.push(clips[clips.length - 1].id);
+ }
+ return ids;
+ }
+
+ it("相邻等长两段:拖右越过邻居中点后才交换、无重叠", () => {
+ const [a, b] = setupConnected([
+ [0, 5],
+ [5, 10],
+ ]);
+ const original = getClips("char_default_a").map((c) => ({ ...c }));
+ // A[0,5] 右拖到 [8,13],越过 B 的中点(7.5) → 交换:A 到 [5,10],B 到 [0,5]
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 8, 13, 0, original);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ expect(clipA.start).toBe(5);
+ expect(clipA.end).toBe(10);
+ expect(clipB.start).toBe(0);
+ expect(clipB.end).toBe(5);
+ });
+
+ it("相邻等长两段:只轻微重叠(未过半)不交换、贴回原处", () => {
+ const [a, b] = setupConnected([
+ [0, 5],
+ [5, 10],
+ ]);
+ const original = getClips("char_default_a").map((c) => ({ ...c }));
+ // A[0,5] 只拖到 [6,11],未越过 B 的中点(7.5) → 不交换,应贴回 [0,5]
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 6, 11, 0, original);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ expect(clipA.start).toBe(0);
+ expect(clipA.end).toBe(5);
+ });
+
+ it("相邻等长两段:从右往左拖越过邻居中点后同样交换", () => {
+ const [a, b] = setupConnected([
+ [0, 5],
+ [5, 10],
+ ]);
+ const original = getClips("char_default_a").map((c) => ({ ...c }));
+ // B[5,10] 左拖到 [2,7],越过 A 的中点(2.5) → 交换:B 到 [0,5],A 到 [5,10]
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", b, 2, 7, 5, original);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ expect(clipB.start).toBe(0);
+ expect(clipA.start).toBe(5);
+ });
+
+ it("不等长两段:越过较长邻居中点才交换,各自保留时长、不重叠", () => {
+ const [a, b] = setupConnected([
+ [0, 5],
+ [5, 12],
+ ]);
+ const original = getClips("char_default_a").map((c) => ({ ...c }));
+ // A 右拖到 [9,14],越过 B 的中点(8.5)才交换:B 向左平移、A 接在后面,仍不重叠
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 9, 14, 0, original);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ const overlap = clipA.start < clipB.end && clipB.start < clipA.end;
+ expect(overlap).toBe(false);
+ // A 落在 B 之后(已被换位到末尾)
+ expect(clipA.start).toBeGreaterThanOrEqual(clipB.end - 1e-6);
+ });
+
+ it("三段:把开头那段一路拖过中间两段中点,重排到末尾、全程不重叠", () => {
+ const [a, b, c] = setupConnected([
+ [0, 5],
+ [5, 10],
+ [10, 15],
+ ]);
+ const original = getClips("char_default_a").map((c) => ({ ...c }));
+ // A 拖到 [13,18],越过 B(7.5) 与 C(12.5) 的中点 → 排到末尾
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 13, 18, 0, original);
+ const all = getClips("char_default_a");
+ for (let i = 0; i < all.length; i += 1) {
+ for (let j = i + 1; j < all.length; j += 1) {
+ const x = all[i];
+ const y = all[j];
+ expect(x.start < y.end && y.start < x.end).toBe(false);
+ }
+ }
+ const clipA = all.find((c) => c.id === a)!;
+ expect(clipA.start).toBe(10);
+ });
+
+ it("没压到任何片段时自由移动,其余片段位置不变", () => {
+ const [a, b] = setupConnected([
+ [0, 5],
+ [10, 15],
+ ]);
+ const original = getClips("char_default_a").map((c) => ({ ...c }));
+ // A 拖到 [20,25](落在 B 右边的空档),不应触发换位
+ useDirectorStore.getState().updateMotionClipTime("char_default_a", a, 20, 25, 0, original);
+ const clipA = getClips("char_default_a").find((c) => c.id === a)!;
+ const clipB = getClips("char_default_a").find((c) => c.id === b)!;
+ expect(clipA.start).toBe(20);
+ expect(clipB.start).toBe(10);
});
});
diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts
index 1db9054..e109978 100644
--- a/src/editor/store/directorStore.ts
+++ b/src/editor/store/directorStore.ts
@@ -194,8 +194,17 @@ export interface DirectorActions {
clipId: string,
patch: Partial>
) => void;
- /** 整体平移 / 缩放某个动作片段的时间区间(拖拽条本体或两端把手时写入)。 */
- updateMotionClipTime: (objectId: string, clipId: string, start: number, end: number, oldStart?: number) => void;
+ /** 整体平移 / 缩放某个动作片段的时间区间(拖拽条本体或两端把手时写入)。
+ * originalClips 传「拖拽开始时该角色的全部动作片段」即可开启「拖动换位」:被拖片段越过谁就与谁交换位置;
+ * 不传则退回原来的「紧贴就近一侧」解析(修修剪剪、程序化写入等场景)。 */
+ updateMotionClipTime: (
+ objectId: string,
+ clipId: string,
+ start: number,
+ end: number,
+ oldStart?: number,
+ originalClips?: MotionClip[]
+ ) => void;
/** 删除某个动作片段。 */
removeMotionClip: (objectId: string, clipId: string) => void;
/** 选中 / 取消选中某个动作片段;clipId 为 null 表示取消。 */
@@ -1563,6 +1572,174 @@ function trimUndoStack(stack: DirectorState[]) {
return stack.length > UNDO_STACK_LIMIT ? stack.slice(stack.length - UNDO_STACK_LIMIT) : stack;
}
+/**
+ * 动作轨道不允许重叠:把 clipId 这段紧贴到与其重叠片段的「最近一侧」(之前 / 之后),
+ * 迭代处理直到与所有片段都不重叠。正确处理无限长片段(片段终点默认到时间轴末尾):
+ * - 被拖片段无限长:只能放在被压片段之后(否则必然覆盖它);
+ * - 被压片段无限长:被拖片段有限时长时放在其之前(终点贴被压片段起点)。
+ */
+function resolveMotionClipOverlap(
+ clips: MotionClip[],
+ clipId: string,
+ duration: number,
+ desiredStartOverride?: number,
+ desiredEndOverride?: number
+): MotionClip[] {
+ // 若给了期望区间,先一次性把被拖片段放到该位置,再迭代解决与邻居的级联重叠
+ let result = clips;
+ if (desiredStartOverride !== undefined && desiredEndOverride !== undefined) {
+ result = result.map((c) =>
+ c.id === clipId ? { ...c, start: desiredStartOverride, end: desiredEndOverride } : c
+ );
+ }
+ const maxIter = result.length + 2;
+ for (let iter = 0; iter < maxIter; iter += 1) {
+ const dragged = result.find((c) => c.id === clipId);
+ if (!dragged) break;
+ const ov = result.find((c) => c.id !== clipId && c.start < dragged.end && c.end > dragged.start);
+ if (!ov) break;
+ const dFinite = Number.isFinite(dragged.end);
+ const dSpan = dFinite ? dragged.end - dragged.start : duration - dragged.start;
+ const dCenter = dragged.start + dSpan / 2;
+ const oFinite = Number.isFinite(ov.end);
+ const oCenter = ov.start + (oFinite ? (ov.end - ov.start) / 2 : (duration - ov.start) / 2);
+ let ns: number;
+ if (!dFinite) {
+ // 无限长被拖片段只能放在被压片段之后,否则必然覆盖它
+ ns = oFinite ? ov.end : 0;
+ } else {
+ const afterStart = oFinite ? ov.end : duration - dSpan;
+ const afterValid = oFinite && afterStart + dSpan <= duration;
+ const beforeStart = ov.start - dSpan;
+ const beforeValid = beforeStart >= 0;
+ const preferAfter = dCenter >= oCenter;
+ if (preferAfter) ns = afterValid ? afterStart : beforeValid ? beforeStart : Math.max(0, afterStart);
+ else ns = beforeValid ? beforeStart : afterValid ? afterStart : 0;
+ }
+ const ne = dFinite ? ns + dSpan : Number.POSITIVE_INFINITY;
+ result = result.map((c) => (c.id === clipId ? { ...c, start: ns, end: ne } : c));
+ }
+ return result;
+}
+
+/**
+ * 在「不重叠」前提下,把被拖片段与同轨道邻居换位:拖动越过谁就与谁交换位置(被跨过的邻居整体平移,
+ * 各自时长不变、不缩放)。换位阈值取「被拖片段前缘越过邻居中点(过半)」,避免刚碰到边界就跳位;
+ * 未过半时不跳到另一侧,而是挡在所压邻居的当前侧边界上。基于当前布局计算,因此换位后可再拖回。
+ * 任一片段无限长则放弃换位(无限长片段无法塞进某个槽),交回上层走通用解析。
+ */
+function resolveMotionClipReorder(
+ clips: MotionClip[],
+ clipId: string,
+ desiredStart: number,
+ desiredEnd: number,
+ originalClips: MotionClip[],
+ duration: number
+): MotionClip[] | null {
+ const dragged = clips.find((c) => c.id === clipId);
+ if (!dragged || !Number.isFinite(desiredEnd)) return null;
+ const length = desiredEnd - desiredStart;
+
+ const cur = [...clips].sort((a, b) => a.start - b.start);
+ if (cur.some((c) => !Number.isFinite(c.end))) return null;
+ const i = cur.findIndex((c) => c.id === clipId);
+ if (i < 0) return null;
+
+ const n = cur.length;
+ const midpoint = (c: MotionClip) => (c.start + c.end) / 2;
+ // 阈值:被拖片段的「前缘」(desiredStart) 越过邻居「中点」(过半)才交换。
+ // 右移看右侧邻居、左移看左侧邻居,越过谁就与谁交换。
+ const rightPassed = cur.filter(
+ (c) => c.id !== clipId && c.start > cur[i].start && desiredStart > midpoint(c)
+ ).length;
+ const leftPassed = cur.filter(
+ (c) => c.id !== clipId && c.start < cur[i].start && desiredStart < midpoint(c)
+ ).length;
+ const delta = desiredStart - cur[i].start; // 当前位置 → 期望位置:判断拖拽方向
+ let j = delta >= 0 ? i + rightPassed : i - leftPassed;
+ j = Math.max(0, Math.min(j, n - 1));
+
+ if (j === i) {
+ // 未过半:挡在所压邻居的当前侧、不跳到另一侧(轻微重叠即弹到对面的手感不要)。
+ const others = cur.filter((c) => c.id !== clipId);
+ const overlap = others.filter((o) => o.start < desiredEnd && o.end > desiredStart);
+ if (overlap.length === 0) return clips;
+ let start: number;
+ if (delta >= 0) {
+ const right =
+ overlap.filter((o) => o.start >= cur[i].start).sort((a, b) => a.start - b.start)[0] ??
+ overlap.slice().sort((a, b) => a.start - b.start)[0];
+ start = right.start - length;
+ } else {
+ const left =
+ overlap.filter((o) => o.end <= cur[i].start).sort((a, b) => b.end - a.end)[0] ??
+ overlap.slice().sort((a, b) => b.end - a.end)[0];
+ start = left.end;
+ }
+ start = Math.max(0, Math.min(start, duration - length));
+ return clips.map((c) => (c.id === clipId ? { ...c, start, end: start + length } : c));
+ }
+
+ // 受影响片段(被跨过的邻居)整体平移、保留各自时长;它们在段内的顺序,以及被拖片段在段的哪一端,取决于方向。
+ const segClips: MotionClip[] =
+ j > i ? [...cur.slice(i + 1, j + 1), dragged] : [dragged, ...cur.slice(j, i)];
+ // 段的左边界:紧贴被拖片段当前的左邻居(右移)或段内首个被跨片段当前的左邻居(左移)之后。
+ const segLeft = j > i ? (i > 0 ? cur[i - 1].end : 0) : j > 0 ? cur[j - 1].end : 0;
+
+ let pos = segLeft;
+ const newStart = new Map();
+ for (const c of segClips) {
+ newStart.set(c.id, pos);
+ pos += c.end - c.start;
+ }
+ const draggedEnd = newStart.get(clipId)! + length;
+
+ // 兜底:与段外邻居 / 时间轴边界重叠则放弃换位
+ if (draggedEnd > duration + 1e-6) return null;
+ if (j > i && j + 1 < n && draggedEnd > cur[j + 1].start + 1e-6) return null;
+ if (j < i && i + 1 < n && draggedEnd > cur[i + 1].start + 1e-6) return null;
+ if (segLeft < -1e-6) return null;
+
+ const curLength = (id: string) => {
+ const o = cur.find((c) => c.id === id)!;
+ return o.end - o.start;
+ };
+ return clips.map((c) => {
+ if (!newStart.has(c.id)) return c;
+ const s = newStart.get(c.id)!;
+ const len = c.id === clipId ? length : curLength(c.id);
+ return { ...c, start: s, end: s + len };
+ });
+}
+
+/**
+ * 决定被拖片段(当前 clips 里还是上一帧的位置)放到期望区间后的最终布局:
+ * - 期望区间没压到任何片段 → 自由移动,直接落到期望区间;
+ * - 压到了且有拖拽起始布局 → 尝试「过半才交换」的换位;
+ * - 否则(修修剪剪 / 无限长兜底)→ 退回「紧贴就近一侧」的通用解析。
+ */
+function resolveMotionClipArrangement(
+ clips: MotionClip[],
+ clipId: string,
+ desiredStart: number,
+ desiredEnd: number,
+ duration: number,
+ originalClips?: MotionClip[]
+): MotionClip[] {
+ const dragged = clips.find((c) => c.id === clipId);
+ if (!dragged) return clips;
+ // 用「期望区间」判断是否与其它片段冲突(clips 里被拖片段仍是上一帧位置)
+ const desiredOverlaps = clips.some((c) => c.id !== clipId && c.start < desiredEnd && c.end > desiredStart);
+ if (!desiredOverlaps) {
+ return clips.map((c) => (c.id === clipId ? { ...c, start: desiredStart, end: desiredEnd } : c));
+ }
+ if (originalClips) {
+ const reordered = resolveMotionClipReorder(clips, clipId, desiredStart, desiredEnd, originalClips, duration);
+ if (reordered) return reordered;
+ }
+ return resolveMotionClipOverlap(clips, clipId, duration, desiredStart, desiredEnd);
+}
+
export const useDirectorStore = create((set, get) => {
const initialRuntimeState = createRuntimeStateFromPersistedState(
createInitialDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true })
@@ -2093,9 +2270,11 @@ export const useDirectorStore = create((set, get) => {
const nextClips = existing.map((clip) =>
clip.end === Number.POSITIVE_INFINITY && clip.start < start ? { ...clip, end: start } : clip
);
+ const merged = [...nextClips, newClip];
+ // 动作轨道不允许重叠:若新片段与已有片段重叠,紧贴到就近一侧。
return {
...item,
- characterRig: { ...item.characterRig, motionClips: [...nextClips, newClip] },
+ characterRig: { ...item.characterRig, motionClips: resolveMotionClipOverlap(merged, newClip.id, duration) },
};
}),
},
@@ -2120,7 +2299,7 @@ export const useDirectorStore = create((set, get) => {
}),
},
})),
- updateMotionClipTime: (objectId, clipId, start, end, oldStart) =>
+ updateMotionClipTime: (objectId, clipId, start, end, oldStart, originalClips) =>
commitMutation((state) => {
const duration = state.project.timeline.duration;
// 先把起点限制在时间轴内,再保证终点不早于起点(避免拖出时间轴后区间倒挂)。
@@ -2128,50 +2307,23 @@ export const useDirectorStore = create((set, get) => {
const clampedEnd = Number.isFinite(end)
? Math.min(Math.max(end, clampedStart + MOTION_CLIP_MIN_DURATION), duration)
: Number.POSITIVE_INFINITY;
- // 拖拽开始前的起点,用于判断拖动方向(右移 / 左移)。
- const oldS = oldStart !== undefined && Number.isFinite(oldStart) ? Math.max(oldStart, 0) : null;
return {
...state,
project: {
...state.project,
objects: updateObjectById(state.project.objects, objectId, (item) => {
if (!item.characterRig?.motionClips) return item;
- let clips = item.characterRig.motionClips.map((clip) =>
- clip.id === clipId ? { ...clip, start: clampedStart, end: clampedEnd } : clip
+ // 被拖片段在 clips 里仍是上一帧的位置;依据期望区间决定最终布局:
+ // 没压到任何片段就自由移动,压到了就尝试「过半才交换」的换位(originalClips 提供拖拽起始布局),
+ // 否则退回紧贴就近一侧的解析。
+ const clips = resolveMotionClipArrangement(
+ item.characterRig.motionClips,
+ clipId,
+ clampedStart,
+ clampedEnd,
+ duration,
+ originalClips
);
- // 拖动导致与另一片段重叠时,保证不重叠:把被压住的片段「推」到当前片段的另一侧贴边,
- // 从而自然实现「交换位置」——把一段拖过另一段时,两者互换先后,且全程不重叠。
- if (oldS !== null) {
- const overlap = clips.find(
- (c) => c.id !== clipId && c.start < clampedEnd && c.end > clampedStart
- );
- if (overlap) {
- const overlapFinite = Number.isFinite(overlap.end);
- const overlapLen = overlapFinite ? overlap.end - overlap.start : 0;
- // 起点是否变化:整段平移 / 左端把手会动起点;仅右端把手(终点扩张)起点不动。
- const startChanged = Math.abs(clampedStart - oldS) > 1e-6;
- if (!startChanged) {
- // 仅拖动右端把手(终点向右扩张):右侧重叠片段被挤到当前片段右侧。
- const s = clampedEnd;
- const e = overlapFinite
- ? Math.min(clampedEnd + overlapLen, duration)
- : Number.POSITIVE_INFINITY;
- clips = clips.map((c) => (c.id === overlap.id ? { ...c, start: s, end: e } : c));
- } else if (clampedStart >= oldS) {
- // 向右拖(整段或左端把手右移):重叠片段推到当前片段左侧(紧贴其左端)。
- const s = overlapFinite ? Math.max(0, clampedStart - overlapLen) : 0;
- const e = clampedStart;
- clips = clips.map((c) => (c.id === overlap.id ? { ...c, start: s, end: e } : c));
- } else {
- // 向左拖:重叠片段推到当前片段右侧(紧贴其右端,必要时延伸到末尾)。
- const s = clampedEnd;
- const e = overlapFinite
- ? Math.min(clampedEnd + overlapLen, duration)
- : Number.POSITIVE_INFINITY;
- clips = clips.map((c) => (c.id === overlap.id ? { ...c, start: s, end: e } : c));
- }
- }
- }
return {
...item,
characterRig: { ...item.characterRig, motionClips: clips },
diff --git a/src/styles/index.css b/src/styles/index.css
index 0274b8a..a9fdbb8 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -4144,6 +4144,13 @@ textarea:focus-visible {
display: flex;
flex-direction: column;
}
+/* 轨道之间的分隔线:每条主轨道(物体轨道 / 运镜组 / 角色组)底部加一条清晰的分隔线,
+ 方便区分相邻轨道;组内的子轨道仍由各自的 cell 边框分隔。 */
+.timeline-grid-body > .timeline-track,
+.timeline-grid-body > .timeline-camera-track,
+.timeline-grid-body > .timeline-character-track {
+ border-bottom: 1px solid rgb(var(--border-rgb) / 0.5);
+}
.timeline-track {
display: grid;
grid-template-columns: var(--timeline-gutter-width) minmax(0, 1fr);
@@ -4609,6 +4616,23 @@ body.is-timeline-dragging * {
box-shadow: 0 1px 3px rgb(var(--overlay-rgb) / 0.35);
transform: translateX(-50%);
}
+/* 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示的磁吸高亮竖线 */
+.timeline-snap-indicator {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ margin-left: -1px;
+ background: rgb(120 220 255 / 0.9);
+ box-shadow: 0 0 6px rgb(120 220 255 / 0.75);
+ opacity: 0;
+ pointer-events: none;
+ z-index: 5;
+ transition: opacity 0.08s ease-out;
+}
+.timeline-snap-indicator.is-visible {
+ opacity: 1;
+}
/* 关键帧:菱形块 + 悬停/选中时露出删除按钮 */
.timeline-keyframe {