feat(timeline): 支持逐切点机位过渡与概览轨编辑
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user