feat: 优化时间轴
This commit is contained in:
1 parent
e66e3374bf
commit
8645ea3007
19 files changed
+1538
-391
No files matched your search
+342
-179
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user