feat(timeline): 支持逐切点机位过渡与概览轨编辑
This commit is contained in:
1 parent
2c59709f6b
commit
8d6c30b548
10 files changed
+542
-37
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user