feat(时间轴): 动作片段支持拖拽调整起止时间
This commit is contained in:
@@ -573,6 +573,8 @@ function ObjectSceneNode({
|
|||||||
rigState={item.characterRig}
|
rigState={item.characterRig}
|
||||||
motionTime={motionTime}
|
motionTime={motionTime}
|
||||||
characterId={item.id}
|
characterId={item.id}
|
||||||
|
motionStartTime={item.motionStartTime}
|
||||||
|
motionEndTime={item.motionEndTime}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
{showLabels ? (
|
{showLabels ? (
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import {
|
|||||||
import type { MotionKind } from "../schema/directorProject";
|
import type { MotionKind } from "../schema/directorProject";
|
||||||
import {
|
import {
|
||||||
TRAJECTORY_POINT_MIN_GAP,
|
TRAJECTORY_POINT_MIN_GAP,
|
||||||
|
getMotionEndTime,
|
||||||
|
getMotionStartTime,
|
||||||
getTrajectoryClipEndTime,
|
getTrajectoryClipEndTime,
|
||||||
getTrajectoryPointTimes,
|
getTrajectoryPointTimes,
|
||||||
getTrajectoryStartTime,
|
getTrajectoryStartTime,
|
||||||
@@ -71,10 +73,11 @@ interface SelectedKeyframe {
|
|||||||
*/
|
*/
|
||||||
type TimelineTimeDragOwner =
|
type TimelineTimeDragOwner =
|
||||||
| { kind: "trajectory"; objectId: string; pointId: string }
|
| { 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}`;
|
return `${kind}:${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +326,9 @@ export function TimelinePanel() {
|
|||||||
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
|
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
|
||||||
const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart);
|
const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart);
|
||||||
const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd);
|
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 timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
|
||||||
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
|
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
|
||||||
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
|
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);
|
return selectedClip === getTimelineClipKey(kind, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,6 +862,11 @@ export function TimelinePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (owner.kind === "motion") {
|
||||||
|
setMotionClipEnd(owner.objectId, targetEnd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
|
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,6 +879,9 @@ export function TimelinePanel() {
|
|||||||
deltaSeconds: number,
|
deltaSeconds: number,
|
||||||
endEdge = duration
|
endEdge = duration
|
||||||
) {
|
) {
|
||||||
|
// 动作片段没有「单个关键帧」可拖(只有起点/终点两端,分别由 trimStart / duration 把手负责)
|
||||||
|
if (owner.kind === "motion") return;
|
||||||
|
|
||||||
const targetTime = (times[index] ?? 0) + deltaSeconds;
|
const targetTime = (times[index] ?? 0) + deltaSeconds;
|
||||||
|
|
||||||
if (owner.kind === "trajectory") {
|
if (owner.kind === "trajectory") {
|
||||||
@@ -901,6 +915,11 @@ export function TimelinePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (owner.kind === "motion") {
|
||||||
|
setMotionClipStart(owner.objectId, startEdge + shift);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
|
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,6 +941,11 @@ export function TimelinePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (owner.kind === "motion") {
|
||||||
|
setMotionClipTrimStart(owner.objectId, startEdge + deltaSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (owner.kind !== "trajectory") return;
|
if (owner.kind !== "trajectory") return;
|
||||||
|
|
||||||
setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
|
setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
|
||||||
@@ -944,7 +968,8 @@ export function TimelinePanel() {
|
|||||||
if (isTrackDragBlockedTarget(event.target)) return;
|
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 lane = event.currentTarget.closest(".timeline-track-lane");
|
||||||
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
|
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
|
||||||
@@ -1718,6 +1743,16 @@ export function TimelinePanel() {
|
|||||||
const motionTitle = motionDisabled
|
const motionTitle = motionDisabled
|
||||||
? `动作轨道:${motionLabel}(当前已关闭)`
|
? `动作轨道:${motionLabel}(当前已关闭)`
|
||||||
: `动作轨道:${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 trackKey = getTimelineTrackKey("character", character.id);
|
||||||
const collapsed = timelineCollapsedTracks.includes(trackKey);
|
const collapsed = timelineCollapsedTracks.includes(trackKey);
|
||||||
const dragProps = getTrackDragProps(trackKey);
|
const dragProps = getTrackDragProps(trackKey);
|
||||||
@@ -1832,14 +1867,68 @@ export function TimelinePanel() {
|
|||||||
|
|
||||||
{collapsed ? null : (
|
{collapsed ? null : (
|
||||||
<>
|
<>
|
||||||
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */}
|
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步),以一段可拖拽的「时间片段」呈现 */}
|
||||||
<div className="timeline-subtrack" title={motionTitle}>
|
<div className="timeline-subtrack" title={motionTitle}>
|
||||||
<div className="timeline-subtrack-label">动作</div>
|
<div className="timeline-subtrack-label">动作</div>
|
||||||
<div className="timeline-track-lane timeline-motion-lane">
|
<div className="timeline-track-lane timeline-motion-lane">
|
||||||
|
{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" : ""}`}>
|
<span className={`timeline-motion-chip${motionDisabled ? " is-disabled" : ""}`}>
|
||||||
{motionLabel}
|
{motionLabel}
|
||||||
{motionDisabled ? "(已关闭)" : ""}
|
{motionDisabled ? "(已关闭)" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ interface CharacterModelProps {
|
|||||||
rigState?: CharacterRigState;
|
rigState?: CharacterRigState;
|
||||||
motionTime?: number;
|
motionTime?: number;
|
||||||
characterId?: string;
|
characterId?: string;
|
||||||
|
/** 动作片段起点(秒):基础动作只在该时间区间内驱动角色,缺省视为时间轴开头 */
|
||||||
|
motionStartTime?: number;
|
||||||
|
/** 动作片段终点(秒):缺省视为时间轴末尾 */
|
||||||
|
motionEndTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CharacterModelBoundary extends Component<
|
class CharacterModelBoundary extends Component<
|
||||||
@@ -33,8 +37,8 @@ class CharacterModelBoundary extends Component<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId }: CharacterModelProps) {
|
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} />;
|
const fallback = <PrimitiveMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} motionStartTime={motionStartTime} motionEndTime={motionEndTime} />;
|
||||||
|
|
||||||
if (rigState?.rigType !== "ue4-mannequin") {
|
if (rigState?.rigType !== "ue4-mannequin") {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -49,6 +53,8 @@ export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState
|
|||||||
rigState={rigState}
|
rigState={rigState}
|
||||||
motionTime={motionTime}
|
motionTime={motionTime}
|
||||||
characterId={characterId}
|
characterId={characterId}
|
||||||
|
motionStartTime={motionStartTime}
|
||||||
|
motionEndTime={motionEndTime}
|
||||||
/>
|
/>
|
||||||
</CharacterModelBoundary>
|
</CharacterModelBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,20 @@ interface PrimitiveMannequinProps {
|
|||||||
rigState?: CharacterRigState;
|
rigState?: CharacterRigState;
|
||||||
motionTime?: number;
|
motionTime?: number;
|
||||||
characterId?: string;
|
characterId?: string;
|
||||||
|
motionStartTime?: number;
|
||||||
|
motionEndTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: PrimitiveMannequinProps) {
|
export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId, motionStartTime, motionEndTime }: PrimitiveMannequinProps) {
|
||||||
return <ProceduralMannequin bodyType={bodyType} color={color} rigState={rigState} motionTime={motionTime} characterId={characterId} />;
|
return (
|
||||||
|
<ProceduralMannequin
|
||||||
|
bodyType={bodyType}
|
||||||
|
color={color}
|
||||||
|
rigState={rigState}
|
||||||
|
motionTime={motionTime}
|
||||||
|
characterId={characterId}
|
||||||
|
motionStartTime={motionStartTime}
|
||||||
|
motionEndTime={motionEndTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ interface UE4MannequinModelProps {
|
|||||||
motionTime?: number;
|
motionTime?: number;
|
||||||
/** 稳定 id,用于群众相位偏移。 */
|
/** 稳定 id,用于群众相位偏移。 */
|
||||||
characterId?: string;
|
characterId?: string;
|
||||||
|
/** 动作片段起点(秒):基础动作只在该时间区间内驱动角色,缺省视为时间轴开头 */
|
||||||
|
motionStartTime?: number;
|
||||||
|
/** 动作片段终点(秒):缺省视为时间轴末尾 */
|
||||||
|
motionEndTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadedGLTF {
|
interface LoadedGLTF {
|
||||||
@@ -136,6 +140,8 @@ export function UE4MannequinModel({
|
|||||||
rigState,
|
rigState,
|
||||||
motionTime,
|
motionTime,
|
||||||
characterId,
|
characterId,
|
||||||
|
motionStartTime,
|
||||||
|
motionEndTime,
|
||||||
}: UE4MannequinModelProps) {
|
}: UE4MannequinModelProps) {
|
||||||
const gltf = useLoader(GLTFLoader, UE4_MANNEQUIN_MODEL_URL) as LoadedGLTF;
|
const gltf = useLoader(GLTFLoader, UE4_MANNEQUIN_MODEL_URL) as LoadedGLTF;
|
||||||
const scene = useMemo(() => cloneSkeleton(gltf.scene) as Group, [gltf.scene]);
|
const scene = useMemo(() => cloneSkeleton(gltf.scene) as Group, [gltf.scene]);
|
||||||
@@ -145,8 +151,15 @@ export function UE4MannequinModel({
|
|||||||
// 基础动作(走/跑)按 motionTime 计算关节角覆盖后合并进 controls(度数域)。
|
// 基础动作(走/跑)按 motionTime 计算关节角覆盖后合并进 controls(度数域)。
|
||||||
// 播放/scrub 时 motionTime 每帧变化 → effect 每帧重摆姿势,即动画本体。
|
// 播放/scrub 时 motionTime 每帧变化 → effect 每帧重摆姿势,即动画本体。
|
||||||
const controlsWithMotion = useMemo(
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ interface ProceduralMannequinProps {
|
|||||||
motionTime?: number;
|
motionTime?: number;
|
||||||
/** 稳定 id,用于驱动 phase 偏移;缺省时所有个体相位一致。 */
|
/** 稳定 id,用于驱动 phase 偏移;缺省时所有个体相位一致。 */
|
||||||
characterId?: string;
|
characterId?: string;
|
||||||
|
/** 动作片段起点(秒):基础动作只在该时间区间内驱动角色,缺省视为时间轴开头 */
|
||||||
|
motionStartTime?: number;
|
||||||
|
/** 动作片段终点(秒):缺省视为时间轴末尾 */
|
||||||
|
motionEndTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampDegrees(value: number, bodyType?: CharacterBodyType) {
|
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 preset = getBodyPreset(bodyType);
|
||||||
const controls = applyMotionOverrides(rigState?.controls ?? {}, {
|
const controls = applyMotionOverrides(rigState?.controls ?? {}, {
|
||||||
motion: rigState?.motion,
|
motion: rigState?.motion,
|
||||||
time: motionTime ?? 0,
|
time: motionTime ?? 0,
|
||||||
characterId: characterId ?? "",
|
characterId: characterId ?? "",
|
||||||
|
startTime: motionStartTime,
|
||||||
|
endTime: motionEndTime,
|
||||||
});
|
});
|
||||||
const p = preset.proportions;
|
const p = preset.proportions;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,21 @@ describe("computeMotionOverrides", () => {
|
|||||||
).toEqual({});
|
).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,走路频率)。 */
|
/** 求使左肢相位 θL = target 的 time(speed=1,走路频率)。 */
|
||||||
function timeForLeftPhase(target: number, characterId: string): number {
|
function timeForLeftPhase(target: number, characterId: string): number {
|
||||||
const phase = hashIdToPhase(characterId);
|
const phase = hashIdToPhase(characterId);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface MotionParams {
|
|||||||
time: number;
|
time: number;
|
||||||
/** 用于产生 phase 偏移的稳定 id(一般是 directorObject.id) */
|
/** 用于产生 phase 偏移的稳定 id(一般是 directorObject.id) */
|
||||||
characterId: string;
|
characterId: string;
|
||||||
|
/** 动作片段起点(秒);缺省 / 非法值视为 0,即时间轴一开始就在做这个动作 */
|
||||||
|
startTime?: number;
|
||||||
|
/** 动作片段终点(秒);缺省 / 非法值视为整条时间轴,即动作一直播到时间轴末尾 */
|
||||||
|
endTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MOTION: CharacterMotionState = {
|
const DEFAULT_MOTION: CharacterMotionState = {
|
||||||
@@ -97,12 +101,19 @@ function swingShoulder(forwardDeg: number, backDeg: number, amp: number, theta:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 计算 idle / walk / run 的关节覆盖;none / 禁用 / 异常输入返回空对象。 */
|
/** 计算 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);
|
const m = normalizeMotion(motion);
|
||||||
if (!m.enabled) return {};
|
if (!m.enabled) return {};
|
||||||
if (m.kind === "none") return {};
|
if (m.kind === "none") return {};
|
||||||
if (!Number.isFinite(time)) 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 phase = hashIdToPhase(characterId);
|
||||||
const omegaT = time * m.speed * Math.PI * 2 + phase;
|
const omegaT = time * m.speed * Math.PI * 2 + phase;
|
||||||
const L = leftPhase(omegaT);
|
const L = leftPhase(omegaT);
|
||||||
|
|||||||
@@ -253,6 +253,16 @@ export interface DirectorObject {
|
|||||||
* 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。
|
* 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。
|
||||||
*/
|
*/
|
||||||
trajectoryEndTime?: number;
|
trajectoryEndTime?: number;
|
||||||
|
/**
|
||||||
|
* 动作片段的起始时间(秒):角色的基础动作(待机 / 行走 / 跑步)从这一刻开始生效。
|
||||||
|
* 缺省 / 为 0 表示时间轴一开始就在做这个动作;大于 0 表示动作之前角色保持静止姿态,到这一刻才开始动。
|
||||||
|
*/
|
||||||
|
motionStartTime?: number;
|
||||||
|
/**
|
||||||
|
* 动作片段的结束时间(秒):动作播放到这一刻停止,角色回到静止姿态。
|
||||||
|
* 缺省 / 为 0 表示动作一直播到时间轴末尾(即整条时间轴都在做这个动作)。
|
||||||
|
*/
|
||||||
|
motionEndTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectorCameraCapture {
|
export interface DirectorCameraCapture {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { degreesToRadians, radiansToDegrees } from "./directorProject";
|
|||||||
import type { DirectorObject, DirectorTrajectoryPoint } from "./directorProject";
|
import type { DirectorObject, DirectorTrajectoryPoint } from "./directorProject";
|
||||||
import {
|
import {
|
||||||
evaluateTrajectoryMotionAtTime,
|
evaluateTrajectoryMotionAtTime,
|
||||||
|
getMotionEndTime,
|
||||||
|
getMotionStartTime,
|
||||||
getTrajectoryEndTime,
|
getTrajectoryEndTime,
|
||||||
getTrajectoryPointTimes,
|
getTrajectoryPointTimes,
|
||||||
getTrajectoryStartTime,
|
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, 4), 5)).toBe(true);
|
||||||
expect(hasTrajectoryMotion(createShiftedCharacter(points, 5), 5)).toBe(false);
|
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 TRAJECTORY_POINT_MIN_GAP = 0.05;
|
||||||
|
|
||||||
|
/** 动作片段允许的最短时长(秒):再短动作就成了瞬间闪一下,没有可编辑/可预览的意义 */
|
||||||
|
export const MOTION_CLIP_MIN_DURATION = 0.1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轨迹片段的起始时间(秒):0 表示从 0s 就开始走位;
|
* 轨迹片段的起始时间(秒):0 表示从 0s 就开始走位;
|
||||||
* 拖到后面(比如 2s)表示角色先在起点原地等待,到这一刻才开始沿轨迹移动。
|
* 拖到后面(比如 2s)表示角色先在起点原地等待,到这一刻才开始沿轨迹移动。
|
||||||
@@ -321,3 +324,37 @@ export function evaluateTrajectoryMotionAtTime(
|
|||||||
rotation: slerpRotationDegrees(fromRotation, toRotation, local),
|
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);
|
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", () => {
|
it("setTimelinePlaybackSettings stores the frame rate and loop flag", () => {
|
||||||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||||||
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60, loop: true });
|
useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60, loop: true });
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ import {
|
|||||||
getTrajectoryPointTimes,
|
getTrajectoryPointTimes,
|
||||||
getTrajectoryStartTime,
|
getTrajectoryStartTime,
|
||||||
shiftTrajectoryClip,
|
shiftTrajectoryClip,
|
||||||
|
getMotionStartTime,
|
||||||
|
getMotionEndTime,
|
||||||
|
MOTION_CLIP_MIN_DURATION,
|
||||||
} from "../schema/trajectoryMotion";
|
} from "../schema/trajectoryMotion";
|
||||||
|
|
||||||
export type TransformMode = "translate" | "rotate" | "scale";
|
export type TransformMode = "translate" | "rotate" | "scale";
|
||||||
@@ -170,6 +173,12 @@ export interface DirectorActions {
|
|||||||
setTrajectoryClipTrimStart: (objectId: string, startTime: number) => void;
|
setTrajectoryClipTrimStart: (objectId: string, startTime: number) => void;
|
||||||
/** 设置人物轨迹片段的结束时间(停留时长):角色走完最后一点后原地停留到这一刻,拖条右端把手时写入 */
|
/** 设置人物轨迹片段的结束时间(停留时长):角色走完最后一点后原地停留到这一刻,拖条右端把手时写入 */
|
||||||
setTrajectoryClipEnd: (objectId: string, endTime: 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;
|
removeTrajectoryPoint: (objectId: string, pointId: string) => void;
|
||||||
/** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */
|
/** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */
|
||||||
selectTrajectoryPoint: (objectId: string | null, pointId: string | null) => void;
|
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) =>
|
removeTrajectoryPoint: (objectId, pointId) =>
|
||||||
commitMutation((state) => ({
|
commitMutation((state) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -4489,6 +4489,27 @@ body.is-timeline-dragging * {
|
|||||||
background: rgb(18 184 134 / 0.2);
|
background: rgb(18 184 134 / 0.2);
|
||||||
box-shadow: 0 0 0 1px rgb(18 184 134 / 0.45);
|
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 {
|
.timeline-playhead-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user