feat(时间轴): 动作片段支持拖拽调整起止时间

This commit is contained in:
2026-09-18 10:55:48 +08:00
parent efe44a35a6
commit 9d3a5b9bea
14 changed files with 366 additions and 18 deletions

View File

@@ -573,6 +573,8 @@ function ObjectSceneNode({
rigState={item.characterRig}
motionTime={motionTime}
characterId={item.id}
motionStartTime={item.motionStartTime}
motionEndTime={item.motionEndTime}
/>
</Suspense>
{showLabels ? (

View File

@@ -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 : (
<>
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */}
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步),以一段可拖拽的「时间片段」呈现 */}
<div className="timeline-subtrack" title={motionTitle}>
<div className="timeline-subtrack-label">动作</div>
<div className="timeline-track-lane timeline-motion-lane">
<span className={`timeline-motion-chip${motionDisabled ? " is-disabled" : ""}`}>
{motionLabel}
{motionDisabled ? "(已关闭)" : ""}
</span>
{hasMotion && duration > 0 ? (
<div
className={`timeline-trajectory-bar timeline-clip-bar-motion${
isClipSelected("motion", character.id) ? " is-selected" : ""
}`}
style={{ left: `${motionLeftPercent}%`, width: `${motionWidthPercent}%` }}
title={`动作片段 ${formatTime(motionStart)} → ${formatTime(motionEnd)}(时长 ${formatTime(
motionEnd - motionStart
)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与停止时间`}
{...getTimeDragProps(motionOwner, "clip", 0, [motionStart, motionEnd], motionStart, motionEnd)}
>
<span className="timeline-trajectory-bar-label">{motionLabel}</span>
{/* 左端把手 = 修剪动作起点:之前角色保持静止,到这一刻才开始动 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${character.name} 动作起点`}
aria-valuemin={0}
aria-valuemax={Math.max(motionEnd - 0.1, 0)}
aria-valuenow={Number(motionStart.toFixed(2))}
aria-valuetext={formatTime(motionStart)}
title={`拖动修剪动作起点(${formatTime(motionStart)} 之前角色保持静止),或用左右方向键微调`}
onKeyDown={(event) =>
handleTrimStartKeyDown(event, motionOwner, [motionStart, motionEnd], motionStart)
}
{...getTimeDragProps(motionOwner, "trimStart", 0, [motionStart, motionEnd], motionStart, motionEnd)}
/>
{/* 右端把手 = 停止时间:动作播放到这一刻停止,角色回到静止 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${character.name} 动作停止时间`}
aria-valuemin={Number(motionStart.toFixed(2))}
aria-valuemax={duration}
aria-valuenow={Number(motionEnd.toFixed(2))}
aria-valuetext={formatTime(motionEnd)}
title="拖动调整动作停止时间(动作播放到这一刻停止),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, motionOwner, [motionStart, motionEnd], motionEnd)
}
{...getTimeDragProps(
motionOwner,
"duration",
1,
[motionStart, motionEnd],
motionEnd,
motionEnd
)}
/>
</div>
) : (
<span className={`timeline-motion-chip${motionDisabled ? " is-disabled" : ""}`}>
{motionLabel}
{motionDisabled ? "(已关闭)" : ""}
</span>
)}
</div>
</div>

View File

@@ -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 = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} />;
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId, motionStartTime, motionEndTime }: CharacterModelProps) {
const fallback = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} motionStartTime={motionStartTime} motionEndTime={motionEndTime} />;
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}
/>
</CharacterModelBoundary>
);

View File

@@ -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 <ProceduralMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} />;
export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId, motionStartTime, motionEndTime }: PrimitiveMannequinProps) {
return (
<ProceduralMannequin
bodyType={bodyType}
color={color}
rigState={rigState}
motionTime={motionTime}
characterId={characterId}
motionStartTime={motionStartTime}
motionEndTime={motionEndTime}
/>
);
}

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -253,6 +253,16 @@ export interface DirectorObject {
* 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。
*/
trajectoryEndTime?: number;
/**
* 动作片段的起始时间(秒):角色的基础动作(待机 / 行走 / 跑步)从这一刻开始生效。
* 缺省 / 为 0 表示时间轴一开始就在做这个动作;大于 0 表示动作之前角色保持静止姿态,到这一刻才开始动。
*/
motionStartTime?: number;
/**
* 动作片段的结束时间(秒):动作播放到这一刻停止,角色回到静止姿态。
* 缺省 / 为 0 表示动作一直播到时间轴末尾(即整条时间轴都在做这个动作)。
*/
motionEndTime?: number;
}
export interface DirectorCameraCapture {

View File

@@ -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);
});

View File

@@ -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));
}

View File

@@ -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 });

View File

@@ -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<DirectorStore>((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,

View File

@@ -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;