refactor(timeline): 解耦机位片段与运镜关键帧并新增过渡片段编辑

- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止
- 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调
- 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导
- 画布视锥线支持机位自定义颜色
- 视频导出时传入过渡片段、机位与场景参数以保持预览一致
- 更新 TimelinePanel 相关测试以覆盖新模型与交互
This commit is contained in:
2026-09-19 07:53:35 +08:00
parent 8d6c30b548
commit 91fcb6841a
12 changed files with 1453 additions and 606 deletions

View File

@@ -146,29 +146,50 @@ export interface CameraKeyframeValue {
lookRotation: TransformTuple;
fov: number;
}
/** 机位活跃片段:机位在 [start, end] 期间为「激活镜头」,时间轴按此自动切换机位。一台机位可有多段。 */
export interface CameraActiveClip {
id: string;
start: number;
end: number;
}
export interface CameraTimelineKeyframe {
id: string;
time: number;
value: CameraKeyframeValue;
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
/** 该关键帧成为激活机位时的切点过渡(硬切 / 平滑运镜);不填则退回时间轴全局默认 cameraTransition */
transition?: CameraTransition;
}
export interface CameraTimelineTrack {
cameraId: string; // 对应 DirectorCameraShot.id
/** 机位活跃时间段:决定「谁在播」。可多段、可拖拽修剪(机位内运镜的载体) */
clips: CameraActiveClip[];
/** 机位内运镜关键帧:在活跃时间段内插值出相机姿态(位置 / 注视 / FOV) */
keyframes: CameraTimelineKeyframe[];
}
/** 运镜(过渡)片段:独立的「运镜轨道」上可拖拽的过渡片段,连接两个机位 */
export interface TransitionClip {
id: string;
start: number;
end: number;
/** 过渡来源机位;null = 自动取 start 之前最近激活的机位 */
fromCameraId: string | null;
toCameraId: string;
type: CameraTransitionType;
}
export interface Timeline {
duration: number; // 秒
tracks: TimelineTrack[];
cameraTracks: CameraTimelineTrack[];
/** 运镜(过渡)轨:多机位切换的显式连接,可拖拽、可单独添加,连接由时间序唯一决定 */
transitionClips: TransitionClip[];
/** 每秒帧数:读数里的帧号与视频导出帧率都按它换算 */
fps: number;
/** 播放到末尾是否循环回起点 */
loop: boolean;
/** 多机位切换过渡(全局默认):硬切或相机平滑运镜 */
/** 全局默认过渡(新建过渡片段的默认类型参考;具体时长由片段 [start,end] 决定) */
cameraTransition: CameraTransition;
}
@@ -239,6 +260,7 @@ export function createDefaultTimeline(): Timeline {
duration: DEFAULT_TIMELINE_DURATION,
tracks: [],
cameraTracks: [],
transitionClips: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
cameraTransition: DEFAULT_CAMERA_TRANSITION,
@@ -357,101 +379,128 @@ export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: numb
}
/**
* 某个时刻应该显示哪个机位(多机位自动切换 / 自动切点):
* 取所有相机轨道里「时间 ≤ t 的最后一帧」中时间最大者所属的那个机位。
* 没有任何机位在 t 之前打过关键帧时,退回 fallback(通常是当前激活机位)。
* 这样时间轴播放时,机位视角会按关键帧时间点自动切到对应机位。
* 机位切换的「切点」类型(仅用于旧工程兼容迁移:把历史 cuts 转成 transitionClips)。新数据不再使用。
*/
export interface CameraSegment {
cameraId: string | null;
/** 该机位在 time 处成为激活机位的切点时间(即「≤ time 最近关键帧」的时间) */
cutTime: number;
export interface CameraCut {
id: string;
/** 切点时刻(秒,>= 0) */
time: number;
/** 切到哪台机位(DirectorCameraShot.id) */
toCameraId: string;
/** 该切点的过渡(硬切 / 平滑运镜);不填则退回全局默认 cameraTransition */
transition: CameraTransition;
}
/** 返回 time 处应显示的机位及其「切点」时间,供过渡计算使用 */
export function getActiveCameraSegmentAtTime(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
/**
* 找到 time 处生效的过渡片段:优先包含 time 的片段中 start 最晚者(片段重叠时后者胜出)。
* 运镜(过渡)轨片段可拖拽,连接由时间序唯一决定。
*/
export function findActiveTransition(
transitionClips: TransitionClip[],
time: number
): CameraSegment {
let bestCameraId: string | null = null;
let bestTime = -Infinity;
for (const track of cameraTracks) {
if (track.keyframes.length === 0) continue;
// 取该机位在 t 之前(含)最近的一帧
let latest = -Infinity;
for (const keyframe of track.keyframes) {
if (keyframe.time <= time && keyframe.time > latest) latest = keyframe.time;
}
if (latest > bestTime) {
bestTime = latest;
bestCameraId = track.cameraId;
): TransitionClip | null {
let best: TransitionClip | null = null;
let bestStart = -Infinity;
for (const clip of transitionClips) {
if (time >= clip.start && time <= clip.end && clip.start > bestStart) {
bestStart = clip.start;
best = clip;
}
}
return { cameraId: bestCameraId ?? fallbackCameraId, cutTime: bestTime };
return best;
}
/**
* 取某机位的有效活跃窗口:
* - 显式 clips 非空时直接用它们;
* - clips 为空但有运镜关键帧时,按「首帧→末帧」自动推导一个兜底窗口,
* 这样「只打关键帧」也能让该机位在其运镜区间生效,不会因缺 clips 而不在播。
*/
function getCameraActiveWindows(track: CameraTimelineTrack): { start: number; end: number }[] {
if (track.clips.length > 0) return track.clips;
if (track.keyframes.length > 0) {
const times = track.keyframes.map((keyframe) => keyframe.time);
return [{ start: Math.min(...times), end: Math.max(...times) }];
}
return [];
}
/**
* 由「机位活跃片段(clips) + 运镜(过渡)片段结束」决定 time 处应显示的机位:
* - 优先包含 time 的机位片段(重叠时取最晚开始者);
* - 否则取在 time 之前结束、结束时刻最晚的事件(机位片段结束 或 过渡片段结束)所属机位;
* 过渡片段结束后其 toCameraId 持续生效,直到下一个片段 / 过渡接管;
* - 都没有则退回 fallback。机位切换完全按时间自动决定。
*/
export function getClipCameraAtTime(
cameraTracks: CameraTimelineTrack[],
transitionClips: TransitionClip[],
fallbackCameraId: string | null,
time: number
): string | null {
let containing: string | null = null;
let containingStart = -Infinity;
let latestEnd: string | null = null;
let latestEndTime = -Infinity;
for (const track of cameraTracks) {
for (const clip of getCameraActiveWindows(track)) {
if (time >= clip.start && time <= clip.end) {
if (clip.start > containingStart) {
containingStart = clip.start;
containing = track.cameraId;
}
} else if (clip.end < time && clip.end > latestEndTime) {
latestEndTime = clip.end;
latestEnd = track.cameraId;
}
}
}
for (const tr of transitionClips) {
if (tr.end < time && tr.end > latestEndTime) {
latestEndTime = tr.end;
latestEnd = tr.toCameraId;
}
}
return containing ?? latestEnd ?? fallbackCameraId;
}
/** 返回 time 处应显示的机位 id(多机位按时间自动切换)。 */
export function getActiveCameraIdAtTime(
transitionClips: TransitionClip[],
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
time: number
): string | null {
return getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time).cameraId;
const transition = findActiveTransition(transitionClips, time);
if (transition) return transition.toCameraId;
return getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, time);
}
// 注:多机位切换现已由「机位活跃片段(clips) + 运镜(过渡)轨片段(transitionClips)」显式表达,
// 不再需要基于 CameraCut 的切点序列展开(getCameraCutSegments 已移除)。
/**
* 时间轴上「激活机位发生变化」的切点序列,用于把多机位切换过渡画在概览轨上:
* 遍历所有相机轨道的关键帧(按时间排序),每当某关键帧使其所属机位成为新的激活机位(与上一刻不同),
* 即记为一个切换点。该切换点的过渡优先取切点关键帧上的 transition,否则退回全局默认。
* 注意:最早期关键帧建立的是时间轴「初始机位」,其前没有任何关键帧,不算一次切换(即便与当前激活机位不同),
* 因此只有当该切点之前已存在其它关键帧时才记为切换,避免 0s 处出现一条无关的镜头切换。
* 旧工程 / 没有显式运镜(过渡)轨时,从运镜关键帧反推出切换轨(兼容迁移用):
* 取全局最早的关键帧作为「初始机位切点」,之后每个带显式 transition 的关键帧生成一个切点。
*/
export interface CameraCutSegment {
/** 切换发生的时刻(= 切点关键帧的时间) */
time: number;
/** 切换前的激活机位(紧邻切点之前);时间轴开头无关键帧时为 fallback */
fromCameraId: string | null;
/** 切换后的激活机位(切点关键帧所属机位) */
toCameraId: string;
/** 切点关键帧(过渡就挂在它上面,编辑时按它定位) */
keyframe: CameraTimelineKeyframe;
/** 解析后的过渡:切点关键帧 transition ?? 全局默认 */
transition: CameraTransition;
}
export function getCameraCutSegments(
export function deriveCameraCutsFromTracks(
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;
): CameraCut[] {
const all = cameraTracks.flatMap((track) => track.keyframes.map((kf) => ({ cameraId: track.cameraId, kf })));
if (all.length === 0) return [];
const sorted = [...all].sort((a, b) => a.kf.time - b.kf.time);
// 旧工程关键帧上已无「过渡覆盖」字段(过渡已移到 TransitionClip),故每个关键帧统一用全局默认过渡
const cuts: CameraCut[] = sorted.map(({ cameraId, kf }) => ({
id: createKeyframeId(),
time: kf.time,
toCameraId: cameraId,
transition: normalizeCameraTransition(null),
}));
if (cuts.length === 0 && fallbackCameraId) {
cuts.push({ id: createKeyframeId(), time: 0, toCameraId: fallbackCameraId, transition: normalizeCameraTransition(null) });
}
return segments;
return cuts;
}
export interface CameraViewAtTime {
@@ -461,62 +510,57 @@ export interface CameraViewAtTime {
/**
* 计算 time 处机位视角应呈现的相机姿态,含多机位切换过渡:
* - 无过渡(硬切)或已过过渡窗口:直接用当前激活机位在 time 处的插值姿态;
* - 平滑运镜下:在 [cutTime, cutTime + duration) 内把姿态从「上一机位在切点处的姿态」
* 按缓动插值到「当前机位在 time 处的姿态」,形成相机平滑运镜过渡。
* - 落在某个运镜(过渡)片段 [start,end] 内:把 fromCameraId → toCameraId 的姿态做混合
* (cut = 起点即切换到 to;smooth = 在 [start,end] 内按缓动插值)。from 为 null 时自动取
* start 之前最近激活的机位。
* - 否则落在某机位活跃片段内:用该机位在 time 处的插值姿态。
* - 否则(空隙):冻结保持在 time 之前最近激活的机位。
* 机位没有运镜关键帧时,用 getStaticPose 取该机位的静态视角(保证过渡也能混合「无运镜」的机位)。
* 单相机即可实现,预览与导出共用,无需双画面渲染。
*/
export function evaluateCameraViewAtTime(
transitionClips: TransitionClip[],
cameraTracks: CameraTimelineTrack[],
transition: CameraTransition,
globalTransition: CameraTransition,
fallbackCameraId: string | null,
time: number
time: number,
getStaticPose?: (cameraId: string) => CameraKeyframeValue | null
): CameraViewAtTime | null {
const segment = getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time);
const incomingTrack = cameraTracks.find((item) => item.cameraId === segment.cameraId);
if (!incomingTrack || incomingTrack.keyframes.length === 0) return null;
const incomingPose = evaluateCameraTrackAtTime(incomingTrack, time);
if (!incomingPose) return null;
const getPose = (cameraId: string | null): CameraKeyframeValue | null => {
if (!cameraId) return null;
const track = cameraTracks.find((item) => item.cameraId === cameraId);
if (track && track.keyframes.length > 0) {
const pose = evaluateCameraTrackAtTime(track, time);
if (pose) return pose;
}
return getStaticPose ? getStaticPose(cameraId) : null;
};
// 切点之前没有任何关键帧:这是时间轴的「初始机位」,不算一次切换,直接落位(与概览轨一致,避免 0s 处出现无关切换)
const hasPriorKeyframe = cameraTracks.some((track) =>
track.keyframes.some((k) => k.time < segment.cutTime - 1e-6)
);
if (!hasPriorKeyframe) {
return { cameraId: segment.cameraId, pose: incomingPose };
const transition = findActiveTransition(transitionClips, time);
if (transition) {
// from 自动解析为「过渡开始前那一刻」正在显示的机位;用 start 之前一瞬(而非 start 本身,
// 因为片段边界时间两端都含,start 处会被 to 机位的片段抢走),避免转场起点取到 to 机位
const fromId =
transition.fromCameraId ??
getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, Math.max(transition.start - 1e-3, 0));
const toId = transition.toCameraId;
const toPose = getPose(toId);
if (!toPose) return null;
if (transition.type !== "smooth") {
return { cameraId: toId, pose: toPose };
}
const span = transition.end - transition.start;
if (span <= 0) return { cameraId: toId, pose: toPose };
const progress = applyEasing("easeInOutCubic", (time - transition.start) / span);
const fromPose = getPose(fromId);
if (!fromPose) return { cameraId: toId, pose: toPose };
return { cameraId: toId, pose: lerpCameraValue(fromPose, toPose, progress) };
}
// 本切点的过渡:优先用切点关键帧上挂的逐个过渡,否则退回全局默认 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 >= effectiveTransition.duration) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
// 过渡窗口内:找到切点之前的上一机位,取其切点处的姿态作为过渡起点
const prevSegment = getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, segment.cutTime - 1e-4);
if (!prevSegment.cameraId || prevSegment.cameraId === segment.cameraId) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const prevTrack = cameraTracks.find((item) => item.cameraId === prevSegment.cameraId);
if (!prevTrack || prevTrack.keyframes.length === 0) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const outgoingPose = evaluateCameraTrackAtTime(prevTrack, segment.cutTime);
if (!outgoingPose) return { cameraId: segment.cameraId, pose: incomingPose };
const progress = applyEasing("easeInOutCubic", elapsed / effectiveTransition.duration);
return { cameraId: segment.cameraId, pose: lerpCameraValue(outgoingPose, incomingPose, progress) };
const clipCameraId = getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, time);
const pose = getPose(clipCameraId);
if (clipCameraId && pose) return { cameraId: clipCameraId, pose };
return null;
}
// ===== 物体增删改 =====
@@ -614,7 +658,7 @@ export function upsertCameraKeyframe(
return { ...track, keyframes: sortKeyframes([...track.keyframes, { id: keyframeId, time: safeTime, value }]) };
});
if (touchedExisting || cameraTracks.some((t) => t.cameraId === cameraId)) return { ...timeline, cameraTracks };
return { ...timeline, cameraTracks: [...timeline.cameraTracks, { cameraId, keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
return { ...timeline, cameraTracks: [...timeline.cameraTracks, { cameraId, clips: [], keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
}
export function removeCameraKeyframe(timeline: Timeline, cameraId: string, keyframeId: string): Timeline {
@@ -669,29 +713,12 @@ 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 TRANSITION_CLIP_MIN_DURATION = 0.1;
/** 机位活跃片段允许的最小时长(秒) */
export const CAMERA_CLIP_MIN_DURATION = 0.1;
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */