feat: 优化时间轴

This commit is contained in:
tongtong committed 2026-09-18 00:52:28 +08:00
1 parent e66e3374bf
commit 8645ea3007
19 files changed
+1538 -391

No files matched your search

+342 -179
View File
@@ -40,12 +40,15 @@ import {
moveCameraKeyframeTime,
normalizeTimelineDuration,
normalizeTimelineFps,
scaleCameraKeyframeStart,
scaleCameraKeyframeTimes,
type CameraKeyframeValue,
type KeyframeTransformValue,
} from "../schema/timeline";
import type { MotionKind } from "../schema/directorProject";
import {
TRAJECTORY_POINT_MIN_GAP,
getTrajectoryClipEndTime,
getTrajectoryPointTimes,
getTrajectoryStartTime,
moveTrajectoryPointTime,
@@ -318,6 +321,8 @@ export function TimelinePanel() {
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes);
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart);
const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd);
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
@@ -368,15 +373,18 @@ export function TimelinePanel() {
* - point:拖单个关键帧,只改这一帧的时间
* - duration:拖条右端把手,整段时长按比例缩放
* - clip:拖条本体,整段左右平移(可以脱离 0s 往后放)
* - trimStart:拖条左端把手,只挪片段起点(各轨迹点原地不动)
*/
const timeDragRef = useRef<{
pointerId: number;
owner: TimelineTimeDragOwner;
kind: "point" | "duration" | "clip";
kind: "point" | "duration" | "clip" | "trimStart";
index: number;
startTimes: number[];
/** 片段左端在按下时的位置(平移基准) */
startEdge: number;
/** 片段右端在按下时的位置(轨迹最后一点停留结束,作为拖关键帧的上界) */
endEdge: number;
startClientX: number;
secondsPerPixel: number;
} | null>(null);
@@ -439,6 +447,30 @@ export function TimelinePanel() {
};
}, [exportMenuOpen]);
// 轨迹关键帧没有独立的删除按钮,选中态(.is-selected)就是「按 Delete 会删掉它」的唯一提示,
// 所以点到别处(轨道空白、别的轨道、面板其它区域,甚至时间轴之外)就必须取消选中并移走焦点,
// 否则选中高亮和焦点框会一直留在那颗关键帧上,看起来像还能按 Delete 删它。
useEffect(() => {
if (!selectedTrajectoryPoint) return;
function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) {
// 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间
if (event.target instanceof Element && event.target.closest(".timeline-keyframe-trajectory")) return;
selectTrajectoryPoint(null, null);
// 焦点还停在那颗关键帧上时一并移走,避免焦点框留在一个已经没选中的元素上
const active = document.activeElement;
if (active instanceof HTMLElement && active.classList.contains("timeline-keyframe-trajectory")) {
active.blur();
}
}
document.addEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
return () => document.removeEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
}, [selectedTrajectoryPoint, selectTrajectoryPoint]);
// 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃)
useEffect(() => {
if (!settingsOpen) return;
@@ -484,7 +516,9 @@ export function TimelinePanel() {
if (selectionIsManualCamera) {
if (!selectedShot || selectedShot.targetMode !== "manual") return;
addCameraKeyframe(selectedShot.id, playhead, snapshotShot());
} else if (selectedObject) {
} else if (selectedObject && selectedObject.kind !== "character") {
// 角色由轨迹(trajectory)驱动,不占 transform 关键帧轨道,
// 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。
addKeyframe(selectedObject.id, playhead, snapshotObjectTransform());
}
}
@@ -511,6 +545,10 @@ export function TimelinePanel() {
}
}
function handleDeleteSelected() {
if (selectedTrajectoryPoint) {
removeTrajectoryPoint(selectedTrajectoryPoint.objectId, selectedTrajectoryPoint.pointId);
return;
}
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object") {
removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
@@ -655,8 +693,15 @@ export function TimelinePanel() {
// 轨道顺序:默认「物体轨道 → 相机运镜轨道 → 人物轨道」,用户可以上移 / 下移调整。
// 视觉顺序靠 flex order 实现,DOM 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。
// 角色由轨迹驱动,只走「角色轨道」;其 transform 关键帧不单独占一条「物体轨道」,
// 否则同一个角色会在时间轴上出现两条。
const nonCharacterObjectIds = new Set(
project.objects.filter((item) => item.kind !== "character").map((item) => item.id)
);
const defaultTrackKeys = [
...timeline.tracks.map((track) => getTimelineTrackKey("object", track.objectId)),
...timeline.tracks
.filter((track) => nonCharacterObjectIds.has(track.objectId))
.map((track) => getTimelineTrackKey("object", track.objectId)),
...timeline.cameraTracks.map((track) => getTimelineTrackKey("camera", track.cameraId)),
...characterObjects.map((item) => getTimelineTrackKey("character", item.id)),
];
@@ -788,38 +833,40 @@ export function TimelinePanel() {
return selectedClip === getTimelineClipKey(kind, id);
}
/** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */
/** 拖整段时长(条右端把手 / 键盘微调):
* - 运镜:末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定;
* - 人物轨迹:末点不再等于时间结束位置,这条把手改的是「停留时长」(角色走完最后一点后
* 原地停留到这一刻),所以只写 trajectoryEndTime,不动各轨迹点的时间。 */
function scaleClipDuration(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
const targetEnd = (times[times.length - 1] ?? startEdge) + deltaSeconds;
const targetEnd =
(owner.kind === "trajectory" ? startEdge : times[times.length - 1] ?? startEdge) + deltaSeconds;
if (owner.kind === "trajectory") {
setTrajectoryPointTimes(
owner.objectId,
scaleTrajectoryPointTimes(times, targetEnd, duration, startEdge)
);
setTrajectoryClipEnd(owner.objectId, targetEnd);
return;
}
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
}
/** 拖单个关键帧:只改它自己的时间(被前后邻居夹住) */
/** 拖单个关键帧:只改它自己的时间(被前后邻居夹住;轨迹最后一点还受停留结束时间约束) */
function moveClipKeyframe(
owner: TimelineTimeDragOwner,
times: number[],
index: number,
startEdge: number,
deltaSeconds: number
deltaSeconds: number,
endEdge = duration
) {
const targetTime = (times[index] ?? 0) + deltaSeconds;
if (owner.kind === "trajectory") {
const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge);
const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge, endEdge);
updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] });
return;
}
@@ -852,6 +899,29 @@ export function TimelinePanel() {
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
}
/**
* 修剪起点(条左端把手),和右端把手对称:
* - 人物轨迹:只挪片段起点 —— 角色在这之前原地等待,各轨迹点与停留结束时间留在原地,
* 所以「等待多久」变了,走位本身的节奏没变;
* - 运镜:末帧固定不动,首帧推到新位置,其余关键帧按比例缩放 —— 和右端把手(首帧固定、
* 末帧伸缩)正好反过来,整段运镜的节奏保持不变。
*/
function trimClipStart(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
if (owner.kind === "camera") {
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeStart(times, startEdge + deltaSeconds, duration));
return;
}
if (owner.kind !== "trajectory") return;
setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
}
/**
* 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」,
* 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。
@@ -859,10 +929,11 @@ export function TimelinePanel() {
function beginTimeDrag(
event: ReactPointerEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
kind: "point" | "duration" | "clip",
kind: "point" | "duration" | "clip" | "trimStart",
index: number,
times: number[],
startEdge = 0
startEdge = 0,
endEdge = duration
) {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
@@ -884,6 +955,7 @@ export function TimelinePanel() {
index,
startTimes: [...times],
startEdge,
endEdge,
startClientX: event.clientX,
secondsPerPixel: duration / laneWidth,
};
@@ -917,7 +989,12 @@ export function TimelinePanel() {
return;
}
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime);
if (drag.kind === "trimStart") {
trimClipStart(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
return;
}
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime, drag.endEdge);
}
function handleTimeDragEnd(event: ReactPointerEvent<HTMLElement>) {
@@ -937,14 +1014,15 @@ export function TimelinePanel() {
/** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */
function getTimeDragProps(
owner: TimelineTimeDragOwner,
kind: "point" | "duration" | "clip",
kind: "point" | "duration" | "clip" | "trimStart",
index: number,
times: number[],
startEdge = 0
startEdge = 0,
endEdge = duration
) {
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) =>
beginTimeDrag(event, owner, kind, index, times, startEdge),
beginTimeDrag(event, owner, kind, index, times, startEdge, endEdge),
onPointerMove: handleTimeDragMove,
onPointerUp: handleTimeDragEnd,
onPointerCancel: handleTimeDragEnd,
@@ -966,6 +1044,21 @@ export function TimelinePanel() {
scaleClipDuration(owner, times, startEdge, delta);
}
/** 修剪起点的把手同样支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
function handleTrimStartKeyDown(
event: ReactKeyboardEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number
) {
const step = event.shiftKey ? 0.5 : 0.1;
const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
if (delta === 0) return;
event.preventDefault();
trimClipStart(owner, times, startEdge, delta);
}
function handleTrackPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
@@ -1110,14 +1203,27 @@ export function TimelinePanel() {
action();
}
// 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。
// 焦点在时间轴上时:
// - 空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放);
// - Delete / Backspace 删除选中的轨迹点(角色轨迹点没有独立删除按钮,选中后按删除键移除)。
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的行为重复触发。
function handlePanelKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== " " && event.key !== "Spacebar") return;
const target = event.target as HTMLElement | null;
if (target && target.closest("button, input, textarea, select, [contenteditable='true']")) {
const insideNativeControl = !!(
target && target.closest("button, input, textarea, select, [contenteditable='true']")
);
if (event.key === "Delete" || event.key === "Backspace") {
if (insideNativeControl) return;
if (selectedTrajectoryPoint) {
event.preventDefault();
handleDeleteSelected();
}
return;
}
if (event.key !== " " && event.key !== "Spacebar") return;
if (insideNativeControl) return;
event.preventDefault();
togglePlaying();
}
@@ -1387,7 +1493,9 @@ export function TimelinePanel() {
{/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
<div className="timeline-grid-body" tabIndex={0} onPointerDown={handleTimelineGridPointerDown}>
{timeline.tracks.map((track) => {
{timeline.tracks
.filter((track) => project.objects.find((o) => o.id === track.objectId)?.kind !== "character")
.map((track) => {
const obj = project.objects.find((o) => o.id === track.objectId);
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
const trackName = obj?.name ?? track.objectId;
@@ -1432,17 +1540,6 @@ export function TimelinePanel() {
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
>
×
</button>
</div>
);
})
@@ -1467,10 +1564,15 @@ export function TimelinePanel() {
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
const clipWidthPercent = duration > 0 ? (clipDuration / duration) * 100 : 0;
const clipOwner: TimelineTimeDragOwner = { kind: "camera", cameraId: track.cameraId };
// 运镜关键帧连线:在「机位」子轨道上把各关键帧用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
// 50 = 轨道垂直中点),直观呈现运镜过程
const cameraConnectorPoints = cameraTimes
.map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
.join(" ");
return (
<div
className={`timeline-track timeline-track-camera${collapsed ? " is-collapsed" : ""}${
className={`timeline-camera-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.cameraId}
@@ -1478,93 +1580,127 @@ export function TimelinePanel() {
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
<div className="timeline-track timeline-track-camera">
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
</div>
{/* 运镜轨道头部车道:展开时放「运镜片段条」,折叠时放一行摘要(与角色轨道头部一致) */}
<div className="timeline-track-lane">
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : cameraTimes.length >= 2 && duration > 0 ? (
<div
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与终点`}
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
{/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜起点`}
aria-valuemin={0}
aria-valuemax={Number(
Math.max(
clipEnd - CAMERA_KEYFRAME_MIN_GAP * Math.max(cameraTimes.length - 1, 0),
0
).toFixed(2)
)}
aria-valuenow={Number(clipStart.toFixed(2))}
aria-valuetext={formatTime(clipStart)}
title={`拖动修剪运镜起点(${formatTime(
clipStart
)} 开始这段运镜,末帧不动、其余关键帧按比例缩放),或用左右方向键微调`}
onKeyDown={(event) => handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)}
{...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)}
/>
{/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜时长`}
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
aria-valuemax={Math.max(duration, clipEnd)}
aria-valuenow={Number(clipEnd.toFixed(2))}
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
}
{...getTimeDragProps(
clipOwner,
"duration",
cameraTimes.length - 1,
cameraTimes,
clipStart
)}
/>
</div>
) : null}
</div>
</div>
<div className="timeline-track-lane">
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : (
<>
{/* 运镜片段条:拖条本体可整段左右平移(前面那段时间相机保持首帧机位),
拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
{cameraTimes.length >= 2 && duration > 0 ? (
<div
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动右端把手改时长`}
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
<span
className="timeline-trajectory-handle"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜时长`}
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
aria-valuemax={Math.max(duration, clipEnd)}
aria-valuenow={Number(clipEnd.toFixed(2))}
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
}
{...getTimeDragProps(
clipOwner,
"duration",
cameraTimes.length - 1,
cameraTimes,
clipStart
)}
/>
</div>
) : null}
{track.keyframes.map((kf, index) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${
isSelected ? " is-selected" : ""
}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
{collapsed ? null : (
<>
{/* 机位子轨道:运镜关键帧单独一条轨道,并用折线把相邻关键帧连起来呈现运镜过程 */}
<div
className="timeline-subtrack"
title="机位关键帧:每个运镜关键帧,可单独左右拖动改时间,选中后点 × 或按 Delete 删除。折线连接相邻关键帧呈现运镜过程"
>
<div className="timeline-subtrack-label">关键帧</div>
<div className="timeline-track-lane timeline-camera-keyframe-lane">
{cameraTimes.length > 0 && duration > 0 ? (
<>
{/* 折线连接各运镜关键帧,呈现运镜过程 */}
<svg
className="timeline-trajectory-connector"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
×
</button>
</div>
);
})}
</>
)}
</div>
<polyline points={cameraConnectorPoints} />
</svg>
{track.keyframes.map((kf, index) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${
isSelected ? " is-selected" : ""
}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
</div>
);
})}
</>
) : null}
</div>
</div>
</>
)}
</div>
);
})}
@@ -1580,11 +1716,18 @@ export function TimelinePanel() {
const trackKey = getTimelineTrackKey("character", character.id);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
// 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)与每个轨迹点的关键帧时间
// 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)、每个轨迹点的关键帧时间,
// 以及片段结束时间(角色走完最后一点后原地停留到这一刻,即「停留时长」)。
// 默认结束时间 = 时间轴末尾,所以最后一个点落在时间结束处;一旦设置 trajectoryEndTime,
// 最后一个点只是路径中途的途经点,条右边缘到结束时间之间都是停留段。
const trajectoryStart = getTrajectoryStartTime(character, duration);
const trajectoryTimes = getTrajectoryPointTimes(trajectoryPoints, duration, trajectoryStart);
const trajectoryEndTime =
trajectoryTimes.length > 0 ? trajectoryTimes[trajectoryTimes.length - 1] : trajectoryStart;
const trajectoryEndTime = getTrajectoryClipEndTime(character, duration, trajectoryStart);
// 轨迹点连线:在「轨迹」轨道上把各轨迹点用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
// 50 = 轨道垂直中点),直观呈现走位路径(最后一个点只是途经点,后面是停留段)
const trajectoryConnectorPoints = [trajectoryStart, ...trajectoryTimes]
.map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
.join(" ");
const trajectoryLeftPercent = duration > 0 ? (trajectoryStart / duration) * 100 : 0;
const trajectoryWidthPercent =
duration > 0 ? Math.min(100, ((trajectoryEndTime - trajectoryStart) / duration) * 100) : 0;
@@ -1613,12 +1756,71 @@ export function TimelinePanel() {
<span className="timeline-track-name">{character.name}</span>
<span className="timeline-track-count">{trajectoryPoints.length}</span>
</div>
{/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
{/* 分组抬头行:展开时显示轨迹片段条,折叠时显示一行摘要 */}
<div className="timeline-track-lane timeline-character-head-lane">
{collapsed ? (
<span className="timeline-track-summary">
{motionLabel} · {trajectoryPoints.length} 个轨迹点
</span>
) : trajectoryTimes.length > 0 && duration > 0 ? (
<div
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime
)}(时长 ${formatTime(
trajectoryEndTime - trajectoryStart
)},结尾停留到 ${formatTime(
trajectoryEndTime
)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与停留终点`}
{...getTimeDragProps(trajectoryOwner, "clip", 0, trajectoryTimes, trajectoryStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
{/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹起点`}
aria-valuemin={0}
aria-valuemax={Number(
Math.max((trajectoryTimes[0] ?? 0) - TRAJECTORY_POINT_MIN_GAP, 0).toFixed(2)
)}
aria-valuenow={Number(trajectoryStart.toFixed(2))}
aria-valuetext={formatTime(trajectoryStart)}
title={`拖动修剪轨迹起点(${formatTime(
trajectoryStart
)} 之前角色原地等待),或用左右方向键微调`}
onKeyDown={(event) =>
handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
}
{...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)}
/>
{/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹停留时长`}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
aria-valuetext={formatTime(trajectoryEndTime)}
title="拖动调整轨迹停留时长(角色走完最后一点后原地停留到这一刻),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime)
}
{...getTimeDragProps(
trajectoryOwner,
"duration",
trajectoryTimes.length - 1,
trajectoryTimes,
trajectoryEndTime
)}
/>
</div>
) : null}
</div>
</div>
@@ -1636,57 +1838,25 @@ export function TimelinePanel() {
</div>
</div>
{/* 人物轨迹:一条「轨迹条」= 角色从起点走到最后一个轨迹点所需的时长。
条本体可左右拖动整段平移(脱离 0s,前面那段时间角色原地等待),
右端把手拖动改整段时长,条上的关键帧可单独拖动改时间 */}
{/* 人物轨迹轨道:一条「轨迹」子轨道,显示各轨迹点关键帧(小菱形),
并用折线把点连起来呈现走位路径。点可单独左右拖动改时间,选中后按 Delete 删除 */}
<div
className="timeline-subtrack"
title="人物轨迹:拖动条本体可整段左右平移(起点之前角色原地等待);拖动右端把手调整整段时长;拖动关键帧调整该点时间"
title="人物轨迹:每个轨迹点一条关键帧,可单独左右拖动改时间,选中后按 Delete 删除"
>
<div className="timeline-subtrack-label">轨迹</div>
<div className="timeline-track-lane timeline-trajectory-lane">
{trajectoryTimes.length > 0 && duration > 0 ? (
<>
<div
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime
)}(时长 ${formatTime(trajectoryEndTime - trajectoryStart)}):拖动条本体整段平移,拖动右端把手改时长`}
{...getTimeDragProps(
trajectoryOwner,
"clip",
0,
trajectoryTimes,
trajectoryStart
)}
{/* 折线连接各轨迹点,呈现走位路径(最后一个点只是途经点) */}
<svg
className="timeline-trajectory-connector"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
<span
className="timeline-trajectory-handle"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹时长`}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
aria-valuetext={formatTime(trajectoryEndTime)}
title="拖动调整轨迹时长(所有轨迹点按比例缩放),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
}
{...getTimeDragProps(
trajectoryOwner,
"duration",
trajectoryTimes.length - 1,
trajectoryTimes,
trajectoryStart
)}
/>
</div>
<polyline points={trajectoryConnectorPoints} />
</svg>
{trajectoryPoints.map((point, index) => {
const pointTime = trajectoryTimes[index] ?? 0;
const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100;
@@ -1704,27 +1874,20 @@ export function TimelinePanel() {
role="button"
tabIndex={0}
aria-label={`轨迹关键帧 ${formatTime(pointTime)}`}
title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}(左右拖动调整时间)`}
title={`第 ${index + 1} 个轨迹点 · ${formatTime(
pointTime
)}(左右拖动调整时间,选中后按 Delete 删除)`}
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
{...getTimeDragProps(
{ kind: "trajectory", objectId: character.id, pointId: point.id },
"point",
index,
trajectoryTimes
trajectoryTimes,
trajectoryStart,
trajectoryEndTime
)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除轨迹关键帧 ${formatTime(pointTime)}`}
onClick={(event) => {
event.stopPropagation();
removeTrajectoryPoint(character.id, point.id);
}}
>
×
</button>
</div>
);
})}