diff --git a/README.md b/README.md index 0139a90..f14ddd3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ - **基础动作**:角色内置走路 / 跑步循环动作(真实步态曲线、左右对侧摆臂、跑步独立步频与屈膝曲线),支持速度 / 幅度调节与群体相位错开;动作之上可按关节叠加姿势偏移微调 - **姿势调节**:两级折叠分组(核心 / 左右臂 / 左右腿),20 种姿势预设一键应用 - **视频导出**:上帝视角或机位视角,WebCodecs 逐帧快速导出 MP4(固定分辨率、不随视口布局变形,导出速度远快于实时) +- **多工程管理**:顶栏左侧「工程」菜单可新建 / 切换 / 重命名 / 复制 / 删除工程,每个工程独立保存场景与关键帧 +- **工程文件**:一键把工程保存为 `.json` 文件(模型二进制内联,文件自包含),也可从文件导入为新工程 - 本地场景与关键帧状态持久化 ## 界面截图 @@ -48,7 +50,7 @@ src ├─ editor/canvas # Three.js / R3F 视口、画幅框、工具条、截图视图、回放覆盖 ├─ editor/panels # 左侧对象树、右侧属性面板、底部时间轴 ├─ editor/store # Zustand 状态管理、撤销、剪贴板、关键帧 actions -├─ editor/io # 截图导出、视频导出、工程导入导出、宿主通信 +├─ editor/io # 截图导出、视频导出、多工程目录、工程文件读写、宿主通信 ├─ editor/loaders # 本地模型与全景图导入 ├─ editor/runtime # 角色渲染、骨骼、姿势应用、播放状态与回放驱动 ├─ editor/schema # 数据结构、机位、视口、时间轴与插值定义 @@ -111,6 +113,10 @@ http://127.0.0.1:4173/ ## 常用操作 - 顶部可切换 `导演视角` 与 `机位视角` +- **顶栏左侧「工程」菜单**: + - 点工程名切换工程(每个工程的场景、机位、关键帧互相独立) + - 每行右侧:重命名 / 复制工程 / 保存到文件 / 删除(删除需二次确认,且至少保留一个工程) + - 底部:`新建工程`(空场景)、`导入工程文件`(把外部 `.json` 导入为新的工程并切换过去) - 左侧用于搜索、选择、分组查看场景对象,并支持可见性 / 锁定 / 删除 - 中央视口用于摆放场景、切换变换模式、添加角色和机位、导入资源与截图 - 右侧属性面板会根据当前选中对象自动切换为场景 / 角色 / 模型 / 摄像机编辑面板;选中摄像机时可直接编辑 位置 / 注视坐标 / FOV @@ -142,10 +148,12 @@ http://127.0.0.1:4173/ ## 数据与嵌入 - 当前场景、本地模型库与关键帧都会写入浏览器 `localStorage` +- **多工程存储**:工程目录存在 `storyai-3d-director-project-catalog`(工程列表 + 当前激活工程),每个工程的场景写在 `storyai-3d-director-desk-demo:<工程 id>` 这个独立槽位上;切工程 = 换槽位,互不干扰 +- **首次升级**:老版本把场景写在基础 key `storyai-3d-director-desk-demo` 上,首次启动会自动把它搬迁成第一个「默认工程」(复制而非删除,旧 key 保留) - 工程数据为 `version: 2`,含 `timeline` 字段(物体轨道 `tracks` + 相机运镜轨道 `cameraTracks`);导入旧 v1 工程会自动迁移、补齐空时间轴 -- 支持导出工程 JSON,也支持通过文件重新导入 +- **工程文件**(`storyai-director-project` 信封格式):保存时会把 IndexedDB 里的 GLB/GLTF 内联为 data URL,使文件自包含;导入时会再把模型落回 IndexedDB,保持 localStorage 轻量。同时兼容早期直接导出的裸工程 JSON - 支持“保存最近工程 / 恢复最近工程” -- 组件已包含宿主页面通信桥,适合嵌入到更大的创作工作台中 +- 组件已包含宿主页面通信桥,适合嵌入到更大的创作工作台中(宿主若通过 `?instanceId=` 指定实例作用域,该作用域优先于多工程的当前工程) ## Electron 桌面版打包 @@ -189,7 +197,7 @@ pnpm test 最近一次核对结果(pnpm 迁移后): - `pnpm run build` 可通过(`tsc -b` 类型检查 + Vite 生产构建) -- `pnpm test`:**40 个测试文件全部通过,395 通过 / 3 跳过 / 0 失败** +- `pnpm test`:**58 个测试文件全部通过,725 通过 / 3 跳过 / 0 失败** - 构建阶段会出现 Vite 警告:模型库缩略图 URL 在构建期不存在(`模型库/` 是仓库外的外部资源目录),会保留到运行时解析;同时主包体积超过默认 chunk 警告阈值。两者都不影响构建结果 > 模型库说明:内置模型库读取的是项目**上一级**的 `模型库/` 目录(`import.meta.glob("../../../../模型库/**/*.fbx")`)。仓库不含该目录,因此模型库面板在纯净检出下为空——glob 匹配为空不会报错,属于预期行为。相关测试在目录缺失时会自动跳过。 diff --git a/electron/main.cjs b/electron/main.cjs index 100bde1..b9ddb00 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -4,7 +4,7 @@ const path = require('path'); // 与 package.json 的 build.appId 保持一致: // Windows 靠 AppUserModelID 做任务栏图标归组与通知身份识别,不设置会出现"图标分身"。 if (process.platform === 'win32') { - app.setAppUserModelId('com.storyai.director-desk'); + app.setAppUserModelId('com.ttstd.director-desk'); } Menu.setApplicationMenu(null); diff --git a/package.json b/package.json index 031b00e..a850712 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "icon": "python scripts/generate_icon.py" }, "build": { - "appId": "com.storyai.director-desk", + "appId": "com.ttstd.director-desk", "productName": "故事AI导演工作台", "directories": { "output": "release" diff --git a/src/App.tsx b/src/App.tsx index 5a1a471..3ec058b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { DirectorDeskShell } from "./app/layout/DirectorDeskShell"; import { DirectorCanvas } from "./editor/canvas/DirectorCanvas"; import { initDirectorDeskHostBridge } from "./editor/io/hostBridge"; +import { ProjectManagerMenu } from "./editor/panels/ProjectManagerMenu"; import { addKeyframeAtPlayhead } from "./editor/runtime/keyframeCommand"; import { useDirectorStore } from "./editor/store/directorStore"; @@ -100,9 +101,11 @@ export default function App() { return (
- {/* 占位:顶栏是「左栏宽 / 中间 1fr / 右栏宽」三列栅格, - 少了这块,中间的视角切换会落到左列里去,左对齐而不是居中。 */} -
+ {/* 顶栏是「左栏宽 / 中间 1fr / 右栏宽」三列栅格:左侧放工程管理入口, + 中间保持视角切换居中(少了左列,视角切换会落到左列去而左对齐)。 */} +
+ +
+ {isOpen ? ( +
+
+

工程

+ +
+
+ {projects.map((project) => { + const isActive = project.id === activeProjectId; + const isRenaming = renamingId === project.id; + const isPendingDelete = pendingDeleteId === project.id; + + return ( +
+ {isRenaming ? ( +
{ + event.preventDefault(); + confirmRename(); + }} + > + setRenameDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setRenamingId(null); + } + }} + /> + + +
+ ) : ( + + )} + {!isRenaming ? ( +
+ {isPendingDelete ? ( + <> + + + + ) : ( + <> + + + + + + )} +
+ ) : null} +
+ ); + })} +
+
+ + +
+ {status ? ( +

+ {status} +

+ ) : null} +
+ ) : null} + { + const input = event.currentTarget; + const file = input.files?.[0]; + if (file) { + void handleImportFile(file); + } + input.value = ""; + }} + /> +
+ ); +} diff --git a/src/editor/store/directorStore.project.test.ts b/src/editor/store/directorStore.project.test.ts new file mode 100644 index 0000000..51a34c1 --- /dev/null +++ b/src/editor/store/directorStore.project.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ensureProjectCatalog, readProjectCatalog } from "../io/projectCatalog"; +import { buildProjectFileText } from "../io/projectFile"; +import { createInitialDirectorState, useDirectorStore } from "./directorStore"; + +function countCharacters() { + return useDirectorStore.getState().project.objects.filter((item) => item.kind === "character").length; +} + +beforeEach(() => { + localStorage.clear(); + const catalog = ensureProjectCatalog(); + + useDirectorStore.setState({ + ...useDirectorStore.getState(), + ...createInitialDirectorState(), + clipboard: [], + clipboardPasteCount: 0, + undoStack: [], + redoStack: [], + }); + // 把存储作用域对到新建的目录上(localStorage 已清空,模块级作用域可能过期) + useDirectorStore.getState().openProject(catalog.activeProjectId); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("creates a new project and switches to it", () => { + const originalId = useDirectorStore.getState().activeProjectId; + + const newId = useDirectorStore.getState().createProject("第二幕"); + + expect(newId).not.toBe(originalId); + const state = useDirectorStore.getState(); + expect(state.activeProjectId).toBe(newId); + expect(state.projects.map((project) => project.name)).toContain("第二幕"); + expect(readProjectCatalog()?.activeProjectId).toBe(newId); + // 新工程是干净的默认场景 + expect(countCharacters()).toBe(1); +}); + +it("keeps each project's edits isolated", () => { + const firstId = useDirectorStore.getState().activeProjectId as string; + useDirectorStore.getState().addPresetCharacter("female"); + const firstCharacterCount = countCharacters(); + expect(firstCharacterCount).toBe(2); + + useDirectorStore.getState().createProject("第二幕"); + expect(countCharacters()).toBe(1); + + useDirectorStore.getState().openProject(firstId); + + expect(useDirectorStore.getState().activeProjectId).toBe(firstId); + expect(countCharacters()).toBe(firstCharacterCount); +}); + +it("renames a project", () => { + const activeId = useDirectorStore.getState().activeProjectId as string; + + useDirectorStore.getState().renameProject(activeId, " 新名字 "); + + const project = useDirectorStore.getState().projects.find((item) => item.id === activeId); + expect(project?.name).toBe("新名字"); + expect(readProjectCatalog()?.projects.find((item) => item.id === activeId)?.name).toBe("新名字"); +}); + +it("ignores blank renames", () => { + const activeId = useDirectorStore.getState().activeProjectId as string; + const before = useDirectorStore.getState().projects.find((item) => item.id === activeId)?.name; + + useDirectorStore.getState().renameProject(activeId, " "); + + expect(useDirectorStore.getState().projects.find((item) => item.id === activeId)?.name).toBe(before); +}); + +it("duplicates a project with its content without switching", () => { + const activeId = useDirectorStore.getState().activeProjectId as string; + useDirectorStore.getState().addPresetCharacter("teen"); + + const duplicateId = useDirectorStore.getState().duplicateProject(activeId); + + expect(duplicateId).not.toBeNull(); + expect(useDirectorStore.getState().activeProjectId).toBe(activeId); + expect(useDirectorStore.getState().projects).toHaveLength(2); + + useDirectorStore.getState().openProject(duplicateId as string); + expect(countCharacters()).toBe(2); +}); + +it("deletes a non-active project without switching", () => { + const firstId = useDirectorStore.getState().activeProjectId as string; + const secondId = useDirectorStore.getState().createProject("第二幕"); + + useDirectorStore.getState().deleteProject(firstId); + + const state = useDirectorStore.getState(); + expect(state.activeProjectId).toBe(secondId); + expect(state.projects.map((project) => project.id)).not.toContain(firstId); +}); + +it("switches to the first remaining project when deleting the active one", () => { + const firstId = useDirectorStore.getState().activeProjectId as string; + const secondId = useDirectorStore.getState().createProject("第二幕"); + + useDirectorStore.getState().deleteProject(secondId); + + const state = useDirectorStore.getState(); + expect(state.projects).toHaveLength(1); + expect(state.activeProjectId).toBe(firstId); + expect(readProjectCatalog()?.activeProjectId).toBe(firstId); +}); + +it("refuses to delete the last remaining project", () => { + const activeId = useDirectorStore.getState().activeProjectId; + + useDirectorStore.getState().deleteProject(activeId as string); + + expect(useDirectorStore.getState().projects).toHaveLength(1); + expect(useDirectorStore.getState().activeProjectId).toBe(activeId); +}); + +it("saves the active project to a downloaded file", async () => { + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + const createObjectUrlSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock"); + + await useDirectorStore.getState().saveProjectToFile(); + + expect(createObjectUrlSpy).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); +}); + +it("imports a project file as a new active project", async () => { + useDirectorStore.getState().addPresetCharacter("female"); + const sourceProject = useDirectorStore.getState().project; + const text = buildProjectFileText(sourceProject, "外部工程"); + const file = new File([text], "外部工程.json", { type: "application/json" }); + + const importedId = await useDirectorStore.getState().importProjectFromFile(file); + + const state = useDirectorStore.getState(); + expect(state.activeProjectId).toBe(importedId); + expect(state.projects.map((project) => project.name)).toContain("外部工程"); + expect(countCharacters()).toBe(2); +}); + +it("falls back to the file name when the imported file has no project name", async () => { + const bare = JSON.stringify({ + ...useDirectorStore.getState().project, + objects: useDirectorStore.getState().project.objects, + }); + const file = new File([bare], "分镜草稿.json", { type: "application/json" }); + + await useDirectorStore.getState().importProjectFromFile(file); + + expect(useDirectorStore.getState().projects.map((project) => project.name)).toContain("分镜草稿"); +}); + +it("throws when the imported file is not a project", async () => { + const file = new File(["{}"], "bad.json", { type: "application/json" }); + + await expect(useDirectorStore.getState().importProjectFromFile(file)).rejects.toThrow(); +}); + +it("refreshes the catalog snapshot from storage", () => { + useDirectorStore.setState({ ...useDirectorStore.getState(), projects: [], activeProjectId: null }); + + useDirectorStore.getState().refreshProjectCatalog(); + + expect(useDirectorStore.getState().projects).toHaveLength(1); + expect(useDirectorStore.getState().activeProjectId).not.toBeNull(); +}); diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts index b342307..a8ccf8f 100644 --- a/src/editor/store/directorStore.test.ts +++ b/src/editor/store/directorStore.test.ts @@ -916,6 +916,7 @@ it("createDefaultDirectorProject ships a default timeline and version 2", () => duration: 5, tracks: [], cameraTracks: [], + transitionClips: [], fps: 30, loop: false, cameraTransition: { type: "smooth", duration: 0.5 }, @@ -973,6 +974,7 @@ it("replaceProject migrates a legacy v1 project into a v2 project with a timelin duration: 5, tracks: [], cameraTracks: [], + transitionClips: [], fps: 30, loop: false, cameraTransition: { type: "smooth", duration: 0.5 }, diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index bbe25b4..619b01b 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -91,6 +91,32 @@ import { getMotionEndTime, MOTION_CLIP_MIN_DURATION, } from "../schema/trajectoryMotion"; +import { + DEFAULT_PROJECT_NAME, + createProjectMeta, + ensureProjectCatalog, + getScopedSceneStorageKey, + normalizeProjectScopeId, + readProjectCatalog, + readScopedSceneRaw, + removeScopedScene, + resolveStartupProjectScopeId, + touchProjectUpdatedAt, + writeProjectCatalog, + writeScopedSceneRaw, + type DirectorProjectCatalog, + type DirectorProjectMeta, +} from "../io/projectCatalog"; +import { + DIRECTOR_PROJECT_FILE_EXTENSION, + buildProjectFileText, + deriveProjectNameFromFileName, + downloadTextFile, + inlineProjectModelBinaries, + parseProjectFile, + rehydrateProjectModelBinaries, + sanitizeProjectFileName, +} from "../io/projectFile"; export type TransformMode = "translate" | "rotate" | "scale"; @@ -181,6 +207,16 @@ interface DirectorInternalState { undoBatchHasTrackedChanges: boolean; } +/** + * 多工程目录的运行时镜像:让顶栏「工程」菜单能响应式地列出全部工程。 + * 真正的数据源是 localStorage 里的目录(见 io/projectCatalog),这里只是它的快照, + * 不属于 `DirectorState`,因此不进撤销栈、也不会写回场景 JSON。 + */ +export interface DirectorProjectLibraryState { + projects: DirectorProjectMeta[]; + activeProjectId: string | null; +} + export interface DirectorActions { setViewMode: (mode: ViewMode) => void; setTransformMode: (mode: TransformMode) => void; @@ -381,9 +417,25 @@ export interface DirectorActions { replaceProject: (project: DirectorProject) => void; saveLatestSnapshot: () => void; restoreLatestSnapshot: () => void; + // ===== 多工程管理 ===== + /** 新建一个空工程并切换到它,返回新工程 id */ + createProject: (name?: string) => string; + /** 切换到指定工程(先落盘当前工程) */ + openProject: (projectId: string) => void; + renameProject: (projectId: string, name: string) => void; + /** 复制一个工程(不切换),返回副本 id;源工程不存在时返回 null */ + duplicateProject: (projectId: string) => string | null; + /** 删除工程(至少保留一个);删除当前激活工程时自动切到第一个 */ + deleteProject: (projectId: string) => void; + /** 把某个工程保存成工程文件下载;不传 id 时保存当前激活工程 */ + saveProjectToFile: (projectId?: string) => Promise; + /** 从工程文件导入为一个新工程并切换到它,返回新工程 id;格式不合法时抛错 */ + importProjectFromFile: (file: File) => Promise; + /** 重新从 localStorage 同步工程目录(外部改动后刷新 UI 用) */ + refreshProjectCatalog: () => void; } -type DirectorRuntimeState = DirectorState & DirectorInternalState; +type DirectorRuntimeState = DirectorState & DirectorInternalState & DirectorProjectLibraryState; export type DirectorStore = DirectorRuntimeState & DirectorActions; @@ -429,8 +481,6 @@ 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, @@ -453,25 +503,30 @@ const DEFAULT_UI_STATE: DirectorUiState = { }; function normalizeDirectorScenePersistenceScopeId(scopeId: string | null | undefined) { - return typeof scopeId === "string" ? scopeId.trim() : ""; + return normalizeProjectScopeId(scopeId); } -function getInitialDirectorScenePersistenceScopeId() { +/** 宿主嵌入时通过 `?instanceId=` 指定实例作用域;没有则为 null */ +function readHostInstanceScopeId() { if (typeof window === "undefined") return null; try { const params = new URLSearchParams(window.location.search); - return normalizeDirectorScenePersistenceScopeId(params.get("instanceId")) || null; + return normalizeProjectScopeId(params.get("instanceId")) || null; } catch { return null; } } +function getInitialDirectorScenePersistenceScopeId() { + // 宿主指定了实例作用域 → 用宿主的;否则打开多工程目录里当前激活的工程 + return resolveStartupProjectScopeId(readHostInstanceScopeId()); +} + 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; + return getScopedSceneStorageKey(scopeId); } function setDirectorScenePersistenceScopeId(scopeId: string | null | undefined) { @@ -944,6 +999,8 @@ function writePersistedDirectorState(state: DirectorState) { try { storage.setItem(getDirectorSceneStorageKey(), JSON.stringify(state)); + // 每次落盘顺手刷新该工程的「最近修改时间」;作用域不是工程(如宿主实例)时静默忽略。 + touchProjectUpdatedAt(directorScenePersistenceScopeId); } catch { // Keep the editor usable if the browser storage quota is exceeded. } @@ -1012,6 +1069,7 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo function createRuntimeStateFromPersistedState(state: DirectorState): DirectorRuntimeState { const snapshot = cloneJsonValue(state); + const catalog = readProjectCatalog(); return { ...snapshot, @@ -1022,6 +1080,9 @@ function createRuntimeStateFromPersistedState(state: DirectorState): DirectorRun undoBatchDepth: 0, undoBatchSnapshot: null, undoBatchHasTrackedChanges: false, + // 工程目录是独立于场景的一份数据:这里重新读一遍,保证 UI 列表与 localStorage 一致。 + projects: catalog?.projects ?? [], + activeProjectId: catalog?.activeProjectId ?? null, }; } @@ -4168,5 +4229,216 @@ export const useDirectorStore = create((set, get) => { }); writePersistedDirectorState(snapshot); }, + createProject: (name) => { + const current = get() as DirectorRuntimeState; + // 切走之前先把当前工程落盘,避免未持久化的改动丢失 + writePersistedDirectorState(extractPersistedDirectorState(current)); + + const catalog = ensureProjectCatalog(); + const meta = createProjectMeta(catalog.projects, name ?? null); + const nextCatalog: DirectorProjectCatalog = { + version: 1, + activeProjectId: meta.id, + projects: [...catalog.projects, meta], + }; + writeProjectCatalog(nextCatalog); + setDirectorScenePersistenceScopeId(meta.id); + + // 新槽位为空 → 加载到默认场景;随后立刻把这份默认场景写进新槽位 + const snapshot = createInitialDirectorState({ + includePersistedLocalAssets: true, + includePersistedScene: true, + persistenceScopeId: meta.id, + }); + set({ + ...createRuntimeStateFromPersistedState(snapshot), + clipboard: current.clipboard, + clipboardPasteCount: current.clipboardPasteCount, + undoStack: [], + redoStack: [], + }); + writePersistedDirectorState(snapshot); + + return meta.id; + }, + openProject: (projectId) => { + const catalog = readProjectCatalog(); + if (!catalog || !catalog.projects.some((project) => project.id === projectId)) return; + + // 已经在这个工程上:只同步一下目录(例如改名后),不重载场景 + if (projectId === directorScenePersistenceScopeId) { + set({ projects: catalog.projects, activeProjectId: projectId }); + return; + } + + const current = get() as DirectorRuntimeState; + writePersistedDirectorState(extractPersistedDirectorState(current)); + + writeProjectCatalog({ ...catalog, activeProjectId: projectId }); + setDirectorScenePersistenceScopeId(projectId); + + const snapshot = createInitialDirectorState({ + includePersistedLocalAssets: true, + includePersistedScene: true, + persistenceScopeId: projectId, + }); + set({ + ...createRuntimeStateFromPersistedState(snapshot), + clipboard: current.clipboard, + clipboardPasteCount: current.clipboardPasteCount, + undoStack: [], + redoStack: [], + }); + writePersistedDirectorState(snapshot); + }, + renameProject: (projectId, name) => { + const trimmedName = name.trim(); + if (!trimmedName) return; + + const catalog = readProjectCatalog(); + if (!catalog) return; + + const projects = catalog.projects.map((project) => + project.id === projectId ? { ...project, name: trimmedName, updatedAt: Date.now() } : project + ); + writeProjectCatalog({ ...catalog, projects }); + set({ projects, activeProjectId: catalog.activeProjectId }); + }, + duplicateProject: (projectId) => { + const catalog = readProjectCatalog(); + const source = catalog?.projects.find((project) => project.id === projectId); + if (!catalog || !source) return null; + + // 当前激活工程用内存里的最新数据,其它工程从各自的槽位读 + const raw = + projectId === directorScenePersistenceScopeId + ? JSON.stringify(extractPersistedDirectorState(get() as DirectorRuntimeState)) + : readScopedSceneRaw(projectId); + + const meta = createProjectMeta(catalog.projects, `${source.name} 副本`); + if (raw) { + writeScopedSceneRaw(meta.id, raw); + } + + const nextCatalog: DirectorProjectCatalog = { + version: 1, + activeProjectId: catalog.activeProjectId, + projects: [...catalog.projects, meta], + }; + writeProjectCatalog(nextCatalog); + set({ projects: nextCatalog.projects, activeProjectId: nextCatalog.activeProjectId }); + + return meta.id; + }, + deleteProject: (projectId) => { + const catalog = readProjectCatalog(); + if (!catalog || catalog.projects.length <= 1) return; + if (!catalog.projects.some((project) => project.id === projectId)) return; + + const projects = catalog.projects.filter((project) => project.id !== projectId); + removeScopedScene(projectId); + + const wasActive = catalog.activeProjectId === projectId; + const nextActiveId = wasActive ? projects[0].id : catalog.activeProjectId; + writeProjectCatalog({ version: 1, activeProjectId: nextActiveId, projects }); + + if (!wasActive) { + set({ projects, activeProjectId: nextActiveId }); + return; + } + + // 删的是当前工程:切到剩下的第一个,并载入它的场景 + setDirectorScenePersistenceScopeId(nextActiveId); + const snapshot = createInitialDirectorState({ + includePersistedLocalAssets: true, + includePersistedScene: true, + persistenceScopeId: nextActiveId, + }); + set({ + ...createRuntimeStateFromPersistedState(snapshot), + undoStack: [], + redoStack: [], + }); + writePersistedDirectorState(snapshot); + }, + saveProjectToFile: async (projectId) => { + const current = get() as DirectorRuntimeState; + const targetId = projectId ?? current.activeProjectId ?? directorScenePersistenceScopeId; + const catalog = readProjectCatalog(); + const meta = catalog?.projects.find((project) => project.id === targetId) ?? null; + + let project: DirectorProject | null = null; + if (!projectId || projectId === directorScenePersistenceScopeId) { + project = current.project; + } else { + const persisted = readPersistedDirectorState({ + includePersistedLocalAssets: true, + persistenceScopeId: projectId, + }); + project = persisted?.project ?? null; + } + + if (!project) { + throw new Error("找不到要保存的工程"); + } + + const name = meta?.name ?? DEFAULT_PROJECT_NAME; + // 把 IndexedDB 里的 GLB/GLTF 内联成 data URL,让导出的文件自包含 + const exportable = await inlineProjectModelBinaries(cloneJsonValue(project)); + const text = buildProjectFileText(exportable, name); + downloadTextFile(`${sanitizeProjectFileName(name)}${DIRECTOR_PROJECT_FILE_EXTENSION}`, text); + }, + importProjectFromFile: async (file) => { + const parsed = parseProjectFile(await file.text()); + if (!parsed) { + throw new Error("工程文件格式不正确"); + } + + const current = get() as DirectorRuntimeState; + writePersistedDirectorState(extractPersistedDirectorState(current)); + + const catalog = ensureProjectCatalog(); + const meta = createProjectMeta( + catalog.projects, + parsed.name ?? deriveProjectNameFromFileName(file.name) + ); + + // 文件里的模型可能是内联 data URL:落回 IndexedDB,保持 localStorage 轻量 + const rehydrated = await rehydrateProjectModelBinaries(parsed.project); + const migrated = migrateDirectorProject(cloneJsonValue(rehydrated)); + + writeScopedSceneRaw(meta.id, JSON.stringify({ ...DEFAULT_UI_STATE, project: migrated })); + + const nextCatalog: DirectorProjectCatalog = { + version: 1, + activeProjectId: meta.id, + projects: [...catalog.projects, meta], + }; + writeProjectCatalog(nextCatalog); + setDirectorScenePersistenceScopeId(meta.id); + + const snapshot = createInitialDirectorState({ + includePersistedLocalAssets: true, + includePersistedScene: true, + persistenceScopeId: meta.id, + }); + set({ + ...createRuntimeStateFromPersistedState(snapshot), + clipboard: current.clipboard, + clipboardPasteCount: current.clipboardPasteCount, + undoStack: [], + redoStack: [], + }); + writePersistedDirectorState(snapshot); + + return meta.id; + }, + refreshProjectCatalog: () => { + const catalog = readProjectCatalog(); + set({ + projects: catalog?.projects ?? [], + activeProjectId: catalog?.activeProjectId ?? null, + }); + }, }; }); diff --git a/src/styles/index.css b/src/styles/index.css index 09c7e3e..4f2e101 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -18,10 +18,13 @@ --right-sidebar-width: 300px; --left-sidebar-content-width: 260px; --right-sidebar-content-width: 260px; - /* 悬浮卡片与视口边缘的间距(左右、以及相对时间轴的上下留白都用它), - 卡片顶部同样取这个值,四周留白保持一致 */ + /* 悬浮卡片与视口边缘的间距(左右、以及相对时间轴的上下留白都用它) */ --floating-inset: 16px; - --floating-top: var(--floating-inset); + /* 顶栏高度:顶栏绝对定位在视口顶部铺满整行,左格放了「工程」入口 */ + --top-bar-height: 70px; + /* 悬浮卡片的起始高度:整体落在顶栏之下,让「顶栏 → 左右面板」自上而下依次排列, + 避免左上角的工程入口压在场景面板上 */ + --floating-top: calc(var(--top-bar-height) + var(--floating-inset)); /* 悬浮卡片可用高度上限:扣掉卡片起始高度、上下内缩、以及悬浮时间轴占用的高度 */ --floating-card-max-height: calc(100vh - var(--floating-top) - var(--floating-inset) * 2 - var(--timeline-panel-height, 0px)); /* 视口快捷工具条高度(ViewportToolbar 会按实测值覆盖),供悬浮时间轴让位使用 */ @@ -134,8 +137,9 @@ textarea:focus-visible { grid-template-columns: var(--left-sidebar-width) minmax(0, 1fr) var(--right-sidebar-width); align-items: center; gap: 0; - min-height: 70px; - padding: 0 20px; + min-height: var(--top-bar-height); + /* 左右内缩与悬浮卡片一致:左格里的工程入口和下方场景卡片左缘对齐 */ + padding: 0 var(--floating-inset); border: 1px solid rgb(var(--border-rgb) / 0.24); background: rgb(var(--panel-rgb)); font-family: "PingFang SC", "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -5461,3 +5465,268 @@ body.is-timeline-dragging * { color: #ffffff; background: rgb(255 77 79 / 0.72); } + +/* ===== 顶栏「工程」管理(多工程:切换 / 新建 / 复制 / 保存到文件 / 导入 / 删除) ===== */ +.project-menu { + position: relative; + display: flex; + align-items: center; + /* 顶栏整条是 pointer-events:none,这里把入口单独收回可点 */ + pointer-events: auto; +} + +/* 覆盖 .top-bar-action-button 的 36px 方形:工程入口是「图标 + 当前工程名 + 箭头」的药丸 */ +.project-menu-trigger { + display: flex; + align-items: center; + gap: 8px; + width: auto; + max-width: 240px; + height: 36px; + padding: 0 12px; + border-radius: 10px; + color: rgb(var(--text-rgb)); + background: rgb(var(--panel-rgb) / 0.82); + box-shadow: var(--ui-shadow-panel); + backdrop-filter: blur(32px); + -webkit-backdrop-filter: blur(32px); +} + +.project-menu-trigger-name { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + line-height: 17px; +} + +.project-menu-trigger-chevron { + flex: 0 0 auto; + opacity: 0.7; +} + +.project-menu-panel { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 60; + display: flex; + flex-direction: column; + /* 宽度对齐左列(与下方场景卡片同宽、左缘对齐),不横向探进中间的视角切换列 */ + width: min(var(--left-sidebar-width), calc(100vw - var(--floating-inset) * 2)); + max-height: min(72vh, 520px); + border: 1px solid rgb(var(--border-rgb) / 0.35); + border-radius: 12px; + color: rgb(var(--text-rgb)); + background: rgb(var(--panel-rgb) / 0.96); + box-shadow: var(--ui-shadow-panel); + backdrop-filter: blur(32px); + -webkit-backdrop-filter: blur(32px); + overflow: hidden; +} + +.project-menu-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + height: 41px; + padding: 0 12px 0 20px; +} + +.project-menu-title { + margin: 0; + color: rgb(var(--text-rgb)); + font-size: 12px; + font-weight: 600; + line-height: 17px; +} + +.project-menu-close-button { + flex: 0 0 auto; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + line-height: 0; +} + +.project-menu-list { + display: grid; + align-content: start; + gap: 2px; + min-height: 0; + padding: 4px 6px 6px; + overflow-y: auto; +} + +.project-menu-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + padding: 2px 4px 2px 10px; + border-radius: 8px; +} + +.project-menu-row:hover { + background: rgb(var(--surface-rgb) / 0.6); +} + +.project-menu-row.is-active { + background: rgb(var(--accent-rgb) / 0.14); +} + +.project-menu-name { + display: grid; + gap: 2px; + min-width: 0; + border: 0; + border-radius: 6px; + padding: 5px 4px; + color: rgb(var(--text-rgb)); + background: transparent; + text-align: left; + cursor: pointer; +} + +.project-menu-row.is-active .project-menu-name-text { + color: rgb(var(--accent-rgb)); +} + +.project-menu-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +.project-menu-name-meta { + color: rgb(var(--text-dim-rgb)); + font-size: 11px; + line-height: 14px; +} + +.project-menu-row-actions { + display: flex; + align-items: center; + gap: 2px; +} + +.project-menu-icon-button { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border: 0; + border-radius: 7px; + padding: 0; + color: rgb(var(--text-muted-rgb)); + background: transparent; + cursor: pointer; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.project-menu-icon-button:hover:not(:disabled), +.project-menu-icon-button:focus-visible:not(:disabled) { + color: rgb(var(--text-rgb)); + background: rgb(var(--surface-hover-rgb)); +} + +.project-menu-icon-button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.project-menu-icon-danger:hover:not(:disabled), +.project-menu-icon-danger:focus-visible:not(:disabled) { + color: #ffffff; + background: rgb(255 77 79 / 0.72); +} + +.project-menu-confirm-delete { + height: 26px; + border: 1px solid rgb(255 77 79 / 0.4); + border-radius: 7px; + padding: 0 10px; + color: #ff4d4f; + background: rgb(255 77 79 / 0.08); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.project-menu-confirm-delete:hover { + color: #ffffff; + background: rgb(255 77 79 / 0.72); +} + +.project-menu-rename { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 4px; +} + +.project-menu-rename-input { + height: 28px; + min-width: 0; +} + +.project-menu-actions { + display: grid; + flex: 0 0 auto; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid rgb(var(--border-rgb) / 0.24); +} + +.project-menu-action-button { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + border: 1px solid rgb(var(--border-rgb) / 0.36); + border-radius: 8px; + color: rgb(var(--text-rgb)); + background: rgb(var(--surface-rgb) / 0.6); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.project-menu-action-button:hover, +.project-menu-action-button:focus-visible { + background: rgb(var(--surface-hover-rgb)); +} + +.project-menu-status { + flex: 0 0 auto; + margin: 0; + padding: 0 12px 10px; + color: rgb(var(--text-dim-rgb)); + font-size: 11px; + line-height: 15px; +} + +@media (max-width: 920px) { + /* 窄屏优先把顶栏让给视角切换:工程入口只留图标 */ + .project-menu-trigger-name { + display: none; + } + + .project-menu-trigger { + max-width: none; + padding: 0 8px; + } +}