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 = { 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 | 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(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 { time: number; value: V; easing?: EasingKind; } function evaluateKeyframes( keyframes: TimedValue[], 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) }; }), }; }