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;