refactor(timeline): 解耦机位片段与运镜关键帧并新增过渡片段编辑

- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止
- 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调
- 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导
- 画布视锥线支持机位自定义颜色
- 视频导出时传入过渡片段、机位与场景参数以保持预览一致
- 更新 TimelinePanel 相关测试以覆盖新模型与交互
This commit is contained in:
2026-09-19 07:53:35 +08:00
parent 8d6c30b548
commit 91fcb6841a
12 changed files with 1453 additions and 606 deletions

View File

@@ -51,21 +51,31 @@ import {
updateCameraKeyframeTime as updateTimelineCameraKeyframeTime,
updateCameraKeyframeValue as updateTimelineCameraKeyframeValue,
updateCameraKeyframeEasing as updateTimelineCameraKeyframeEasing,
setCameraKeyframeTransition as setTimelineCameraKeyframeTransition,
setCameraKeyframeTimes as setTimelineCameraKeyframeTimes,
updateTimelineDuration,
updateTimelinePlaybackSettings,
normalizeTimelineDuration,
normalizeTimelineFps,
normalizeCameraTransition,
deriveCameraCutsFromTracks,
TRANSITION_CLIP_MIN_DURATION,
CAMERA_CLIP_MIN_DURATION,
DEFAULT_TIMELINE_DURATION,
DEFAULT_CAMERA_TRANSITION_DURATION,
DEFAULT_CAMERA_TRANSITION,
createKeyframeId,
type KeyframeTransformValue,
type CameraKeyframeValue,
type CameraTransition,
type CameraCut,
type TransitionClip,
type CameraActiveClip,
type CameraTimelineTrack,
type CameraTransitionType,
type EasingKind,
type Timeline,
} from "../schema/timeline";
import { usePlaybackStore } from "../runtime/playbackStore";
import {
TRAJECTORY_POINT_MIN_GAP,
getTrajectoryPointTimes,
@@ -284,7 +294,14 @@ export interface DirectorActions {
updateCameraKeyframeTime: (cameraId: string, keyframeId: string, time: number) => void;
updateCameraKeyframeValue: (cameraId: string, keyframeId: string, value: CameraKeyframeValue) => void;
updateCameraKeyframeEasing: (cameraId: string, keyframeId: string, easing: EasingKind) => void;
setCameraKeyframeTransition: (cameraId: string, keyframeId: string, transition: CameraTransition | null) => void;
/** 机位活跃片段(机位轨道上的时间段):决定「谁在播」,可独立增删改、拖拽修剪 */
addCameraClip: (cameraId: string, start: number, end: number) => string;
updateCameraClip: (cameraId: string, clipId: string, patch: Partial<CameraActiveClip>) => void;
removeCameraClip: (cameraId: string, clipId: string) => void;
/** 运镜(过渡)轨片段:连接两个机位,可拖拽、单独添加、改前后机位 */
addTransitionClip: (fromCameraId: string | null, toCameraId: string, type?: CameraTransitionType) => string;
updateTransitionClip: (clipId: string, patch: Partial<TransitionClip>) => void;
removeTransitionClip: (clipId: string) => void;
/** 一次性覆盖整条运镜的所有关键帧时间(拖动「运镜时长」把手按比例缩放用) */
setCameraKeyframeTimes: (cameraId: string, times: number[]) => void;
applyCameraKeyframeToShot: (cameraId: string, value: CameraKeyframeValue) => void;
@@ -338,6 +355,17 @@ const CHARACTER_COLOR_PALETTE = [
"#00B8D9",
"#FF7A45",
];
/** 机位标识色调色板:刻意避开角色调色板那 8 个色,保证多机位各自颜色醒目不混。 */
const CAMERA_COLOR_PALETTE = [
"#0CA678",
"#7048E8",
"#1098AD",
"#E8590C",
"#4263EB",
"#82C91E",
"#D6336C",
"#AE3EC9",
];
const GEOMETRY_PRIMITIVE_COLOR = "#d7e7ff";
const ADDED_MODEL_WORLD_SPACING = 1.25;
const COPY_PASTE_POSITION_OFFSET = 0.6;
@@ -553,7 +581,12 @@ function withTimeline(project: DirectorProject): DirectorProject {
timeline: {
duration: normalizeTimelineDuration(duration),
tracks: timeline.tracks ?? [],
cameraTracks: timeline.cameraTracks ?? [],
cameraTracks: (timeline.cameraTracks ?? []).map((track) => ({
...track,
// 兼容:旧工程相机轨道可能没有 clips 字段,补上空数组,避免求值遍历崩溃
clips: Array.isArray(track.clips) ? track.clips : [],
})),
transitionClips: timeline.transitionClips ?? [],
fps: normalizeTimelineFps(timeline.fps),
loop: timeline.loop === true,
cameraTransition: normalizeCameraTransition(timeline.cameraTransition),
@@ -664,8 +697,110 @@ function migrateCharacterMotionClips(object: DirectorObject): DirectorObject {
return { ...object, characterRig: { ...rig, motionClips: clips } };
}
/** 旧工程机位没有 color 字段:按调色板顺序补上,保证刷新后颜色稳定(与角色取色同理)。 */
function withCameraColors(project: DirectorProject): DirectorProject {
const cameras = project.cameras ?? [];
if (!cameras.some((camera) => !camera.color)) return project;
let colorIndex = 0;
const nextCameras = cameras.map((camera) => {
if (camera.color) return camera;
const color = CAMERA_COLOR_PALETTE[colorIndex % CAMERA_COLOR_PALETTE.length];
colorIndex += 1;
return { ...camera, color };
});
return { ...project, cameras: nextCameras };
}
/**
* 旧工程兼容迁移:把历史的「切换轨(cuts) / 关键帧过渡」转成新的
* 「机位活跃片段(clips) + 运镜(过渡)轨片段(transitionClips)」模型:
* - 已有 transitionClips 则原样保留;
* - 否则把旧 cuts 逐个转成 transitionClip(from 自动、to=cut.toCameraId、时长=cut.transition.duration);
* - 都没有 cuts 但有带过渡的关键帧,则先用 deriveCameraCutsFromTracks 兜底再转;
* - 每台机位的 cameraTracks 补上 clips(无 clips 时按关键帧跨度生成一段,保证旧工程也能按时间切换)。
*/
function withTransitionClips(project: DirectorProject): DirectorProject {
const timeline = project.timeline;
if (!timeline) return project;
const cameraTracks = (timeline.cameraTracks ?? []).map((track) => {
const clips =
Array.isArray(track.clips) && track.clips.length > 0
? track.clips
: // 旧工程无 clips:用关键帧跨度生成一段活跃片段
track.keyframes.length > 0
? [
{
id: createKeyframeId(),
start: track.keyframes[0].time,
end: track.keyframes[track.keyframes.length - 1].time,
},
]
: [];
return { ...track, clips };
});
let transitionClips = timeline.transitionClips ?? [];
if (transitionClips.length === 0) {
// 兼容旧工程:直接读原始 cuts 字段(新 Timeline 类型里已不存在,这里做一次宽容转换)
const rawCuts = (timeline as Timeline & { cuts?: CameraCut[] }).cuts;
const legacyCuts =
(rawCuts && rawCuts.length > 0
? rawCuts
: deriveCameraCutsFromTracks(cameraTracks, project.activeCameraId)) ?? [];
transitionClips = legacyCuts.map((cut) => ({
id: cut.id,
start: cut.time,
end: cut.time + (cut.transition?.duration ?? DEFAULT_CAMERA_TRANSITION_DURATION),
fromCameraId: null,
toCameraId: cut.toCameraId,
type: cut.transition?.type ?? "smooth",
}));
}
return {
...project,
timeline: { ...timeline, cameraTracks, transitionClips },
};
}
/** 确保某机位存在一条运镜轨道(含 clips / keyframes 空数组),没有则补上。 */
function ensureCameraTrack(
cameraTracks: CameraTimelineTrack[],
cameraId: string
): CameraTimelineTrack[] {
if (cameraTracks.some((track) => track.cameraId === cameraId)) return cameraTracks;
return [...cameraTracks, { cameraId, clips: [], keyframes: [] }];
}
/** 在「运镜(过渡)轨」追加一段从 fromCameraId → toCameraId 的过渡片段(放在 playhead 处)。 */
function appendTransitionClip(
timeline: Timeline,
fromCameraId: string,
toCameraId: string,
playhead: number,
globalTransition: CameraTransition
): TransitionClip[] {
const start = Math.min(Math.max(playhead, 0), timeline.duration);
const baseEnd = globalTransition.type === "cut" ? start : start + globalTransition.duration;
const end = Math.max(baseEnd, start + TRANSITION_CLIP_MIN_DURATION);
const clip: TransitionClip = {
id: createKeyframeId(),
start,
end: Math.min(end, timeline.duration),
fromCameraId,
toCameraId,
type: globalTransition.type,
};
return [...(timeline.transitionClips ?? []), clip];
}
function migrateDirectorProject(project: DirectorProject): DirectorProject {
const withTimelineProject = withSceneDefaults(withCameraLookRotations(withTimeline(project)));
const withTimelineProject = withTransitionClips(
withCameraColors(withSceneDefaults(withCameraLookRotations(withTimeline(project))))
);
return {
...withTimelineProject,
// 旧工程(v2 早期)没有 exportSettings,补默认值,避免读取时 undefined
@@ -830,6 +965,7 @@ export function createDefaultDirectorProject({
const camera: DirectorCameraShot = {
id: "cam_1",
name: formatSceneItemName("机位", 1),
color: CAMERA_COLOR_PALETTE[0],
fov: DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.fov,
transform: createTransform(getCameraRigPositionFromViewSnapshot(DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT)),
targetMode: "manual",
@@ -872,7 +1008,11 @@ export function createDefaultDirectorProject({
cameras: [camera],
activeCameraId: camera.id,
panoramaAssetId: null,
timeline: createDefaultTimeline(),
timeline: {
...createDefaultTimeline(),
// 默认工程只有一个机位,无需过渡片段;机位片段(clips)空,靠 fallback 直接显示该机位
transitionClips: [],
},
exportSettings: DEFAULT_EXPORT_SETTINGS,
};
}
@@ -957,6 +1097,16 @@ function getNextCharacterColor(objects: DirectorObject[]) {
return CHARACTER_COLOR_PALETTE[characterCount % CHARACTER_COLOR_PALETTE.length];
}
/** 取下一个可用机位色:优先用调色板里尚未被占用的,全部占满后按数量循环(与角色取色同策略)。 */
function getNextCameraColor(cameras: DirectorCameraShot[]): string {
const usedColors = new Set(cameras.map((camera) => camera.color).filter(Boolean));
const unusedColor = CAMERA_COLOR_PALETTE.find((color) => !usedColors.has(color));
if (unusedColor) return unusedColor;
return CAMERA_COLOR_PALETTE[cameras.length % CAMERA_COLOR_PALETTE.length];
}
function getGeometryPrimitiveLabel(geometryType: GeometryPrimitiveType) {
return GEOMETRY_PRIMITIVE_OPTIONS.find((option) => option.type === geometryType)?.label ?? "几何模型";
}
@@ -1352,9 +1502,16 @@ function deleteObjectsByIds(state: DirectorRuntimeState, objectIds: string[]): D
const nextCameraTracks = state.project.timeline.cameraTracks.filter(
(track) => !linkedCameraIds.has(track.cameraId)
);
// 运镜(过渡)轨:删掉的机位对应的过渡片段一并移除(from/to 任一指向被删机位都清掉)
const nextTransitionClips = linkedCameraIds.size
? state.project.timeline.transitionClips.filter(
(clip) => !linkedCameraIds.has(clip.toCameraId) && !linkedCameraIds.has(clip.fromCameraId ?? "")
)
: state.project.timeline.transitionClips;
const timelineUnchanged =
nextTracks.length === state.project.timeline.tracks.length &&
nextCameraTracks.length === state.project.timeline.cameraTracks.length;
nextCameraTracks.length === state.project.timeline.cameraTracks.length &&
nextTransitionClips.length === state.project.timeline.transitionClips.length;
const remainingSelectedObjectIds = getOrderedSelectedObjectIds(state).filter((id) => !objectIdSet.has(id));
const remainingSelectedCrowdId =
state.selectedCrowdId && nextObjects.some((item) => item.crowdId === state.selectedCrowdId)
@@ -1388,6 +1545,7 @@ function deleteObjectsByIds(state: DirectorRuntimeState, objectIds: string[]): D
...state.project.timeline,
tracks: nextTracks,
cameraTracks: nextCameraTracks,
transitionClips: nextTransitionClips,
},
},
};
@@ -1492,6 +1650,7 @@ function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeStat
const nextCamera: DirectorCameraShot = {
...entry.camera,
id: nextCameraId,
color: getNextCameraColor(state.project.cameras),
name: formatSceneItemName("机位", cameraIndex),
transform: applyOffsetToTransform(entry.camera.transform, offset),
// 注视是朝向角,平移机位不会改变它(锁物体的机位稍后按新位置重算)
@@ -2918,6 +3077,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
const nextCamera: DirectorCameraShot = {
id: cameraId,
name: formatSceneItemName("机位", cameraIndex),
color: getNextCameraColor(state.project.cameras),
fov: snapshot?.fov ?? 50,
transform,
targetMode: "manual",
@@ -2948,6 +3108,25 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
cameras: [...state.project.cameras, nextCamera],
activeCameraId: cameraId,
objects: [...state.project.objects, nextCameraObject],
// 解耦后:新增机位时,若当前已有其它机位,就在「运镜(过渡)轨」加一段从当前机位到新机位的过渡片段,
// 让多机位切换立刻可见、可拖拽;单台机位则无需过渡片段。
timeline: {
...state.project.timeline,
transitionClips:
state.project.cameras.length > 0
? appendTransitionClip(
state.project.timeline,
state.project.activeCameraId ?? state.project.cameras[state.project.cameras.length - 1].id,
cameraId,
Math.min(
Math.max(usePlaybackStore.getState().playhead, 0),
state.project.timeline.duration
),
state.project.timeline.cameraTransition
)
: state.project.timeline.transitionClips ?? [],
cameraTracks: ensureCameraTrack(state.project.timeline.cameraTracks, cameraId),
},
},
};
});
@@ -3326,12 +3505,107 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
timeline: updateTimelineCameraKeyframeEasing(state.project.timeline, cameraId, keyframeId, easing),
},
})),
setCameraKeyframeTransition: (cameraId, keyframeId, transition) =>
addCameraClip: (cameraId, start, end) => {
const clipId = createKeyframeId();
commitMutation((state) => {
const clampStart = Math.min(Math.max(start, 0), state.project.timeline.duration);
const clampEnd = Math.min(Math.max(end, clampStart + CAMERA_CLIP_MIN_DURATION), state.project.timeline.duration);
const nextClips = Array.isArray(
state.project.timeline.cameraTracks.find((t) => t.cameraId === cameraId)?.clips
)
? state.project.timeline.cameraTracks.find((t) => t.cameraId === cameraId)!.clips
: [];
const nextTracks = ensureCameraTrack(state.project.timeline.cameraTracks, cameraId).map((track) =>
track.cameraId === cameraId
? { ...track, clips: [...nextClips, { id: clipId, start: clampStart, end: clampEnd }] }
: track
);
return {
...state,
project: { ...state.project, timeline: { ...state.project.timeline, cameraTracks: nextTracks } },
};
});
return clipId;
},
updateCameraClip: (cameraId, clipId, patch) =>
commitMutation((state) => {
const nextTracks = state.project.timeline.cameraTracks.map((track) => {
if (track.cameraId !== cameraId) return track;
return {
...track,
clips: track.clips.map((clip) => {
if (clip.id !== clipId) return clip;
const merged = { ...clip, ...patch };
const start = Math.min(Math.max(merged.start, 0), state.project.timeline.duration);
const end = Math.min(
Math.max(merged.end, start + CAMERA_CLIP_MIN_DURATION),
state.project.timeline.duration
);
return { ...merged, start, end };
}),
};
});
return {
...state,
project: { ...state.project, timeline: { ...state.project.timeline, cameraTracks: nextTracks } },
};
}),
removeCameraClip: (cameraId, clipId) =>
commitMutation((state) => {
const nextTracks = state.project.timeline.cameraTracks.map((track) =>
track.cameraId === cameraId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
: track
);
return {
...state,
project: { ...state.project, timeline: { ...state.project.timeline, cameraTracks: nextTracks } },
};
}),
addTransitionClip: (fromCameraId, toCameraId, type) => {
const clipId = createKeyframeId();
commitMutation((state) => {
const transitionType = type ?? state.project.timeline.cameraTransition.type;
const playhead = Math.min(Math.max(usePlaybackStore.getState().playhead, 0), state.project.timeline.duration);
const baseEnd = transitionType === "cut" ? playhead : playhead + DEFAULT_CAMERA_TRANSITION_DURATION;
const end = Math.min(Math.max(baseEnd, playhead + TRANSITION_CLIP_MIN_DURATION), state.project.timeline.duration);
const nextClips = [
...state.project.timeline.transitionClips,
{ id: clipId, start: playhead, end, fromCameraId, toCameraId, type: transitionType },
];
return {
...state,
project: { ...state.project, timeline: { ...state.project.timeline, transitionClips: nextClips } },
};
});
return clipId;
},
updateTransitionClip: (clipId, patch) =>
commitMutation((state) => {
const nextClips = state.project.timeline.transitionClips.map((clip) => {
if (clip.id !== clipId) return clip;
const merged = { ...clip, ...patch };
const start = Math.min(Math.max(merged.start, 0), state.project.timeline.duration);
const end = Math.min(
Math.max(merged.end, start + TRANSITION_CLIP_MIN_DURATION),
state.project.timeline.duration
);
return { ...merged, start, end };
});
return {
...state,
project: { ...state.project, timeline: { ...state.project.timeline, transitionClips: nextClips } },
};
}),
removeTransitionClip: (clipId) =>
commitMutation((state) => ({
...state,
project: {
...state.project,
timeline: setTimelineCameraKeyframeTransition(state.project.timeline, cameraId, keyframeId, transition),
timeline: {
...state.project.timeline,
transitionClips: state.project.timeline.transitionClips.filter((clip) => clip.id !== clipId),
},
},
})),
setCameraKeyframeTimes: (cameraId, times) =>