From 9d3a5b9bea47d86ca2d3eab5ad214c535e0968ff Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Fri, 18 Sep 2026 10:55:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=97=B6=E9=97=B4=E8=BD=B4):=20=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E7=89=87=E6=AE=B5=E6=94=AF=E6=8C=81=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=B5=B7=E6=AD=A2=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/canvas/SceneRoot.tsx | 2 + src/editor/panels/TimelinePanel.tsx | 109 ++++++++++++++++-- src/editor/runtime/CharacterModel.tsx | 10 +- src/editor/runtime/PrimitiveMannequin.tsx | 16 ++- src/editor/runtime/UE4MannequinModel.tsx | 17 ++- .../runtime/mannequin/ProceduralMannequin.tsx | 8 +- src/editor/runtime/mannequin/motion.test.ts | 15 +++ src/editor/runtime/mannequin/motion.ts | 13 ++- src/editor/schema/directorProject.ts | 10 ++ src/editor/schema/trajectoryMotion.test.ts | 27 +++++ src/editor/schema/trajectoryMotion.ts | 37 ++++++ src/editor/store/directorStore.test.ts | 34 ++++++ src/editor/store/directorStore.ts | 65 +++++++++++ src/styles/index.css | 21 ++++ 14 files changed, 366 insertions(+), 18 deletions(-) diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index 82e203b..a27de15 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -573,6 +573,8 @@ function ObjectSceneNode({ rigState={item.characterRig} motionTime={motionTime} characterId={item.id} + motionStartTime={item.motionStartTime} + motionEndTime={item.motionEndTime} /> {showLabels ? ( diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index 39b7dba..7d518f1 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -48,6 +48,8 @@ import { import type { MotionKind } from "../schema/directorProject"; import { TRAJECTORY_POINT_MIN_GAP, + getMotionEndTime, + getMotionStartTime, getTrajectoryClipEndTime, getTrajectoryPointTimes, getTrajectoryStartTime, @@ -71,10 +73,11 @@ interface SelectedKeyframe { */ type TimelineTimeDragOwner = | { kind: "trajectory"; objectId: string; pointId: string } - | { kind: "camera"; cameraId: string }; + | { kind: "camera"; cameraId: string } + | { kind: "motion"; objectId: string }; -/** 时间轴片段(人物轨迹条 / 运镜片段条)的稳定标识 */ -function getTimelineClipKey(kind: "trajectory" | "camera", id: string) { +/** 时间轴片段(人物轨迹条 / 运镜片段条 / 动作片段条)的稳定标识 */ +function getTimelineClipKey(kind: "trajectory" | "camera" | "motion", id: string) { return `${kind}:${id}`; } @@ -323,6 +326,9 @@ export function TimelinePanel() { const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart); const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart); const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd); + const setMotionClipStart = useDirectorStore((state) => state.setMotionClipStart); + const setMotionClipTrimStart = useDirectorStore((state) => state.setMotionClipTrimStart); + const setMotionClipEnd = useDirectorStore((state) => state.setMotionClipEnd); const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder); const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks); const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder); @@ -834,7 +840,7 @@ export function TimelinePanel() { * 任何一次片段手势(拖条本体 / 拖把手 / 拖条上的关键帧)都会把这条片段标成选中, * 点轨道空白处取消。不跟关键帧 / 轨迹点选中做联动,是为了让「点空白处取消选中」始终有效。 */ - function isClipSelected(kind: "trajectory" | "camera", id: string) { + function isClipSelected(kind: "trajectory" | "camera" | "motion", id: string) { return selectedClip === getTimelineClipKey(kind, id); } @@ -856,6 +862,11 @@ export function TimelinePanel() { return; } + if (owner.kind === "motion") { + setMotionClipEnd(owner.objectId, targetEnd); + return; + } + setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration)); } @@ -868,6 +879,9 @@ export function TimelinePanel() { deltaSeconds: number, endEdge = duration ) { + // 动作片段没有「单个关键帧」可拖(只有起点/终点两端,分别由 trimStart / duration 把手负责) + if (owner.kind === "motion") return; + const targetTime = (times[index] ?? 0) + deltaSeconds; if (owner.kind === "trajectory") { @@ -901,6 +915,11 @@ export function TimelinePanel() { return; } + if (owner.kind === "motion") { + setMotionClipStart(owner.objectId, startEdge + shift); + return; + } + setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift)); } @@ -922,6 +941,11 @@ export function TimelinePanel() { return; } + if (owner.kind === "motion") { + setMotionClipTrimStart(owner.objectId, startEdge + deltaSeconds); + return; + } + if (owner.kind !== "trajectory") return; setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds); @@ -944,7 +968,8 @@ export function TimelinePanel() { if (isTrackDragBlockedTarget(event.target)) return; // 任何一次片段手势(拖条本体 / 拖把手 / 拖关键帧)都先把这段片段标成选中 - setSelectedClip(getTimelineClipKey(owner.kind, owner.kind === "trajectory" ? owner.objectId : owner.cameraId)); + const clipOwnerId = owner.kind === "camera" ? owner.cameraId : owner.objectId; + setSelectedClip(getTimelineClipKey(owner.kind, clipOwnerId)); const lane = event.currentTarget.closest(".timeline-track-lane"); const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0; @@ -1718,6 +1743,16 @@ export function TimelinePanel() { const motionTitle = motionDisabled ? `动作轨道:${motionLabel}(当前已关闭)` : `动作轨道:${motionLabel}`; + // 动作片段的时间区间:动作从该区间的起点开始生效、到终点停止(区间外角色保持静止)。 + // 没拖过片段时默认覆盖整条时间轴(起点 0、终点 = 时间轴末尾),保持与旧行为一致。 + const hasMotion = Boolean(motion && motion.kind !== "none"); + const motionStart = getMotionStartTime(character, duration); + const motionEnd = getMotionEndTime(character, duration, motionStart); + const motionLeftPercent = duration > 0 ? (motionStart / duration) * 100 : 0; + const motionWidthPercent = + duration > 0 ? Math.min(100, ((motionEnd - motionStart) / duration) * 100) : 0; + // 动作片段条本体 / 两端把手共用的拖拽归属:kind=motion 让拖拽逻辑走角色动作分支 + const motionOwner: TimelineTimeDragOwner = { kind: "motion", objectId: character.id }; const trackKey = getTimelineTrackKey("character", character.id); const collapsed = timelineCollapsedTracks.includes(trackKey); const dragProps = getTrackDragProps(trackKey); @@ -1832,14 +1867,68 @@ export function TimelinePanel() { {collapsed ? null : ( <> - {/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */} + {/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步),以一段可拖拽的「时间片段」呈现 */}
动作
- - {motionLabel} - {motionDisabled ? "(已关闭)" : ""} - + {hasMotion && duration > 0 ? ( +
+ {motionLabel} + {/* 左端把手 = 修剪动作起点:之前角色保持静止,到这一刻才开始动 */} + + handleTrimStartKeyDown(event, motionOwner, [motionStart, motionEnd], motionStart) + } + {...getTimeDragProps(motionOwner, "trimStart", 0, [motionStart, motionEnd], motionStart, motionEnd)} + /> + {/* 右端把手 = 停止时间:动作播放到这一刻停止,角色回到静止 */} + + handleTimeHandleKeyDown(event, motionOwner, [motionStart, motionEnd], motionEnd) + } + {...getTimeDragProps( + motionOwner, + "duration", + 1, + [motionStart, motionEnd], + motionEnd, + motionEnd + )} + /> +
+ ) : ( + + {motionLabel} + {motionDisabled ? "(已关闭)" : ""} + + )}
diff --git a/src/editor/runtime/CharacterModel.tsx b/src/editor/runtime/CharacterModel.tsx index 99a0bdf..6bd359a 100644 --- a/src/editor/runtime/CharacterModel.tsx +++ b/src/editor/runtime/CharacterModel.tsx @@ -11,6 +11,10 @@ interface CharacterModelProps { rigState?: CharacterRigState; motionTime?: number; characterId?: string; + /** 动作片段起点(秒):基础动作只在该时间区间内驱动角色,缺省视为时间轴开头 */ + motionStartTime?: number; + /** 动作片段终点(秒):缺省视为时间轴末尾 */ + motionEndTime?: number; } class CharacterModelBoundary extends Component< @@ -33,8 +37,8 @@ class CharacterModelBoundary extends Component< } } -export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId }: CharacterModelProps) { - const fallback = ; +export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId, motionStartTime, motionEndTime }: CharacterModelProps) { + const fallback = ; if (rigState?.rigType !== "ue4-mannequin") { return fallback; @@ -49,6 +53,8 @@ export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState rigState={rigState} motionTime={motionTime} characterId={characterId} + motionStartTime={motionStartTime} + motionEndTime={motionEndTime} /> ); diff --git a/src/editor/runtime/PrimitiveMannequin.tsx b/src/editor/runtime/PrimitiveMannequin.tsx index 8dfaa15..00b1ea2 100644 --- a/src/editor/runtime/PrimitiveMannequin.tsx +++ b/src/editor/runtime/PrimitiveMannequin.tsx @@ -8,8 +8,20 @@ interface PrimitiveMannequinProps { rigState?: CharacterRigState; motionTime?: number; characterId?: string; + motionStartTime?: number; + motionEndTime?: number; } -export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: PrimitiveMannequinProps) { - return ; +export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId, motionStartTime, motionEndTime }: PrimitiveMannequinProps) { + return ( + + ); } diff --git a/src/editor/runtime/UE4MannequinModel.tsx b/src/editor/runtime/UE4MannequinModel.tsx index 75e8122..4ddf3fc 100644 --- a/src/editor/runtime/UE4MannequinModel.tsx +++ b/src/editor/runtime/UE4MannequinModel.tsx @@ -32,6 +32,10 @@ interface UE4MannequinModelProps { motionTime?: number; /** 稳定 id,用于群众相位偏移。 */ characterId?: string; + /** 动作片段起点(秒):基础动作只在该时间区间内驱动角色,缺省视为时间轴开头 */ + motionStartTime?: number; + /** 动作片段终点(秒):缺省视为时间轴末尾 */ + motionEndTime?: number; } interface LoadedGLTF { @@ -136,6 +140,8 @@ export function UE4MannequinModel({ rigState, motionTime, characterId, + motionStartTime, + motionEndTime, }: UE4MannequinModelProps) { const gltf = useLoader(GLTFLoader, UE4_MANNEQUIN_MODEL_URL) as LoadedGLTF; const scene = useMemo(() => cloneSkeleton(gltf.scene) as Group, [gltf.scene]); @@ -145,8 +151,15 @@ export function UE4MannequinModel({ // 基础动作(走/跑)按 motionTime 计算关节角覆盖后合并进 controls(度数域)。 // 播放/scrub 时 motionTime 每帧变化 → effect 每帧重摆姿势,即动画本体。 const controlsWithMotion = useMemo( - () => applyMotionOverrides(rigControls, { motion: rigState?.motion, time: motionTime ?? 0, characterId: characterId ?? "" }), - [rigControls, rigState?.motion, motionTime, characterId] + () => + applyMotionOverrides(rigControls, { + motion: rigState?.motion, + time: motionTime ?? 0, + characterId: characterId ?? "", + startTime: motionStartTime, + endTime: motionEndTime, + }), + [rigControls, rigState?.motion, motionTime, characterId, motionStartTime, motionEndTime] ); useLayoutEffect(() => { diff --git a/src/editor/runtime/mannequin/ProceduralMannequin.tsx b/src/editor/runtime/mannequin/ProceduralMannequin.tsx index 650b135..abb07b8 100644 --- a/src/editor/runtime/mannequin/ProceduralMannequin.tsx +++ b/src/editor/runtime/mannequin/ProceduralMannequin.tsx @@ -12,6 +12,10 @@ interface ProceduralMannequinProps { motionTime?: number; /** 稳定 id,用于驱动 phase 偏移;缺省时所有个体相位一致。 */ characterId?: string; + /** 动作片段起点(秒):基础动作只在该时间区间内驱动角色,缺省视为时间轴开头 */ + motionStartTime?: number; + /** 动作片段终点(秒):缺省视为时间轴末尾 */ + motionEndTime?: number; } function clampDegrees(value: number, bodyType?: CharacterBodyType) { @@ -31,12 +35,14 @@ function getLimbRotation( ]; } -export function ProceduralMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: ProceduralMannequinProps) { +export function ProceduralMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId, motionStartTime, motionEndTime }: ProceduralMannequinProps) { const preset = getBodyPreset(bodyType); const controls = applyMotionOverrides(rigState?.controls ?? {}, { motion: rigState?.motion, time: motionTime ?? 0, characterId: characterId ?? "", + startTime: motionStartTime, + endTime: motionEndTime, }); const p = preset.proportions; diff --git a/src/editor/runtime/mannequin/motion.test.ts b/src/editor/runtime/mannequin/motion.test.ts index 8845665..93a01a6 100644 --- a/src/editor/runtime/mannequin/motion.test.ts +++ b/src/editor/runtime/mannequin/motion.test.ts @@ -31,6 +31,21 @@ describe("computeMotionOverrides", () => { ).toEqual({}); }); + it("only drives the character inside the [startTime, endTime] segment", () => { + const segment = { motion: WALK, time: 1, characterId: "x", startTime: 0.5, endTime: 2.5 }; + + // 区间内:正常驱动 + expect(computeMotionOverrides(segment)).not.toEqual({}); + // 区间起点之前:保持静止 + expect( + computeMotionOverrides({ motion: WALK, time: 0.1, characterId: "x", startTime: 0.5, endTime: 2.5 }) + ).toEqual({}); + // 区间终点之后:保持静止 + expect( + computeMotionOverrides({ motion: WALK, time: 3, characterId: "x", startTime: 0.5, endTime: 2.5 }) + ).toEqual({}); + }); + /** 求使左肢相位 θL = target 的 time(speed=1,走路频率)。 */ function timeForLeftPhase(target: number, characterId: string): number { const phase = hashIdToPhase(characterId); diff --git a/src/editor/runtime/mannequin/motion.ts b/src/editor/runtime/mannequin/motion.ts index 34f08f2..647592d 100644 --- a/src/editor/runtime/mannequin/motion.ts +++ b/src/editor/runtime/mannequin/motion.ts @@ -20,6 +20,10 @@ export interface MotionParams { time: number; /** 用于产生 phase 偏移的稳定 id(一般是 directorObject.id) */ characterId: string; + /** 动作片段起点(秒);缺省 / 非法值视为 0,即时间轴一开始就在做这个动作 */ + startTime?: number; + /** 动作片段终点(秒);缺省 / 非法值视为整条时间轴,即动作一直播到时间轴末尾 */ + endTime?: number; } const DEFAULT_MOTION: CharacterMotionState = { @@ -97,12 +101,19 @@ function swingShoulder(forwardDeg: number, backDeg: number, amp: number, theta: } /** 计算 idle / walk / run 的关节覆盖;none / 禁用 / 异常输入返回空对象。 */ -export function computeMotionOverrides({ motion, time, characterId }: MotionParams): MotionOverride { +export function computeMotionOverrides({ motion, time, characterId, startTime, endTime }: MotionParams): MotionOverride { const m = normalizeMotion(motion); if (!m.enabled) return {}; if (m.kind === "none") return {}; if (!Number.isFinite(time)) return {}; + // 动作时间片段:只在 [startTime, endTime] 区间内驱动角色,区间外保持静止姿态。 + // 缺省(未设置片段)视作整条时间轴都在做这个动作(下限按 -∞,不拦截 0s 之前的理论时间)。 + const segStart = Number.isFinite(startTime) ? startTime! : Number.NEGATIVE_INFINITY; + const segEnd = Number.isFinite(endTime) ? endTime! : Number.POSITIVE_INFINITY; + if (time < segStart || time > segEnd) return {}; + + // 相位基于全局时间连续推进(与拖动片段前保持一致),只是在片段区间外静音。 const phase = hashIdToPhase(characterId); const omegaT = time * m.speed * Math.PI * 2 + phase; const L = leftPhase(omegaT); diff --git a/src/editor/schema/directorProject.ts b/src/editor/schema/directorProject.ts index 8b7ea1d..8bdf1a5 100644 --- a/src/editor/schema/directorProject.ts +++ b/src/editor/schema/directorProject.ts @@ -253,6 +253,16 @@ export interface DirectorObject { * 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。 */ trajectoryEndTime?: number; + /** + * 动作片段的起始时间(秒):角色的基础动作(待机 / 行走 / 跑步)从这一刻开始生效。 + * 缺省 / 为 0 表示时间轴一开始就在做这个动作;大于 0 表示动作之前角色保持静止姿态,到这一刻才开始动。 + */ + motionStartTime?: number; + /** + * 动作片段的结束时间(秒):动作播放到这一刻停止,角色回到静止姿态。 + * 缺省 / 为 0 表示动作一直播到时间轴末尾(即整条时间轴都在做这个动作)。 + */ + motionEndTime?: number; } export interface DirectorCameraCapture { diff --git a/src/editor/schema/trajectoryMotion.test.ts b/src/editor/schema/trajectoryMotion.test.ts index 257e09e..51f2da5 100644 --- a/src/editor/schema/trajectoryMotion.test.ts +++ b/src/editor/schema/trajectoryMotion.test.ts @@ -4,6 +4,8 @@ import { degreesToRadians, radiansToDegrees } from "./directorProject"; import type { DirectorObject, DirectorTrajectoryPoint } from "./directorProject"; import { evaluateTrajectoryMotionAtTime, + getMotionEndTime, + getMotionStartTime, getTrajectoryEndTime, getTrajectoryPointTimes, getTrajectoryStartTime, @@ -355,3 +357,28 @@ it("stops driving the character once the clip start reaches the timeline end", ( expect(hasTrajectoryMotion(createShiftedCharacter(points, 4), 5)).toBe(true); expect(hasTrajectoryMotion(createShiftedCharacter(points, 5), 5)).toBe(false); }); + +it("motion segment defaults to the whole timeline when unset", () => { + const character = createCharacter([]); + + expect(getMotionStartTime(character, 5)).toBe(0); + expect(getMotionEndTime(character, 5)).toBe(5); +}); + +it("motion segment honors explicit start / end and clamps to a valid range", () => { + const character = createCharacter([]); + character.motionStartTime = 2; + character.motionEndTime = 3; + + expect(getMotionStartTime(character, 5)).toBe(2); + expect(getMotionEndTime(character, 5, 2)).toBe(3); + + // 起点不能越过时间轴末尾,结束时间不能越过起点(最短时长约束) + character.motionStartTime = 9; + expect(getMotionStartTime(character, 5)).toBe(5); + + character.motionStartTime = 2; + character.motionEndTime = 2.05; + // 起点 2s、最短时长 0.1s → 结束时间至少被夹到 2.1s + expect(getMotionEndTime(character, 5, 2)).toBe(2.1); +}); diff --git a/src/editor/schema/trajectoryMotion.ts b/src/editor/schema/trajectoryMotion.ts index e5c0f94..b6c35ff 100644 --- a/src/editor/schema/trajectoryMotion.ts +++ b/src/editor/schema/trajectoryMotion.ts @@ -16,6 +16,9 @@ export interface TrajectoryMotionSample { /** 相邻轨迹点之间允许的最小时间间隔(秒):保证时间严格递增,拖到底也不会重叠 */ export const TRAJECTORY_POINT_MIN_GAP = 0.05; +/** 动作片段允许的最短时长(秒):再短动作就成了瞬间闪一下,没有可编辑/可预览的意义 */ +export const MOTION_CLIP_MIN_DURATION = 0.1; + /** * 轨迹片段的起始时间(秒):0 表示从 0s 就开始走位; * 拖到后面(比如 2s)表示角色先在起点原地等待,到这一刻才开始沿轨迹移动。 @@ -321,3 +324,37 @@ export function evaluateTrajectoryMotionAtTime( rotation: slerpRotationDegrees(fromRotation, toRotation, local), }; } + +/** + * 动作片段的起始时间(秒):角色的基础动作(待机 / 行走 / 跑步)从这一刻开始生效。 + * 缺省 / 非法值时取 0(时间轴一开始就在做这个动作)。 + */ +export function getMotionStartTime(owner: DirectorObject, duration = Number.POSITIVE_INFINITY): number { + const raw = owner.motionStartTime; + const start = typeof raw === "number" && Number.isFinite(raw) ? Math.max(raw, 0) : 0; + const upper = Number.isFinite(duration) ? Math.max(duration, 0) : Number.POSITIVE_INFINITY; + + return Math.min(start, upper); +} + +/** + * 动作片段的结束时间(秒):动作播放到这一刻停止,角色回到静止姿态。 + * 缺省 / 非法值时取时间轴总时长(整条时间轴都在做这个动作)。 + * 始终夹在「片段起点 + 最短时长 ~ 时间轴末尾」之间,保证片段始终合法、不为空。 + */ +export function getMotionEndTime( + owner: DirectorObject, + duration = Number.POSITIVE_INFINITY, + startTime = 0 +): number { + const start = Math.min( + Math.max(Number.isFinite(startTime) ? startTime : 0, 0), + Number.isFinite(duration) ? Math.max(duration, 0) : Number.POSITIVE_INFINITY + ); + const safeDuration = Number.isFinite(duration) ? duration : 0; + const stored = owner.motionEndTime; + const rawEnd = typeof stored === "number" && Number.isFinite(stored) ? stored : safeDuration; + const lower = start + MOTION_CLIP_MIN_DURATION; + + return Math.min(Math.max(rawEnd, lower), Math.max(safeDuration, lower)); +} diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts index 96ef199..1903081 100644 --- a/src/editor/store/directorStore.test.ts +++ b/src/editor/store/directorStore.test.ts @@ -989,6 +989,40 @@ it("setTrajectoryClipStart clamps the clip inside the timeline", () => { expect(updated.trajectoryPoints![0].time).toBe(5); }); +it("setMotionClipEnd / setMotionClipTrimStart / setMotionClipStart drive the action segment", () => { + useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never); + const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; + + // 先把动作片段收到 0s → 3s,留出可拖动空间(默认覆盖整条时间轴,无法整体平移) + useDirectorStore.getState().setMotionClipEnd(character.id, 3); + let updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.motionEndTime).toBe(3); + expect(updated.motionStartTime).toBeUndefined(); + + // 整体平移:起点 0→2,结束时间 3→5(时长 3s 保持不变) + useDirectorStore.getState().setMotionClipStart(character.id, 2); + updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.motionStartTime).toBe(2); + expect(updated.motionEndTime).toBe(5); + + // 修剪起点:只挪起点到 1s,结束时间仍为 5s + useDirectorStore.getState().setMotionClipTrimStart(character.id, 1); + updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.motionStartTime).toBe(1); + expect(updated.motionEndTime).toBe(5); + + // 设置结束时间:结束收到 2s,起点仍为 1s + useDirectorStore.getState().setMotionClipEnd(character.id, 2); + updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.motionStartTime).toBe(1); + expect(updated.motionEndTime).toBe(2); + + // 结束时间不能越过起点(最短时长约束),越界值被夹住 + useDirectorStore.getState().setMotionClipEnd(character.id, 1.05); + updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.motionEndTime).toBe(1.1); +}); + it("setTimelinePlaybackSettings stores the frame rate and loop flag", () => { useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never); useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60, loop: true }); diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index e132764..5fc94c0 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -60,6 +60,9 @@ import { getTrajectoryPointTimes, getTrajectoryStartTime, shiftTrajectoryClip, + getMotionStartTime, + getMotionEndTime, + MOTION_CLIP_MIN_DURATION, } from "../schema/trajectoryMotion"; export type TransformMode = "translate" | "rotate" | "scale"; @@ -170,6 +173,12 @@ export interface DirectorActions { setTrajectoryClipTrimStart: (objectId: string, startTime: number) => void; /** 设置人物轨迹片段的结束时间(停留时长):角色走完最后一点后原地停留到这一刻,拖条右端把手时写入 */ setTrajectoryClipEnd: (objectId: string, endTime: number) => void; + /** 整体平移动作片段(拖动作条本体):片段起点与结束时间同步平移,保持动作时长不变 */ + setMotionClipStart: (objectId: string, startTime: number) => void; + /** 修剪动作片段起点(拖动作条左端把手):只挪动作开始时间,结束时间不动 */ + setMotionClipTrimStart: (objectId: string, startTime: number) => void; + /** 设置动作片段的结束时间(拖动作条右端把手):动作播放到这一刻停止,起点不动 */ + setMotionClipEnd: (objectId: string, endTime: number) => void; removeTrajectoryPoint: (objectId: string, pointId: string) => void; /** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */ selectTrajectoryPoint: (objectId: string | null, pointId: string | null) => void; @@ -1903,6 +1912,62 @@ export const useDirectorStore = create((set, get) => { }), }, })), + setMotionClipStart: (objectId, startTime) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, objectId, (item) => { + const duration = state.project.timeline.duration; + const currentStart = getMotionStartTime(item, duration); + const currentEnd = getMotionEndTime(item, duration, currentStart); + // 片段刚性平移:动作时长(起点→终点)保持不变,起点夹在时间轴内,终点跟着走同样的量。 + const minSpan = MOTION_CLIP_MIN_DURATION; + const span = Math.max(currentEnd - currentStart, minSpan); + const upper = Math.max(duration - span, 0); + const nextStart = Math.min(Math.max(startTime, 0), upper); + const delta = nextStart - currentStart; + + return { + ...item, + motionStartTime: nextStart, + motionEndTime: currentEnd + delta, + }; + }), + }, + })), + setMotionClipTrimStart: (objectId, startTime) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, objectId, (item) => { + const duration = state.project.timeline.duration; + const currentEnd = getMotionEndTime(item, duration, getMotionStartTime(item, duration)); + // 起点最多挪到「结束时间再往前一个最短时长」处,不能越过终点,否则片段就空了。 + const upper = Math.max(currentEnd - MOTION_CLIP_MIN_DURATION, 0); + const nextStart = Math.min(Math.max(startTime, 0), upper); + + return { ...item, motionStartTime: nextStart }; + }), + }, + })), + setMotionClipEnd: (objectId, endTime) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, objectId, (item) => { + const duration = state.project.timeline.duration; + const currentStart = getMotionStartTime(item, duration); + // 结束时间夹在「起点 + 最短时长 ~ 时间轴末尾」之间,保证片段始终合法、不为空。 + const lower = currentStart + MOTION_CLIP_MIN_DURATION; + const nextEnd = Math.min(Math.max(endTime, lower), duration); + + return { ...item, motionEndTime: nextEnd }; + }), + }, + })), removeTrajectoryPoint: (objectId, pointId) => commitMutation((state) => ({ ...state, diff --git a/src/styles/index.css b/src/styles/index.css index adf49a7..52d78db 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -4489,6 +4489,27 @@ body.is-timeline-dragging * { background: rgb(18 184 134 / 0.2); box-shadow: 0 0 0 1px rgb(18 184 134 / 0.45); } +/* 动作片段条:与轨迹条 / 运镜条同构(片段条 + 左右两端把手),配色换成动作橙以和前两者区分 */ +.timeline-clip-bar-motion { + border-color: rgb(247 148 30 / 0.5); + background: rgb(247 148 30 / 0.14); +} +.timeline-clip-bar-motion .timeline-trajectory-bar-label { + color: #f7941e; +} +.timeline-clip-bar-motion .timeline-trajectory-trim { + background: rgb(247 148 30 / 0.5); +} +.timeline-clip-bar-motion .timeline-trajectory-trim:hover, +.timeline-clip-bar-motion .timeline-trajectory-trim:focus-visible, +.timeline-clip-bar-motion.is-selected .timeline-trajectory-trim { + background: #f7941e; +} +.timeline-clip-bar-motion.is-selected { + border-color: rgb(247 148 30 / 0.85); + background: rgb(247 148 30 / 0.22); + box-shadow: 0 0 0 1px rgb(247 148 30 / 0.45); +} .timeline-playhead-layer { position: absolute; top: 0;