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 {