- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止 - 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调 - 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导 - 画布视锥线支持机位自定义颜色 - 视频导出时传入过渡片段、机位与场景参数以保持预览一致 - 更新 TimelinePanel 相关测试以覆盖新模型与交互
838 lines
32 KiB
TypeScript
838 lines
32 KiB
TypeScript
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) };
|
||
}),
|
||
};
|
||
}
|