feat(时间轴): 动作片段支持拖拽调整起止时间
This commit is contained in:
@@ -573,6 +573,8 @@ function ObjectSceneNode({
|
||||
rigState={item.characterRig}
|
||||
motionTime={motionTime}
|
||||
characterId={item.id}
|
||||
motionStartTime={item.motionStartTime}
|
||||
motionEndTime={item.motionEndTime}
|
||||
/>
|
||||
</Suspense>
|
||||
{showLabels ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -253,6 +253,16 @@ export interface DirectorObject {
|
||||
* 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。
|
||||
*/
|
||||
trajectoryEndTime?: number;
|
||||
/**
|
||||
* 动作片段的起始时间(秒):角色的基础动作(待机 / 行走 / 跑步)从这一刻开始生效。
|
||||
* 缺省 / 为 0 表示时间轴一开始就在做这个动作;大于 0 表示动作之前角色保持静止姿态,到这一刻才开始动。
|
||||
*/
|
||||
motionStartTime?: number;
|
||||
/**
|
||||
* 动作片段的结束时间(秒):动作播放到这一刻停止,角色回到静止姿态。
|
||||
* 缺省 / 为 0 表示动作一直播到时间轴末尾(即整条时间轴都在做这个动作)。
|
||||
*/
|
||||
motionEndTime?: number;
|
||||
}
|
||||
|
||||
export interface DirectorCameraCapture {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user