feat(timeline): 支持拖拽调整时长与运镜/轨迹关键帧时间
This commit is contained in:
@@ -40,13 +40,66 @@ export interface Timeline {
|
||||
duration: number; // 秒
|
||||
tracks: TimelineTrack[];
|
||||
cameraTracks: CameraTimelineTrack[];
|
||||
/** 每秒帧数:读数里的帧号与视频导出帧率都按它换算 */
|
||||
fps: number;
|
||||
/** 播放到末尾是否循环回起点 */
|
||||
loop: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TIMELINE_DURATION = 5;
|
||||
/** 时长下限(秒):再短就没有可编辑的区间了 */
|
||||
export const MIN_TIMELINE_DURATION = 0.1;
|
||||
/** 时长上限(秒):1 小时,够用且能挡住误输入 */
|
||||
export const MAX_TIMELINE_DURATION = 3600;
|
||||
/** 默认帧率:与视频导出的常用帧率一致 */
|
||||
export const DEFAULT_TIMELINE_FPS = 30;
|
||||
export const MIN_TIMELINE_FPS = 1;
|
||||
export const MAX_TIMELINE_FPS = 120;
|
||||
/** 时长微调刻度(秒):上下按钮与键盘方向键都按它走 */
|
||||
export const TIMELINE_DURATION_STEP = 0.1;
|
||||
|
||||
const KEYFRAME_TIME_TOLERANCE = 0.001;
|
||||
|
||||
/** 把任意输入夹成合法的时长(秒):非法值回落到下限,避免 NaN 把整条时间轴搞坏 */
|
||||
export function normalizeTimelineDuration(value: unknown): number {
|
||||
const seconds = typeof value === "number" && Number.isFinite(value) ? value : MIN_TIMELINE_DURATION;
|
||||
return Math.min(Math.max(seconds, MIN_TIMELINE_DURATION), MAX_TIMELINE_DURATION);
|
||||
}
|
||||
|
||||
/** 把任意输入夹成合法的帧率(取整) */
|
||||
export function normalizeTimelineFps(value: unknown): number {
|
||||
const fps = typeof value === "number" && Number.isFinite(value) ? Math.round(value) : DEFAULT_TIMELINE_FPS;
|
||||
return Math.min(Math.max(fps, MIN_TIMELINE_FPS), MAX_TIMELINE_FPS);
|
||||
}
|
||||
|
||||
export function createDefaultTimeline(): Timeline {
|
||||
return { duration: DEFAULT_TIMELINE_DURATION, tracks: [], cameraTracks: [] };
|
||||
return {
|
||||
duration: DEFAULT_TIMELINE_DURATION,
|
||||
tracks: [],
|
||||
cameraTracks: [],
|
||||
fps: DEFAULT_TIMELINE_FPS,
|
||||
loop: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 写入总时长(统一做夹取,拖动 / 输入框 / 微调按钮共用) */
|
||||
export function updateTimelineDuration(timeline: Timeline, seconds: number): Timeline {
|
||||
return { ...timeline, duration: normalizeTimelineDuration(seconds) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入播放设置(帧率 / 循环)。设置弹窗只在点「完成」时调用一次,
|
||||
* 所以没传的字段保持原值,不会把用户没改的那一项覆盖掉。
|
||||
*/
|
||||
export function updateTimelinePlaybackSettings(
|
||||
timeline: Timeline,
|
||||
patch: { fps?: number; loop?: boolean }
|
||||
): Timeline {
|
||||
return {
|
||||
...timeline,
|
||||
fps: patch.fps === undefined ? timeline.fps : normalizeTimelineFps(patch.fps),
|
||||
loop: patch.loop === undefined ? timeline.loop : patch.loop === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createKeyframeId(): string {
|
||||
@@ -247,3 +300,86 @@ export function updateCameraKeyframeValue(timeline: Timeline, cameraId: string,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
|
||||
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
|
||||
export const CAMERA_KEYFRAME_MIN_GAP = 0.05;
|
||||
|
||||
/**
|
||||
* 一次运镜占用的时间区间:第一个关键帧是「运镜开始」,最后一个是「运镜结束」,
|
||||
* 两者之差就是这段运镜的时长。没有关键帧时返回 null。
|
||||
*/
|
||||
export function getCameraClipSpan(keyframes: { time: number }[]): { start: number; end: number } | null {
|
||||
if (keyframes.length === 0) return null;
|
||||
return { start: keyframes[0].time, end: keyframes[keyframes.length - 1].time };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某个运镜关键帧拖到新时间:夹在前后邻居之间(各留最小间隔)。
|
||||
* 与轨迹点不同的是,第一个关键帧的下界是 0——相机完全可以在 0s 就摆好第一个机位;
|
||||
* 最后一个关键帧的上界是时间轴末尾。
|
||||
*/
|
||||
export function moveCameraKeyframeTime(times: number[], index: number, time: number, duration: number): number[] {
|
||||
if (index < 0 || index >= times.length) return times;
|
||||
|
||||
const isLast = index === times.length - 1;
|
||||
const lower = index === 0 ? 0 : times[index - 1] + CAMERA_KEYFRAME_MIN_GAP;
|
||||
const upper = isLast ? Math.max(duration, lower) : times[index + 1] - CAMERA_KEYFRAME_MIN_GAP;
|
||||
const next = [...times];
|
||||
next[index] = Math.min(Math.max(time, lower), Math.max(lower, upper));
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖动运镜片段右端把手:末帧落到 newEnd,其余关键帧按同一比例缩放,
|
||||
* 第一个关键帧保持不动——这就是「整体缩放这段运镜的时间」,各帧之间的相对节奏不变。
|
||||
* newEnd 夹在「起点 + 最小跨度 ~ 时间轴末尾」之间。
|
||||
*/
|
||||
export function scaleCameraKeyframeTimes(times: number[], newEnd: number, duration: number): number[] {
|
||||
const count = times.length;
|
||||
if (count < 2) return times;
|
||||
|
||||
const start = times[0];
|
||||
const currentEnd = times[count - 1];
|
||||
const currentSpan = currentEnd - start;
|
||||
if (!Number.isFinite(currentSpan) || currentSpan <= 0) return times;
|
||||
|
||||
// 缩放下界:所有关键帧还得能依次排开
|
||||
const minSpan = CAMERA_KEYFRAME_MIN_GAP * (count - 1);
|
||||
const lower = start + minSpan;
|
||||
const end = Math.min(Math.max(newEnd, lower), Math.max(lower, duration));
|
||||
if (Math.abs(end - currentEnd) < 1e-6) return times;
|
||||
|
||||
const ratio = (end - start) / currentSpan;
|
||||
const scaled = times.map((value, index) => {
|
||||
if (index === 0) return start;
|
||||
const next = start + (value - start) * ratio;
|
||||
return Math.min(Math.max(next, start + CAMERA_KEYFRAME_MIN_GAP * index), end);
|
||||
});
|
||||
scaled[count - 1] = end;
|
||||
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性覆盖整条运镜轨道的所有关键帧时间(按索引对应,最后统一按时间重排)。
|
||||
* 拖动把手 / 拖动关键帧都走这里,避免逐个更新时反复排序导致索引错位。
|
||||
*/
|
||||
export function setCameraKeyframeTimes(timeline: Timeline, cameraId: string, times: number[]): Timeline {
|
||||
return {
|
||||
...timeline,
|
||||
cameraTracks: timeline.cameraTracks.map((track) => {
|
||||
if (track.cameraId !== cameraId) return track;
|
||||
|
||||
const keyframes = track.keyframes.map((keyframe, index) => {
|
||||
const time = times[index];
|
||||
return typeof time === "number" && Number.isFinite(time)
|
||||
? { ...keyframe, time: clampTime(time, timeline.duration) }
|
||||
: keyframe;
|
||||
});
|
||||
|
||||
return { ...track, keyframes: sortKeyframes(keyframes) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user