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> ) => 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> ) => 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) => void; removePanoramaAsset: () => void; removeImportedAsset: (assetId: string) => void; updateObjectTransform: (id: string, patch: Partial) => void; updateCrowdTransform: (crowdId: string, patch: Partial) => 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; 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 & { 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) => void; removeCameraClip: (cameraId: string, clipId: string) => void; /** 运镜(过渡)轨片段:连接两个机位,可拖拽、单独添加、改前后机位 */ addTransitionClip: (fromCameraId: string | null, toCameraId: string, type?: CameraTransitionType) => string; updateTransitionClip: (clipId: string, patch: Partial) => 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) => 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(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; 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; 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; 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 = { ...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 ) { 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 ) { 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(); const crowdIdMap = new Map(); 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(); 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((set, get) => { const initialRuntimeState = createRuntimeStateFromPersistedState( createInitialDirectorState({ includePersistedLocalAssets: true, includePersistedScene: true }) ); /** 拖拽(撤销批次)期间的持久化防抖间隔:连续拖拽时完全不序列化,停手 150ms 后落盘一次 */ const UNDO_BATCH_PERSIST_DEBOUNCE_MS = 150; let undoBatchPersistTimer: ReturnType | 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); }, }; });