feat: 新增导出设置、过渡与缓动及重做支持
This commit is contained in:
1 parent
482d649f84
commit
2c59709f6b
18 files changed
+1174
-351
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user