feat: 新增导出设置、过渡与缓动及重做支持

This commit is contained in:
tongtong committed 2026-09-19 04:41:48 +08:00
1 parent 482d649f84
commit 2c59709f6b
18 files changed
+1174 -351

No files matched your search

+271 -7
View File
@@ -4,6 +4,123 @@ 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;
@@ -14,7 +131,8 @@ export interface TimelineKeyframe {
id: string;
time: number; // 秒,>= 0
value: KeyframeTransformValue;
easing?: "linear"; // 预留:MVP 只用线性插值,该字段当前不读取
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
}
export interface TimelineTrack {
objectId: string; // 对应 DirectorObject.id
@@ -32,6 +150,8 @@ export interface CameraTimelineKeyframe {
id: string;
time: number;
value: CameraKeyframeValue;
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
easing?: EasingKind;
}
export interface CameraTimelineTrack {
cameraId: string; // 对应 DirectorCameraShot.id
@@ -46,6 +166,8 @@ export interface Timeline {
fps: number;
/** 播放到末尾是否循环回起点 */
loop: boolean;
/** 多机位切换过渡(全局默认):硬切或相机平滑运镜 */
cameraTransition: CameraTransition;
}
export const DEFAULT_TIMELINE_DURATION = 5;
@@ -56,7 +178,43 @@ 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 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;
@@ -81,6 +239,7 @@ export function createDefaultTimeline(): Timeline {
cameraTracks: [],
fps: DEFAULT_TIMELINE_FPS,
loop: false,
cameraTransition: DEFAULT_CAMERA_TRANSITION,
};
}
@@ -95,12 +254,16 @@ export function updateTimelineDuration(timeline: Timeline, seconds: number): Tim
*/
export function updateTimelinePlaybackSettings(
timeline: Timeline,
patch: { fps?: number; loop?: boolean }
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),
};
}
@@ -126,6 +289,7 @@ function lerpTuple(a: TransformTuple, b: TransformTuple, t: number): TransformTu
interface TimedValue<V> {
time: number;
value: V;
easing?: EasingKind;
}
function evaluateKeyframes<V>(
keyframes: TimedValue<V>[],
@@ -142,7 +306,9 @@ function evaluateKeyframes<V>(
if (time >= a.time && time <= b.time) {
const span = b.time - a.time;
const t = span === 0 ? 0 : (time - a.time) / span;
return lerpFn(a.value, b.value, t);
// 取「起始关键帧」的缓动曲线作用于整段;端点处 applyEasing 返回 0/1,保证关键帧不被推离
const eased = applyEasing(a.easing ?? "linear", t);
return lerpFn(a.value, b.value, eased);
}
}
return last.value;
@@ -194,11 +360,18 @@ export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: numb
* 没有任何机位在 t 之前打过关键帧时,退回 fallback(通常是当前激活机位)。
* 这样时间轴播放时,机位视角会按关键帧时间点自动切到对应机位。
*/
export function getActiveCameraIdAtTime(
export interface CameraSegment {
cameraId: string | null;
/** 该机位在 time 处成为激活机位的切点时间(即「≤ time 最近关键帧」的时间) */
cutTime: number;
}
/** 返回 time 处应显示的机位及其「切点」时间,供过渡计算使用 */
export function getActiveCameraSegmentAtTime(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
time: number
): string | null {
): CameraSegment {
let bestCameraId: string | null = null;
let bestTime = -Infinity;
@@ -217,7 +390,64 @@ export function getActiveCameraIdAtTime(
}
}
return bestCameraId ?? fallbackCameraId;
return { cameraId: bestCameraId ?? fallbackCameraId, cutTime: bestTime };
}
export function getActiveCameraIdAtTime(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
time: number
): string | null {
return getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time).cameraId;
}
export interface CameraViewAtTime {
cameraId: string | null;
pose: CameraKeyframeValue;
}
/**
* 计算 time 处机位视角应呈现的相机姿态,含多机位切换过渡:
* - 无过渡(硬切)或已过过渡窗口:直接用当前激活机位在 time 处的插值姿态;
* - 平滑运镜下:在 [cutTime, cutTime + duration) 内把姿态从「上一机位在切点处的姿态」
* 按缓动插值到「当前机位在 time 处的姿态」,形成相机平滑运镜过渡。
* 单相机即可实现,预览与导出共用,无需双画面渲染。
*/
export function evaluateCameraViewAtTime(
cameraTracks: CameraTimelineTrack[],
transition: CameraTransition,
fallbackCameraId: string | null,
time: number
): 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;
if (transition.type !== "smooth" || transition.duration <= 0) {
return { cameraId: segment.cameraId, pose: incomingPose };
}
const elapsed = time - segment.cutTime;
if (elapsed >= transition.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 / transition.duration);
return { cameraId: segment.cameraId, pose: lerpCameraValue(outgoingPose, incomingPose, progress) };
}
// ===== 物体增删改 =====
@@ -278,6 +508,23 @@ export function updateKeyframeValue(timeline: Timeline, objectId: string, keyfra
};
}
/** 修改某个物体关键帧「出去的那一段」的缓动曲线(位置 / 旋转 / 缩放共用) */
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,
@@ -336,6 +583,23 @@ export function updateCameraKeyframeValue(timeline: Timeline, cameraId: string,
};
}
/** 修改某个运镜关键帧「出去的那一段」的缓动曲线(位置 / 注视 / 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 CAMERA_KEYFRAME_MIN_GAP = 0.05;