feat(timeline): 增强时间轴拖拽吸附与片段换位

This commit is contained in:
tongtong committed 2026-09-19 01:01:07 +08:00
1 parent 38dc27501a
commit 2015d8d3c3
7 files changed
+769 -78

No files matched your search

+193 -41
View File
@@ -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 },