refactor(timeline): 解耦机位片段与运镜关键帧并新增过渡片段编辑
- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止 - 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调 - 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导 - 画布视锥线支持机位自定义颜色 - 视频导出时传入过渡片段、机位与场景参数以保持预览一致 - 更新 TimelinePanel 相关测试以覆盖新模型与交互
This commit is contained in:
@@ -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;
|
||||
|
||||
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
|
||||
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
|
||||
|
||||
Reference in New Issue
Block a user