feat(timeline): 支持逐切点机位过渡与概览轨编辑

This commit is contained in:
tongtong committed 2026-09-19 05:44:26 +08:00
1 parent 2c59709f6b
commit 8d6c30b548
10 files changed
+542 -37

No files matched your search

+154
View File
@@ -21,6 +21,7 @@ import {
Play,
Plus,
RefreshCw,
Repeat,
Settings2,
SkipBack,
SkipForward,
@@ -49,7 +50,9 @@ import {
scaleCameraKeyframeTimes,
EASING_KINDS,
EASING_LABELS,
getCameraCutSegments,
type CameraKeyframeValue,
type CameraTransition,
type CameraTransitionType,
type EasingKind,
type KeyframeTransformValue,
@@ -331,6 +334,7 @@ export function TimelinePanel() {
const updateKeyframeEasing = useDirectorStore((state) => state.updateKeyframeEasing);
const updateExportSettings = useDirectorStore((state) => state.updateExportSettings);
const updateCameraKeyframeEasing = useDirectorStore((state) => state.updateCameraKeyframeEasing);
const setCameraKeyframeTransition = useDirectorStore((state) => state.setCameraKeyframeTransition);
const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes);
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
@@ -400,6 +404,19 @@ export function TimelinePanel() {
// 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次;
// 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。
const [draggingTrackKey, setDraggingTrackKey] = useState<string | null>(null);
/** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */
const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState<string | null>(null);
useEffect(() => {
if (!selectedCutKeyframeId) return;
function handlePointerDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.closest(".timeline-transition-editor")) return;
if (target.closest(".timeline-transition-marker")) return;
setSelectedCutKeyframeId(null);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [selectedCutKeyframeId]);
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
/** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */
@@ -1882,6 +1899,143 @@ export function TimelinePanel() {
);
})}
{/* 机位切换概览轨:把每个切换切点画成过渡标记(硬切=竖线、平滑=渐变区),点击可编辑类型/时长(逐切点覆盖全局默认) */}
{(() => {
const cuts = getCameraCutSegments(
timeline.cameraTracks,
timeline.cameraTransition,
project.activeCameraId
);
if (cuts.length === 0) return null;
const selectedCut = cuts.find((item) => item.keyframe.id === selectedCutKeyframeId) ?? null;
const selLeft = selectedCut && duration > 0 ? (selectedCut.time / duration) * 100 : 0;
return (
<div className="timeline-track timeline-track-transition">
<div className="timeline-track-label" title="机位切换:每个切换点的过渡类型与时长,点击可编辑">
<Repeat aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">机位切换</span>
<span className="timeline-track-count">{cuts.length}</span>
</div>
<div className="timeline-track-lane timeline-transition-lane">
{cuts.map((cut, index) => {
const leftPercent = duration > 0 ? (cut.time / duration) * 100 : 0;
const nextTime = cuts[index + 1]?.time ?? duration;
const maxDur = Math.max(0, nextTime - cut.time);
const shownDuration =
cut.transition.type === "smooth"
? Math.min(cut.transition.duration, maxDur > 0 ? maxDur : cut.transition.duration)
: 0;
const widthPercent = duration > 0 ? (shownDuration / duration) * 100 : 0;
const isSelected = cut.keyframe.id === selectedCutKeyframeId;
const fromName =
project.cameras.find((c) => c.id === cut.fromCameraId)?.name ?? (cut.fromCameraId ?? "起始");
const toName = project.cameras.find((c) => c.id === cut.toCameraId)?.name ?? cut.toCameraId;
const transitionLabel =
cut.transition.type === "smooth" ? `平滑运镜 ${formatTime(cut.transition.duration)}` : "硬切";
return (
<div
key={cut.keyframe.id}
className={`timeline-transition-marker timeline-transition-${cut.transition.type}${
isSelected ? " is-selected" : ""
}`}
style={
cut.transition.type === "smooth"
? { left: `${leftPercent}%`, width: `${widthPercent}%` }
: { left: `${leftPercent}%` }
}
role="button"
tabIndex={0}
aria-label={`机位切换 ${formatTime(cut.time)} · ${fromName} → ${toName} · ${transitionLabel}`}
title={`${fromName} → ${toName} · ${transitionLabel}(点击编辑)`}
onClick={() => setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id);
}
}}
>
{cut.transition.type === "smooth" ? (
<span className="timeline-transition-duration">{formatTime(cut.transition.duration)}</span>
) : null}
</div>
);
})}
{selectedCut ? (
<div
className="timeline-transition-editor"
style={{ left: `${selLeft}%` }}
role="dialog"
aria-label="编辑机位切换过渡"
>
<div className="timeline-transition-editor-row">
<span className="timeline-transition-editor-label">类型</span>
<InspectorDropdown
className="timeline-transition-dropdown"
ariaLabel="切换类型"
value={selectedCut.transition.type}
options={[
{ value: "cut", label: "硬切" },
{ value: "smooth", label: "平滑运镜" },
]}
onChange={(value) =>
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
type: value as CameraTransitionType,
duration: selectedCut.transition.duration,
})
}
/>
</div>
<div className="timeline-transition-editor-row">
<span className="timeline-transition-editor-label">时长</span>
<div className="timeline-transition-editor-duration-field">
<input
className="timeline-settings-input timeline-transition-duration-input"
aria-label="过渡时长(秒)"
type="number"
min={MIN_CAMERA_TRANSITION_DURATION}
max={MAX_CAMERA_TRANSITION_DURATION}
step={0.1}
value={selectedCut.transition.duration}
disabled={selectedCut.transition.type !== "smooth"}
onChange={(event) =>
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
type: selectedCut.transition.type,
duration: Math.min(
MAX_CAMERA_TRANSITION_DURATION,
Math.max(MIN_CAMERA_TRANSITION_DURATION, Number(event.target.value))
),
})
}
/>
<span className="timeline-settings-unit">秒</span>
</div>
</div>
<div className="timeline-transition-editor-actions">
<button
type="button"
className="timeline-transition-editor-reset"
disabled={!selectedCut.keyframe.transition}
onClick={() => setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, null)}
>
重置为全局默认
</button>
<button
type="button"
className="timeline-transition-editor-close"
aria-label="关闭编辑器"
onClick={() => setSelectedCutKeyframeId(null)}
>
<X aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
</div>
</div>
) : null}
</div>
</div>
);
})()}
{timeline.cameraTracks.map((track) => {
const shot = project.cameras.find((c) => c.id === track.cameraId);
const trackName = shot?.name ?? track.cameraId;