Files
ttstd-3d-director/src/editor/schema/timeline.ts
TongTongStudio 91fcb6841a refactor(timeline): 解耦机位片段与运镜关键帧并新增过渡片段编辑
- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止
- 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调
- 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导
- 画布视锥线支持机位自定义颜色
- 视频导出时传入过渡片段、机位与场景参数以保持预览一致
- 更新 TimelinePanel 相关测试以覆盖新模型与交互
2026-09-19 07:53:35 +08:00

838 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Euler, Quaternion } from "three";
import { degreesToRadians, radiansToDegrees } from "./directorProject";
import { slerpCameraLookRotation } from "./cameraGeometry";
export type TransformTuple = [number, number, number];
// ===== 关键帧缓动曲线 =====
export type EasingKind =
| "linear"
| "easeInSine"
| "easeOutSine"
| "easeInOutSine"
| "easeInQuad"
| "easeOutQuad"
| "easeInOutQuad"
| "easeInCubic"
| "easeOutCubic"
| "easeInOutCubic"
| "easeInExpo"
| "easeOutExpo"
| "easeInOutExpo"
| "easeInBack"
| "easeOutBack"
| "easeInOutBack";
/** 时间轴上缓动下拉用的固定顺序(linear 排第一作为默认) */
export const EASING_KINDS: EasingKind[] = [
"linear",
"easeInSine",
"easeOutSine",
"easeInOutSine",
"easeInQuad",
"easeOutQuad",
"easeInOutQuad",
"easeInCubic",
"easeOutCubic",
"easeInOutCubic",
"easeInExpo",
"easeOutExpo",
"easeInOutExpo",
"easeInBack",
"easeOutBack",
"easeInOutBack",
];
export const EASING_LABELS: Record<EasingKind, string> = {
linear: "线性(默认)",
easeInSine: "缓入·正弦",
easeOutSine: "缓出·正弦",
easeInOutSine: "缓入缓出·正弦",
easeInQuad: "缓入·二次",
easeOutQuad: "缓出·二次",
easeInOutQuad: "缓入缓出·二次",
easeInCubic: "缓入·三次",
easeOutCubic: "缓出·三次",
easeInOutCubic: "缓入缓出·三次",
easeInExpo: "缓入·指数",
easeOutExpo: "缓出·指数",
easeInOutExpo: "缓入缓出·指数",
easeInBack: "缓入·回弹",
easeOutBack: "缓出·回弹",
easeInOutBack: "缓入缓出·回弹",
};
/**
* 把「段内归一化进度 t∈[0,1]」映射成缓动后的进度。
* 缓动作用于关键帧之间的整段(取该段「起始关键帧」的 easing 设置),
* 所有通道(位置 / 旋转 / 缩放 / 注视 / FOV)共用同一条缓动曲线。
* 端点严格保持:t<=0 返回 0、t>=1 返回 1,确保关键帧本身不会被缓动推离原值。
*/
export function applyEasing(kind: EasingKind, t: number): number {
const x = Math.min(1, Math.max(0, t));
if (x <= 0) return 0;
if (x >= 1) return 1;
switch (kind) {
case "linear":
return x;
case "easeInSine":
return 1 - Math.cos((x * Math.PI) / 2);
case "easeOutSine":
return Math.sin((x * Math.PI) / 2);
case "easeInOutSine":
return -(Math.cos(Math.PI * x) - 1) / 2;
case "easeInQuad":
return x * x;
case "easeOutQuad":
return 1 - (1 - x) * (1 - x);
case "easeInOutQuad":
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
case "easeInCubic":
return x * x * x;
case "easeOutCubic":
return 1 - Math.pow(1 - x, 3);
case "easeInOutCubic":
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
case "easeInExpo":
return Math.pow(2, 10 * x - 10);
case "easeOutExpo":
return 1 - Math.pow(2, -10 * x);
case "easeInOutExpo":
return x < 0.5 ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2;
case "easeInBack": {
const c1 = 1.70158;
const c3 = c1 + 1;
return c3 * x * x * x - c1 * x * x;
}
case "easeOutBack": {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
}
case "easeInOutBack": {
const c1 = 1.70158;
const c2 = c1 * 1.525;
return x < 0.5
? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2
: (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (2 * x - 2) + c2) + 2) / 2;
}
default:
return x;
}
}
// ===== 物体关键帧 =====
export interface KeyframeTransformValue {
position: TransformTuple;
rotation: TransformTuple;
scale: TransformTuple;
}
export interface TimelineKeyframe {
id: string;
time: number; // 秒,>= 0
value: KeyframeTransformValue;
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
}
export interface TimelineTrack {
objectId: string; // 对应 DirectorObject.id
keyframes: TimelineKeyframe[]; // 保持按 time 升序
}
// ===== 相机关键帧 =====
export interface CameraKeyframeValue {
position: TransformTuple;
/** 注视朝向(度):[俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)],与机位的 lookRotation 同一套语义 */
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;
}
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;
}
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 = 144;
/** 帧率固定档位:用下拉选择代替手动输入,覆盖常用档(含 144),避免非法值 */
export const TIMELINE_FPS_TIERS: number[] = [24, 30, 60, 90, 120, 144];
// ===== 多机位切换过渡 =====
export type CameraTransitionType = "cut" | "smooth";
export interface CameraTransition {
type: CameraTransitionType;
/** 过渡时长(秒):切点时相机姿态从上一机位插值过渡到新机位的时间 */
duration: number;
}
export const MIN_CAMERA_TRANSITION_DURATION = 0.1;
export const MAX_CAMERA_TRANSITION_DURATION = 5;
export const DEFAULT_CAMERA_TRANSITION_DURATION = 0.5;
/** 把任意输入夹成合法的过渡时长(秒),非法值回落到默认,避免 NaN 把切点搞坏 */
export function normalizeCameraTransitionDuration(value: unknown): number {
const seconds = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_CAMERA_TRANSITION_DURATION;
return Math.min(Math.max(seconds, MIN_CAMERA_TRANSITION_DURATION), MAX_CAMERA_TRANSITION_DURATION);
}
/** 全局默认开启「相机平滑运镜」过渡,让多机位切换不再生硬硬切 */
export const DEFAULT_CAMERA_TRANSITION: CameraTransition = {
type: "smooth",
duration: DEFAULT_CAMERA_TRANSITION_DURATION,
};
/** 把任意(含旧工程缺失)的过渡配置归一化,保证字段齐全、数值合法 */
export function normalizeCameraTransition(
transition: Partial<CameraTransition> | null | undefined
): CameraTransition {
if (!transition) return { ...DEFAULT_CAMERA_TRANSITION };
const type: CameraTransitionType = transition.type === "smooth" ? "smooth" : "cut";
return { type, duration: normalizeCameraTransitionDuration(transition.duration) };
}
/** 时长微调刻度(秒):上下按钮与键盘方向键都按它走 */
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: [],
transitionClips: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
cameraTransition: DEFAULT_CAMERA_TRANSITION,
};
}
/** 写入总时长(统一做夹取,拖动 / 输入框 / 微调按钮共用) */
export function updateTimelineDuration(timeline: Timeline, seconds: number): Timeline {
return { ...timeline, duration: normalizeTimelineDuration(seconds) };
}
/**
* 写入播放设置(帧率 / 循环)。设置弹窗只在点「完成」时调用一次,
* 所以没传的字段保持原值,不会把用户没改的那一项覆盖掉。
*/
export function updateTimelinePlaybackSettings(
timeline: Timeline,
patch: { fps?: number; loop?: boolean; cameraTransition?: CameraTransition }
): Timeline {
return {
...timeline,
fps: patch.fps === undefined ? timeline.fps : normalizeTimelineFps(patch.fps),
loop: patch.loop === undefined ? timeline.loop : patch.loop === true,
cameraTransition:
patch.cameraTransition === undefined
? timeline.cameraTransition
: normalizeCameraTransition(patch.cameraTransition),
};
}
export function createKeyframeId(): string {
return `kf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export function sortKeyframes<K extends { time: number }>(keyframes: K[]): K[] {
return [...keyframes].sort((a, b) => a.time - b.time);
}
function clampTime(time: number, duration: number) {
return Math.max(0, Math.min(time, duration));
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
function lerpTuple(a: TransformTuple, b: TransformTuple, t: number): TransformTuple {
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}
// ===== 共享求值(物体与相机同构) =====
interface TimedValue<V> {
time: number;
value: V;
easing?: EasingKind;
}
function evaluateKeyframes<V>(
keyframes: TimedValue<V>[],
time: number,
lerpFn: (a: V, b: V, t: number) => V
): V | null {
if (keyframes.length === 0) return null;
if (time <= keyframes[0].time) return keyframes[0].value;
const last = keyframes[keyframes.length - 1];
if (time >= last.time) return last.value;
for (let i = 0; i < keyframes.length - 1; i += 1) {
const a = keyframes[i];
const b = keyframes[i + 1];
if (time >= a.time && time <= b.time) {
const span = b.time - a.time;
const t = span === 0 ? 0 : (time - a.time) / span;
// 取「起始关键帧」的缓动曲线作用于整段;端点处 applyEasing 返回 0/1,保证关键帧不被推离
const eased = applyEasing(a.easing ?? "linear", t);
return lerpFn(a.value, b.value, eased);
}
}
return last.value;
}
// ===== 物体插值 =====
const _qa = new Quaternion();
const _qb = new Quaternion();
const _qr = new Quaternion();
const _tmpEuler = new Euler();
function slerpRotation(a: TransformTuple, b: TransformTuple, t: number): TransformTuple {
if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return [a[0], a[1], a[2]];
// 关键帧里存的是度数;四元数插值要弧度,插完再转回度数,保持数据模型单位一致
_qa.setFromEuler(_tmpEuler.set(degreesToRadians(a[0]), degreesToRadians(a[1]), degreesToRadians(a[2])));
_qb.setFromEuler(_tmpEuler.set(degreesToRadians(b[0]), degreesToRadians(b[1]), degreesToRadians(b[2])));
_qr.slerpQuaternions(_qa, _qb, t);
_tmpEuler.setFromQuaternion(_qr);
return [radiansToDegrees(_tmpEuler.x), radiansToDegrees(_tmpEuler.y), radiansToDegrees(_tmpEuler.z)];
}
function lerpTransformValue(a: KeyframeTransformValue, b: KeyframeTransformValue, t: number): KeyframeTransformValue {
return {
position: lerpTuple(a.position, b.position, t),
rotation: slerpRotation(a.rotation, b.rotation, t),
scale: lerpTuple(a.scale, b.scale, t),
};
}
export function evaluateTrackAtTime(track: TimelineTrack, time: number): KeyframeTransformValue | null {
return evaluateKeyframes(track.keyframes, time, lerpTransformValue);
}
// ===== 相机插值 =====
function lerpCameraValue(a: CameraKeyframeValue, b: CameraKeyframeValue, t: number): CameraKeyframeValue {
return {
position: lerpTuple(a.position, b.position, t),
// 注视朝向是角度,走四元数插值(与轨迹点一致),位置 / FOV 仍按线性插值
lookRotation: slerpCameraLookRotation(a.lookRotation, b.lookRotation, t),
fov: lerp(a.fov, b.fov, t),
};
}
export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: number): CameraKeyframeValue | null {
return evaluateKeyframes(track.keyframes, time, lerpCameraValue);
}
/**
* 机位切换的「切点」类型(仅用于旧工程兼容迁移:把历史 cuts 转成 transitionClips)。新数据不再使用。
*/
export interface CameraCut {
id: string;
/** 切点时刻(秒,>= 0) */
time: number;
/** 切到哪台机位(DirectorCameraShot.id) */
toCameraId: string;
/** 该切点的过渡(硬切 / 平滑运镜);不填则退回全局默认 cameraTransition */
transition: CameraTransition;
}
/**
* 找到 time 处生效的过渡片段:优先包含 time 的片段中 start 最晚者(片段重叠时后者胜出)。
* 运镜(过渡)轨片段可拖拽,连接由时间序唯一决定。
*/
export function findActiveTransition(
transitionClips: TransitionClip[],
time: number
): 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 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 {
const transition = findActiveTransition(transitionClips, time);
if (transition) return transition.toCameraId;
return getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, time);
}
// 注:多机位切换现已由「机位活跃片段(clips) + 运镜(过渡)轨片段(transitionClips)」显式表达,
// 不再需要基于 CameraCut 的切点序列展开(getCameraCutSegments 已移除)。
/**
* 旧工程 / 没有显式运镜(过渡)轨时,从运镜关键帧反推出切换轨(兼容迁移用):
* 取全局最早的关键帧作为「初始机位切点」,之后每个带显式 transition 的关键帧生成一个切点。
*/
export function deriveCameraCutsFromTracks(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null
): 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 cuts;
}
export interface CameraViewAtTime {
cameraId: string | null;
pose: CameraKeyframeValue;
}
/**
* 计算 time 处机位视角应呈现的相机姿态,含多机位切换过渡:
* - 落在某个运镜(过渡)片段 [start,end] 内:把 fromCameraId → toCameraId 的姿态做混合
* (cut = 起点即切换到 to;smooth = 在 [start,end] 内按缓动插值)。from 为 null 时自动取
* start 之前最近激活的机位。
* - 否则落在某机位活跃片段内:用该机位在 time 处的插值姿态。
* - 否则(空隙):冻结保持在 time 之前最近激活的机位。
* 机位没有运镜关键帧时,用 getStaticPose 取该机位的静态视角(保证过渡也能混合「无运镜」的机位)。
* 单相机即可实现,预览与导出共用,无需双画面渲染。
*/
export function evaluateCameraViewAtTime(
transitionClips: TransitionClip[],
cameraTracks: CameraTimelineTrack[],
globalTransition: CameraTransition,
fallbackCameraId: string | null,
time: number,
getStaticPose?: (cameraId: string) => CameraKeyframeValue | null
): CameraViewAtTime | 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;
};
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) };
}
const clipCameraId = getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, time);
const pose = getPose(clipCameraId);
if (clipCameraId && pose) return { cameraId: clipCameraId, pose };
return null;
}
// ===== 物体增删改 =====
export function upsertKeyframe(
timeline: Timeline,
objectId: string,
time: number,
value: KeyframeTransformValue,
keyframeId: string = createKeyframeId()
): Timeline {
const safeTime = clampTime(time, timeline.duration);
let touchedExisting = false;
const tracks = timeline.tracks.map((track) => {
if (track.objectId !== objectId) return track;
const idx = track.keyframes.findIndex((k) => Math.abs(k.time - safeTime) < KEYFRAME_TIME_TOLERANCE);
if (idx >= 0) {
touchedExisting = true;
return { ...track, keyframes: track.keyframes.map((k, i) => (i === idx ? { ...k, value } : k)) };
}
return { ...track, keyframes: sortKeyframes([...track.keyframes, { id: keyframeId, time: safeTime, value }]) };
});
if (touchedExisting || tracks.some((t) => t.objectId === objectId)) return { ...timeline, tracks };
return { ...timeline, tracks: [...timeline.tracks, { objectId, keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
}
export function removeKeyframe(timeline: Timeline, objectId: string, keyframeId: string): Timeline {
return {
...timeline,
tracks: timeline.tracks
.map((track) =>
track.objectId === objectId
? { ...track, keyframes: track.keyframes.filter((k) => k.id !== keyframeId) }
: track
)
.filter((track) => track.keyframes.length > 0),
};
}
export function updateKeyframeTime(timeline: Timeline, objectId: string, keyframeId: string, time: number): Timeline {
const safeTime = clampTime(time, timeline.duration);
return {
...timeline,
tracks: timeline.tracks.map((track) => {
if (track.objectId !== objectId) return track;
return { ...track, keyframes: sortKeyframes(track.keyframes.map((k) => (k.id === keyframeId ? { ...k, time: safeTime } : k))) };
}),
};
}
export function updateKeyframeValue(timeline: Timeline, objectId: string, keyframeId: string, value: KeyframeTransformValue): Timeline {
return {
...timeline,
tracks: timeline.tracks.map((track) =>
track.objectId === objectId
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, value } : k)) }
: track
),
};
}
/** 修改某个物体关键帧「出去的那一段」的缓动曲线(位置 / 旋转 / 缩放共用) */
export function updateKeyframeEasing(
timeline: Timeline,
objectId: string,
keyframeId: string,
easing: EasingKind
): Timeline {
return {
...timeline,
tracks: timeline.tracks.map((track) =>
track.objectId === objectId
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, easing } : k)) }
: track
),
};
}
// ===== 相机增删改(与物体同构,操作 cameraTracks) =====
export function upsertCameraKeyframe(
timeline: Timeline,
cameraId: string,
time: number,
value: CameraKeyframeValue,
keyframeId: string = createKeyframeId()
): Timeline {
const safeTime = clampTime(time, timeline.duration);
let touchedExisting = false;
const cameraTracks = timeline.cameraTracks.map((track) => {
if (track.cameraId !== cameraId) return track;
const idx = track.keyframes.findIndex((k) => Math.abs(k.time - safeTime) < KEYFRAME_TIME_TOLERANCE);
if (idx >= 0) {
touchedExisting = true;
return { ...track, keyframes: track.keyframes.map((k, i) => (i === idx ? { ...k, value } : k)) };
}
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, clips: [], keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
}
export function removeCameraKeyframe(timeline: Timeline, cameraId: string, keyframeId: string): Timeline {
return {
...timeline,
cameraTracks: timeline.cameraTracks
.map((track) =>
track.cameraId === cameraId
? { ...track, keyframes: track.keyframes.filter((k) => k.id !== keyframeId) }
: track
)
.filter((track) => track.keyframes.length > 0),
};
}
export function updateCameraKeyframeTime(timeline: Timeline, cameraId: string, keyframeId: string, time: number): Timeline {
const safeTime = clampTime(time, timeline.duration);
return {
...timeline,
cameraTracks: timeline.cameraTracks.map((track) => {
if (track.cameraId !== cameraId) return track;
return { ...track, keyframes: sortKeyframes(track.keyframes.map((k) => (k.id === keyframeId ? { ...k, time: safeTime } : k))) };
}),
};
}
export function updateCameraKeyframeValue(timeline: Timeline, cameraId: string, keyframeId: string, value: CameraKeyframeValue): Timeline {
return {
...timeline,
cameraTracks: timeline.cameraTracks.map((track) =>
track.cameraId === cameraId
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, value } : k)) }
: track
),
};
}
/** 修改某个运镜关键帧「出去的那一段」的缓动曲线(位置 / 注视 / FOV 共用) */
export function updateCameraKeyframeEasing(
timeline: Timeline,
cameraId: string,
keyframeId: string,
easing: EasingKind
): Timeline {
return {
...timeline,
cameraTracks: timeline.cameraTracks.map((track) =>
track.cameraId === cameraId
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, easing } : k)) }
: track
),
};
}
// ===== 运镜(过渡)片段时间 =====
/** 运镜(过渡)片段里允许的最小时长(秒),保证可拖拽、视觉上可见 */
export const TRANSITION_CLIP_MIN_DURATION = 0.1;
/** 机位活跃片段允许的最小时长(秒) */
export const CAMERA_CLIP_MIN_DURATION = 0.1;
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
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;
}
/**
* 拖动运镜片段左端把手(修剪起点):与右端把手完全对称 —— 末帧固定不动,
* 首帧落到 newStart,其余关键帧按同一比例缩放,各帧之间的相对节奏不变。
* newStart 夹在「0 ~ 末帧 - 最小跨度」之间。
*/
export function scaleCameraKeyframeStart(times: number[], newStart: number, duration: number): number[] {
const count = times.length;
if (count < 2) return times;
const end = times[count - 1];
const start = times[0];
const currentSpan = end - start;
if (!Number.isFinite(currentSpan) || currentSpan <= 0) return times;
// 缩放上界:末帧不动,首帧最多贴到「末帧 - 最小跨度」处,保证各帧还能依次排开
const minSpan = CAMERA_KEYFRAME_MIN_GAP * (count - 1);
const upper = Math.max(Math.min(end - minSpan, duration), 0);
const nextStart = Math.min(Math.max(newStart, 0), upper);
if (Math.abs(nextStart - start) < 1e-6) return times;
const ratio = (end - nextStart) / currentSpan;
const scaled = times.map((value, index) => {
if (index === count - 1) return end;
const next = end - (end - value) * ratio;
// 这一帧之后还剩 count-1-index 帧,各自要留够最小间隔
const ceiling = end - CAMERA_KEYFRAME_MIN_GAP * (count - 1 - index);
return Math.max(Math.min(next, ceiling), nextStart);
});
scaled[0] = nextStart;
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) };
}),
};
}