feat(timeline): 支持角色轨迹关键帧与轨道拖拽排序

This commit is contained in:
2026-09-17 14:47:29 +08:00
parent 0794486857
commit bab92c05b8
18 changed files with 1862 additions and 135 deletions

View File

@@ -90,6 +90,11 @@ export interface DirectorUiState {
viewportPanelsCollapsed: boolean;
/** 悬浮时间轴(录制面板)是否显示,由视口快捷工具条的「录制」按钮切换 */
timelineVisible: boolean;
/** 用户自定义的时间轴轨道顺序(轨道 key 列表)。
* 只记录「被调整过」的轨道;未出现在列表里的新轨道按默认顺序排在后面。 */
timelineTrackOrder: string[];
/** 已折叠的时间轴轨道 key(角色轨道折叠后只留抬头行) */
timelineCollapsedTracks: string[];
}
export interface DirectorState extends DirectorUiState {
@@ -119,6 +124,10 @@ export interface DirectorActions {
setViewportPanelsCollapsed: (collapsed: boolean) => void;
toggleTimelineVisible: () => void;
setTimelineVisible: (visible: boolean) => void;
/** 覆盖时间轴轨道顺序(传入解析后的完整顺序) */
setTimelineTrackOrder: (order: string[]) => void;
/** 折叠 / 展开某个时间轴轨道 */
toggleTimelineTrackCollapsed: (trackKey: string) => void;
selectObject: (id: string | null) => void;
selectCrowd: (crowdId: string | null) => void;
toggleObjectSelection: (id: string) => void;
@@ -257,6 +266,8 @@ const DEFAULT_UI_STATE: DirectorUiState = {
viewportRuleOfThirdsEnabled: false,
viewportPanelsCollapsed: false,
timelineVisible: true,
timelineTrackOrder: [],
timelineCollapsedTracks: [],
};
function normalizeDirectorScenePersistenceScopeId(scopeId: string | null | undefined) {
@@ -392,6 +403,11 @@ function readPersistedTrajectoryPointRef(value: unknown): DirectorTrajectoryPoin
: null;
}
/** 读取持久化里的字符串数组(时间轴轨道顺序 / 折叠状态),非法值一律回落到空数组 */
function readPersistedStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function removePersistedLocalModelAsset(assetId: string) {
writePersistedLocalModelAssets(readPersistedLocalModelAssets().filter((asset) => asset.id !== assetId));
}
@@ -494,6 +510,8 @@ function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorSta
viewportRuleOfThirdsEnabled: state.viewportRuleOfThirdsEnabled,
viewportPanelsCollapsed: state.viewportPanelsCollapsed,
timelineVisible: state.timelineVisible,
timelineTrackOrder: state.timelineTrackOrder,
timelineCollapsedTracks: state.timelineCollapsedTracks,
project: state.project,
});
}
@@ -554,6 +572,8 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo
viewportRuleOfThirdsEnabled: Boolean(state.viewportRuleOfThirdsEnabled),
viewportPanelsCollapsed: Boolean(state.viewportPanelsCollapsed),
timelineVisible: state.timelineVisible === false ? false : true,
timelineTrackOrder: readPersistedStringArray(state.timelineTrackOrder),
timelineCollapsedTracks: readPersistedStringArray(state.timelineCollapsedTracks),
project: withPersistedLocalAssets(
migrateDirectorProject(cloneJsonValue(state.project)),
options.includePersistedLocalAssets
@@ -1317,6 +1337,17 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
createInitialDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true })
);
/** 拖拽(撤销批次)期间的持久化防抖间隔:连续拖拽时完全不序列化,停手 150ms 后落盘一次 */
const UNDO_BATCH_PERSIST_DEBOUNCE_MS = 150;
let undoBatchPersistTimer: ReturnType<typeof setTimeout> | null = null;
function cancelPendingUndoBatchPersist() {
if (undoBatchPersistTimer === null) return;
clearTimeout(undoBatchPersistTimer);
undoBatchPersistTimer = null;
}
function commitMutation(
updater: (state: DirectorRuntimeState) => DirectorRuntimeState,
options: { trackUndo?: boolean; persist?: boolean } = {}
@@ -1325,10 +1356,14 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
set((state) => {
const currentState = state as DirectorRuntimeState;
const previousSnapshot = createUndoStackEntry(currentState);
// 撤销批次内(gizmo / 轨迹点拖拽中)不会再有新的撤销快照 —— 批次开始时的快照已经够回退。
// 因此这里跳过「整工程深克隆 + 全量 JSON 比较」:拖拽时每次指针移动都做一遍的话,
// 工程里只要带了 base64 全景图就会直接卡死(实测单次写入 39ms @2MB 资源)。
const isInsideUndoBatch = trackUndo && currentState.undoBatchDepth > 0;
const previousSnapshot = isInsideUndoBatch ? null : createUndoStackEntry(currentState);
const nextState = updater(currentState);
const nextSnapshot = extractPersistedDirectorState(nextState);
const didChange = !isSameDirectorState(previousSnapshot, nextSnapshot);
const didChange =
previousSnapshot === null || !isSameDirectorState(previousSnapshot, extractPersistedDirectorState(nextState));
if (!didChange) {
return {
@@ -1340,27 +1375,37 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
};
}
const shouldCaptureUndoBatchSnapshot =
trackUndo && currentState.undoBatchDepth > 0 && currentState.undoBatchSnapshot === null;
const shouldCaptureUndoBatchSnapshot = isInsideUndoBatch && currentState.undoBatchSnapshot === null;
const nextUndoStack =
trackUndo && currentState.undoBatchDepth === 0
previousSnapshot && currentState.undoBatchDepth === 0
? trimUndoStack([...currentState.undoStack, previousSnapshot])
: nextState.undoStack;
const runtimeState: DirectorRuntimeState = {
...nextState,
undoStack: nextUndoStack,
undoBatchSnapshot: shouldCaptureUndoBatchSnapshot ? previousSnapshot : nextState.undoBatchSnapshot,
undoBatchHasTrackedChanges:
trackUndo && currentState.undoBatchDepth > 0 ? true : nextState.undoBatchHasTrackedChanges,
undoBatchSnapshot: shouldCaptureUndoBatchSnapshot
? createUndoStackEntry(currentState)
: nextState.undoBatchSnapshot,
undoBatchHasTrackedChanges: isInsideUndoBatch ? true : nextState.undoBatchHasTrackedChanges,
};
if (persist) {
writePersistedDirectorState(extractPersistedDirectorState(runtimeState));
if (isInsideUndoBatch) {
// 拖拽中不落盘:整工程序列化 + 同步写 localStorage 会直接卡住指针跟随,
// 改成停手 150ms 后写一次,endUndoBatch 时还会立刻补写最终结果。
cancelPendingUndoBatchPersist();
undoBatchPersistTimer = setTimeout(() => {
undoBatchPersistTimer = null;
writePersistedDirectorState(extractPersistedDirectorState(get() as DirectorRuntimeState));
}, UNDO_BATCH_PERSIST_DEBOUNCE_MS);
} else {
writePersistedDirectorState(extractPersistedDirectorState(runtimeState));
}
}
return runtimeState;
});
}
});
}
function commitUiMutation(updater: (state: DirectorRuntimeState) => DirectorRuntimeState) {
commitMutation(updater, { trackUndo: false, persist: true });
@@ -1381,6 +1426,8 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
});
},
endUndoBatch: () => {
const depthBefore = (get() as DirectorRuntimeState).undoBatchDepth;
set((state) => {
const currentState = state as DirectorRuntimeState;
if (currentState.undoBatchDepth === 0) return currentState;
@@ -1409,6 +1456,12 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
undoBatchHasTrackedChanges: false,
};
});
// 批次内的落盘是防抖的,收尾时立刻补写一次,保证刷新页面拿到的是拖拽后的最终结果
if (depthBefore > 0) {
cancelPendingUndoBatchPersist();
writePersistedDirectorState(extractPersistedDirectorState(get() as DirectorRuntimeState));
}
},
setTransformMode: (mode) =>
commitUiMutation((state) => ({
@@ -1445,6 +1498,18 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
...state,
timelineVisible: visible,
})),
setTimelineTrackOrder: (order) =>
commitUiMutation((state) => ({
...state,
timelineTrackOrder: order.filter((key): key is string => typeof key === "string"),
})),
toggleTimelineTrackCollapsed: (trackKey) =>
commitUiMutation((state) => ({
...state,
timelineCollapsedTracks: state.timelineCollapsedTracks.includes(trackKey)
? state.timelineCollapsedTracks.filter((key) => key !== trackKey)
: [...state.timelineCollapsedTracks, trackKey],
})),
setViewMode: (mode) =>
commitUiMutation((state) => ({
...state,