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

This commit is contained in:
2026-09-19 05:44:26 +08:00
parent 2c59709f6b
commit 8d6c30b548
10 changed files with 542 additions and 37 deletions

View File

@@ -152,6 +152,8 @@ export interface CameraTimelineKeyframe {
value: CameraKeyframeValue;
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
/** 该关键帧成为激活机位时的切点过渡(硬切 / 平滑运镜);不填则退回时间轴全局默认 cameraTransition */
transition?: CameraTransition;
}
export interface CameraTimelineTrack {
cameraId: string; // 对应 DirectorCameraShot.id
@@ -401,6 +403,57 @@ export function getActiveCameraIdAtTime(
return getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time).cameraId;
}
/**
* 时间轴上「激活机位发生变化」的切点序列,用于把多机位切换过渡画在概览轨上:
* 遍历所有相机轨道的关键帧(按时间排序),每当某关键帧使其所属机位成为新的激活机位(与上一刻不同),
* 即记为一个切换点。该切换点的过渡优先取切点关键帧上的 transition,否则退回全局默认。
* 注意:最早期关键帧建立的是时间轴「初始机位」,其前没有任何关键帧,不算一次切换(即便与当前激活机位不同),
* 因此只有当该切点之前已存在其它关键帧时才记为切换,避免 0s 处出现一条无关的镜头切换。
*/
export interface CameraCutSegment {
/** 切换发生的时刻(= 切点关键帧的时间) */
time: number;
/** 切换前的激活机位(紧邻切点之前);时间轴开头无关键帧时为 fallback */
fromCameraId: string | null;
/** 切换后的激活机位(切点关键帧所属机位) */
toCameraId: string;
/** 切点关键帧(过渡就挂在它上面,编辑时按它定位) */
keyframe: CameraTimelineKeyframe;
/** 解析后的过渡:切点关键帧 transition ?? 全局默认 */
transition: CameraTransition;
}
export function getCameraCutSegments(
cameraTracks: CameraTimelineTrack[],
globalTransition: CameraTransition,
fallbackCameraId: string | null
): CameraCutSegment[] {
const items = cameraTracks
.flatMap((track) => track.keyframes.map((kf) => ({ cameraId: track.cameraId, kf })))
.sort((a, b) => a.kf.time - b.kf.time);
const segments: CameraCutSegment[] = [];
let prevCameraId = fallbackCameraId;
for (const { cameraId, kf } of items) {
const activeCameraId = getActiveCameraIdAtTime(cameraTracks, fallbackCameraId, kf.time);
// 仅当该切点之前已存在其它关键帧时才记为一次切换:最早期关键帧(无前序关键帧)是时间轴初始机位,
// 即便与当前激活机位不同也不算一次切换,不画在概览轨上(也避免 0s 处出现无关切换)
const hasPriorKeyframe = cameraTracks.some((track) =>
track.keyframes.some((k) => k.time < kf.time - 1e-6)
);
if (activeCameraId !== null && activeCameraId !== prevCameraId && hasPriorKeyframe) {
segments.push({
time: kf.time,
fromCameraId: prevCameraId,
toCameraId: activeCameraId,
keyframe: kf,
transition: kf.transition ? normalizeCameraTransition(kf.transition) : globalTransition,
});
}
prevCameraId = activeCameraId;
}
return segments;
}
export interface CameraViewAtTime {
cameraId: string | null;
pose: CameraKeyframeValue;
@@ -425,12 +478,28 @@ export function evaluateCameraViewAtTime(
const incomingPose = evaluateCameraTrackAtTime(incomingTrack, time);
if (!incomingPose) return null;
if (transition.type !== "smooth" || transition.duration <= 0) {
// 切点之前没有任何关键帧:这是时间轴的「初始机位」,不算一次切换,直接落位(与概览轨一致,避免 0s 处出现无关切换)
const hasPriorKeyframe = cameraTracks.some((track) =>
track.keyframes.some((k) => k.time < segment.cutTime - 1e-6)
);
if (!hasPriorKeyframe) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
// 本切点的过渡:优先用切点关键帧上挂的逐个过渡,否则退回全局默认 cameraTransition
const cutKeyframe = incomingTrack.keyframes.find(
(kf) => Math.abs(kf.time - segment.cutTime) < KEYFRAME_TIME_TOLERANCE
);
const effectiveTransition = cutKeyframe?.transition
? normalizeCameraTransition(cutKeyframe.transition)
: transition;
if (effectiveTransition.type !== "smooth" || effectiveTransition.duration <= 0) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const elapsed = time - segment.cutTime;
if (elapsed >= transition.duration) {
if (elapsed >= effectiveTransition.duration) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
@@ -446,7 +515,7 @@ export function evaluateCameraViewAtTime(
const outgoingPose = evaluateCameraTrackAtTime(prevTrack, segment.cutTime);
if (!outgoingPose) return { cameraId: segment.cameraId, pose: incomingPose };
const progress = applyEasing("easeInOutCubic", elapsed / transition.duration);
const progress = applyEasing("easeInOutCubic", elapsed / effectiveTransition.duration);
return { cameraId: segment.cameraId, pose: lerpCameraValue(outgoingPose, incomingPose, progress) };
}
@@ -600,6 +669,30 @@ export function updateCameraKeyframeEasing(
};
}
/** 设置 / 清除某个运镜关键帧上的「切点过渡」覆盖(transition 为 null = 清除,退回全局默认 cameraTransition) */
export function setCameraKeyframeTransition(
timeline: Timeline,
cameraId: string,
keyframeId: string,
transition: CameraTransition | null
): Timeline {
return {
...timeline,
cameraTracks: timeline.cameraTracks.map((track) =>
track.cameraId !== cameraId
? track
: {
...track,
keyframes: track.keyframes.map((kf) =>
kf.id === keyframeId
? { ...kf, transition: transition ? normalizeCameraTransition(transition) : undefined }
: kf
),
}
),
};
}
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
export const CAMERA_KEYFRAME_MIN_GAP = 0.05;