feat(timeline): 增强时间轴拖拽吸附与片段换位
This commit is contained in:
@@ -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(<TimelinePanel />);
|
||||
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(<TimelinePanel />);
|
||||
render(<CameraPanel />);
|
||||
|
||||
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(<TimelinePanel />);
|
||||
render(<CameraPanel />);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
|
||||
const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
/** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */
|
||||
const snapIndicatorRef = useRef<HTMLDivElement | null>(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<string, TrackRowRect>());
|
||||
@@ -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<HTMLDivElement>) {
|
||||
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<HTMLElement>) {
|
||||
@@ -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<typeof timeDragRef.current>;
|
||||
|
||||
/**
|
||||
* 收集拖拽时可吸附的目标时刻:同轨道(同角色 / 同相机)其它片段的边缘 + 播放头。
|
||||
* 过滤掉被拖元素自身当前的边缘 / 关键帧时刻,避免「吸回原处」导致无法小幅移动。
|
||||
*/
|
||||
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<HTMLElement>,
|
||||
@@ -2076,6 +2249,8 @@ export function TimelinePanel() {
|
||||
<div className="timeline-playhead" style={{ left: `${playheadPercent}%` }}>
|
||||
<span className="timeline-playhead-knob" />
|
||||
</div>
|
||||
{/* 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示 */}
|
||||
<div className="timeline-snap-indicator" ref={snapIndicatorRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { vi } from "vitest";
|
||||
import { CharacterModel } from "./CharacterModel";
|
||||
|
||||
vi.mock("./UE4MannequinModel", () => ({
|
||||
UE4MannequinModel: ({ bodyType }: { bodyType?: string }) => (
|
||||
<div data-body-type={bodyType} data-testid="mock-ue4-mannequin" />
|
||||
UE4MannequinModel: ({ bodyType, rigState }: { bodyType?: string; rigState?: { motion?: { kind?: string } } }) => (
|
||||
<div data-body-type={bodyType} data-motion-kind={rigState?.motion?.kind} data-testid="mock-ue4-mannequin" />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./PrimitiveMannequin", () => ({
|
||||
PrimitiveMannequin: ({ bodyType }: { bodyType?: string }) => (
|
||||
<div data-body-type={bodyType} data-testid="mock-procedural-mannequin" />
|
||||
PrimitiveMannequin: ({ bodyType, rigState }: { bodyType?: string; rigState?: { motion?: { kind?: string } } }) => (
|
||||
<div data-body-type={bodyType} data-motion-kind={rigState?.motion?.kind} data-testid="mock-procedural-mannequin" />
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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(
|
||||
<CharacterModel
|
||||
bodyType="female"
|
||||
rigState={{
|
||||
rigType: "ue4-mannequin",
|
||||
posePresetId: "stand",
|
||||
motion: { kind: "walk", speed: 1, amplitude: 1, enabled: true },
|
||||
controls: {},
|
||||
}}
|
||||
motionTime={3}
|
||||
motionClips={[
|
||||
{ id: "a", kind: "walk", speed: 1, amplitude: 1, enabled: true, start: 0, end: 2 },
|
||||
{ id: "b", kind: "run", speed: 1, amplitude: 1, enabled: true, start: 5, end: 8 },
|
||||
]}
|
||||
duration={10}
|
||||
/>
|
||||
);
|
||||
|
||||
// t=3 落在两个片段之间的空隙 → 静止(none),而非沿用默认 walk
|
||||
expect(screen.getByTestId("mock-ue4-mannequin")).toHaveAttribute("data-motion-kind", "none");
|
||||
|
||||
// t=6 落在 run 片段内 → 使用 run
|
||||
rerender(
|
||||
<CharacterModel
|
||||
bodyType="female"
|
||||
rigState={{
|
||||
rigType: "ue4-mannequin",
|
||||
posePresetId: "stand",
|
||||
motion: { kind: "walk", speed: 1, amplitude: 1, enabled: true },
|
||||
controls: {},
|
||||
}}
|
||||
motionTime={6}
|
||||
motionClips={[
|
||||
{ id: "a", kind: "walk", speed: 1, amplitude: 1, enabled: true, start: 0, end: 2 },
|
||||
{ id: "b", kind: "run", speed: 1, amplitude: 1, enabled: true, start: 5, end: 8 },
|
||||
]}
|
||||
duration={10}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<CharacterModel
|
||||
bodyType="female"
|
||||
rigState={{
|
||||
rigType: "ue4-mannequin",
|
||||
posePresetId: "stand",
|
||||
motion: { kind: "walk", speed: 1, amplitude: 1, enabled: true },
|
||||
controls: {},
|
||||
}}
|
||||
motionTime={3}
|
||||
duration={10}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-ue4-mannequin")).toHaveAttribute("data-motion-kind", "walk");
|
||||
});
|
||||
|
||||
@@ -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 = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={resolvedRigState} motionTime={motionTime} characterId={characterId} motionStartTime={resolvedStart} motionEndTime={resolvedEnd} />;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,8 +194,17 @@ export interface DirectorActions {
|
||||
clipId: string,
|
||||
patch: Partial<Pick<MotionClip, "kind" | "speed" | "amplitude" | "enabled">>
|
||||
) => 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<string, number>();
|
||||
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<DirectorStore>((set, get) => {
|
||||
const initialRuntimeState = createRuntimeStateFromPersistedState(
|
||||
createInitialDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true })
|
||||
@@ -2093,9 +2270,11 @@ export const useDirectorStore = create<DirectorStore>((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<DirectorStore>((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<DirectorStore>((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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user