- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止 - 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调 - 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导 - 画布视锥线支持机位自定义颜色 - 视频导出时传入过渡片段、机位与场景参数以保持预览一致 - 更新 TimelinePanel 相关测试以覆盖新模型与交互
3757 lines
142 KiB
TypeScript
3757 lines
142 KiB
TypeScript
import { create } from "zustand";
|
||
import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets";
|
||
import {
|
||
DEFAULT_GROUND_COLOR,
|
||
GEOMETRY_PRIMITIVE_OPTIONS,
|
||
DEFAULT_EXPORT_SETTINGS,
|
||
normalizeTrajectoryRotation,
|
||
normalizeTransformRotation,
|
||
} from "../schema/directorProject";
|
||
import type {
|
||
DirectorAssetRef,
|
||
DirectorAssetSource,
|
||
CharacterBodyType,
|
||
CharacterRigState,
|
||
DirectorAssetKind,
|
||
DirectorCameraCapture,
|
||
DirectorCameraShot,
|
||
DirectorObject,
|
||
DirectorProject,
|
||
DirectorTrajectoryPoint,
|
||
DirectorTrajectoryPointRef,
|
||
DirectorTransform,
|
||
ExportSettings,
|
||
GeometryPrimitiveType,
|
||
MotionClip,
|
||
MotionKind,
|
||
PanoramaProjectionMode,
|
||
SceneSettings,
|
||
ViewMode,
|
||
} from "../schema/directorProject";
|
||
import type { PosePresetId } from "../schema/poseSchema";
|
||
import { getDirectorObjectFocusTarget } from "../schema/cameraTarget";
|
||
import { deleteModelBinary, loadModelBinary } from "../loaders/modelBinaryStore";
|
||
import { DEFAULT_CHARACTER_BODY_TYPE, normalizeBodyType } from "../runtime/mannequin/bodyTypes";
|
||
import {
|
||
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
|
||
getCameraRigPositionFromViewSnapshot,
|
||
getCameraLookRotationFromTarget,
|
||
migrateLegacyCameraShot,
|
||
} from "../schema/cameraGeometry";
|
||
import type { ViewportAspectRatio } from "../schema/viewportAspectRatio";
|
||
import {
|
||
createDefaultTimeline,
|
||
upsertKeyframe,
|
||
removeKeyframe as removeTimelineKeyframe,
|
||
updateKeyframeTime as updateTimelineKeyframeTime,
|
||
updateKeyframeValue as updateTimelineKeyframeValue,
|
||
updateKeyframeEasing as updateTimelineKeyframeEasing,
|
||
upsertCameraKeyframe,
|
||
removeCameraKeyframe as removeTimelineCameraKeyframe,
|
||
updateCameraKeyframeTime as updateTimelineCameraKeyframeTime,
|
||
updateCameraKeyframeValue as updateTimelineCameraKeyframeValue,
|
||
updateCameraKeyframeEasing as updateTimelineCameraKeyframeEasing,
|
||
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,
|
||
getTrajectoryStartTime,
|
||
shiftTrajectoryClip,
|
||
getMotionStartTime,
|
||
getMotionEndTime,
|
||
MOTION_CLIP_MIN_DURATION,
|
||
} from "../schema/trajectoryMotion";
|
||
|
||
export type TransformMode = "translate" | "rotate" | "scale";
|
||
|
||
export interface ImportedAssetInput {
|
||
kind: DirectorAssetKind;
|
||
name: string;
|
||
fileName: string;
|
||
url: string;
|
||
/** GLB/GLTF 二进制在 IndexedDB 中的存储 id(可选,仅二进制模型有)。 */
|
||
binaryId?: string;
|
||
addToScene?: boolean;
|
||
assetSource?: DirectorAssetSource;
|
||
projectionMode?: PanoramaProjectionMode;
|
||
}
|
||
|
||
export interface CameraShotSnapshot {
|
||
fov: number;
|
||
position: [number, number, number];
|
||
target: [number, number, number];
|
||
}
|
||
|
||
export interface CrowdCharactersInput {
|
||
bodyType?: CharacterBodyType;
|
||
rows: number;
|
||
columns: number;
|
||
spacing: number;
|
||
}
|
||
|
||
export interface DirectorStateOptions {
|
||
includePersistedLocalAssets?: boolean;
|
||
includePersistedScene?: boolean;
|
||
persistenceScopeId?: string | null;
|
||
}
|
||
|
||
export interface DirectorUiState {
|
||
viewMode: ViewMode;
|
||
selectedObjectId: string | null;
|
||
selectedObjectIds: string[];
|
||
selectedCrowdId: string | null;
|
||
/** 当前选中的轨迹点(决定右侧显示「轨迹点」卡片) */
|
||
selectedTrajectoryPoint: DirectorTrajectoryPointRef | null;
|
||
/** 当前选中的动作片段 id(决定动作轨道上高亮哪段、右侧动作参数卡片编辑哪段) */
|
||
selectedMotionClipId: string | null;
|
||
/** 轨迹打点模式的目标角色 id;非空时画布不可选取其它对象,双击地面即落一个轨迹点 */
|
||
trajectoryPlacementObjectId: string | null;
|
||
directorInspectorMode: "auto" | "scene";
|
||
transformMode: TransformMode;
|
||
viewportAspectRatio: ViewportAspectRatio;
|
||
viewportRuleOfThirdsEnabled: boolean;
|
||
viewportPanelsCollapsed: boolean;
|
||
/** 悬浮时间轴(录制面板)是否显示,由视口快捷工具条的「录制」按钮切换 */
|
||
timelineVisible: boolean;
|
||
/** 用户自定义的时间轴轨道顺序(轨道 key 列表)。
|
||
* 只记录「被调整过」的轨道;未出现在列表里的新轨道按默认顺序排在后面。 */
|
||
timelineTrackOrder: string[];
|
||
/** 已折叠的时间轴轨道 key(角色轨道折叠后只留抬头行) */
|
||
timelineCollapsedTracks: string[];
|
||
}
|
||
|
||
export interface DirectorState extends DirectorUiState {
|
||
project: DirectorProject;
|
||
}
|
||
|
||
export interface DirectorClipboardEntry {
|
||
object: DirectorObject;
|
||
camera?: DirectorCameraShot;
|
||
}
|
||
|
||
interface DirectorInternalState {
|
||
clipboard: DirectorClipboardEntry[];
|
||
clipboardPasteCount: number;
|
||
undoStack: DirectorState[];
|
||
/** 重做栈:撤销后把被撤销的状态压进来,新编辑发生时清空 */
|
||
redoStack: DirectorState[];
|
||
undoBatchDepth: number;
|
||
undoBatchSnapshot: DirectorState | null;
|
||
undoBatchHasTrackedChanges: boolean;
|
||
}
|
||
|
||
export interface DirectorActions {
|
||
setViewMode: (mode: ViewMode) => void;
|
||
setTransformMode: (mode: TransformMode) => void;
|
||
setViewportAspectRatio: (ratio: ViewportAspectRatio) => void;
|
||
setViewportRuleOfThirdsEnabled: (enabled: boolean) => void;
|
||
toggleViewportPanelsCollapsed: () => void;
|
||
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;
|
||
openSceneInspector: () => void;
|
||
// 角色运动轨迹(平面轨迹点)
|
||
/** 进入轨迹打点模式:只接受角色,进入后画布不可选取其它对象,双击地面即落一个点 */
|
||
startTrajectoryPlacement: (objectId: string) => void;
|
||
stopTrajectoryPlacement: () => void;
|
||
/** 在场景坐标系里落一个轨迹点(平面:y 固定 0),返回新点 id;目标不是角色时返回 null */
|
||
addTrajectoryPoint: (objectId: string, position: [number, number, number]) => string | null;
|
||
updateTrajectoryPoint: (
|
||
objectId: string,
|
||
pointId: string,
|
||
patch: Partial<Pick<DirectorTrajectoryPoint, "position" | "rotation" | "time">>
|
||
) => void;
|
||
/** 一次性覆盖整条轨迹的所有关键帧时间(拖动「轨迹时长」把手时按比例缩放用) */
|
||
setTrajectoryPointTimes: (objectId: string, times: number[]) => void;
|
||
/** 整体平移人物轨迹片段(拖轨迹条本体):片段起点 + 所有轨迹点时间同步平移 */
|
||
setTrajectoryClipStart: (objectId: string, startTime: number) => void;
|
||
/** 修剪人物轨迹片段起点(拖轨迹条左端把手):只挪片段起点,轨迹点与停留结束时间都不动 */
|
||
setTrajectoryClipTrimStart: (objectId: string, startTime: number) => void;
|
||
/** 设置人物轨迹片段的结束时间(停留时长):角色走完最后一点后原地停留到这一刻,拖条右端把手时写入 */
|
||
setTrajectoryClipEnd: (objectId: string, endTime: number) => void;
|
||
/** 整体平移动作片段(拖动作条本体):片段起点与结束时间同步平移,保持动作时长不变 */
|
||
setMotionClipStart: (objectId: string, startTime: number) => void;
|
||
/** 修剪动作片段起点(拖动作条左端把手):只挪动作开始时间,结束时间不动 */
|
||
setMotionClipTrimStart: (objectId: string, startTime: number) => void;
|
||
/** 设置动作片段的结束时间(拖动作条右端把手):动作播放到这一刻停止,起点不动 */
|
||
setMotionClipEnd: (objectId: string, endTime: number) => void;
|
||
/** 在动作轨道上添加一个动作片段(动作序列);默认从 options.start 开始、到时间轴末尾。 */
|
||
addMotionClip: (objectId: string, options?: { kind?: MotionKind; start?: number; end?: number }) => void;
|
||
/** 更新某个动作片段的属性(种类 / 速度 / 幅度 / 启用)。 */
|
||
updateMotionClip: (
|
||
objectId: string,
|
||
clipId: string,
|
||
patch: Partial<Pick<MotionClip, "kind" | "speed" | "amplitude" | "enabled">>
|
||
) => void;
|
||
/** 整体平移 / 缩放某个动作片段的时间区间(拖拽条本体或两端把手时写入)。
|
||
* originalClips 传「拖拽开始时该角色的全部动作片段」即可开启「拖动换位」:被拖片段越过谁就与谁交换位置;
|
||
* 不传则退回原来的「紧贴就近一侧」解析(修修剪剪、程序化写入等场景)。 */
|
||
updateMotionClipTime: (
|
||
objectId: string,
|
||
clipId: string,
|
||
start: number,
|
||
end: number,
|
||
oldStart?: number,
|
||
originalClips?: MotionClip[]
|
||
) => void;
|
||
/** 删除某个动作片段。 */
|
||
removeMotionClip: (objectId: string, clipId: string) => void;
|
||
/** 选中 / 取消选中某个动作片段;clipId 为 null 表示取消。 */
|
||
selectMotionClip: (clipId: string | null) => void;
|
||
removeTrajectoryPoint: (objectId: string, pointId: string) => void;
|
||
/** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */
|
||
selectTrajectoryPoint: (objectId: string | null, pointId: string | null) => void;
|
||
updateScene: (patch: Partial<SceneSettings>) => void;
|
||
removePanoramaAsset: () => void;
|
||
removeImportedAsset: (assetId: string) => void;
|
||
updateObjectTransform: (id: string, patch: Partial<DirectorTransform>) => void;
|
||
updateCrowdTransform: (crowdId: string, patch: Partial<DirectorTransform>) => void;
|
||
updateObjectName: (id: string, name: string) => void;
|
||
updateCrowdLabel: (crowdId: string, label: string) => void;
|
||
updateObjectColor: (id: string, color: string) => void;
|
||
updateCrowdColor: (crowdId: string, color: string) => void;
|
||
updateCharacterBodyType: (id: string, bodyType: CharacterBodyType) => void;
|
||
updateUniformScale: (id: string, scale: number) => void;
|
||
updateCrowdUniformScale: (crowdId: string, scale: number) => void;
|
||
addImportedAsset: (input: ImportedAssetInput) => void;
|
||
/** 启动时把持久化的 GLB/GLTF 二进制从 IndexedDB 还原成 objectURL(localStorage 不存大文件)。 */
|
||
hydratePersistedModelBinaries: () => Promise<void>;
|
||
addObjectFromAsset: (assetId: string, options?: { asCharacter?: boolean }) => string | null;
|
||
addPresetCharacter: (bodyType?: CharacterBodyType) => void;
|
||
addCrowdCharacters: (input: CrowdCharactersInput) => string[];
|
||
addGeometryPrimitive: (geometryType: GeometryPrimitiveType) => void;
|
||
addCameraShot: (snapshot?: CameraShotSnapshot) => string;
|
||
deleteSelectedObject: () => void;
|
||
/** 按 id 删除单个对象(场景树每行的删除按钮) */
|
||
deleteObject: (id: string) => void;
|
||
/** 按 crowdId 删除整组群众(场景树群众行的删除按钮) */
|
||
deleteCrowd: (crowdId: string) => void;
|
||
toggleObjectVisible: (id: string) => void;
|
||
toggleObjectLocked: (id: string) => void;
|
||
applyPosePreset: (id: string, presetId: PosePresetId) => void;
|
||
applyCrowdPosePreset: (crowdId: string, presetId: PosePresetId) => void;
|
||
updatePoseControl: (id: string, key: string, value: number) => void;
|
||
updateCrowdPoseControl: (crowdId: string, key: string, value: number) => void;
|
||
// 基础动作(走路 / 跑步)
|
||
setMotion: (id: string, kind: MotionKind) => void;
|
||
setCrowdMotion: (crowdId: string, kind: MotionKind) => void;
|
||
setMotionSpeed: (id: string, speed: number) => void;
|
||
setCrowdMotionSpeed: (crowdId: string, speed: number) => void;
|
||
setMotionAmplitude: (id: string, amplitude: number) => void;
|
||
setCrowdMotionAmplitude: (crowdId: string, amplitude: number) => void;
|
||
setMotionEnabled: (id: string, enabled: boolean) => void;
|
||
setCrowdMotionEnabled: (crowdId: string, enabled: boolean) => void;
|
||
setActiveCamera: (cameraId: string) => void;
|
||
addCameraCaptures: (cameraId: string | null | undefined, dataUrls: string[]) => void;
|
||
updateCamera: (
|
||
cameraId: string,
|
||
patch: Partial<DirectorCameraShot> & {
|
||
transform?: DirectorTransform;
|
||
lookRotation?: [number, number, number];
|
||
}
|
||
) => void;
|
||
// 物体关键帧
|
||
addKeyframe: (objectId: string, time: number, value: KeyframeTransformValue) => string;
|
||
removeKeyframe: (objectId: string, keyframeId: string) => void;
|
||
updateKeyframeTime: (objectId: string, keyframeId: string, time: number) => void;
|
||
updateKeyframeValue: (objectId: string, keyframeId: string, value: KeyframeTransformValue) => void;
|
||
updateKeyframeEasing: (objectId: string, keyframeId: string, easing: EasingKind) => void;
|
||
applyKeyframeToObjectTransform: (objectId: string, value: KeyframeTransformValue) => void;
|
||
// 相机运镜关键帧
|
||
addCameraKeyframe: (cameraId: string, time: number, value: CameraKeyframeValue) => string;
|
||
removeCameraKeyframe: (cameraId: string, keyframeId: string) => void;
|
||
updateCameraKeyframeTime: (cameraId: string, keyframeId: string, time: number) => void;
|
||
updateCameraKeyframeValue: (cameraId: string, keyframeId: string, value: CameraKeyframeValue) => void;
|
||
updateCameraKeyframeEasing: (cameraId: string, keyframeId: string, easing: EasingKind) => 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;
|
||
// 时长
|
||
setTimelineDuration: (seconds: number) => void;
|
||
/** 时间轴播放设置(每秒帧数 / 是否循环):设置弹窗里点「完成」时一次性写入 */
|
||
setTimelinePlaybackSettings: (patch: { fps?: number; loop?: boolean; cameraTransition?: CameraTransition }) => void;
|
||
/** 更新视频导出设置(分辨率 / 码率 / 编码档位),支持部分更新 */
|
||
updateExportSettings: (patch: Partial<ExportSettings>) => void;
|
||
clearAllKeyframes: () => void;
|
||
beginUndoBatch: () => void;
|
||
endUndoBatch: () => void;
|
||
copySelectedObjects: () => void;
|
||
pasteClipboardObjects: () => void;
|
||
undo: () => void;
|
||
/** 重做:把最近一次「撤销」回退掉,恢复被撤销的状态(新编辑会清空重做栈) */
|
||
redo: () => void;
|
||
openScopedScene: (scopeId: string | null | undefined) => void;
|
||
replaceProject: (project: DirectorProject) => void;
|
||
saveLatestSnapshot: () => void;
|
||
restoreLatestSnapshot: () => void;
|
||
}
|
||
|
||
type DirectorRuntimeState = DirectorState & DirectorInternalState;
|
||
|
||
export type DirectorStore = DirectorRuntimeState & DirectorActions;
|
||
|
||
const DEFAULT_SCENE: SceneSettings = {
|
||
scale: 1,
|
||
position: [0, 0, 0],
|
||
rotation: [0, 0, 0],
|
||
backgroundColor: "#000000",
|
||
panoramaYaw: 0,
|
||
panoramaRadius: 60,
|
||
showLabels: true,
|
||
captureShowGrid: false,
|
||
snapToGrid: false,
|
||
showGround: true,
|
||
groundColor: DEFAULT_GROUND_COLOR,
|
||
groundOpacity: 0.4,
|
||
groundHeight: 0,
|
||
};
|
||
|
||
const CHARACTER_COLOR_PALETTE = [
|
||
"#4F8EF7",
|
||
"#E0524D",
|
||
"#E91E63",
|
||
"#F2A900",
|
||
"#9C4DCC",
|
||
"#12B886",
|
||
"#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;
|
||
const UNDO_STACK_LIMIT = 80;
|
||
const LOCAL_MODEL_LIBRARY_STORAGE_KEY = "storyai-3d-director-local-model-library";
|
||
const DIRECTOR_SCENE_STORAGE_KEY = "storyai-3d-director-desk-demo";
|
||
const DIRECTOR_SCENE_STORAGE_KEY_PREFIX = `${DIRECTOR_SCENE_STORAGE_KEY}:`;
|
||
const DEFAULT_UI_STATE: DirectorUiState = {
|
||
viewMode: "director",
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
selectedCrowdId: null,
|
||
selectedTrajectoryPoint: null,
|
||
selectedMotionClipId: null,
|
||
trajectoryPlacementObjectId: null,
|
||
directorInspectorMode: "auto",
|
||
transformMode: "translate",
|
||
viewportAspectRatio: "auto",
|
||
viewportRuleOfThirdsEnabled: false,
|
||
viewportPanelsCollapsed: false,
|
||
timelineVisible: true,
|
||
timelineTrackOrder: [],
|
||
timelineCollapsedTracks: [],
|
||
};
|
||
|
||
function normalizeDirectorScenePersistenceScopeId(scopeId: string | null | undefined) {
|
||
return typeof scopeId === "string" ? scopeId.trim() : "";
|
||
}
|
||
|
||
function getInitialDirectorScenePersistenceScopeId() {
|
||
if (typeof window === "undefined") return null;
|
||
|
||
try {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return normalizeDirectorScenePersistenceScopeId(params.get("instanceId")) || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
let directorScenePersistenceScopeId: string | null = getInitialDirectorScenePersistenceScopeId();
|
||
|
||
function getDirectorSceneStorageKey(scopeId: string | null | undefined = directorScenePersistenceScopeId) {
|
||
const normalizedScopeId = normalizeDirectorScenePersistenceScopeId(scopeId);
|
||
return normalizedScopeId ? `${DIRECTOR_SCENE_STORAGE_KEY_PREFIX}${normalizedScopeId}` : DIRECTOR_SCENE_STORAGE_KEY;
|
||
}
|
||
|
||
function setDirectorScenePersistenceScopeId(scopeId: string | null | undefined) {
|
||
const normalizedScopeId = normalizeDirectorScenePersistenceScopeId(scopeId);
|
||
directorScenePersistenceScopeId = normalizedScopeId || null;
|
||
}
|
||
|
||
function createTransform(
|
||
position: [number, number, number],
|
||
rotation: [number, number, number] = [0, 0, 0],
|
||
scale: [number, number, number] = [1, 1, 1]
|
||
): DirectorTransform {
|
||
return { position, rotation, scale };
|
||
}
|
||
|
||
function roundTransformValue(value: number) {
|
||
return Number(value.toFixed(6));
|
||
}
|
||
|
||
function roundTransformTuple(values: [number, number, number]): [number, number, number] {
|
||
return values.map((value) => roundTransformValue(value)) as [number, number, number];
|
||
}
|
||
|
||
function formatSceneItemName(prefix: "角色" | "机位" | "导入角色", index: number) {
|
||
return `${prefix}${String(index).padStart(2, "0")}`;
|
||
}
|
||
|
||
function createTrajectoryPointId(): string {
|
||
return `tp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
/** 画布上单击得到的是精确浮点世界坐标,直接整段存进轨迹点,不取整也不收敛 —— 鼠标点到哪里就是哪里(含小数坐标) */
|
||
function getNextSequentialId(existingIds: string[], prefix: string, minimumIndex = 1) {
|
||
let maxIndex = minimumIndex - 1;
|
||
|
||
for (const id of existingIds) {
|
||
if (!id.startsWith(prefix)) continue;
|
||
|
||
const suffix = id.slice(prefix.length);
|
||
if (!/^\d+$/.test(suffix)) continue;
|
||
|
||
maxIndex = Math.max(maxIndex, Number.parseInt(suffix, 10));
|
||
}
|
||
|
||
return `${prefix}${maxIndex + 1}`;
|
||
}
|
||
|
||
function isLocalModelLibraryAsset(asset: DirectorAssetRef) {
|
||
return asset.sourceType === "model" && asset.kind !== "panorama" && asset.assetSource === "local";
|
||
}
|
||
|
||
function getLocalStorageSafe() {
|
||
if (typeof localStorage === "undefined") return null;
|
||
|
||
return localStorage;
|
||
}
|
||
|
||
function cloneJsonValue<T>(value: T): T {
|
||
return JSON.parse(JSON.stringify(value)) as T;
|
||
}
|
||
|
||
function readPersistedLocalModelAssets() {
|
||
const storage = getLocalStorageSafe();
|
||
if (!storage) return [];
|
||
|
||
try {
|
||
const snapshot = storage.getItem(LOCAL_MODEL_LIBRARY_STORAGE_KEY);
|
||
if (!snapshot) return [];
|
||
|
||
const parsed = JSON.parse(snapshot);
|
||
if (!Array.isArray(parsed)) return [];
|
||
|
||
return parsed.filter(
|
||
(asset): asset is DirectorAssetRef =>
|
||
asset &&
|
||
typeof asset.id === "string" &&
|
||
typeof asset.fileName === "string" &&
|
||
typeof asset.url === "string" &&
|
||
isLocalModelLibraryAsset(asset)
|
||
);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function writePersistedLocalModelAssets(assets: DirectorAssetRef[]) {
|
||
const storage = getLocalStorageSafe();
|
||
if (!storage) return;
|
||
|
||
try {
|
||
storage.setItem(LOCAL_MODEL_LIBRARY_STORAGE_KEY, JSON.stringify(assets.filter(isLocalModelLibraryAsset)));
|
||
} catch {
|
||
// Local model files can exceed browser storage limits; keep the current scene usable if persistence fails.
|
||
}
|
||
}
|
||
|
||
function persistLocalModelAsset(asset: DirectorAssetRef) {
|
||
if (!isLocalModelLibraryAsset(asset)) return;
|
||
|
||
const persistedAssets = readPersistedLocalModelAssets().filter((item) => item.id !== asset.id);
|
||
// GLB/GLTF 二进制走 IndexedDB:localStorage 只保留轻量元数据,url 置空避免 base64 撑爆配额。
|
||
// 运行时通过 hydratePersistedModelBinaries 用 binaryId 还原 objectURL。
|
||
const persisted: DirectorAssetRef = asset.binaryId ? { ...asset, url: "" } : asset;
|
||
writePersistedLocalModelAssets([...persistedAssets, persisted]);
|
||
}
|
||
|
||
function readPersistedTrajectoryPointRef(value: unknown): DirectorTrajectoryPointRef | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
|
||
const ref = value as Partial<DirectorTrajectoryPointRef>;
|
||
|
||
return typeof ref.objectId === "string" && typeof ref.pointId === "string"
|
||
? { objectId: ref.objectId, pointId: ref.pointId }
|
||
: 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));
|
||
// 一并清理 IndexedDB 中的二进制(GLB 有 binaryId 才需要;失败静默忽略)。
|
||
void deleteModelBinary(assetId).catch(() => {});
|
||
}
|
||
|
||
function isDirectorProjectShape(value: unknown): value is DirectorProject {
|
||
if (!value || typeof value !== "object") return false;
|
||
|
||
const project = value as Partial<DirectorProject>;
|
||
const version = (value as { version?: unknown }).version;
|
||
return (
|
||
(version === 1 || version === 2) &&
|
||
Array.isArray(project.assets) &&
|
||
Array.isArray(project.objects) &&
|
||
Array.isArray(project.cameras) &&
|
||
Boolean(project.scene) &&
|
||
typeof project.scene?.backgroundColor === "string"
|
||
);
|
||
}
|
||
|
||
function withPersistedLocalAssets(project: DirectorProject, includePersistedLocalAssets = false): DirectorProject {
|
||
if (!includePersistedLocalAssets) return project;
|
||
|
||
const persistedAssets = readPersistedLocalModelAssets();
|
||
if (!persistedAssets.length) return project;
|
||
|
||
const existingAssetIds = new Set(project.assets.map((asset) => asset.id));
|
||
|
||
return {
|
||
...project,
|
||
assets: [...project.assets, ...persistedAssets.filter((asset) => !existingAssetIds.has(asset.id))],
|
||
};
|
||
}
|
||
|
||
function withTimeline(project: DirectorProject): DirectorProject {
|
||
const timeline = project.timeline ?? createDefaultTimeline();
|
||
// 旧工程里没有 fps / loop:补默认值(30fps、不循环),duration 也顺手夹一次
|
||
const duration =
|
||
typeof timeline.duration === "number" && Number.isFinite(timeline.duration)
|
||
? timeline.duration
|
||
: DEFAULT_TIMELINE_DURATION;
|
||
|
||
return {
|
||
...project,
|
||
version: 2,
|
||
timeline: {
|
||
duration: normalizeTimelineDuration(duration),
|
||
tracks: timeline.tracks ?? [],
|
||
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),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 旧工程里还没有「地面颜色」这一项(当时是写死的深灰):补上默认值,
|
||
* 否则加载后地面会落到材质默认色(白),和天空色一起看非常刺眼。
|
||
*/
|
||
function withSceneDefaults(project: DirectorProject): DirectorProject {
|
||
const scene = project.scene;
|
||
if (!scene) return project;
|
||
|
||
const nextScene = { ...scene };
|
||
if (!(typeof scene.groundColor === "string" && scene.groundColor)) {
|
||
nextScene.groundColor = DEFAULT_GROUND_COLOR;
|
||
}
|
||
// 旧工程没有「导出时是否带网格」这一项:补默认值(false = 导出时藏起网格)
|
||
if (typeof nextScene.captureShowGrid !== "boolean") {
|
||
nextScene.captureShowGrid = false;
|
||
}
|
||
|
||
if (nextScene.groundColor === scene.groundColor && nextScene.captureShowGrid === scene.captureShowGrid) {
|
||
return project;
|
||
}
|
||
return { ...project, scene: nextScene };
|
||
}
|
||
|
||
/**
|
||
* 旧工程里机位的注视是一个世界坐标 target:统一换算成注视朝向角
|
||
* (水平角 / 俯仰角 / 横滚角),运镜关键帧同理。
|
||
*/
|
||
function withCameraLookRotations(project: DirectorProject): DirectorProject {
|
||
const cameras = project.cameras ?? [];
|
||
const cameraTracks = project.timeline?.cameraTracks ?? [];
|
||
const legacyCamera = cameras.some((camera) => Boolean(camera) && !Array.isArray(camera.lookRotation));
|
||
const legacyKeyframe = cameraTracks.some((track) =>
|
||
(track.keyframes ?? []).some((keyframe) => Boolean(keyframe) && !Array.isArray(keyframe.value?.lookRotation))
|
||
);
|
||
|
||
if (!legacyCamera && !legacyKeyframe) return project;
|
||
|
||
return {
|
||
...project,
|
||
cameras: cameras.map((camera) => (camera ? migrateLegacyCameraShot(camera) : camera)),
|
||
timeline: legacyKeyframe
|
||
? {
|
||
...project.timeline,
|
||
cameraTracks: cameraTracks.map((track) => ({
|
||
...track,
|
||
keyframes: (track.keyframes ?? []).map((keyframe) => ({
|
||
...keyframe,
|
||
value: migrateLegacyCameraKeyframeValue(keyframe.value),
|
||
})),
|
||
})),
|
||
}
|
||
: project.timeline,
|
||
};
|
||
}
|
||
|
||
function migrateLegacyCameraKeyframeValue(value: {
|
||
position: [number, number, number];
|
||
fov: number;
|
||
lookRotation?: [number, number, number];
|
||
target?: [number, number, number];
|
||
}): CameraKeyframeValue {
|
||
return {
|
||
position: value.position,
|
||
fov: value.fov,
|
||
lookRotation: Array.isArray(value.lookRotation)
|
||
? value.lookRotation
|
||
: Array.isArray(value.target)
|
||
? getCameraLookRotationFromTarget(value.position, value.target)
|
||
: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||
};
|
||
}
|
||
|
||
/** 把旧工程的单一 motion 字段转换为动作片段数组(动作序列的基础)。群众成员保持单一 motion,不生成片段。 */
|
||
function legacyMotionToClips(rig: CharacterRigState | undefined, object: DirectorObject): MotionClip[] | undefined {
|
||
const motion = rig?.motion;
|
||
if (!motion || motion.kind === "none") return undefined;
|
||
return [
|
||
{
|
||
id: createKeyframeId(),
|
||
kind: motion.kind,
|
||
speed: motion.speed ?? 1,
|
||
amplitude: motion.amplitude ?? 1,
|
||
enabled: motion.enabled !== false,
|
||
start: Number.isFinite(object.motionStartTime) ? Math.max(object.motionStartTime ?? 0, 0) : 0,
|
||
end: Number.isFinite(object.motionEndTime) ? (object.motionEndTime ?? 0) : Number.POSITIVE_INFINITY,
|
||
},
|
||
];
|
||
}
|
||
|
||
/** 把角色的 motionClips 字段补齐(旧工程可能没有):已有片段则保留,否则从单一 motion 迁移。 */
|
||
function migrateCharacterMotionClips(object: DirectorObject): DirectorObject {
|
||
const rig = object.characterRig;
|
||
if (!rig) return object;
|
||
// 群众成员由 setCrowdMotion 统一驱动单一 motion,不生成片段,避免与群体动作冲突。
|
||
if (object.crowdId) {
|
||
return rig.motionClips && rig.motionClips.length
|
||
? object
|
||
: { ...object, characterRig: { ...rig, motionClips: undefined } };
|
||
}
|
||
const clips = rig.motionClips && rig.motionClips.length ? rig.motionClips : legacyMotionToClips(rig, object);
|
||
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 = withTransitionClips(
|
||
withCameraColors(withSceneDefaults(withCameraLookRotations(withTimeline(project))))
|
||
);
|
||
return {
|
||
...withTimelineProject,
|
||
// 旧工程(v2 早期)没有 exportSettings,补默认值,避免读取时 undefined
|
||
exportSettings: project.exportSettings ?? DEFAULT_EXPORT_SETTINGS,
|
||
objects: withTimelineProject.objects.map((object) => {
|
||
if (object.kind !== "character") return object;
|
||
|
||
const rig = object.characterRig;
|
||
if (rig?.rigType === "ue4-mannequin") {
|
||
return migrateCharacterMotionClips(object);
|
||
}
|
||
|
||
return {
|
||
...object,
|
||
characterRig: {
|
||
rigType: "ue4-mannequin",
|
||
posePresetId: rig?.posePresetId ?? "stand",
|
||
controls: rig?.controls ?? {},
|
||
// 保留旧 motion 作为 fallback,并迁移成动作片段数组支持多段动作序列。
|
||
motion: rig?.motion,
|
||
motionClips: rig?.motionClips ?? legacyMotionToClips(rig, object),
|
||
},
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* blob: URL 只对创建它的那次页面会话有效,刷新后浏览器已回收。持久化读回来时若全景图还是
|
||
* 这种失效地址,说明是上一版本(或上一次会话)留下的,加载必然失败 —— 直接剔除并清空
|
||
* panoramaAssetId,回落到纯色天空,而不是弹「全景图加载失败」。
|
||
* 新导入的全景图已统一存成 data URL(见 panoramaImport.ts),不会再走到这里。
|
||
*/
|
||
function dropExpiredPanoramaAsset(project: DirectorProject): DirectorProject {
|
||
const panoramaAssetId = project.panoramaAssetId;
|
||
if (!panoramaAssetId) return project;
|
||
|
||
const panoramaAsset = project.assets.find((asset) => asset.id === panoramaAssetId);
|
||
if (!panoramaAsset || !panoramaAsset.url.startsWith("blob:")) return project;
|
||
|
||
return {
|
||
...project,
|
||
assets: project.assets.filter((asset) => asset.id !== panoramaAssetId),
|
||
panoramaAssetId: null,
|
||
};
|
||
}
|
||
|
||
function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorState {
|
||
return cloneJsonValue({
|
||
viewMode: state.viewMode,
|
||
selectedObjectId: state.selectedObjectId,
|
||
selectedObjectIds: state.selectedObjectIds,
|
||
selectedCrowdId: state.selectedCrowdId,
|
||
selectedTrajectoryPoint: state.selectedTrajectoryPoint,
|
||
selectedMotionClipId: typeof state.selectedMotionClipId === "string" ? state.selectedMotionClipId : null,
|
||
trajectoryPlacementObjectId: state.trajectoryPlacementObjectId,
|
||
directorInspectorMode: state.directorInspectorMode,
|
||
transformMode: state.transformMode,
|
||
viewportAspectRatio: state.viewportAspectRatio,
|
||
viewportRuleOfThirdsEnabled: state.viewportRuleOfThirdsEnabled,
|
||
viewportPanelsCollapsed: state.viewportPanelsCollapsed,
|
||
timelineVisible: state.timelineVisible,
|
||
timelineTrackOrder: state.timelineTrackOrder,
|
||
timelineCollapsedTracks: state.timelineCollapsedTracks,
|
||
project: state.project,
|
||
});
|
||
}
|
||
|
||
function writePersistedDirectorState(state: DirectorState) {
|
||
const storage = getLocalStorageSafe();
|
||
if (!storage) return;
|
||
|
||
try {
|
||
storage.setItem(getDirectorSceneStorageKey(), JSON.stringify(state));
|
||
} catch {
|
||
// Keep the editor usable if the browser storage quota is exceeded.
|
||
}
|
||
}
|
||
|
||
function createStateFromPersistedProject(project: DirectorProject, options: DirectorStateOptions = {}): DirectorState {
|
||
return {
|
||
...DEFAULT_UI_STATE,
|
||
project: dropExpiredPanoramaAsset(
|
||
withPersistedLocalAssets(migrateDirectorProject(cloneJsonValue(project)), options.includePersistedLocalAssets)
|
||
),
|
||
};
|
||
}
|
||
|
||
function readPersistedDirectorState(options: DirectorStateOptions = {}): DirectorState | null {
|
||
const storage = getLocalStorageSafe();
|
||
if (!storage) return null;
|
||
|
||
try {
|
||
const snapshot = storage.getItem(getDirectorSceneStorageKey(options.persistenceScopeId));
|
||
if (!snapshot) return null;
|
||
|
||
const parsed = JSON.parse(snapshot) as unknown;
|
||
|
||
if (isDirectorProjectShape(parsed)) {
|
||
return createStateFromPersistedProject(parsed, options);
|
||
}
|
||
|
||
if (!parsed || typeof parsed !== "object") return null;
|
||
|
||
const state = parsed as Partial<DirectorState>;
|
||
if (!isDirectorProjectShape(state.project)) return null;
|
||
|
||
return {
|
||
viewMode: state.viewMode === "camera" ? "camera" : "director",
|
||
selectedObjectId: typeof state.selectedObjectId === "string" ? state.selectedObjectId : null,
|
||
selectedObjectIds: Array.isArray(state.selectedObjectIds)
|
||
? state.selectedObjectIds.filter((item): item is string => typeof item === "string")
|
||
: [],
|
||
selectedCrowdId: typeof state.selectedCrowdId === "string" ? state.selectedCrowdId : null,
|
||
selectedTrajectoryPoint: readPersistedTrajectoryPointRef(state.selectedTrajectoryPoint),
|
||
selectedMotionClipId: typeof state.selectedMotionClipId === "string" ? state.selectedMotionClipId : null,
|
||
trajectoryPlacementObjectId:
|
||
typeof state.trajectoryPlacementObjectId === "string" ? state.trajectoryPlacementObjectId : null,
|
||
directorInspectorMode: state.directorInspectorMode === "scene" ? "scene" : "auto",
|
||
transformMode:
|
||
state.transformMode === "rotate" || state.transformMode === "scale" ? state.transformMode : "translate",
|
||
viewportAspectRatio: state.viewportAspectRatio ?? "auto",
|
||
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
|
||
),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function createRuntimeStateFromPersistedState(state: DirectorState): DirectorRuntimeState {
|
||
const snapshot = cloneJsonValue(state);
|
||
|
||
return {
|
||
...snapshot,
|
||
clipboard: [],
|
||
clipboardPasteCount: 0,
|
||
undoStack: [],
|
||
redoStack: [],
|
||
undoBatchDepth: 0,
|
||
undoBatchSnapshot: null,
|
||
undoBatchHasTrackedChanges: false,
|
||
};
|
||
}
|
||
|
||
function createUndoStackEntry(state: DirectorRuntimeState) {
|
||
return extractPersistedDirectorState(state);
|
||
}
|
||
|
||
export function createDefaultDirectorProject({
|
||
includePersistedLocalAssets = false,
|
||
}: {
|
||
includePersistedLocalAssets?: boolean;
|
||
} = {}): DirectorProject {
|
||
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",
|
||
lookRotation: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||
lastCaptureUrl: null,
|
||
captures: [],
|
||
};
|
||
|
||
const role: DirectorObject = {
|
||
id: "char_default_a",
|
||
name: formatSceneItemName("角色", 1),
|
||
kind: "character",
|
||
visible: true,
|
||
locked: false,
|
||
bodyType: DEFAULT_CHARACTER_BODY_TYPE,
|
||
color: "#4F8EF7",
|
||
transform: createTransform([0, 0, 0]),
|
||
characterRig: {
|
||
rigType: "ue4-mannequin",
|
||
posePresetId: "stand",
|
||
controls: {},
|
||
},
|
||
};
|
||
|
||
const cameraObject: DirectorObject = {
|
||
id: "cam_object_1",
|
||
name: camera.name,
|
||
kind: "camera",
|
||
visible: true,
|
||
locked: false,
|
||
linkedCameraId: camera.id,
|
||
transform: camera.transform,
|
||
};
|
||
|
||
return {
|
||
version: 2,
|
||
scene: DEFAULT_SCENE,
|
||
assets: includePersistedLocalAssets ? readPersistedLocalModelAssets() : [],
|
||
objects: [role, cameraObject],
|
||
cameras: [camera],
|
||
activeCameraId: camera.id,
|
||
panoramaAssetId: null,
|
||
timeline: {
|
||
...createDefaultTimeline(),
|
||
// 默认工程只有一个机位,无需过渡片段;机位片段(clips)空,靠 fallback 直接显示该机位
|
||
transitionClips: [],
|
||
},
|
||
exportSettings: DEFAULT_EXPORT_SETTINGS,
|
||
};
|
||
}
|
||
|
||
export function createInitialDirectorState(options: DirectorStateOptions = {}): DirectorState {
|
||
const persistedState = options.includePersistedScene ? readPersistedDirectorState(options) : null;
|
||
|
||
if (persistedState) {
|
||
return persistedState;
|
||
}
|
||
|
||
return {
|
||
...DEFAULT_UI_STATE,
|
||
project: createDefaultDirectorProject({ includePersistedLocalAssets: options.includePersistedLocalAssets }),
|
||
};
|
||
}
|
||
|
||
function updateObjectById(
|
||
objects: DirectorObject[],
|
||
id: string,
|
||
updater: (item: DirectorObject) => DirectorObject
|
||
) {
|
||
return objects.map((item) => (item.id === id ? updater(item) : item));
|
||
}
|
||
|
||
/** 把 motion 字段的 patch 应用到单个 character;若没有 rig 则保持不变。 */
|
||
function applyMotionPatchToCharacter(
|
||
character: DirectorObject,
|
||
patch: Partial<{ kind: MotionKind; speed: number; amplitude: number; enabled: boolean }>
|
||
): DirectorObject {
|
||
if (!character.characterRig) return character;
|
||
const current = character.characterRig.motion ?? {
|
||
kind: "none" as MotionKind,
|
||
speed: 1,
|
||
amplitude: 1,
|
||
enabled: true,
|
||
};
|
||
return {
|
||
...character,
|
||
characterRig: {
|
||
...character.characterRig,
|
||
motion: {
|
||
kind: patch.kind ?? current.kind,
|
||
speed: typeof patch.speed === "number" ? patch.speed : current.speed,
|
||
amplitude: typeof patch.amplitude === "number" ? patch.amplitude : current.amplitude,
|
||
enabled: typeof patch.enabled === "boolean" ? patch.enabled : current.enabled,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 姿势与动作互斥(选择层面):
|
||
* - 任何动作切换(无→walk、walk→run、run→无…)都把姿势数据整体重置为
|
||
* 动作默认状态(站立基础态),避免上一动作/姿势的偏移残留到下一动作
|
||
* - 重复选择同一动作不触发重置 */
|
||
function applyMotionSelectionToCharacter(character: DirectorObject, kind: MotionKind): DirectorObject {
|
||
if (!character.characterRig) return character;
|
||
const current = character.characterRig.motion ?? {
|
||
kind: "none" as MotionKind,
|
||
speed: 1,
|
||
amplitude: 1,
|
||
enabled: true,
|
||
};
|
||
const nextRig: NonNullable<DirectorObject["characterRig"]> = {
|
||
...character.characterRig,
|
||
motion: { ...current, kind },
|
||
};
|
||
if (kind !== current.kind) {
|
||
nextRig.posePresetId = "stand";
|
||
nextRig.controls = {};
|
||
}
|
||
return { ...character, characterRig: nextRig };
|
||
}
|
||
|
||
function getNextCharacterColor(objects: DirectorObject[]) {
|
||
const usedColors = new Set(objects.filter((item) => item.kind === "character").map((item) => item.color));
|
||
const unusedColor = CHARACTER_COLOR_PALETTE.find((color) => !usedColors.has(color));
|
||
|
||
if (unusedColor) return unusedColor;
|
||
|
||
const characterCount = objects.filter((item) => item.kind === "character").length;
|
||
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 ?? "几何模型";
|
||
}
|
||
|
||
function getAddedModelColumnOffset(index: number) {
|
||
const side = index % 2 === 1 ? -1 : 1;
|
||
const step = Math.ceil(index / 2);
|
||
|
||
return side * step * ADDED_MODEL_WORLD_SPACING;
|
||
}
|
||
|
||
function getCrowdCharacterPositions(rows: number, columns: number, spacing: number) {
|
||
const safeRows = Math.max(1, rows);
|
||
const safeColumns = Math.max(1, columns);
|
||
const safeSpacing = Math.max(0.1, spacing);
|
||
const xOffset = ((safeColumns - 1) * safeSpacing) / 2;
|
||
const zOffset = ((safeRows - 1) * safeSpacing) / 2;
|
||
const positions: Array<[number, number, number]> = [];
|
||
|
||
for (let rowIndex = 0; rowIndex < safeRows; rowIndex += 1) {
|
||
for (let columnIndex = 0; columnIndex < safeColumns; columnIndex += 1) {
|
||
positions.push([
|
||
Number((columnIndex * safeSpacing - xOffset).toFixed(4)),
|
||
0,
|
||
Number((rowIndex * safeSpacing - zOffset).toFixed(4)),
|
||
]);
|
||
}
|
||
}
|
||
|
||
return positions;
|
||
}
|
||
|
||
function getCrowdCharacterOffset(objects: DirectorObject[], spacing: number): [number, number, number] {
|
||
const safeSpacing = Math.max(0.1, spacing);
|
||
const characterPositions = objects
|
||
.filter((item) => item.kind === "character")
|
||
.map((item) => item.transform.position);
|
||
const maxZ = characterPositions.length ? Math.max(...characterPositions.map((position) => position[2])) : 0;
|
||
|
||
return [0, 0, Number((maxZ + safeSpacing * 2).toFixed(4))];
|
||
}
|
||
|
||
function formatCrowdLabel(rows: number, columns: number) {
|
||
return `群众(${rows}x${columns})`;
|
||
}
|
||
|
||
function buildPresetCharacterObject(
|
||
state: DirectorRuntimeState,
|
||
bodyType: CharacterBodyType,
|
||
position: [number, number, number],
|
||
crowdMetadata?: {
|
||
crowdId: string;
|
||
crowdLabel: string;
|
||
}
|
||
) {
|
||
const characterCount = state.project.objects.filter((item) => item.kind === "character").length;
|
||
const characterIndex = characterCount + 1;
|
||
const objectId = getNextSequentialId(
|
||
state.project.objects.map((item) => item.id),
|
||
"char_preset_",
|
||
characterIndex
|
||
);
|
||
const normalizedBodyType = normalizeBodyType(bodyType);
|
||
|
||
return {
|
||
id: objectId,
|
||
name: formatSceneItemName("角色", characterIndex),
|
||
kind: "character" as const,
|
||
visible: true,
|
||
locked: false,
|
||
bodyType: normalizedBodyType,
|
||
color: getNextCharacterColor(state.project.objects),
|
||
crowdId: crowdMetadata?.crowdId,
|
||
crowdLabel: crowdMetadata?.crowdLabel,
|
||
transform: createTransform(position),
|
||
characterRig: {
|
||
rigType: "ue4-mannequin" as const,
|
||
posePresetId: "stand",
|
||
controls: {},
|
||
},
|
||
} satisfies DirectorObject;
|
||
}
|
||
|
||
function formatCameraCaptureName(cameraName: string, captureIndex: number) {
|
||
return `${cameraName}-截图${String(captureIndex).padStart(2, "0")}`;
|
||
}
|
||
|
||
function buildCameraCaptures(camera: DirectorCameraShot, dataUrls: string[]) {
|
||
const existingCaptures = camera.captures ?? [];
|
||
|
||
return dataUrls.map((dataUrl, indexOffset): DirectorCameraCapture => {
|
||
const captureIndex = existingCaptures.length + indexOffset + 1;
|
||
|
||
return {
|
||
id: `${camera.id}-capture-${String(captureIndex).padStart(2, "0")}`,
|
||
index: captureIndex,
|
||
name: formatCameraCaptureName(camera.name, captureIndex),
|
||
dataUrl,
|
||
};
|
||
});
|
||
}
|
||
|
||
function createDisplayNameFromFileName(fileName: string) {
|
||
return fileName.replace(/\.(fbx|obj|jpe?g|png|webp)$/i, "");
|
||
}
|
||
|
||
function createSceneObjectFromAsset(asset: DirectorAssetRef, existingObjects: DirectorObject[]) {
|
||
const nextObjectId = getNextSequentialId(
|
||
existingObjects.map((item) => item.id),
|
||
"obj_",
|
||
existingObjects.length + 1
|
||
);
|
||
|
||
return {
|
||
id: nextObjectId,
|
||
name: asset.name ?? createDisplayNameFromFileName(asset.fileName),
|
||
kind: asset.kind,
|
||
visible: true,
|
||
locked: false,
|
||
assetRefId: asset.id,
|
||
transform: createTransform([0, 0, 0]),
|
||
} satisfies DirectorObject;
|
||
}
|
||
|
||
/**
|
||
* 从导入的 GLB/GLTF 资产创建一个「人形角色」对象:几何来自资产 url,动作由通用人形骨骼映射驱动。
|
||
* 注意:不设置 assetRefId(否则会被当成道具渲染),而是把资产 id 放进 characterRig.modelAssetId。
|
||
*/
|
||
function createCharacterFromAsset(asset: DirectorAssetRef, existingObjects: DirectorObject[]) {
|
||
const characterCount = existingObjects.filter((item) => item.kind === "character").length;
|
||
const characterIndex = characterCount + 1;
|
||
const objectId = getNextSequentialId(
|
||
existingObjects.map((item) => item.id),
|
||
"char_imported_",
|
||
characterIndex
|
||
);
|
||
|
||
return {
|
||
id: objectId,
|
||
name: formatSceneItemName("导入角色", characterIndex),
|
||
kind: "character" as const,
|
||
visible: true,
|
||
locked: false,
|
||
transform: createTransform([0, 0, 0]),
|
||
characterRig: {
|
||
rigType: "custom-humanoid" as const,
|
||
posePresetId: "stand",
|
||
controls: {},
|
||
modelAssetId: asset.id,
|
||
motion: { kind: "walk", speed: 1, amplitude: 1, enabled: true },
|
||
},
|
||
color: getNextCharacterColor(existingObjects),
|
||
} satisfies DirectorObject;
|
||
}
|
||
|
||
function refreshCamerasFocusedOnObject(cameras: DirectorCameraShot[], object: DirectorObject) {
|
||
return cameras.map((camera) =>
|
||
camera.targetMode === "object" && camera.targetObjectId === object.id
|
||
? {
|
||
...camera,
|
||
// 锁定物体:把「机位 → 物体」这条视线换算成注视朝向角存下来
|
||
lookRotation: getCameraLookRotationFromTarget(
|
||
camera.transform.position,
|
||
getDirectorObjectFocusTarget(object)
|
||
),
|
||
}
|
||
: camera
|
||
);
|
||
}
|
||
|
||
function refreshCamerasFocusedOnObjects(
|
||
cameras: DirectorCameraShot[],
|
||
objects: DirectorObject[],
|
||
focusedObjectIds: Iterable<string>
|
||
) {
|
||
const focusedIdSet = new Set(focusedObjectIds);
|
||
if (focusedIdSet.size === 0) return cameras;
|
||
|
||
const objectsById = new Map(objects.map((item) => [item.id, item]));
|
||
|
||
return cameras.map((camera) => {
|
||
if (camera.targetMode !== "object" || !camera.targetObjectId || !focusedIdSet.has(camera.targetObjectId)) {
|
||
return camera;
|
||
}
|
||
|
||
const targetObject = objectsById.get(camera.targetObjectId);
|
||
if (!targetObject) {
|
||
return {
|
||
...camera,
|
||
targetMode: "manual" as const,
|
||
targetObjectId: null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
...camera,
|
||
lookRotation: getCameraLookRotationFromTarget(
|
||
camera.transform.position,
|
||
getDirectorObjectFocusTarget(targetObject)
|
||
),
|
||
};
|
||
});
|
||
}
|
||
|
||
function getCrowdMemberObjects(objects: DirectorObject[], crowdId: string) {
|
||
return objects.filter((item) => item.kind === "character" && item.crowdId === crowdId);
|
||
}
|
||
|
||
function getCrowdMemberIds(objects: DirectorObject[], crowdId: string) {
|
||
return getCrowdMemberObjects(objects, crowdId).map((item) => item.id);
|
||
}
|
||
|
||
export function getCrowdAnchorTransform(objects: DirectorObject[], crowdId: string): DirectorTransform | null {
|
||
const crowdMembers = getCrowdMemberObjects(objects, crowdId);
|
||
if (!crowdMembers.length) return null;
|
||
|
||
const position = crowdMembers.reduce(
|
||
(accumulator, item) => {
|
||
accumulator[0] += item.transform.position[0];
|
||
accumulator[1] += item.transform.position[1];
|
||
accumulator[2] += item.transform.position[2];
|
||
return accumulator;
|
||
},
|
||
[0, 0, 0] as [number, number, number]
|
||
);
|
||
const memberCount = crowdMembers.length;
|
||
const anchorPosition = roundTransformTuple([
|
||
position[0] / memberCount,
|
||
position[1] / memberCount,
|
||
position[2] / memberCount,
|
||
]);
|
||
const referenceMember = crowdMembers[0];
|
||
|
||
return createTransform(
|
||
anchorPosition,
|
||
[...referenceMember.transform.rotation] as [number, number, number],
|
||
[...referenceMember.transform.scale] as [number, number, number]
|
||
);
|
||
}
|
||
|
||
function getNextCrowdId(objects: DirectorObject[]) {
|
||
return getNextSequentialId(
|
||
objects.map((item) => item.crowdId).filter((item): item is string => typeof item === "string"),
|
||
"crowd_",
|
||
1
|
||
);
|
||
}
|
||
|
||
function applyCrowdTransformPatch(
|
||
objects: DirectorObject[],
|
||
crowdId: string,
|
||
patch: Partial<DirectorTransform>
|
||
) {
|
||
const anchor = getCrowdAnchorTransform(objects, crowdId);
|
||
if (!anchor) {
|
||
return {
|
||
objects,
|
||
changedObjectIds: [],
|
||
};
|
||
}
|
||
|
||
const nextPosition = patch.position ?? anchor.position;
|
||
// 旋转一律存度数,且按 0–360° 一整圈收敛(避免转很多圈)
|
||
const nextRotation = patch.rotation ? normalizeTransformRotation(patch.rotation) : anchor.rotation;
|
||
const nextScale = patch.scale ?? anchor.scale;
|
||
const deltaRotation: [number, number, number] = [
|
||
nextRotation[0] - anchor.rotation[0],
|
||
nextRotation[1] - anchor.rotation[1],
|
||
nextRotation[2] - anchor.rotation[2],
|
||
];
|
||
const scaleRatio: [number, number, number] = [
|
||
anchor.scale[0] === 0 ? 1 : nextScale[0] / anchor.scale[0],
|
||
anchor.scale[1] === 0 ? 1 : nextScale[1] / anchor.scale[1],
|
||
anchor.scale[2] === 0 ? 1 : nextScale[2] / anchor.scale[2],
|
||
];
|
||
const anchorPosition = anchor.position;
|
||
const changedObjectIds = getCrowdMemberIds(objects, crowdId);
|
||
const changedIdSet = new Set(changedObjectIds);
|
||
|
||
return {
|
||
changedObjectIds,
|
||
objects: objects.map((item) => {
|
||
if (!changedIdSet.has(item.id)) return item;
|
||
|
||
const offsetX = (item.transform.position[0] - anchorPosition[0]) * scaleRatio[0];
|
||
const offsetY = (item.transform.position[1] - anchorPosition[1]) * scaleRatio[1];
|
||
const offsetZ = (item.transform.position[2] - anchorPosition[2]) * scaleRatio[2];
|
||
const cosX = Math.cos(deltaRotation[0]);
|
||
const sinX = Math.sin(deltaRotation[0]);
|
||
const cosY = Math.cos(deltaRotation[1]);
|
||
const sinY = Math.sin(deltaRotation[1]);
|
||
const cosZ = Math.cos(deltaRotation[2]);
|
||
const sinZ = Math.sin(deltaRotation[2]);
|
||
|
||
const x1 = offsetX;
|
||
const y1 = offsetY * cosX - offsetZ * sinX;
|
||
const z1 = offsetY * sinX + offsetZ * cosX;
|
||
|
||
const x2 = x1 * cosY + z1 * sinY;
|
||
const y2 = y1;
|
||
const z2 = -x1 * sinY + z1 * cosY;
|
||
|
||
const x3 = x2 * cosZ - y2 * sinZ;
|
||
const y3 = x2 * sinZ + y2 * cosZ;
|
||
const z3 = z2;
|
||
|
||
return {
|
||
...item,
|
||
transform: {
|
||
position: roundTransformTuple([
|
||
nextPosition[0] + x3,
|
||
nextPosition[1] + y3,
|
||
nextPosition[2] + z3,
|
||
]),
|
||
rotation: roundTransformTuple([
|
||
item.transform.rotation[0] + deltaRotation[0],
|
||
item.transform.rotation[1] + deltaRotation[1],
|
||
item.transform.rotation[2] + deltaRotation[2],
|
||
]),
|
||
scale: roundTransformTuple([
|
||
item.transform.scale[0] * scaleRatio[0],
|
||
item.transform.scale[1] * scaleRatio[1],
|
||
item.transform.scale[2] * scaleRatio[2],
|
||
]),
|
||
},
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
function getOrderedSelectedObjectIds(state: DirectorState) {
|
||
if (state.selectedObjectIds.length) return state.selectedObjectIds;
|
||
return state.selectedObjectId ? [state.selectedObjectId] : [];
|
||
}
|
||
|
||
/**
|
||
* 按 id 删除一组对象,并做级联清理:
|
||
* - 摄像机对象连带删掉它背后的机位(cameras)
|
||
* - 原本注视被删对象的机位退回「手动坐标」注视
|
||
* - 只被这些对象引用的模型素材一并回收(本地导入的素材保留)
|
||
* - 时间轴上对应的物体轨道 / 相机轨道一起删掉
|
||
* 没被删掉的选中项继续保持选中(删别人不会丢掉当前选中)。
|
||
*/
|
||
function deleteObjectsByIds(state: DirectorRuntimeState, objectIds: string[]): DirectorRuntimeState {
|
||
if (!objectIds.length) return state;
|
||
|
||
const objectIdSet = new Set(objectIds);
|
||
const targetObjects = state.project.objects.filter((item) => objectIdSet.has(item.id));
|
||
|
||
if (!targetObjects.length) {
|
||
return {
|
||
...state,
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
};
|
||
}
|
||
|
||
const linkedCameraIds = new Set(
|
||
targetObjects.filter((item) => item.kind === "camera" && item.linkedCameraId).map((item) => item.linkedCameraId)
|
||
);
|
||
const nextCameras = linkedCameraIds.size
|
||
? state.project.cameras.filter((camera) => !linkedCameraIds.has(camera.id))
|
||
: state.project.cameras;
|
||
const nextFocusedCameras = nextCameras.map((camera) =>
|
||
camera.targetObjectId && objectIdSet.has(camera.targetObjectId)
|
||
? {
|
||
...camera,
|
||
targetMode: "manual" as const,
|
||
targetObjectId: null,
|
||
}
|
||
: camera
|
||
);
|
||
const nextActiveCameraId =
|
||
state.project.activeCameraId && linkedCameraIds.has(state.project.activeCameraId)
|
||
? nextFocusedCameras[0]?.id ?? null
|
||
: state.project.activeCameraId;
|
||
const nextObjects = state.project.objects.filter((item) => !objectIdSet.has(item.id));
|
||
const assetsById = new Map(state.project.assets.map((item) => [item.id, item]));
|
||
const remainingAssetRefIds = new Set(
|
||
nextObjects.map((item) => item.assetRefId).filter((assetRefId): assetRefId is string => Boolean(assetRefId))
|
||
);
|
||
const removedAssetRefIds = new Set(
|
||
targetObjects
|
||
.map((item) => item.assetRefId)
|
||
.filter((assetRefId): assetRefId is string => {
|
||
if (typeof assetRefId !== "string" || remainingAssetRefIds.has(assetRefId)) return false;
|
||
return assetsById.get(assetRefId)?.assetSource !== "local";
|
||
})
|
||
);
|
||
|
||
// 同步清理被删对象的关键帧轨道:物体轨道按 objectId、相机轨道按 linkedCameraId
|
||
const nextTracks = state.project.timeline.tracks.filter((track) => !objectIdSet.has(track.objectId));
|
||
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 &&
|
||
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)
|
||
? state.selectedCrowdId
|
||
: null;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: remainingSelectedObjectIds[remainingSelectedObjectIds.length - 1] ?? null,
|
||
selectedObjectIds: remainingSelectedObjectIds,
|
||
selectedCrowdId: remainingSelectedCrowdId,
|
||
// 所属角色被删了,它的轨迹点选中态 / 打点模式也要一并清掉
|
||
selectedTrajectoryPoint:
|
||
state.selectedTrajectoryPoint && objectIdSet.has(state.selectedTrajectoryPoint.objectId)
|
||
? null
|
||
: state.selectedTrajectoryPoint,
|
||
trajectoryPlacementObjectId:
|
||
state.trajectoryPlacementObjectId && objectIdSet.has(state.trajectoryPlacementObjectId)
|
||
? null
|
||
: state.trajectoryPlacementObjectId,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
assets: state.project.assets.filter((item) => !removedAssetRefIds.has(item.id)),
|
||
objects: nextObjects,
|
||
cameras: nextFocusedCameras,
|
||
activeCameraId: nextActiveCameraId,
|
||
timeline: timelineUnchanged
|
||
? state.project.timeline
|
||
: {
|
||
...state.project.timeline,
|
||
tracks: nextTracks,
|
||
cameraTracks: nextCameraTracks,
|
||
transitionClips: nextTransitionClips,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function createObjectIdForDuplicate(existingObjects: DirectorObject[], source: DirectorObject) {
|
||
if (source.kind === "camera") {
|
||
return getNextSequentialId(
|
||
existingObjects.map((item) => item.id),
|
||
"cam_object_",
|
||
existingObjects.filter((item) => item.kind === "camera").length + 1
|
||
);
|
||
}
|
||
|
||
if (source.kind === "character") {
|
||
return getNextSequentialId(
|
||
existingObjects.map((item) => item.id),
|
||
"char_paste_",
|
||
existingObjects.filter((item) => item.kind === "character").length + 1
|
||
);
|
||
}
|
||
|
||
if (source.geometryType) {
|
||
return getNextSequentialId(
|
||
existingObjects.map((item) => item.id),
|
||
`geo_${source.geometryType}_copy_`,
|
||
existingObjects.length + 1
|
||
);
|
||
}
|
||
|
||
return getNextSequentialId(existingObjects.map((item) => item.id), "obj_", existingObjects.length + 1);
|
||
}
|
||
|
||
function applyPositionOffset(position: [number, number, number], offset: number): [number, number, number] {
|
||
return [position[0] + offset, position[1], position[2] + offset];
|
||
}
|
||
|
||
function applyOffsetToTransform(transform: DirectorTransform, offset: number): DirectorTransform {
|
||
return {
|
||
...transform,
|
||
position: applyPositionOffset(transform.position, offset),
|
||
};
|
||
}
|
||
|
||
function buildClipboardEntries(state: DirectorState): DirectorClipboardEntry[] {
|
||
const selectedObjectIds = getOrderedSelectedObjectIds(state);
|
||
if (!selectedObjectIds.length) return [];
|
||
|
||
return selectedObjectIds.flatMap((objectId) => {
|
||
const object = state.project.objects.find((item) => item.id === objectId);
|
||
if (!object) return [];
|
||
|
||
const camera =
|
||
object.kind === "camera" && object.linkedCameraId
|
||
? state.project.cameras.find((item) => item.id === object.linkedCameraId)
|
||
: undefined;
|
||
|
||
return [
|
||
{
|
||
object: cloneJsonValue(object),
|
||
camera: camera ? cloneJsonValue(camera) : undefined,
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeState {
|
||
if (state.clipboard.length === 0) return state;
|
||
|
||
const pasteIteration = state.clipboardPasteCount + 1;
|
||
const offset = COPY_PASTE_POSITION_OFFSET * pasteIteration;
|
||
const nextObjects = [...state.project.objects];
|
||
const nextCameras = [...state.project.cameras];
|
||
const idMap = new Map<string, string>();
|
||
const crowdIdMap = new Map<string, string>();
|
||
const pastedObjectIds: string[] = [];
|
||
|
||
function getPastedCrowdId(sourceCrowdId: string) {
|
||
const existing = crowdIdMap.get(sourceCrowdId);
|
||
if (existing) return existing;
|
||
|
||
const nextCrowdId = getNextCrowdId(nextObjects);
|
||
crowdIdMap.set(sourceCrowdId, nextCrowdId);
|
||
return nextCrowdId;
|
||
}
|
||
|
||
state.clipboard.forEach((entry) => {
|
||
if (entry.object.kind === "camera" && entry.camera) {
|
||
const cameraIndex = nextCameras.length + 1;
|
||
const nextCameraId = getNextSequentialId(
|
||
nextCameras.map((item) => item.id),
|
||
"cam_",
|
||
cameraIndex
|
||
);
|
||
const nextObjectId = createObjectIdForDuplicate(nextObjects, entry.object);
|
||
idMap.set(entry.object.id, nextObjectId);
|
||
if (entry.object.linkedCameraId) {
|
||
idMap.set(entry.object.linkedCameraId, nextCameraId);
|
||
}
|
||
|
||
const targetObjectId = entry.camera.targetObjectId ? idMap.get(entry.camera.targetObjectId) : null;
|
||
const nextCamera: DirectorCameraShot = {
|
||
...entry.camera,
|
||
id: nextCameraId,
|
||
color: getNextCameraColor(state.project.cameras),
|
||
name: formatSceneItemName("机位", cameraIndex),
|
||
transform: applyOffsetToTransform(entry.camera.transform, offset),
|
||
// 注视是朝向角,平移机位不会改变它(锁物体的机位稍后按新位置重算)
|
||
lookRotation: [...entry.camera.lookRotation],
|
||
targetObjectId: targetObjectId ?? entry.camera.targetObjectId ?? null,
|
||
captures: [],
|
||
lastCaptureUrl: null,
|
||
};
|
||
const nextCameraObject: DirectorObject = {
|
||
...entry.object,
|
||
id: nextObjectId,
|
||
name: nextCamera.name,
|
||
linkedCameraId: nextCamera.id,
|
||
transform: nextCamera.transform,
|
||
};
|
||
|
||
nextCameras.push(nextCamera);
|
||
nextObjects.push(nextCameraObject);
|
||
pastedObjectIds.push(nextObjectId);
|
||
return;
|
||
}
|
||
|
||
const nextObjectId = createObjectIdForDuplicate(nextObjects, entry.object);
|
||
idMap.set(entry.object.id, nextObjectId);
|
||
const nextCharacterCount =
|
||
entry.object.kind === "character" ? nextObjects.filter((item) => item.kind === "character").length + 1 : null;
|
||
const duplicatedObject: DirectorObject = {
|
||
...entry.object,
|
||
id: nextObjectId,
|
||
name:
|
||
entry.object.kind === "character" && nextCharacterCount
|
||
? formatSceneItemName("角色", nextCharacterCount)
|
||
: entry.object.name,
|
||
crowdId: entry.object.crowdId ? getPastedCrowdId(entry.object.crowdId) : entry.object.crowdId,
|
||
transform: applyOffsetToTransform(entry.object.transform, offset),
|
||
};
|
||
|
||
nextObjects.push(duplicatedObject);
|
||
pastedObjectIds.push(nextObjectId);
|
||
});
|
||
|
||
const nextObjectsById = new Map(nextObjects.map((item) => [item.id, item]));
|
||
const normalizedCameras = nextCameras.map((camera) => {
|
||
if (camera.targetMode !== "object" || !camera.targetObjectId) return camera;
|
||
|
||
const mappedTargetObjectId = idMap.get(camera.targetObjectId) ?? camera.targetObjectId;
|
||
const targetObject = nextObjectsById.get(mappedTargetObjectId);
|
||
if (!targetObject) {
|
||
return {
|
||
...camera,
|
||
targetMode: "manual" as const,
|
||
targetObjectId: null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
...camera,
|
||
targetObjectId: mappedTargetObjectId,
|
||
lookRotation: getCameraLookRotationFromTarget(
|
||
camera.transform.position,
|
||
getDirectorObjectFocusTarget(targetObject)
|
||
),
|
||
};
|
||
});
|
||
const lastPastedObject = pastedObjectIds.length
|
||
? nextObjects.find((item) => item.id === pastedObjectIds[pastedObjectIds.length - 1])
|
||
: null;
|
||
const pastedCrowdIds = Array.from(
|
||
new Set(
|
||
pastedObjectIds
|
||
.map((objectId) => nextObjects.find((item) => item.id === objectId)?.crowdId)
|
||
.filter((crowdId): crowdId is string => typeof crowdId === "string")
|
||
)
|
||
);
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: pastedObjectIds[pastedObjectIds.length - 1] ?? null,
|
||
selectedObjectIds: pastedObjectIds,
|
||
selectedCrowdId: pastedCrowdIds.length === 1 ? pastedCrowdIds[0] : null,
|
||
directorInspectorMode: "auto",
|
||
clipboardPasteCount: pasteIteration,
|
||
project: {
|
||
...state.project,
|
||
objects: nextObjects,
|
||
cameras: normalizedCameras,
|
||
activeCameraId:
|
||
lastPastedObject?.kind === "camera"
|
||
? lastPastedObject.linkedCameraId ?? state.project.activeCameraId
|
||
: state.project.activeCameraId,
|
||
},
|
||
};
|
||
}
|
||
|
||
function isSameDirectorState(a: DirectorState, b: DirectorState) {
|
||
return JSON.stringify(a) === JSON.stringify(b);
|
||
}
|
||
|
||
function trimUndoStack(stack: DirectorState[]) {
|
||
return stack.length > UNDO_STACK_LIMIT ? stack.slice(stack.length - UNDO_STACK_LIMIT) : stack;
|
||
}
|
||
|
||
/**
|
||
* 动作轨道不允许重叠:把 clipId 这段紧贴到与其重叠片段的「最近一侧」(之前 / 之后),
|
||
* 迭代处理直到与所有片段都不重叠。正确处理无限长片段(片段终点默认到时间轴末尾):
|
||
* - 被拖片段无限长:只能放在被压片段之后(否则必然覆盖它);
|
||
* - 被压片段无限长:被拖片段有限时长时放在其之前(终点贴被压片段起点)。
|
||
*/
|
||
function resolveMotionClipOverlap(
|
||
clips: MotionClip[],
|
||
clipId: string,
|
||
duration: number,
|
||
desiredStartOverride?: number,
|
||
desiredEndOverride?: number
|
||
): MotionClip[] {
|
||
// 若给了期望区间,先一次性把被拖片段放到该位置,再迭代解决与邻居的级联重叠
|
||
let result = clips;
|
||
if (desiredStartOverride !== undefined && desiredEndOverride !== undefined) {
|
||
result = result.map((c) =>
|
||
c.id === clipId ? { ...c, start: desiredStartOverride, end: desiredEndOverride } : c
|
||
);
|
||
}
|
||
const maxIter = result.length + 2;
|
||
for (let iter = 0; iter < maxIter; iter += 1) {
|
||
const dragged = result.find((c) => c.id === clipId);
|
||
if (!dragged) break;
|
||
const ov = result.find((c) => c.id !== clipId && c.start < dragged.end && c.end > dragged.start);
|
||
if (!ov) break;
|
||
const dFinite = Number.isFinite(dragged.end);
|
||
const dSpan = dFinite ? dragged.end - dragged.start : duration - dragged.start;
|
||
const dCenter = dragged.start + dSpan / 2;
|
||
const oFinite = Number.isFinite(ov.end);
|
||
const oCenter = ov.start + (oFinite ? (ov.end - ov.start) / 2 : (duration - ov.start) / 2);
|
||
let ns: number;
|
||
if (!dFinite) {
|
||
// 无限长被拖片段只能放在被压片段之后,否则必然覆盖它
|
||
ns = oFinite ? ov.end : 0;
|
||
} else {
|
||
const afterStart = oFinite ? ov.end : duration - dSpan;
|
||
const afterValid = oFinite && afterStart + dSpan <= duration;
|
||
const beforeStart = ov.start - dSpan;
|
||
const beforeValid = beforeStart >= 0;
|
||
const preferAfter = dCenter >= oCenter;
|
||
if (preferAfter) ns = afterValid ? afterStart : beforeValid ? beforeStart : Math.max(0, afterStart);
|
||
else ns = beforeValid ? beforeStart : afterValid ? afterStart : 0;
|
||
}
|
||
const ne = dFinite ? ns + dSpan : Number.POSITIVE_INFINITY;
|
||
result = result.map((c) => (c.id === clipId ? { ...c, start: ns, end: ne } : c));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 在「不重叠」前提下,把被拖片段与同轨道邻居换位:拖动越过谁就与谁交换位置(被跨过的邻居整体平移,
|
||
* 各自时长不变、不缩放)。换位阈值取「被拖片段前缘越过邻居中点(过半)」,避免刚碰到边界就跳位;
|
||
* 未过半时不跳到另一侧,而是挡在所压邻居的当前侧边界上。基于当前布局计算,因此换位后可再拖回。
|
||
* 任一片段无限长则放弃换位(无限长片段无法塞进某个槽),交回上层走通用解析。
|
||
*/
|
||
function resolveMotionClipReorder(
|
||
clips: MotionClip[],
|
||
clipId: string,
|
||
desiredStart: number,
|
||
desiredEnd: number,
|
||
originalClips: MotionClip[],
|
||
duration: number
|
||
): MotionClip[] | null {
|
||
const dragged = clips.find((c) => c.id === clipId);
|
||
if (!dragged || !Number.isFinite(desiredEnd)) return null;
|
||
const length = desiredEnd - desiredStart;
|
||
|
||
const cur = [...clips].sort((a, b) => a.start - b.start);
|
||
if (cur.some((c) => !Number.isFinite(c.end))) return null;
|
||
const i = cur.findIndex((c) => c.id === clipId);
|
||
if (i < 0) return null;
|
||
|
||
const n = cur.length;
|
||
const midpoint = (c: MotionClip) => (c.start + c.end) / 2;
|
||
// 阈值:被拖片段的「前缘」(desiredStart) 越过邻居「中点」(过半)才交换。
|
||
// 右移看右侧邻居、左移看左侧邻居,越过谁就与谁交换。
|
||
const rightPassed = cur.filter(
|
||
(c) => c.id !== clipId && c.start > cur[i].start && desiredStart > midpoint(c)
|
||
).length;
|
||
const leftPassed = cur.filter(
|
||
(c) => c.id !== clipId && c.start < cur[i].start && desiredStart < midpoint(c)
|
||
).length;
|
||
const delta = desiredStart - cur[i].start; // 当前位置 → 期望位置:判断拖拽方向
|
||
let j = delta >= 0 ? i + rightPassed : i - leftPassed;
|
||
j = Math.max(0, Math.min(j, n - 1));
|
||
|
||
if (j === i) {
|
||
// 未过半:挡在所压邻居的当前侧、不跳到另一侧(轻微重叠即弹到对面的手感不要)。
|
||
const others = cur.filter((c) => c.id !== clipId);
|
||
const overlap = others.filter((o) => o.start < desiredEnd && o.end > desiredStart);
|
||
if (overlap.length === 0) return clips;
|
||
let start: number;
|
||
if (delta >= 0) {
|
||
const right =
|
||
overlap.filter((o) => o.start >= cur[i].start).sort((a, b) => a.start - b.start)[0] ??
|
||
overlap.slice().sort((a, b) => a.start - b.start)[0];
|
||
start = right.start - length;
|
||
} else {
|
||
const left =
|
||
overlap.filter((o) => o.end <= cur[i].start).sort((a, b) => b.end - a.end)[0] ??
|
||
overlap.slice().sort((a, b) => b.end - a.end)[0];
|
||
start = left.end;
|
||
}
|
||
start = Math.max(0, Math.min(start, duration - length));
|
||
return clips.map((c) => (c.id === clipId ? { ...c, start, end: start + length } : c));
|
||
}
|
||
|
||
// 受影响片段(被跨过的邻居)整体平移、保留各自时长;它们在段内的顺序,以及被拖片段在段的哪一端,取决于方向。
|
||
const segClips: MotionClip[] =
|
||
j > i ? [...cur.slice(i + 1, j + 1), dragged] : [dragged, ...cur.slice(j, i)];
|
||
// 段的左边界:紧贴被拖片段当前的左邻居(右移)或段内首个被跨片段当前的左邻居(左移)之后。
|
||
const segLeft = j > i ? (i > 0 ? cur[i - 1].end : 0) : j > 0 ? cur[j - 1].end : 0;
|
||
|
||
let pos = segLeft;
|
||
const newStart = new Map<string, number>();
|
||
for (const c of segClips) {
|
||
newStart.set(c.id, pos);
|
||
pos += c.end - c.start;
|
||
}
|
||
const draggedEnd = newStart.get(clipId)! + length;
|
||
|
||
// 兜底:与段外邻居 / 时间轴边界重叠则放弃换位
|
||
if (draggedEnd > duration + 1e-6) return null;
|
||
if (j > i && j + 1 < n && draggedEnd > cur[j + 1].start + 1e-6) return null;
|
||
if (j < i && i + 1 < n && draggedEnd > cur[i + 1].start + 1e-6) return null;
|
||
if (segLeft < -1e-6) return null;
|
||
|
||
const curLength = (id: string) => {
|
||
const o = cur.find((c) => c.id === id)!;
|
||
return o.end - o.start;
|
||
};
|
||
return clips.map((c) => {
|
||
if (!newStart.has(c.id)) return c;
|
||
const s = newStart.get(c.id)!;
|
||
const len = c.id === clipId ? length : curLength(c.id);
|
||
return { ...c, start: s, end: s + len };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 决定被拖片段(当前 clips 里还是上一帧的位置)放到期望区间后的最终布局:
|
||
* - 期望区间没压到任何片段 → 自由移动,直接落到期望区间;
|
||
* - 压到了且有拖拽起始布局 → 尝试「过半才交换」的换位;
|
||
* - 否则(修修剪剪 / 无限长兜底)→ 退回「紧贴就近一侧」的通用解析。
|
||
*/
|
||
function resolveMotionClipArrangement(
|
||
clips: MotionClip[],
|
||
clipId: string,
|
||
desiredStart: number,
|
||
desiredEnd: number,
|
||
duration: number,
|
||
originalClips?: MotionClip[]
|
||
): MotionClip[] {
|
||
const dragged = clips.find((c) => c.id === clipId);
|
||
if (!dragged) return clips;
|
||
// 用「期望区间」判断是否与其它片段冲突(clips 里被拖片段仍是上一帧位置)
|
||
const desiredOverlaps = clips.some((c) => c.id !== clipId && c.start < desiredEnd && c.end > desiredStart);
|
||
if (!desiredOverlaps) {
|
||
return clips.map((c) => (c.id === clipId ? { ...c, start: desiredStart, end: desiredEnd } : c));
|
||
}
|
||
if (originalClips) {
|
||
const reordered = resolveMotionClipReorder(clips, clipId, desiredStart, desiredEnd, originalClips, duration);
|
||
if (reordered) return reordered;
|
||
}
|
||
return resolveMotionClipOverlap(clips, clipId, duration, desiredStart, desiredEnd);
|
||
}
|
||
|
||
export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||
const initialRuntimeState = createRuntimeStateFromPersistedState(
|
||
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 } = {}
|
||
) {
|
||
const { trackUndo = true, persist = true } = options;
|
||
|
||
set((state) => {
|
||
const currentState = state as DirectorRuntimeState;
|
||
// 撤销批次内(gizmo / 轨迹点拖拽中)不会再有新的撤销快照 —— 批次开始时的快照已经够回退。
|
||
// 因此这里跳过「整工程深克隆 + 全量 JSON 比较」:拖拽时每次指针移动都做一遍的话,
|
||
// 工程里只要带了 base64 全景图就会直接卡死(实测单次写入 39ms @2MB 资源)。
|
||
const isInsideUndoBatch = trackUndo && currentState.undoBatchDepth > 0;
|
||
const previousSnapshot = isInsideUndoBatch ? null : createUndoStackEntry(currentState);
|
||
const nextState = updater(currentState);
|
||
const didChange =
|
||
previousSnapshot === null || !isSameDirectorState(previousSnapshot, extractPersistedDirectorState(nextState));
|
||
|
||
if (!didChange) {
|
||
return {
|
||
...nextState,
|
||
undoStack: trackUndo ? currentState.undoStack : nextState.undoStack,
|
||
redoStack: currentState.redoStack ?? [],
|
||
undoBatchDepth: nextState.undoBatchDepth,
|
||
undoBatchSnapshot: nextState.undoBatchSnapshot,
|
||
undoBatchHasTrackedChanges: nextState.undoBatchHasTrackedChanges,
|
||
};
|
||
}
|
||
|
||
const shouldCaptureUndoBatchSnapshot = isInsideUndoBatch && currentState.undoBatchSnapshot === null;
|
||
// 压入新的撤销快照(真正的用户编辑,非撤销批次内)时,清空重做栈 —— 重做链到此截断
|
||
const pushedNewUndoEntry = Boolean(previousSnapshot) && currentState.undoBatchDepth === 0;
|
||
const nextUndoStack =
|
||
previousSnapshot && currentState.undoBatchDepth === 0
|
||
? trimUndoStack([...currentState.undoStack, previousSnapshot])
|
||
: nextState.undoStack;
|
||
const nextRedoStack = pushedNewUndoEntry ? [] : (currentState.redoStack ?? []);
|
||
const runtimeState: DirectorRuntimeState = {
|
||
...nextState,
|
||
undoStack: nextUndoStack,
|
||
redoStack: nextRedoStack,
|
||
undoBatchSnapshot: shouldCaptureUndoBatchSnapshot
|
||
? createUndoStackEntry(currentState)
|
||
: nextState.undoBatchSnapshot,
|
||
undoBatchHasTrackedChanges: isInsideUndoBatch ? true : nextState.undoBatchHasTrackedChanges,
|
||
};
|
||
|
||
if (persist) {
|
||
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 });
|
||
}
|
||
|
||
return {
|
||
...initialRuntimeState,
|
||
beginUndoBatch: () => {
|
||
set((state) => {
|
||
const currentState = state as DirectorRuntimeState;
|
||
|
||
return {
|
||
...currentState,
|
||
undoBatchDepth: currentState.undoBatchDepth + 1,
|
||
undoBatchSnapshot: currentState.undoBatchDepth === 0 ? createUndoStackEntry(currentState) : currentState.undoBatchSnapshot,
|
||
undoBatchHasTrackedChanges: currentState.undoBatchDepth === 0 ? false : currentState.undoBatchHasTrackedChanges,
|
||
};
|
||
});
|
||
},
|
||
endUndoBatch: () => {
|
||
const depthBefore = (get() as DirectorRuntimeState).undoBatchDepth;
|
||
|
||
set((state) => {
|
||
const currentState = state as DirectorRuntimeState;
|
||
if (currentState.undoBatchDepth === 0) return currentState;
|
||
|
||
const nextUndoBatchDepth = currentState.undoBatchDepth - 1;
|
||
if (nextUndoBatchDepth > 0) {
|
||
return {
|
||
...currentState,
|
||
undoBatchDepth: nextUndoBatchDepth,
|
||
};
|
||
}
|
||
|
||
const currentSnapshot = extractPersistedDirectorState(currentState);
|
||
const shouldPushUndoEntry =
|
||
currentState.undoBatchHasTrackedChanges &&
|
||
currentState.undoBatchSnapshot !== null &&
|
||
!isSameDirectorState(currentState.undoBatchSnapshot, currentSnapshot);
|
||
|
||
return {
|
||
...currentState,
|
||
undoStack: shouldPushUndoEntry
|
||
? trimUndoStack([...currentState.undoStack, currentState.undoBatchSnapshot!])
|
||
: currentState.undoStack,
|
||
// 批次提交也是一次真实编辑,重做栈同样截断
|
||
redoStack: shouldPushUndoEntry ? [] : (currentState.redoStack ?? []),
|
||
undoBatchDepth: 0,
|
||
undoBatchSnapshot: null,
|
||
undoBatchHasTrackedChanges: false,
|
||
};
|
||
});
|
||
|
||
// 批次内的落盘是防抖的,收尾时立刻补写一次,保证刷新页面拿到的是拖拽后的最终结果
|
||
if (depthBefore > 0) {
|
||
cancelPendingUndoBatchPersist();
|
||
writePersistedDirectorState(extractPersistedDirectorState(get() as DirectorRuntimeState));
|
||
}
|
||
},
|
||
setTransformMode: (mode) =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
transformMode: mode,
|
||
})),
|
||
setViewportAspectRatio: (ratio) =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
viewportAspectRatio: ratio,
|
||
})),
|
||
setViewportRuleOfThirdsEnabled: (enabled) =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
viewportRuleOfThirdsEnabled: enabled,
|
||
})),
|
||
toggleViewportPanelsCollapsed: () =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
viewportPanelsCollapsed: !state.viewportPanelsCollapsed,
|
||
})),
|
||
setViewportPanelsCollapsed: (collapsed) =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
viewportPanelsCollapsed: collapsed,
|
||
})),
|
||
toggleTimelineVisible: () =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
timelineVisible: !state.timelineVisible,
|
||
})),
|
||
setTimelineVisible: (visible) =>
|
||
commitUiMutation((state) => ({
|
||
...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,
|
||
viewMode: mode,
|
||
project: {
|
||
...state.project,
|
||
activeCameraId:
|
||
mode === "camera"
|
||
? state.project.activeCameraId ?? state.project.cameras[0]?.id ?? null
|
||
: state.project.activeCameraId,
|
||
},
|
||
})),
|
||
selectObject: (id) =>
|
||
commitUiMutation((state) => {
|
||
const selectedObject = state.project.objects.find((item) => item.id === id);
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: id,
|
||
selectedObjectIds: id ? [id] : [],
|
||
selectedCrowdId: null,
|
||
// 改选其它对象就退出「轨迹点」卡片
|
||
selectedTrajectoryPoint: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
activeCameraId:
|
||
selectedObject?.kind === "camera" && selectedObject.linkedCameraId
|
||
? selectedObject.linkedCameraId
|
||
: state.project.activeCameraId,
|
||
},
|
||
};
|
||
}),
|
||
selectCrowd: (crowdId) =>
|
||
commitUiMutation((state) => {
|
||
if (!crowdId) {
|
||
return {
|
||
...state,
|
||
selectedCrowdId: null,
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
};
|
||
}
|
||
|
||
const crowdMemberIds = getCrowdMemberIds(state.project.objects, crowdId);
|
||
if (!crowdMemberIds.length) return state;
|
||
|
||
return {
|
||
...state,
|
||
selectedCrowdId: crowdId,
|
||
selectedObjectId: crowdMemberIds[crowdMemberIds.length - 1] ?? null,
|
||
selectedObjectIds: crowdMemberIds,
|
||
selectedTrajectoryPoint: null,
|
||
directorInspectorMode: "auto",
|
||
};
|
||
}),
|
||
toggleObjectSelection: (id) =>
|
||
commitUiMutation((state) => {
|
||
const selectedObject = state.project.objects.find((item) => item.id === id);
|
||
if (!selectedObject) return state;
|
||
|
||
const selectedObjectIds = getOrderedSelectedObjectIds(state);
|
||
const nextSelectedObjectIds = selectedObjectIds.includes(id)
|
||
? selectedObjectIds.filter((itemId) => itemId !== id)
|
||
: [...selectedObjectIds, id];
|
||
const nextSelectedObjectId = nextSelectedObjectIds[nextSelectedObjectIds.length - 1] ?? null;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: nextSelectedObjectId,
|
||
selectedObjectIds: nextSelectedObjectIds,
|
||
selectedCrowdId: null,
|
||
selectedTrajectoryPoint: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
activeCameraId:
|
||
selectedObject.kind === "camera" && selectedObject.linkedCameraId
|
||
? selectedObject.linkedCameraId
|
||
: state.project.activeCameraId,
|
||
},
|
||
};
|
||
}),
|
||
openSceneInspector: () =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
directorInspectorMode: "scene",
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
selectedCrowdId: null,
|
||
selectedTrajectoryPoint: null,
|
||
})),
|
||
startTrajectoryPlacement: (objectId) =>
|
||
commitUiMutation((state) => {
|
||
const target = state.project.objects.find((item) => item.id === objectId);
|
||
if (!target || target.kind !== "character") return state;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: objectId,
|
||
selectedObjectIds: [objectId],
|
||
selectedCrowdId: null,
|
||
selectedTrajectoryPoint: null,
|
||
directorInspectorMode: "auto",
|
||
trajectoryPlacementObjectId: objectId,
|
||
};
|
||
}),
|
||
stopTrajectoryPlacement: () =>
|
||
commitUiMutation((state) => ({ ...state, trajectoryPlacementObjectId: null })),
|
||
addTrajectoryPoint: (objectId, position) => {
|
||
// 只有角色能挂运动轨迹,先在外面判定,目标不合法时直接返回 null
|
||
const target = get().project.objects.find((item) => item.id === objectId);
|
||
if (!target || target.kind !== "character") return null;
|
||
|
||
const pointId = createTrajectoryPointId();
|
||
|
||
commitMutation((state) => {
|
||
const target = state.project.objects.find((item) => item.id === objectId);
|
||
if (!target || target.kind !== "character") return state;
|
||
|
||
const nextPoint: DirectorTrajectoryPoint = {
|
||
id: pointId,
|
||
// 只取 x / z:新建的轨迹点永远落在地面(y = 0),所以轨迹默认是平面的。
|
||
// 坐标不取整:鼠标在地面点到的精确浮点位置就是落点(用户要求非整数坐标也能放)。
|
||
position: [position[0], 0, position[2]],
|
||
rotation: [0, 0, 0],
|
||
};
|
||
|
||
return {
|
||
...state,
|
||
selectedTrajectoryPoint: { objectId, pointId },
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => ({
|
||
...item,
|
||
trajectoryPoints: [...(item.trajectoryPoints ?? []), nextPoint],
|
||
})),
|
||
},
|
||
};
|
||
});
|
||
|
||
return pointId;
|
||
},
|
||
updateTrajectoryPoint: (objectId, pointId, patch) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => ({
|
||
...item,
|
||
trajectoryPoints: (item.trajectoryPoints ?? []).map((point) =>
|
||
point.id === pointId
|
||
? {
|
||
...point,
|
||
// 位置直接采用传入的精确值(含小数),不再收敛
|
||
position: patch.position ? patch.position : point.position,
|
||
// 朝向角归一化:俯仰角夹到 [-90,90],水平角/横滚角按整圈取模收敛
|
||
rotation: patch.rotation
|
||
? ([
|
||
normalizeTrajectoryRotation(0, patch.rotation[0]),
|
||
normalizeTrajectoryRotation(1, patch.rotation[1]),
|
||
normalizeTrajectoryRotation(2, patch.rotation[2]),
|
||
] as [number, number, number])
|
||
: point.rotation,
|
||
// 关键帧时间:拖到哪儿就是哪儿(合法性由拖动时的夹取保证)
|
||
time:
|
||
typeof patch.time === "number" && Number.isFinite(patch.time)
|
||
? patch.time
|
||
: point.time,
|
||
}
|
||
: point
|
||
),
|
||
})),
|
||
},
|
||
})),
|
||
setTrajectoryPointTimes: (objectId, times) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => ({
|
||
...item,
|
||
trajectoryPoints: (item.trajectoryPoints ?? []).map((point, index) => {
|
||
const time = times[index];
|
||
return typeof time === "number" && Number.isFinite(time) ? { ...point, time } : point;
|
||
}),
|
||
})),
|
||
},
|
||
})),
|
||
setTrajectoryClipStart: (objectId, startTime) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
const points = item.trajectoryPoints ?? [];
|
||
if (points.length === 0) return item;
|
||
|
||
// 片段刚性平移:起点夹在时间轴内,所有轨迹点跟着走同样的量。
|
||
// 点的时间在这里落成显式值,避免默认推导把片段「压缩」而不是「平移」。
|
||
const currentStart = getTrajectoryStartTime(item, state.project.timeline.duration);
|
||
const { startTime: nextStart, times } = shiftTrajectoryClip(
|
||
points,
|
||
state.project.timeline.duration,
|
||
currentStart,
|
||
startTime
|
||
);
|
||
const delta = nextStart - currentStart;
|
||
// 停留结束时间(trajectoryEndTime)同样随片段刚性平移,保持与轨迹点的相对关系
|
||
const nextEndTime =
|
||
typeof item.trajectoryEndTime === "number"
|
||
? Math.min(Math.max(item.trajectoryEndTime + delta, 0), state.project.timeline.duration)
|
||
: item.trajectoryEndTime;
|
||
|
||
return {
|
||
...item,
|
||
trajectoryStartTime: nextStart,
|
||
trajectoryEndTime: nextEndTime,
|
||
trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })),
|
||
};
|
||
}),
|
||
},
|
||
})),
|
||
setTrajectoryClipTrimStart: (objectId, startTime) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
const points = item.trajectoryPoints ?? [];
|
||
if (points.length === 0) return item;
|
||
|
||
const duration = state.project.timeline.duration;
|
||
const currentStart = getTrajectoryStartTime(item, duration);
|
||
const times = getTrajectoryPointTimes(points, duration, currentStart);
|
||
const firstPointTime = times[0] ?? 0;
|
||
// 起点最多挪到「第一个轨迹点再往前一个最小间隔」处 —— 不能越过第一个点,
|
||
// 否则那一列点就落在片段起点之前,和「起点之前原地等待」的语义冲突。
|
||
const upper = Math.max(firstPointTime - TRAJECTORY_POINT_MIN_GAP, 0);
|
||
const nextStart = Math.min(Math.max(startTime, 0), upper);
|
||
|
||
return {
|
||
...item,
|
||
trajectoryStartTime: nextStart,
|
||
// 把点的时间落成显式值:这些点不该跟着起点走,否则就退化成整段平移而不是修剪起点。
|
||
// (没有显式 time 的点会按「起点 → 时间轴末尾」重新推导,改起点会连点一起拖走。)
|
||
trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })),
|
||
};
|
||
}),
|
||
},
|
||
})),
|
||
setTrajectoryClipEnd: (objectId, endTime) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
const points = item.trajectoryPoints ?? [];
|
||
if (points.length === 0) return item;
|
||
|
||
const duration = state.project.timeline.duration;
|
||
const times = getTrajectoryPointTimes(points, duration, getTrajectoryStartTime(item, duration));
|
||
const lastPointTime = times.length > 0 ? times[times.length - 1] : 0;
|
||
const nextEnd = Math.min(Math.max(endTime, lastPointTime), duration);
|
||
|
||
return { ...item, trajectoryEndTime: nextEnd };
|
||
}),
|
||
},
|
||
})),
|
||
setMotionClipStart: (objectId, startTime) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
const duration = state.project.timeline.duration;
|
||
const currentStart = getMotionStartTime(item, duration);
|
||
const currentEnd = getMotionEndTime(item, duration, currentStart);
|
||
// 片段刚性平移:动作时长(起点→终点)保持不变,起点夹在时间轴内,终点跟着走同样的量。
|
||
const minSpan = MOTION_CLIP_MIN_DURATION;
|
||
const span = Math.max(currentEnd - currentStart, minSpan);
|
||
const upper = Math.max(duration - span, 0);
|
||
const nextStart = Math.min(Math.max(startTime, 0), upper);
|
||
const delta = nextStart - currentStart;
|
||
|
||
return {
|
||
...item,
|
||
motionStartTime: nextStart,
|
||
motionEndTime: currentEnd + delta,
|
||
};
|
||
}),
|
||
},
|
||
})),
|
||
setMotionClipTrimStart: (objectId, startTime) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
const duration = state.project.timeline.duration;
|
||
const currentEnd = getMotionEndTime(item, duration, getMotionStartTime(item, duration));
|
||
// 起点最多挪到「结束时间再往前一个最短时长」处,不能越过终点,否则片段就空了。
|
||
const upper = Math.max(currentEnd - MOTION_CLIP_MIN_DURATION, 0);
|
||
const nextStart = Math.min(Math.max(startTime, 0), upper);
|
||
|
||
return { ...item, motionStartTime: nextStart };
|
||
}),
|
||
},
|
||
})),
|
||
setMotionClipEnd: (objectId, endTime) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
const duration = state.project.timeline.duration;
|
||
const currentStart = getMotionStartTime(item, duration);
|
||
// 结束时间夹在「起点 + 最短时长 ~ 时间轴末尾」之间,保证片段始终合法、不为空。
|
||
const lower = currentStart + MOTION_CLIP_MIN_DURATION;
|
||
const nextEnd = Math.min(Math.max(endTime, lower), duration);
|
||
|
||
return { ...item, motionEndTime: nextEnd };
|
||
}),
|
||
},
|
||
})),
|
||
addMotionClip: (objectId, options) =>
|
||
commitMutation((state) => {
|
||
const duration = state.project.timeline.duration;
|
||
const start = Number.isFinite(options?.start) ? Math.max(options?.start ?? 0, 0) : 0;
|
||
const end = Number.isFinite(options?.end) ? (options?.end ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY;
|
||
const kind = options?.kind ?? "walk";
|
||
const newClip: MotionClip = {
|
||
id: createKeyframeId(),
|
||
kind,
|
||
speed: 1,
|
||
amplitude: 1,
|
||
enabled: true,
|
||
start,
|
||
end,
|
||
};
|
||
return {
|
||
...state,
|
||
selectedMotionClipId: newClip.id,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
if (!item.characterRig) return item;
|
||
const existing = item.characterRig.motionClips ?? [];
|
||
// 自动把紧邻的前一个「到末尾」片段截断到新片段起点,形成连续序列(后者胜)。
|
||
const nextClips = existing.map((clip) =>
|
||
clip.end === Number.POSITIVE_INFINITY && clip.start < start ? { ...clip, end: start } : clip
|
||
);
|
||
const merged = [...nextClips, newClip];
|
||
// 动作轨道不允许重叠:若新片段与已有片段重叠,紧贴到就近一侧。
|
||
return {
|
||
...item,
|
||
characterRig: { ...item.characterRig, motionClips: resolveMotionClipOverlap(merged, newClip.id, duration) },
|
||
};
|
||
}),
|
||
},
|
||
};
|
||
}),
|
||
updateMotionClip: (objectId, clipId, patch) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
if (!item.characterRig?.motionClips) return item;
|
||
return {
|
||
...item,
|
||
characterRig: {
|
||
...item.characterRig,
|
||
motionClips: item.characterRig.motionClips.map((clip) =>
|
||
clip.id === clipId ? { ...clip, ...patch } : clip
|
||
),
|
||
},
|
||
};
|
||
}),
|
||
},
|
||
})),
|
||
updateMotionClipTime: (objectId, clipId, start, end, oldStart, originalClips) =>
|
||
commitMutation((state) => {
|
||
const duration = state.project.timeline.duration;
|
||
// 先把起点限制在时间轴内,再保证终点不早于起点(避免拖出时间轴后区间倒挂)。
|
||
const clampedStart = Math.min(Math.max(start, 0), Math.max(duration - MOTION_CLIP_MIN_DURATION, 0));
|
||
const clampedEnd = Number.isFinite(end)
|
||
? Math.min(Math.max(end, clampedStart + MOTION_CLIP_MIN_DURATION), duration)
|
||
: Number.POSITIVE_INFINITY;
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
if (!item.characterRig?.motionClips) return item;
|
||
// 被拖片段在 clips 里仍是上一帧的位置;依据期望区间决定最终布局:
|
||
// 没压到任何片段就自由移动,压到了就尝试「过半才交换」的换位(originalClips 提供拖拽起始布局),
|
||
// 否则退回紧贴就近一侧的解析。
|
||
const clips = resolveMotionClipArrangement(
|
||
item.characterRig.motionClips,
|
||
clipId,
|
||
clampedStart,
|
||
clampedEnd,
|
||
duration,
|
||
originalClips
|
||
);
|
||
return {
|
||
...item,
|
||
characterRig: { ...item.characterRig, motionClips: clips },
|
||
};
|
||
}),
|
||
},
|
||
};
|
||
}),
|
||
removeMotionClip: (objectId, clipId) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
selectedMotionClipId: state.selectedMotionClipId === clipId ? null : state.selectedMotionClipId,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||
if (!item.characterRig?.motionClips) return item;
|
||
return {
|
||
...item,
|
||
characterRig: {
|
||
...item.characterRig,
|
||
motionClips: item.characterRig.motionClips.filter((clip) => clip.id !== clipId),
|
||
},
|
||
};
|
||
}),
|
||
},
|
||
})),
|
||
selectMotionClip: (clipId) =>
|
||
commitUiMutation((state) => ({
|
||
...state,
|
||
selectedMotionClipId: clipId,
|
||
})),
|
||
removeTrajectoryPoint: (objectId, pointId) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
selectedTrajectoryPoint:
|
||
state.selectedTrajectoryPoint?.objectId === objectId && state.selectedTrajectoryPoint.pointId === pointId
|
||
? null
|
||
: state.selectedTrajectoryPoint,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => ({
|
||
...item,
|
||
trajectoryPoints: (item.trajectoryPoints ?? []).filter((point) => point.id !== pointId),
|
||
})),
|
||
},
|
||
})),
|
||
selectTrajectoryPoint: (objectId, pointId) =>
|
||
commitUiMutation((state) => {
|
||
// 取消选中:只清掉「轨迹点」这一层,不动角色本身的选择,避免点一下空白就把对象也取消选中
|
||
if (!objectId || !pointId) {
|
||
return state.selectedTrajectoryPoint ? { ...state, selectedTrajectoryPoint: null } : state;
|
||
}
|
||
|
||
const target = state.project.objects.find((item) => item.id === objectId);
|
||
if (!target?.trajectoryPoints?.some((point) => point.id === pointId)) return state;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: objectId,
|
||
selectedObjectIds: [objectId],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
selectedTrajectoryPoint: { objectId, pointId },
|
||
};
|
||
}),
|
||
updateScene: (patch) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
scene: {
|
||
...state.project.scene,
|
||
...patch,
|
||
// 场景旋转一律存度数,且按 0–360° 一整圈收敛(避免转很多圈)
|
||
...(patch.rotation ? { rotation: normalizeTransformRotation(patch.rotation) } : {}),
|
||
},
|
||
},
|
||
})),
|
||
removePanoramaAsset: () =>
|
||
commitMutation((state) => {
|
||
const panoramaAssetId = state.project.panoramaAssetId;
|
||
if (!panoramaAssetId) return state;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
assets: state.project.assets.filter((item) => item.id !== panoramaAssetId),
|
||
panoramaAssetId: null,
|
||
},
|
||
};
|
||
}),
|
||
removeImportedAsset: (assetId) =>
|
||
commitMutation((state) => {
|
||
const targetAsset = state.project.assets.find((item) => item.id === assetId);
|
||
if (!targetAsset || targetAsset.sourceType !== "model") return state;
|
||
|
||
removePersistedLocalModelAsset(assetId);
|
||
|
||
const removedObjectIds = new Set(
|
||
state.project.objects.filter((item) => item.assetRefId === assetId).map((item) => item.id)
|
||
);
|
||
const nextObjects = state.project.objects.filter((item) => item.assetRefId !== assetId);
|
||
const nextCameras = state.project.cameras.map((camera) =>
|
||
camera.targetObjectId && removedObjectIds.has(camera.targetObjectId)
|
||
? {
|
||
...camera,
|
||
targetMode: "manual" as const,
|
||
targetObjectId: null,
|
||
}
|
||
: camera
|
||
);
|
||
const selectedObjectIds = state.selectedObjectIds.filter((id) => !removedObjectIds.has(id));
|
||
const selectedObjectId =
|
||
state.selectedObjectId && removedObjectIds.has(state.selectedObjectId)
|
||
? selectedObjectIds[selectedObjectIds.length - 1] ?? null
|
||
: state.selectedObjectId;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId,
|
||
selectedObjectIds,
|
||
selectedCrowdId: null,
|
||
project: {
|
||
...state.project,
|
||
assets: state.project.assets.filter((item) => item.id !== assetId),
|
||
objects: nextObjects,
|
||
cameras: nextCameras,
|
||
},
|
||
};
|
||
}),
|
||
updateObjectTransform: (id, patch) =>
|
||
commitMutation((state) => {
|
||
const currentObject = state.project.objects.find((item) => item.id === id);
|
||
// 旋转一律存度数,且按 0–360° 一整圈收敛(避免转很多圈)
|
||
const nextRotation = patch.rotation ? normalizeTransformRotation(patch.rotation) : currentObject?.transform.rotation;
|
||
const nextTransform = currentObject
|
||
? {
|
||
position: patch.position ?? currentObject.transform.position,
|
||
rotation: nextRotation ?? currentObject.transform.rotation,
|
||
scale: patch.scale ?? currentObject.transform.scale,
|
||
}
|
||
: null;
|
||
const nextObject = currentObject && nextTransform ? { ...currentObject, transform: nextTransform } : null;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
transform: {
|
||
position: patch.position ?? item.transform.position,
|
||
rotation: nextRotation ?? item.transform.rotation,
|
||
scale: patch.scale ?? item.transform.scale,
|
||
},
|
||
})),
|
||
cameras:
|
||
currentObject?.kind === "camera" && currentObject.linkedCameraId && nextTransform
|
||
? state.project.cameras.map((camera) =>
|
||
camera.id === currentObject.linkedCameraId
|
||
? {
|
||
...camera,
|
||
transform: nextTransform,
|
||
}
|
||
: camera
|
||
)
|
||
: nextObject
|
||
? refreshCamerasFocusedOnObject(state.project.cameras, nextObject)
|
||
: state.project.cameras,
|
||
},
|
||
};
|
||
}),
|
||
updateCrowdTransform: (crowdId, patch) =>
|
||
commitMutation((state) => {
|
||
const nextTransformState = applyCrowdTransformPatch(state.project.objects, crowdId, patch);
|
||
if (nextTransformState.changedObjectIds.length === 0) return state;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: nextTransformState.objects,
|
||
cameras: refreshCamerasFocusedOnObjects(
|
||
state.project.cameras,
|
||
nextTransformState.objects,
|
||
nextTransformState.changedObjectIds
|
||
),
|
||
},
|
||
};
|
||
}),
|
||
updateObjectName: (id, name) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
name,
|
||
})),
|
||
},
|
||
})),
|
||
updateCrowdLabel: (crowdId, label) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId
|
||
? {
|
||
...item,
|
||
crowdLabel: label,
|
||
}
|
||
: item
|
||
),
|
||
},
|
||
})),
|
||
updateObjectColor: (id, color) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
color,
|
||
})),
|
||
},
|
||
})),
|
||
updateCrowdColor: (crowdId, color) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId
|
||
? {
|
||
...item,
|
||
color,
|
||
}
|
||
: item
|
||
),
|
||
},
|
||
})),
|
||
updateCharacterBodyType: (id, bodyType) =>
|
||
commitMutation((state) => {
|
||
const normalizedBodyType = normalizeBodyType(bodyType);
|
||
const currentObject = state.project.objects.find((item) => item.id === id);
|
||
const nextObject =
|
||
currentObject?.kind === "character"
|
||
? {
|
||
...currentObject,
|
||
bodyType: normalizedBodyType,
|
||
}
|
||
: null;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||
item.kind === "character"
|
||
? {
|
||
...item,
|
||
bodyType: normalizedBodyType,
|
||
}
|
||
: item
|
||
),
|
||
cameras: nextObject ? refreshCamerasFocusedOnObject(state.project.cameras, nextObject) : state.project.cameras,
|
||
},
|
||
};
|
||
}),
|
||
updateUniformScale: (id, scale) =>
|
||
commitMutation((state) => {
|
||
const currentObject = state.project.objects.find((item) => item.id === id);
|
||
const nextObject = currentObject
|
||
? {
|
||
...currentObject,
|
||
transform: {
|
||
...currentObject.transform,
|
||
scale: [scale, scale, scale] as [number, number, number],
|
||
},
|
||
}
|
||
: null;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
transform: {
|
||
...item.transform,
|
||
scale: [scale, scale, scale],
|
||
},
|
||
})),
|
||
cameras: nextObject ? refreshCamerasFocusedOnObject(state.project.cameras, nextObject) : state.project.cameras,
|
||
},
|
||
};
|
||
}),
|
||
updateCrowdUniformScale: (crowdId, scale) =>
|
||
commitMutation((state) => {
|
||
const nextTransformState = applyCrowdTransformPatch(state.project.objects, crowdId, {
|
||
scale: [scale, scale, scale],
|
||
});
|
||
if (nextTransformState.changedObjectIds.length === 0) return state;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: nextTransformState.objects,
|
||
cameras: refreshCamerasFocusedOnObjects(
|
||
state.project.cameras,
|
||
nextTransformState.objects,
|
||
nextTransformState.changedObjectIds
|
||
),
|
||
},
|
||
};
|
||
}),
|
||
addImportedAsset: (input) =>
|
||
commitMutation((state) => {
|
||
const assetId = getNextSequentialId(
|
||
state.project.assets.map((item) => item.id),
|
||
"asset_",
|
||
state.project.assets.length + 1
|
||
);
|
||
const nextAsset = {
|
||
id: assetId,
|
||
kind: input.kind,
|
||
sourceType: input.kind === "panorama" ? "image" : "model",
|
||
fileName: input.fileName,
|
||
name: input.name,
|
||
url: input.url,
|
||
binaryId: input.binaryId,
|
||
assetSource: input.kind === "panorama" ? undefined : (input.assetSource ?? "local"),
|
||
projectionMode: input.projectionMode,
|
||
} satisfies DirectorAssetRef;
|
||
|
||
if (input.kind === "panorama") {
|
||
return {
|
||
...state,
|
||
directorInspectorMode: "scene",
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
selectedCrowdId: null,
|
||
project: {
|
||
...state.project,
|
||
assets: [...state.project.assets, nextAsset],
|
||
panoramaAssetId: assetId,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (input.addToScene === false) {
|
||
persistLocalModelAsset(nextAsset);
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
assets: [...state.project.assets, nextAsset],
|
||
},
|
||
};
|
||
}
|
||
|
||
const nextObject = createSceneObjectFromAsset(nextAsset, state.project.objects);
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: nextObject.id,
|
||
selectedObjectIds: [nextObject.id],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
assets: [...state.project.assets, nextAsset],
|
||
objects: [...state.project.objects, nextObject],
|
||
},
|
||
};
|
||
}),
|
||
hydratePersistedModelBinaries: async () => {
|
||
const assets = get().project.assets;
|
||
const needsHydration = assets.filter((asset) => asset.binaryId && !asset.url);
|
||
|
||
if (!needsHydration.length) return;
|
||
|
||
await Promise.all(
|
||
needsHydration.map(async (asset) => {
|
||
try {
|
||
const blob = await loadModelBinary(asset.binaryId!);
|
||
if (!blob) return;
|
||
|
||
const url = URL.createObjectURL(blob);
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
assets: state.project.assets.map((item) => (item.id === asset.id ? { ...item, url } : item)),
|
||
},
|
||
}));
|
||
} catch {
|
||
// 单个还原失败不影响其余资产
|
||
}
|
||
})
|
||
);
|
||
},
|
||
addObjectFromAsset: (assetId, options) => {
|
||
let nextObjectId: string | null = null;
|
||
|
||
commitMutation((state) => {
|
||
const asset = state.project.assets.find((item) => item.id === assetId);
|
||
if (!asset || asset.sourceType !== "model" || asset.kind === "panorama") return state;
|
||
|
||
// 「作为角色导入」仅对 GLB/GLTF 等可识别人形的资产有意义(FBX/OBJ 忽略该选项)。
|
||
const asCharacter = options?.asCharacter === true && /\.(glb|gltf)$/i.test(asset.fileName);
|
||
const nextObject = asCharacter
|
||
? createCharacterFromAsset(asset, state.project.objects)
|
||
: createSceneObjectFromAsset(asset, state.project.objects);
|
||
nextObjectId = nextObject.id;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: nextObject.id,
|
||
selectedObjectIds: [nextObject.id],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
objects: [...state.project.objects, nextObject],
|
||
},
|
||
};
|
||
});
|
||
|
||
return nextObjectId;
|
||
},
|
||
addPresetCharacter: (bodyType = DEFAULT_CHARACTER_BODY_TYPE) =>
|
||
commitMutation((state) => {
|
||
const presetCharacterCount = state.project.objects.filter(
|
||
(item) => item.kind === "character" && item.id.startsWith("char_preset_")
|
||
).length;
|
||
const presetCharacterIndex = presetCharacterCount + 1;
|
||
const row = Math.floor((presetCharacterIndex - 1) / 4);
|
||
const x = getAddedModelColumnOffset(presetCharacterIndex - row * 4);
|
||
const z = row * 0.8;
|
||
const nextObject = buildPresetCharacterObject(state, bodyType, [x, 0, z]);
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: nextObject.id,
|
||
selectedObjectIds: [nextObject.id],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
objects: [...state.project.objects, nextObject],
|
||
},
|
||
};
|
||
}),
|
||
addCrowdCharacters: ({ bodyType = DEFAULT_CHARACTER_BODY_TYPE, rows, columns, spacing }) => {
|
||
const createdIds: string[] = [];
|
||
|
||
commitMutation((state) => {
|
||
const positions = getCrowdCharacterPositions(rows, columns, spacing);
|
||
const offset = getCrowdCharacterOffset(state.project.objects, spacing);
|
||
const nextObjects = [...state.project.objects];
|
||
const crowdLabel = formatCrowdLabel(rows, columns);
|
||
const crowdId = getNextCrowdId(state.project.objects);
|
||
|
||
positions.forEach((position) => {
|
||
const nextState = {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: nextObjects,
|
||
},
|
||
} as DirectorRuntimeState;
|
||
const nextObject = buildPresetCharacterObject(nextState, bodyType, [
|
||
Number((position[0] + offset[0]).toFixed(4)),
|
||
Number((position[1] + offset[1]).toFixed(4)),
|
||
Number((position[2] + offset[2]).toFixed(4)),
|
||
], {
|
||
crowdId,
|
||
crowdLabel,
|
||
});
|
||
nextObjects.push(nextObject);
|
||
createdIds.push(nextObject.id);
|
||
});
|
||
|
||
if (!createdIds.length) return state;
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: createdIds[createdIds.length - 1] ?? null,
|
||
selectedObjectIds: createdIds,
|
||
selectedCrowdId: crowdId,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
objects: nextObjects,
|
||
},
|
||
};
|
||
});
|
||
|
||
return createdIds;
|
||
},
|
||
addGeometryPrimitive: (geometryType) =>
|
||
commitMutation((state) => {
|
||
const geometryObjects = state.project.objects.filter((item) => item.kind === "prop" && item.geometryType);
|
||
const geometryIndex = geometryObjects.length + 1;
|
||
const sameTypeCount = geometryObjects.filter((item) => item.geometryType === geometryType).length;
|
||
const row = Math.floor((geometryIndex - 1) / 4);
|
||
const column = (geometryIndex - 1) % 4;
|
||
const x = column * 1.15 - 1.725;
|
||
const z = row * 0.75 + 1.15;
|
||
const label = getGeometryPrimitiveLabel(geometryType);
|
||
const objectId = getNextSequentialId(
|
||
state.project.objects.map((item) => item.id),
|
||
`geo_${geometryType}_`,
|
||
geometryIndex
|
||
);
|
||
const nextObject: DirectorObject = {
|
||
id: objectId,
|
||
name: sameTypeCount === 0 ? label : `${label}${String(sameTypeCount + 1).padStart(2, "0")}`,
|
||
kind: "prop",
|
||
visible: true,
|
||
locked: false,
|
||
geometryType,
|
||
color: GEOMETRY_PRIMITIVE_COLOR,
|
||
transform: createTransform([x, 0, z]),
|
||
};
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: objectId,
|
||
selectedObjectIds: [objectId],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
objects: [...state.project.objects, nextObject],
|
||
},
|
||
};
|
||
}),
|
||
addCameraShot: (snapshot) => {
|
||
let nextCameraId = "";
|
||
|
||
commitMutation((state) => {
|
||
const cameraIndex = state.project.cameras.length + 1;
|
||
const cameraId = getNextSequentialId(
|
||
state.project.cameras.map((item) => item.id),
|
||
"cam_",
|
||
cameraIndex
|
||
);
|
||
const objectId = getNextSequentialId(
|
||
state.project.objects.map((item) => item.id),
|
||
"cam_object_",
|
||
cameraIndex
|
||
);
|
||
nextCameraId = cameraId;
|
||
const transform = createTransform(
|
||
snapshot ? getCameraRigPositionFromViewSnapshot(snapshot) : [cameraIndex * 1.2, 2.2, 9]
|
||
);
|
||
const nextCamera: DirectorCameraShot = {
|
||
id: cameraId,
|
||
name: formatSceneItemName("机位", cameraIndex),
|
||
color: getNextCameraColor(state.project.cameras),
|
||
fov: snapshot?.fov ?? 50,
|
||
transform,
|
||
targetMode: "manual",
|
||
lookRotation: snapshot
|
||
? getCameraLookRotationFromTarget(snapshot.position, snapshot.target)
|
||
: [...DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation],
|
||
lastCaptureUrl: null,
|
||
captures: [],
|
||
};
|
||
const nextCameraObject: DirectorObject = {
|
||
id: objectId,
|
||
name: nextCamera.name,
|
||
kind: "camera",
|
||
visible: true,
|
||
locked: false,
|
||
linkedCameraId: cameraId,
|
||
transform,
|
||
};
|
||
|
||
return {
|
||
...state,
|
||
selectedObjectId: objectId,
|
||
selectedObjectIds: [objectId],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
project: {
|
||
...state.project,
|
||
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),
|
||
},
|
||
},
|
||
};
|
||
});
|
||
|
||
return nextCameraId;
|
||
},
|
||
deleteSelectedObject: () =>
|
||
commitMutation((state) => deleteObjectsByIds(state, getOrderedSelectedObjectIds(state))),
|
||
/** 删除单个对象(角色 / 几何体 / 模型 / 摄像机对象),供场景树每行的删除按钮使用 */
|
||
deleteObject: (id) => commitMutation((state) => deleteObjectsByIds(state, [id])),
|
||
/** 删除整个群众组 */
|
||
deleteCrowd: (crowdId) =>
|
||
commitMutation((state) => deleteObjectsByIds(state, getCrowdMemberIds(state.project.objects, crowdId))),
|
||
toggleObjectVisible: (id) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
visible: !item.visible,
|
||
})),
|
||
},
|
||
})),
|
||
toggleObjectLocked: (id) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
locked: !item.locked,
|
||
})),
|
||
},
|
||
})),
|
||
applyPosePreset: (id, presetId) =>
|
||
commitMutation((state) => {
|
||
const preset = MANNEQUIN_POSE_PRESETS.find((item) => item.id === presetId);
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
// 姿势与动作互斥:摆造型自动关掉动作循环(保留速度/幅度等调参)
|
||
characterRig: item.characterRig
|
||
? {
|
||
...item.characterRig,
|
||
posePresetId: presetId,
|
||
controls: preset ? { ...preset.controls } : item.characterRig.controls,
|
||
motion: item.characterRig.motion
|
||
? { ...item.characterRig.motion, kind: "none" }
|
||
: item.characterRig.motion,
|
||
}
|
||
: item.characterRig,
|
||
})),
|
||
},
|
||
};
|
||
}),
|
||
applyCrowdPosePreset: (crowdId, presetId) =>
|
||
commitMutation((state) => {
|
||
const preset = MANNEQUIN_POSE_PRESETS.find((item) => item.id === presetId);
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId
|
||
? {
|
||
...item,
|
||
// 姿势与动作互斥:摆造型自动关掉动作循环(保留速度/幅度等调参)
|
||
characterRig: item.characterRig
|
||
? {
|
||
...item.characterRig,
|
||
posePresetId: presetId,
|
||
controls: preset ? { ...preset.controls } : item.characterRig.controls,
|
||
motion: item.characterRig.motion
|
||
? { ...item.characterRig.motion, kind: "none" }
|
||
: item.characterRig.motion,
|
||
}
|
||
: item.characterRig,
|
||
}
|
||
: item
|
||
),
|
||
},
|
||
};
|
||
}),
|
||
updatePoseControl: (id, key, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) => ({
|
||
...item,
|
||
characterRig: item.characterRig
|
||
? {
|
||
...item.characterRig,
|
||
controls: {
|
||
...item.characterRig.controls,
|
||
[key]: value,
|
||
},
|
||
}
|
||
: item.characterRig,
|
||
})),
|
||
},
|
||
})),
|
||
updateCrowdPoseControl: (crowdId, key, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId
|
||
? {
|
||
...item,
|
||
characterRig: item.characterRig
|
||
? {
|
||
...item.characterRig,
|
||
controls: {
|
||
...item.characterRig.controls,
|
||
[key]: value,
|
||
},
|
||
}
|
||
: item.characterRig,
|
||
}
|
||
: item
|
||
),
|
||
},
|
||
})),
|
||
setMotion: (id, kind) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||
item.kind === "character" ? applyMotionSelectionToCharacter(item, kind) : item
|
||
),
|
||
},
|
||
})),
|
||
setCrowdMotion: (crowdId, kind) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId ? applyMotionSelectionToCharacter(item, kind) : item
|
||
),
|
||
},
|
||
})),
|
||
setMotionSpeed: (id, speed) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||
item.kind === "character" ? applyMotionPatchToCharacter(item, { speed }) : item
|
||
),
|
||
},
|
||
})),
|
||
setCrowdMotionSpeed: (crowdId, speed) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { speed }) : item
|
||
),
|
||
},
|
||
})),
|
||
setMotionAmplitude: (id, amplitude) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||
item.kind === "character" ? applyMotionPatchToCharacter(item, { amplitude }) : item
|
||
),
|
||
},
|
||
})),
|
||
setCrowdMotionAmplitude: (crowdId, amplitude) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { amplitude }) : item
|
||
),
|
||
},
|
||
})),
|
||
setMotionEnabled: (id, enabled) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, id, (item) =>
|
||
item.kind === "character" ? applyMotionPatchToCharacter(item, { enabled }) : item
|
||
),
|
||
},
|
||
})),
|
||
setCrowdMotionEnabled: (crowdId, enabled) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { enabled }) : item
|
||
),
|
||
},
|
||
})),
|
||
setActiveCamera: (cameraId) =>
|
||
commitUiMutation((state) => {
|
||
const selectedObjectId =
|
||
state.project.objects.find((item) => item.kind === "camera" && item.linkedCameraId === cameraId)?.id ??
|
||
null;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
activeCameraId: cameraId,
|
||
},
|
||
selectedObjectId,
|
||
selectedObjectIds: selectedObjectId ? [selectedObjectId] : [],
|
||
selectedCrowdId: null,
|
||
};
|
||
}),
|
||
addCameraCaptures: (cameraId, dataUrls) =>
|
||
commitMutation((state) => {
|
||
if (dataUrls.length === 0) return state;
|
||
|
||
const targetCameraId = cameraId ?? state.project.activeCameraId ?? state.project.cameras[0]?.id ?? null;
|
||
if (!targetCameraId) return state;
|
||
|
||
let updated = false;
|
||
const cameras = state.project.cameras.map((camera) => {
|
||
if (camera.id !== targetCameraId) return camera;
|
||
|
||
updated = true;
|
||
const nextCaptures = buildCameraCaptures(camera, dataUrls);
|
||
|
||
return {
|
||
...camera,
|
||
lastCaptureUrl: nextCaptures[nextCaptures.length - 1]?.dataUrl ?? camera.lastCaptureUrl ?? null,
|
||
captures: [...(camera.captures ?? []), ...nextCaptures],
|
||
};
|
||
});
|
||
|
||
if (!updated) return state;
|
||
|
||
return {
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
cameras,
|
||
},
|
||
};
|
||
}),
|
||
updateCamera: (cameraId, patch) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
cameras: state.project.cameras.map((item) =>
|
||
item.id === cameraId
|
||
? {
|
||
...item,
|
||
...patch,
|
||
transform: patch.transform ?? item.transform,
|
||
lookRotation: patch.lookRotation ?? item.lookRotation,
|
||
}
|
||
: item
|
||
),
|
||
objects: state.project.objects.map((item) =>
|
||
item.kind === "camera" && item.linkedCameraId === cameraId && patch.transform
|
||
? { ...item, transform: patch.transform }
|
||
: item
|
||
),
|
||
},
|
||
})),
|
||
addKeyframe: (objectId, time, value) => {
|
||
const keyframeId = createKeyframeId();
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: upsertKeyframe(state.project.timeline, objectId, time, value, keyframeId),
|
||
},
|
||
}));
|
||
return keyframeId;
|
||
},
|
||
removeKeyframe: (objectId, keyframeId) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: removeTimelineKeyframe(state.project.timeline, objectId, keyframeId),
|
||
},
|
||
})),
|
||
updateKeyframeTime: (objectId, keyframeId, time) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineKeyframeTime(state.project.timeline, objectId, keyframeId, time),
|
||
},
|
||
})),
|
||
updateKeyframeValue: (objectId, keyframeId, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineKeyframeValue(state.project.timeline, objectId, keyframeId, value),
|
||
},
|
||
})),
|
||
updateKeyframeEasing: (objectId, keyframeId, easing) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineKeyframeEasing(state.project.timeline, objectId, keyframeId, easing),
|
||
},
|
||
})),
|
||
applyKeyframeToObjectTransform: (objectId, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => ({
|
||
...item,
|
||
transform: { position: value.position, rotation: value.rotation, scale: value.scale },
|
||
})),
|
||
},
|
||
})),
|
||
addCameraKeyframe: (cameraId, time, value) => {
|
||
const keyframeId = createKeyframeId();
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: upsertCameraKeyframe(state.project.timeline, cameraId, time, value, keyframeId),
|
||
},
|
||
}));
|
||
return keyframeId;
|
||
},
|
||
removeCameraKeyframe: (cameraId, keyframeId) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: removeTimelineCameraKeyframe(state.project.timeline, cameraId, keyframeId),
|
||
},
|
||
})),
|
||
updateCameraKeyframeTime: (cameraId, keyframeId, time) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineCameraKeyframeTime(state.project.timeline, cameraId, keyframeId, time),
|
||
},
|
||
})),
|
||
updateCameraKeyframeValue: (cameraId, keyframeId, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineCameraKeyframeValue(state.project.timeline, cameraId, keyframeId, value),
|
||
},
|
||
})),
|
||
updateCameraKeyframeEasing: (cameraId, keyframeId, easing) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineCameraKeyframeEasing(state.project.timeline, cameraId, keyframeId, easing),
|
||
},
|
||
})),
|
||
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: {
|
||
...state.project.timeline,
|
||
transitionClips: state.project.timeline.transitionClips.filter((clip) => clip.id !== clipId),
|
||
},
|
||
},
|
||
})),
|
||
setCameraKeyframeTimes: (cameraId, times) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: setTimelineCameraKeyframeTimes(state.project.timeline, cameraId, times),
|
||
},
|
||
})),
|
||
applyCameraKeyframeToShot: (cameraId, value) => {
|
||
// 只改 position/注视朝向/fov,scale 保留 shot 原值
|
||
const camera = get().project.cameras.find((item) => item.id === cameraId);
|
||
if (!camera) return;
|
||
get().updateCamera(cameraId, {
|
||
fov: value.fov,
|
||
lookRotation: value.lookRotation,
|
||
transform: { ...camera.transform, position: value.position },
|
||
});
|
||
},
|
||
setTimelineDuration: (seconds) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineDuration(state.project.timeline, seconds),
|
||
},
|
||
})),
|
||
setTimelinePlaybackSettings: (patch) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelinePlaybackSettings(state.project.timeline, patch),
|
||
},
|
||
})),
|
||
updateExportSettings: (patch) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
exportSettings: { ...state.project.exportSettings, ...patch },
|
||
},
|
||
})),
|
||
clearAllKeyframes: () =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: { ...state.project.timeline, tracks: [], cameraTracks: [] },
|
||
},
|
||
})),
|
||
copySelectedObjects: () => {
|
||
const currentState = get() as DirectorRuntimeState;
|
||
const clipboard = buildClipboardEntries(currentState);
|
||
set({
|
||
...currentState,
|
||
clipboard,
|
||
clipboardPasteCount: 0,
|
||
});
|
||
},
|
||
pasteClipboardObjects: () => commitMutation((state) => pasteClipboardEntries(state)),
|
||
undo: () => {
|
||
const currentState = get() as DirectorRuntimeState;
|
||
const undoStack = currentState.undoStack;
|
||
if (undoStack.length === 0) return;
|
||
|
||
const previousState = undoStack[undoStack.length - 1];
|
||
// 被撤销的「当前状态」压进重做栈,供 redo 恢复(这正是之前缺失的能力)
|
||
const currentSnapshot = extractPersistedDirectorState(currentState);
|
||
const runtimeState = createRuntimeStateFromPersistedState(previousState);
|
||
set({
|
||
...runtimeState,
|
||
clipboard: currentState.clipboard,
|
||
clipboardPasteCount: currentState.clipboardPasteCount,
|
||
undoStack: undoStack.slice(0, -1),
|
||
redoStack: trimUndoStack([...(currentState.redoStack ?? []), currentSnapshot]),
|
||
});
|
||
writePersistedDirectorState(previousState);
|
||
},
|
||
redo: () => {
|
||
const currentState = get() as DirectorRuntimeState;
|
||
const redoStack = currentState.redoStack ?? [];
|
||
if (redoStack.length === 0) return;
|
||
|
||
const nextState = redoStack[redoStack.length - 1];
|
||
// 被重做的「当前状态」压回撤销栈,重做链可继续来回切换
|
||
const currentSnapshot = extractPersistedDirectorState(currentState);
|
||
const runtimeState = createRuntimeStateFromPersistedState(nextState);
|
||
set({
|
||
...runtimeState,
|
||
clipboard: currentState.clipboard,
|
||
clipboardPasteCount: currentState.clipboardPasteCount,
|
||
undoStack: trimUndoStack([...currentState.undoStack, currentSnapshot]),
|
||
redoStack: redoStack.slice(0, -1),
|
||
});
|
||
writePersistedDirectorState(nextState);
|
||
},
|
||
openScopedScene: (scopeId) => {
|
||
const currentState = get() as DirectorRuntimeState;
|
||
setDirectorScenePersistenceScopeId(scopeId);
|
||
const snapshot = createInitialDirectorState({
|
||
includePersistedLocalAssets: true,
|
||
includePersistedScene: true,
|
||
persistenceScopeId: directorScenePersistenceScopeId,
|
||
});
|
||
const runtimeState = createRuntimeStateFromPersistedState(snapshot);
|
||
|
||
set({
|
||
...runtimeState,
|
||
clipboard: currentState.clipboard,
|
||
clipboardPasteCount: currentState.clipboardPasteCount,
|
||
undoStack: [],
|
||
});
|
||
writePersistedDirectorState(snapshot);
|
||
},
|
||
replaceProject: (project) =>
|
||
commitMutation((state) => {
|
||
const incoming = cloneJsonValue(project);
|
||
const migrated = migrateDirectorProject(
|
||
isDirectorProjectShape(incoming) ? incoming : createDefaultDirectorProject()
|
||
);
|
||
return {
|
||
...state,
|
||
project: migrated,
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
};
|
||
}),
|
||
saveLatestSnapshot: () => {
|
||
writePersistedDirectorState(extractPersistedDirectorState(get() as DirectorRuntimeState));
|
||
},
|
||
restoreLatestSnapshot: () => {
|
||
const snapshot = readPersistedDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true });
|
||
if (!snapshot) return;
|
||
|
||
set({
|
||
...createRuntimeStateFromPersistedState(snapshot),
|
||
clipboard: (get() as DirectorRuntimeState).clipboard,
|
||
clipboardPasteCount: (get() as DirectorRuntimeState).clipboardPasteCount,
|
||
undoStack: [],
|
||
});
|
||
writePersistedDirectorState(snapshot);
|
||
},
|
||
};
|
||
});
|