Compare commits
3 Commits
07d9e5e798
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a036c0d917 | |||
| ada16fa3ec | |||
| 341ad949e1 |
16
README.md
16
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 匹配为空不会报错,属于预期行为。相关测试在目录缺失时会自动跳过。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -72,6 +72,27 @@ it("collapses both side panels from the fullscreen toolbar action", async () =>
|
||||
expect(screen.getByLabelText("属性")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("collapses only one side panel and leaves a handle to bring it back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<App />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "收起场景面板" })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useDirectorStore.getState().toggleLeftPanelVisible();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".director-shell-fullbleed.is-left-collapsed")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("场景")).toHaveAttribute("aria-hidden", "true");
|
||||
// 右侧不受影响
|
||||
expect(screen.getByLabelText("属性")).not.toHaveAttribute("aria-hidden");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "展开场景面板" }));
|
||||
|
||||
expect(screen.getByLabelText("场景")).not.toHaveAttribute("aria-hidden");
|
||||
expect(container.querySelector(".director-shell-fullbleed.is-left-collapsed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the floating timeline by default and hides it from the record action", () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
|
||||
30
src/App.tsx
30
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";
|
||||
|
||||
@@ -41,6 +42,27 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 面板显隐: [ 左侧场景栏、] 右侧属性栏、\ 底部时间轴。都不带修饰键,方便单手切换。
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
if (event.key === "[") {
|
||||
event.preventDefault();
|
||||
useDirectorStore.getState().toggleLeftPanelVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "]") {
|
||||
event.preventDefault();
|
||||
useDirectorStore.getState().toggleRightPanelVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "\\") {
|
||||
event.preventDefault();
|
||||
useDirectorStore.getState().toggleTimelineVisible();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.metaKey && !event.ctrlKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -79,9 +101,11 @@ export default function App() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
{/* 占位:顶栏是「左栏宽 / 中间 1fr / 右栏宽」三列栅格,
|
||||
少了这块,中间的视角切换会落到左列里去,左对齐而不是居中。 */}
|
||||
<div className="top-bar-left" />
|
||||
{/* 顶栏是「左栏宽 / 中间 1fr / 右栏宽」三列栅格:左侧放工程管理入口,
|
||||
中间保持视角切换居中(少了左列,视角切换会落到左列去而左对齐)。 */}
|
||||
<div className="top-bar-left">
|
||||
<ProjectManagerMenu />
|
||||
</div>
|
||||
<div className="top-bar-center">
|
||||
<div className="mode-toggle ui-segmented" role="group" aria-label="视角切换">
|
||||
<button
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "lucide-react";
|
||||
import { ObjectTreePanel } from "../../editor/panels/ObjectTreePanel";
|
||||
import { RightPanel } from "../../editor/panels/RightPanel";
|
||||
import { TimelinePanel } from "../../editor/panels/TimelinePanel";
|
||||
import { useDirectorStore } from "../../editor/store/directorStore";
|
||||
|
||||
const HANDLE_ICON_PROPS = { "aria-hidden": true, size: 15, strokeWidth: 2 } as const;
|
||||
|
||||
export function DirectorDeskShell({ children }: { children: ReactNode }) {
|
||||
const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed);
|
||||
const leftPanelVisible = useDirectorStore((state) => state.leftPanelVisible);
|
||||
const rightPanelVisible = useDirectorStore((state) => state.rightPanelVisible);
|
||||
const timelineVisible = useDirectorStore((state) => state.timelineVisible);
|
||||
const toggleLeftPanelVisible = useDirectorStore((state) => state.toggleLeftPanelVisible);
|
||||
const toggleRightPanelVisible = useDirectorStore((state) => state.toggleRightPanelVisible);
|
||||
|
||||
// 「专注模式」是临时一次收起两侧,最终可见性由两侧各自的开关与之相与决定
|
||||
const leftVisible = !viewportPanelsCollapsed && leftPanelVisible;
|
||||
const rightVisible = !viewportPanelsCollapsed && rightPanelVisible;
|
||||
|
||||
const shellClassName = [
|
||||
"director-shell",
|
||||
"director-shell-fullbleed",
|
||||
leftVisible ? "" : "is-left-collapsed",
|
||||
rightVisible ? "" : "is-right-collapsed",
|
||||
!leftVisible && !rightVisible ? "is-sidebars-collapsed" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`director-shell director-shell-fullbleed${viewportPanelsCollapsed ? " is-sidebars-collapsed" : ""}`}
|
||||
>
|
||||
<div className={shellClassName}>
|
||||
<section className="viewport-column" aria-label="3D视口">
|
||||
<div className="viewport-stack">
|
||||
{children}
|
||||
@@ -20,18 +39,38 @@ export function DirectorDeskShell({ children }: { children: ReactNode }) {
|
||||
</section>
|
||||
<aside
|
||||
className="left-sidebar director-sidebar"
|
||||
aria-hidden={viewportPanelsCollapsed ? "true" : undefined}
|
||||
aria-hidden={leftVisible ? undefined : "true"}
|
||||
aria-label="场景"
|
||||
>
|
||||
<ObjectTreePanel />
|
||||
</aside>
|
||||
<aside
|
||||
className="right-sidebar director-sidebar"
|
||||
aria-hidden={viewportPanelsCollapsed ? "true" : undefined}
|
||||
aria-hidden={rightVisible ? undefined : "true"}
|
||||
aria-label="属性"
|
||||
>
|
||||
<RightPanel />
|
||||
</aside>
|
||||
<button
|
||||
aria-expanded={leftVisible}
|
||||
aria-label={leftVisible ? "收起场景面板" : "展开场景面板"}
|
||||
className="panel-edge-handle panel-edge-handle-left"
|
||||
title={leftVisible ? "收起场景面板([)" : "展开场景面板([)"}
|
||||
type="button"
|
||||
onClick={toggleLeftPanelVisible}
|
||||
>
|
||||
{leftVisible ? <PanelLeftClose {...HANDLE_ICON_PROPS} /> : <PanelLeftOpen {...HANDLE_ICON_PROPS} />}
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={rightVisible}
|
||||
aria-label={rightVisible ? "收起属性面板" : "展开属性面板"}
|
||||
className="panel-edge-handle panel-edge-handle-right"
|
||||
title={rightVisible ? "收起属性面板(])" : "展开属性面板(])"}
|
||||
type="button"
|
||||
onClick={toggleRightPanelVisible}
|
||||
>
|
||||
{rightVisible ? <PanelRightClose {...HANDLE_ICON_PROPS} /> : <PanelRightOpen {...HANDLE_ICON_PROPS} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef } from "react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { DoubleSide, PerspectiveCamera, Vector3 } from "three";
|
||||
import { clearViewportCaptureHandler, requestViewportCapture } from "../io/captureBridge";
|
||||
@@ -459,7 +459,8 @@ it("toggles the viewport rule-of-thirds guide from the aspect frame button", ()
|
||||
it("renders the native 3D viewport gizmo in an overlay canvas above the aspect mask", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getAllByTestId("mock-r3f-canvas")).toHaveLength(2);
|
||||
// 三块画布:主视口 + 原生坐标控件浮层 + 镜头监视器(机位画面由监视器渲染)
|
||||
expect(screen.getAllByTestId("mock-r3f-canvas")).toHaveLength(3);
|
||||
expect(screen.getByLabelText("3D视口原生坐标控件")).toContainElement(screen.getByTestId("native-gizmo-helper"));
|
||||
expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-alignment", "center-center");
|
||||
expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-margin", "[0,0]");
|
||||
@@ -484,6 +485,32 @@ it("offsets the native viewport gizmo inward when overlay side panels are open",
|
||||
});
|
||||
});
|
||||
|
||||
it("pulls the viewport overlays to the edge when only the right panel is collapsed", () => {
|
||||
act(() => {
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
viewMode: "director",
|
||||
viewportPanelsCollapsed: false,
|
||||
leftPanelVisible: true,
|
||||
rightPanelVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByLabelText("3D视口原生坐标控件")).toHaveStyle({ right: "332px" });
|
||||
|
||||
act(() => {
|
||||
useDirectorStore.getState().toggleRightPanelVisible();
|
||||
});
|
||||
|
||||
// 右侧面板收起后这一侧整块让给画布:坐标控件 / 重置视角 / 镜头监视器都要贴到边缘,
|
||||
// 不能只看「专注模式」总开关
|
||||
expect(screen.getByLabelText("3D视口原生坐标控件")).toHaveStyle({ right: "20px" });
|
||||
expect(document.querySelector(".viewport-view-reset")).toHaveStyle({ right: "20px" });
|
||||
expect(screen.getByLabelText("镜头监视器")).toHaveStyle({ right: "20px" });
|
||||
});
|
||||
|
||||
it("resets the director view from the button below the native gizmo", () => {
|
||||
useDirectorStore.getState().setViewMode("camera");
|
||||
render(<App />);
|
||||
|
||||
@@ -935,6 +935,8 @@ export function DirectorCanvas() {
|
||||
const viewportAspectRatio = useDirectorStore((state) => state.viewportAspectRatio);
|
||||
const viewportRuleOfThirdsEnabled = useDirectorStore((state) => state.viewportRuleOfThirdsEnabled);
|
||||
const viewportPanelsCollapsed = useDirectorStore((state) => state.viewportPanelsCollapsed);
|
||||
const leftPanelVisible = useDirectorStore((state) => state.leftPanelVisible);
|
||||
const rightPanelVisible = useDirectorStore((state) => state.rightPanelVisible);
|
||||
const trajectoryPlacementObjectId = useDirectorStore((state) => state.trajectoryPlacementObjectId);
|
||||
const setViewMode = useDirectorStore((state) => state.setViewMode);
|
||||
const setViewportRuleOfThirdsEnabled = useDirectorStore((state) => state.setViewportRuleOfThirdsEnabled);
|
||||
@@ -947,17 +949,18 @@ export function DirectorCanvas() {
|
||||
target: getCameraLookPoint(activeCameraWorldView.position, activeCameraWorldView.lookRotation),
|
||||
}
|
||||
: directorViewSnapshot;
|
||||
const viewportSafeAreaInsets: ViewportSafeAreaInsets = viewportPanelsCollapsed
|
||||
? { left: 0, right: 0, top: 0, bottom: 0 }
|
||||
: {
|
||||
left: LEFT_PANEL_WIDTH + FLOATING_PANEL_GAP,
|
||||
right: RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
const gizmoRightOffset = viewportPanelsCollapsed
|
||||
? GIZMO_EDGE_PADDING
|
||||
: RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP;
|
||||
// 某一侧面板收起后,画布这一侧就整块让出来了:坐标控件 / 重置视角 / 镜头监视器、
|
||||
// 以及画幅安全区都得跟着贴到新的画布边缘。判据是「这一侧是否真的显示」,
|
||||
// 不能只看「专注模式」总开关 —— 单独按 [ / ] 收起一侧时总开关并没有变。
|
||||
const leftPanelShown = !viewportPanelsCollapsed && leftPanelVisible;
|
||||
const rightPanelShown = !viewportPanelsCollapsed && rightPanelVisible;
|
||||
const viewportSafeAreaInsets: ViewportSafeAreaInsets = {
|
||||
left: leftPanelShown ? LEFT_PANEL_WIDTH + FLOATING_PANEL_GAP : 0,
|
||||
right: rightPanelShown ? RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP : 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
};
|
||||
const gizmoRightOffset = rightPanelShown ? RIGHT_PANEL_WIDTH + FLOATING_PANEL_GAP : GIZMO_EDGE_PADDING;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = toolbarRef.current;
|
||||
|
||||
@@ -64,7 +64,7 @@ function LensMonitorCamera({
|
||||
// playhead 自己订阅(而不是从外层当 prop 传进来):外壳组件不必因为这个每秒变几十次的值
|
||||
// 去 reconcile 整棵场景树(见 LensMonitorCanvas 的 memo)。
|
||||
const playhead = usePlaybackStore((state) => state.playhead);
|
||||
// 拖动播放头期间走草稿档:跳过景深整套后处理,把帧预算留给「跟手」
|
||||
// 拖动播放头期间走草稿档:景深照常渲染(移焦 / 变焦要看得到),只把模糊采样点数降到最低档
|
||||
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
|
||||
// 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值,
|
||||
// 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。
|
||||
@@ -128,7 +128,8 @@ function LensMonitorCamera({
|
||||
// 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套
|
||||
// EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。
|
||||
// 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。
|
||||
// 拖动播放头时传 draft:跳过整套景深后处理(松手立刻恢复),拖动才跟手。
|
||||
// 拖动播放头时传 draft:景深仍然整套跑(拖动期间「移焦 / 变焦 / 虚化强弱」必须看得见),
|
||||
// 只是把模糊采样点数压到最低档省开销,松手立刻回到自适应档。
|
||||
renderSceneWithBokeh(gl, scene, renderCamera, lens, undefined, { draft: isScrubbing });
|
||||
}, 1);
|
||||
|
||||
|
||||
@@ -12,6 +12,22 @@ import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
|
||||
const mockCharacterModelShouldSuspend = vi.hoisted(() => ({ current: false }));
|
||||
|
||||
// SceneRoot 在这里是「裸渲染」的(外层不包 <Canvas>)。真实的 useThree / createPortal 依赖
|
||||
// R3F 上下文,会抛 "Hooks can only be used within the Canvas component!",
|
||||
// 于是所有会挂 gizmo 的用例都会失败。这里只替换这两个 API:
|
||||
// gizmo 需要的 scene 根给一个空 Scene,portal 退化成内联渲染。
|
||||
vi.mock("@react-three/fiber", async () => {
|
||||
const actual = await vi.importActual<typeof import("@react-three/fiber")>("@react-three/fiber");
|
||||
const { Scene } = await vi.importActual<typeof import("three")>("three");
|
||||
const testScene = new Scene();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useThree: () => ({ scene: testScene, gl: {} }),
|
||||
createPortal: (children: React.ReactNode) => children,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@react-three/drei", async () => {
|
||||
const actual = await vi.importActual<typeof import("@react-three/drei")>("@react-three/drei");
|
||||
|
||||
|
||||
@@ -1237,7 +1237,7 @@ export function SceneRoot({ editorHelpers = true }: { editorHelpers?: boolean })
|
||||
camera={camera}
|
||||
object={object}
|
||||
selected={object?.id === selectedObjectId}
|
||||
showLabel={false}
|
||||
showLabel={labelVisible}
|
||||
editorHelpers={editorHelpers}
|
||||
transformMode={transformMode}
|
||||
transformable={Boolean(object && !object.locked)}
|
||||
|
||||
@@ -71,7 +71,7 @@ it("renders the viewport capsule as project icon-system buttons", () => {
|
||||
"当前视角截图",
|
||||
"四方位截图",
|
||||
"十二方位截图",
|
||||
"全屏",
|
||||
"专注模式",
|
||||
"添加轨迹",
|
||||
"录制",
|
||||
];
|
||||
@@ -113,7 +113,7 @@ it("uses the requested viewport toolbar SVG icons for camera and capture actions
|
||||
expect(screen.getByRole("button", { name: "十二方位截图" }).querySelector("svg")).toHaveClass("lucide-grid3x3");
|
||||
});
|
||||
|
||||
it("uses the fullscreen button to collapse the side panels instead of entering browser fullscreen", async () => {
|
||||
it("uses the focus-mode button to collapse the side panels instead of entering browser fullscreen", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ViewportToolbar />);
|
||||
|
||||
@@ -121,7 +121,7 @@ it("uses the fullscreen button to collapse the side panels instead of entering b
|
||||
false
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "全屏" }));
|
||||
await user.click(screen.getByRole("button", { name: "专注模式" }));
|
||||
|
||||
expect((useDirectorStore.getState() as { viewportPanelsCollapsed?: boolean }).viewportPanelsCollapsed ?? false).toBe(
|
||||
true
|
||||
|
||||
@@ -143,7 +143,11 @@ export function ViewportToolbar({
|
||||
const setViewMode = useDirectorStore((state) => state.setViewMode);
|
||||
const setTransformMode = useDirectorStore((state) => state.setTransformMode);
|
||||
const setViewportAspectRatio = useDirectorStore((state) => state.setViewportAspectRatio);
|
||||
const toggleViewportPanelsCollapsed = useDirectorStore((state) => state.toggleViewportPanelsCollapsed);
|
||||
const setViewportPanelsCollapsed = useDirectorStore((state) => state.setViewportPanelsCollapsed);
|
||||
// 左右侧栏是否都不见了:既可能是「专注模式」,也可能是被 [ / ] 各自收起
|
||||
const sidePanelsHidden = useDirectorStore(
|
||||
(state) => state.viewportPanelsCollapsed || (!state.leftPanelVisible && !state.rightPanelVisible)
|
||||
);
|
||||
const project = useDirectorStore((state) => state.project);
|
||||
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
|
||||
const timelineVisible = useDirectorStore((state) => state.timelineVisible);
|
||||
@@ -511,7 +515,15 @@ export function ViewportToolbar({
|
||||
{ label: "当前视角截图", icon: ScanLine, onClick: () => void handleCapture("current") },
|
||||
{ label: "四方位截图", icon: Grid2X2, onClick: () => void handleCapture("four") },
|
||||
{ label: "十二方位截图", icon: Grid3X3, onClick: () => void handleCapture("twelve") },
|
||||
{ label: "全屏", icon: Expand, onClick: toggleViewportPanelsCollapsed },
|
||||
{
|
||||
label: "专注模式",
|
||||
icon: Expand,
|
||||
pressed: sidePanelsHidden,
|
||||
title: sidePanelsHidden
|
||||
? "恢复左右面板(可用 [ 左侧 / ] 右侧 单独切换一侧)"
|
||||
: "专注模式:一次收起左右面板,只看画布(可用 [ 左侧 / ] 右侧 单独切换一侧)",
|
||||
onClick: () => setViewportPanelsCollapsed(!sidePanelsHidden),
|
||||
},
|
||||
// 打点 / 录制放在最右侧
|
||||
{
|
||||
label: "添加轨迹",
|
||||
|
||||
@@ -150,15 +150,6 @@ export function renderSceneWithBokeh(
|
||||
viewportRect?: { left: number; top: number; width: number; height: number },
|
||||
options: { draft?: boolean } = {}
|
||||
): void {
|
||||
// 草稿档(拖动播放头时用):直接普通渲染,跳过「深度 pass + 41~96 点景深」整套。
|
||||
// 拖动期间人看的是时间与构图,这里省掉的是最贵的那几笔(约 2~3 倍开销),松手立刻恢复完整景深。
|
||||
// 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配),
|
||||
// 反倒让每次开始拖动的头几帧卡一下。
|
||||
if (options.draft) {
|
||||
gl.render(scene, camera);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = ensureBokehState(
|
||||
gl,
|
||||
scene,
|
||||
@@ -178,7 +169,12 @@ export function renderSceneWithBokeh(
|
||||
renderHeight: state.height,
|
||||
pixelRatio: state.pixelRatio,
|
||||
});
|
||||
configureAdaptiveBokeh(state.bokeh, settings.taps);
|
||||
// 草稿档(拖动播放头时):**景深照常渲染**,只把采样点数压到最低档。
|
||||
// 拖动时恰恰要看着「移焦 / 变焦 / 虚化强弱」跟着变,直接跳过景深会让人以为镜头没动(画面里根本没有虚化),
|
||||
// 所以这里只省最贵的「逐像素 41~96 次采样」,模糊的观感几乎不变。
|
||||
// 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配),
|
||||
// 反倒让每次开始拖动的头几帧卡一下。
|
||||
configureAdaptiveBokeh(state.bokeh, options.draft ? BOKEH_MIN_TAPS : settings.taps);
|
||||
|
||||
const uniforms = (state.bokeh as unknown as { uniforms?: Record<string, { value: number }> }).uniforms;
|
||||
if (uniforms) {
|
||||
|
||||
116
src/editor/io/projectCatalog.test.ts
Normal file
116
src/editor/io/projectCatalog.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PROJECT_NAME,
|
||||
DIRECTOR_SCENE_STORAGE_KEY,
|
||||
PROJECT_CATALOG_STORAGE_KEY,
|
||||
createProjectMeta,
|
||||
ensureProjectCatalog,
|
||||
getScopedSceneStorageKey,
|
||||
readProjectCatalog,
|
||||
readScopedSceneRaw,
|
||||
removeScopedScene,
|
||||
resolveStartupProjectScopeId,
|
||||
touchProjectUpdatedAt,
|
||||
writeProjectCatalog,
|
||||
writeScopedSceneRaw,
|
||||
} from "./projectCatalog";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("creates a default catalog with one project on first run", () => {
|
||||
const catalog = ensureProjectCatalog(1000);
|
||||
|
||||
expect(catalog.projects).toHaveLength(1);
|
||||
expect(catalog.projects[0]?.name).toBe(DEFAULT_PROJECT_NAME);
|
||||
expect(catalog.projects[0]?.createdAt).toBe(1000);
|
||||
expect(catalog.activeProjectId).toBe(catalog.projects[0]?.id);
|
||||
expect(readProjectCatalog()).toEqual(catalog);
|
||||
});
|
||||
|
||||
it("keeps an existing catalog untouched when ensuring again", () => {
|
||||
const first = ensureProjectCatalog();
|
||||
const second = ensureProjectCatalog();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(second.projects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("migrates the legacy single-scene slot into the first project", () => {
|
||||
const legacyScene = JSON.stringify({ project: { legacy: true } });
|
||||
localStorage.setItem(DIRECTOR_SCENE_STORAGE_KEY, legacyScene);
|
||||
|
||||
const catalog = ensureProjectCatalog();
|
||||
|
||||
expect(readScopedSceneRaw(catalog.activeProjectId)).toBe(legacyScene);
|
||||
// 搬迁是复制而非删除:基础 key 保持原样,避免破坏旧版本回滚
|
||||
expect(localStorage.getItem(DIRECTOR_SCENE_STORAGE_KEY)).toBe(legacyScene);
|
||||
});
|
||||
|
||||
it("rebuilds a corrupt catalog instead of failing", () => {
|
||||
localStorage.setItem(PROJECT_CATALOG_STORAGE_KEY, "{ not json");
|
||||
|
||||
const catalog = ensureProjectCatalog();
|
||||
|
||||
expect(catalog.projects).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("builds scoped scene keys and falls back to the base key", () => {
|
||||
expect(getScopedSceneStorageKey("proj_1")).toBe(`${DIRECTOR_SCENE_STORAGE_KEY}:proj_1`);
|
||||
expect(getScopedSceneStorageKey(null)).toBe(DIRECTOR_SCENE_STORAGE_KEY);
|
||||
expect(getScopedSceneStorageKey(undefined)).toBe(DIRECTOR_SCENE_STORAGE_KEY);
|
||||
expect(getScopedSceneStorageKey(" ")).toBe(DIRECTOR_SCENE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("prefers the host instance scope over the active project", () => {
|
||||
const catalog = ensureProjectCatalog();
|
||||
|
||||
expect(resolveStartupProjectScopeId("host-instance")).toBe("host-instance");
|
||||
expect(resolveStartupProjectScopeId(null)).toBe(catalog.activeProjectId);
|
||||
expect(resolveStartupProjectScopeId(" ")).toBe(catalog.activeProjectId);
|
||||
});
|
||||
|
||||
it("names new projects without colliding with existing ones", () => {
|
||||
const first = createProjectMeta([], undefined, 1, "p1");
|
||||
const second = createProjectMeta([first], undefined, 2, "p2");
|
||||
|
||||
expect(first.name).toBe("未命名工程");
|
||||
expect(second.name).toBe("未命名工程 2");
|
||||
});
|
||||
|
||||
it("trims requested names and falls back when they are blank", () => {
|
||||
expect(createProjectMeta([], " 我的剧本 ", 1, "p1").name).toBe("我的剧本");
|
||||
expect(createProjectMeta([], " ", 1, "p2").name).toBe("未命名工程");
|
||||
});
|
||||
|
||||
it("touches updatedAt only for known projects", () => {
|
||||
const catalog = ensureProjectCatalog(1000);
|
||||
const projectId = catalog.activeProjectId;
|
||||
|
||||
touchProjectUpdatedAt(projectId, 5000);
|
||||
|
||||
expect(readProjectCatalog()?.projects[0]?.updatedAt).toBe(5000);
|
||||
expect(() => touchProjectUpdatedAt("missing", 9000)).not.toThrow();
|
||||
expect(readProjectCatalog()?.projects[0]?.updatedAt).toBe(5000);
|
||||
});
|
||||
|
||||
it("writes, reads and removes scoped scenes", () => {
|
||||
writeScopedSceneRaw("proj_x", "scene-x");
|
||||
writeScopedSceneRaw("proj_y", "scene-y");
|
||||
|
||||
expect(readScopedSceneRaw("proj_x")).toBe("scene-x");
|
||||
expect(readScopedSceneRaw("proj_y")).toBe("scene-y");
|
||||
|
||||
removeScopedScene("proj_x");
|
||||
|
||||
expect(readScopedSceneRaw("proj_x")).toBeNull();
|
||||
expect(readScopedSceneRaw("proj_y")).toBe("scene-y");
|
||||
});
|
||||
|
||||
it("falls back to the first project when activeProjectId is stale", () => {
|
||||
const catalog = ensureProjectCatalog();
|
||||
writeProjectCatalog({ ...catalog, activeProjectId: "ghost" });
|
||||
|
||||
expect(readProjectCatalog()?.activeProjectId).toBe(catalog.projects[0]?.id);
|
||||
});
|
||||
240
src/editor/io/projectCatalog.ts
Normal file
240
src/editor/io/projectCatalog.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 多工程目录(project catalog)
|
||||
*
|
||||
* 单工程时代,整个控制台只有一个场景槽位,写在 localStorage 的基础 key 上。
|
||||
* 支持多工程后,把「一个工程 = 一个命名存储槽」这个已有机制复用起来:
|
||||
* 每个工程分配一个稳定 id,其状态写在 `${DIRECTOR_SCENE_STORAGE_KEY}:${projectId}` 上,
|
||||
* 目录本身(有哪些工程、当前打开的是哪个)写在独立的一份 JSON 里。
|
||||
*
|
||||
* 这样切工程 = 换存储槽 + 换目录里的 activeProjectId,不改动既有的
|
||||
* readPersistedDirectorState / writePersistedDirectorState 读写路径。
|
||||
*
|
||||
* 首次升级(没有目录、但基础 key 上有旧场景)会把旧场景搬迁成第一个工程,保证老用户不丢数据。
|
||||
*/
|
||||
|
||||
export const DIRECTOR_SCENE_STORAGE_KEY = "storyai-3d-director-desk-demo";
|
||||
export const DIRECTOR_SCENE_STORAGE_KEY_PREFIX = `${DIRECTOR_SCENE_STORAGE_KEY}:`;
|
||||
export const PROJECT_CATALOG_STORAGE_KEY = "storyai-3d-director-project-catalog";
|
||||
|
||||
export const DEFAULT_PROJECT_NAME = "默认工程";
|
||||
export const UNTITLED_PROJECT_NAME = "未命名工程";
|
||||
|
||||
export interface DirectorProjectMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DirectorProjectCatalog {
|
||||
version: 1;
|
||||
activeProjectId: string;
|
||||
projects: DirectorProjectMeta[];
|
||||
}
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
/** 归一化工程 / 宿主实例的作用域 id:非字符串一律视为「没有作用域」(落回基础 key) */
|
||||
export function normalizeProjectScopeId(scopeId: string | null | undefined) {
|
||||
return typeof scopeId === "string" ? scopeId.trim() : "";
|
||||
}
|
||||
|
||||
/** 由作用域 id 得到该工程在 localStorage 里的场景 key;没有作用域时退回基础 key */
|
||||
export function getScopedSceneStorageKey(scopeId: string | null | undefined) {
|
||||
const normalizedScopeId = normalizeProjectScopeId(scopeId);
|
||||
return normalizedScopeId
|
||||
? `${DIRECTOR_SCENE_STORAGE_KEY_PREFIX}${normalizedScopeId}`
|
||||
: DIRECTOR_SCENE_STORAGE_KEY;
|
||||
}
|
||||
|
||||
export function generateProjectId(): string {
|
||||
return `proj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** 「未命名工程」「未命名工程 2」「未命名工程 3」…… 自动避开重名 */
|
||||
export function createUntitledProjectName(existing: DirectorProjectMeta[]): string {
|
||||
const used = new Set(existing.map((project) => project.name));
|
||||
if (!used.has(UNTITLED_PROJECT_NAME)) return UNTITLED_PROJECT_NAME;
|
||||
|
||||
let index = 2;
|
||||
while (used.has(`${UNTITLED_PROJECT_NAME} ${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return `${UNTITLED_PROJECT_NAME} ${index}`;
|
||||
}
|
||||
|
||||
export function createProjectMeta(
|
||||
existing: DirectorProjectMeta[],
|
||||
name?: string | null,
|
||||
now = Date.now(),
|
||||
id: string = generateProjectId()
|
||||
): DirectorProjectMeta {
|
||||
const trimmedName = typeof name === "string" ? name.trim() : "";
|
||||
|
||||
return {
|
||||
id,
|
||||
name: trimmedName || createUntitledProjectName(existing),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectMeta(value: unknown): value is DirectorProjectMeta {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
const meta = value as Partial<DirectorProjectMeta>;
|
||||
return (
|
||||
typeof meta.id === "string" &&
|
||||
meta.id.length > 0 &&
|
||||
typeof meta.name === "string" &&
|
||||
typeof meta.createdAt === "number" &&
|
||||
typeof meta.updatedAt === "number"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取工程目录;不存在 / 损坏 / 空目录一律返回 null,由 ensureProjectCatalog 负责重建。
|
||||
* activeProjectId 不在列表里时回落到第一个工程,避免目录自相矛盾。
|
||||
*/
|
||||
export function readProjectCatalog(): DirectorProjectCatalog | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(PROJECT_CATALOG_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<DirectorProjectCatalog> | null;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
|
||||
const projects = Array.isArray(parsed.projects) ? parsed.projects.filter(isProjectMeta) : [];
|
||||
if (projects.length === 0) return null;
|
||||
|
||||
const activeProjectId =
|
||||
typeof parsed.activeProjectId === "string" &&
|
||||
projects.some((project) => project.id === parsed.activeProjectId)
|
||||
? parsed.activeProjectId
|
||||
: projects[0].id;
|
||||
|
||||
return { version: 1, activeProjectId, projects };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProjectCatalog(catalog: DirectorProjectCatalog): void {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
storage.setItem(PROJECT_CATALOG_STORAGE_KEY, JSON.stringify(catalog));
|
||||
} catch {
|
||||
// 目录很小,几乎不会写失败;真失败也不该让编辑器崩掉。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证目录存在:不存在就建一个,并把基础 key 上的旧单工程场景搬迁为第一个工程。
|
||||
* 只在「完全没有目录」时创建,已有目录原样返回,不覆盖用户数据。
|
||||
*/
|
||||
export function ensureProjectCatalog(now = Date.now()): DirectorProjectCatalog {
|
||||
const existing = readProjectCatalog();
|
||||
if (existing) return existing;
|
||||
|
||||
const storage = getStorage();
|
||||
const project = createProjectMeta([], DEFAULT_PROJECT_NAME, now);
|
||||
|
||||
// 旧数据搬迁:单工程时代写在基础 key 上,这里复制(不删除)到新工程的槽位。
|
||||
if (storage) {
|
||||
try {
|
||||
const legacy = storage.getItem(DIRECTOR_SCENE_STORAGE_KEY);
|
||||
if (legacy) {
|
||||
storage.setItem(getScopedSceneStorageKey(project.id), legacy);
|
||||
}
|
||||
} catch {
|
||||
// 搬迁失败不影响目录创建:新工程从默认场景开始。
|
||||
}
|
||||
}
|
||||
|
||||
const catalog: DirectorProjectCatalog = {
|
||||
version: 1,
|
||||
activeProjectId: project.id,
|
||||
projects: [project],
|
||||
};
|
||||
writeProjectCatalog(catalog);
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
/** 更新某个工程的 updatedAt(每次落盘时调用),工程不存在则静默忽略 */
|
||||
export function touchProjectUpdatedAt(projectId: string | null | undefined, now = Date.now()): void {
|
||||
const normalizedId = normalizeProjectScopeId(projectId);
|
||||
if (!normalizedId) return;
|
||||
|
||||
const catalog = readProjectCatalog();
|
||||
if (!catalog) return;
|
||||
|
||||
const index = catalog.projects.findIndex((project) => project.id === normalizedId);
|
||||
if (index < 0) return;
|
||||
|
||||
const projects = catalog.projects.map((project, projectIndex) =>
|
||||
projectIndex === index ? { ...project, updatedAt: now } : project
|
||||
);
|
||||
writeProjectCatalog({ ...catalog, projects });
|
||||
}
|
||||
|
||||
/** 读取某个工程槽位里的原始场景 JSON 字符串(未做迁移 / 校验) */
|
||||
export function readScopedSceneRaw(scopeId: string | null | undefined): string | null {
|
||||
const storage = getStorage();
|
||||
if (!storage) return null;
|
||||
|
||||
try {
|
||||
return storage.getItem(getScopedSceneStorageKey(scopeId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 直接写入某个工程槽位的原始场景 JSON(用于复制 / 导入工程文件) */
|
||||
export function writeScopedSceneRaw(scopeId: string | null | undefined, raw: string): void {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
storage.setItem(getScopedSceneStorageKey(scopeId), raw);
|
||||
} catch {
|
||||
// 场景可能含 base64 全景图,超配额时静默失败,保持编辑器可用。
|
||||
}
|
||||
}
|
||||
|
||||
export function removeScopedScene(scopeId: string | null | undefined): void {
|
||||
const storage = getStorage();
|
||||
if (!storage) return;
|
||||
|
||||
try {
|
||||
storage.removeItem(getScopedSceneStorageKey(scopeId));
|
||||
} catch {
|
||||
// 忽略:删除失败最多留一份孤儿数据。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时决定应该打开哪个工程槽位:
|
||||
* - 宿主通过 `?instanceId=` 指定了实例作用域时,优先用宿主作用域(嵌入场景不抢多工程的槽位);
|
||||
* - 否则打开目录里当前激活的工程(首次运行会先建目录并搬迁旧场景)。
|
||||
*/
|
||||
export function resolveStartupProjectScopeId(hostScopeId: string | null | undefined): string | null {
|
||||
const normalizedHostId = normalizeProjectScopeId(hostScopeId);
|
||||
if (normalizedHostId) return normalizedHostId;
|
||||
|
||||
try {
|
||||
return ensureProjectCatalog().activeProjectId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
102
src/editor/io/projectFile.test.ts
Normal file
102
src/editor/io/projectFile.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { DirectorProject } from "../schema/directorProject";
|
||||
import {
|
||||
DIRECTOR_PROJECT_FILE_KIND,
|
||||
blobToDataUrl,
|
||||
buildProjectFileText,
|
||||
dataUrlToBlob,
|
||||
deriveProjectNameFromFileName,
|
||||
downloadTextFile,
|
||||
isPlausibleDirectorProject,
|
||||
parseProjectFile,
|
||||
sanitizeProjectFileName,
|
||||
} from "./projectFile";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createSampleProject(): DirectorProject {
|
||||
return {
|
||||
version: 2,
|
||||
scene: { backgroundColor: "#000000" },
|
||||
assets: [],
|
||||
objects: [],
|
||||
cameras: [],
|
||||
} as unknown as DirectorProject;
|
||||
}
|
||||
|
||||
it("wraps the project in an identifiable envelope", () => {
|
||||
const text = buildProjectFileText(createSampleProject(), "我的工程", new Date("2026-01-02T03:04:05.000Z"));
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
expect(parsed.kind).toBe(DIRECTOR_PROJECT_FILE_KIND);
|
||||
expect(parsed.name).toBe("我的工程");
|
||||
expect(parsed.savedAt).toBe("2026-01-02T03:04:05.000Z");
|
||||
expect(parsed.project.scene.backgroundColor).toBe("#000000");
|
||||
});
|
||||
|
||||
it("round-trips a project file", () => {
|
||||
const text = buildProjectFileText(createSampleProject(), "我的工程");
|
||||
const parsed = parseProjectFile(text);
|
||||
|
||||
expect(parsed?.name).toBe("我的工程");
|
||||
expect(parsed?.project.scene.backgroundColor).toBe("#000000");
|
||||
});
|
||||
|
||||
it("still accepts the bare project JSON exported by older versions", () => {
|
||||
const parsed = parseProjectFile(JSON.stringify(createSampleProject()));
|
||||
|
||||
expect(parsed?.name).toBeNull();
|
||||
expect(parsed?.project.version).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects files that are not projects", () => {
|
||||
expect(parseProjectFile("not json at all")).toBeNull();
|
||||
expect(parseProjectFile(JSON.stringify({ hello: "world" }))).toBeNull();
|
||||
expect(parseProjectFile(JSON.stringify({ kind: DIRECTOR_PROJECT_FILE_KIND, project: { version: 2 } }))).toBeNull();
|
||||
});
|
||||
|
||||
it("recognises plausible projects", () => {
|
||||
expect(isPlausibleDirectorProject(createSampleProject())).toBe(true);
|
||||
expect(isPlausibleDirectorProject(null)).toBe(false);
|
||||
expect(isPlausibleDirectorProject({ version: 9, assets: [], objects: [], cameras: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it("sanitises project names for use as file names", () => {
|
||||
expect(sanitizeProjectFileName("我的/工程:第一幕?")).toBe("我的_工程_第一幕_");
|
||||
expect(sanitizeProjectFileName(" ")).toBe("未命名工程");
|
||||
expect(sanitizeProjectFileName("x".repeat(200)).length).toBe(80);
|
||||
});
|
||||
|
||||
it("derives a project name from the imported file name", () => {
|
||||
expect(deriveProjectNameFromFileName("我的工程.json")).toBe("我的工程");
|
||||
expect(deriveProjectNameFromFileName("storyai-director-片段.json")).toBe("片段");
|
||||
expect(deriveProjectNameFromFileName(".json")).toBe("未命名工程");
|
||||
});
|
||||
|
||||
it("downloads text through a real anchor click", () => {
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
const createObjectUrlSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock");
|
||||
|
||||
downloadTextFile("工程.json", '{"a":1}');
|
||||
|
||||
expect(createObjectUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("converts between data URLs and blobs", async () => {
|
||||
const original = new Blob([new Uint8Array([1, 2, 3, 250])], { type: "application/octet-stream" });
|
||||
const dataUrl = await blobToDataUrl(original);
|
||||
|
||||
expect(dataUrl?.startsWith("data:application/octet-stream;base64,")).toBe(true);
|
||||
|
||||
const restored = dataUrlToBlob(dataUrl as string);
|
||||
|
||||
expect(restored).not.toBeNull();
|
||||
expect(new Uint8Array(await (restored as Blob).arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 250]));
|
||||
});
|
||||
|
||||
it("returns null for malformed data URLs", () => {
|
||||
expect(dataUrlToBlob("https://example.com/a.png")).toBeNull();
|
||||
});
|
||||
225
src/editor/io/projectFile.ts
Normal file
225
src/editor/io/projectFile.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 工程文件读写(保存到文件 / 从文件导入)
|
||||
*
|
||||
* 文件格式是一个带元信息的信封,便于识别来源与工程名:
|
||||
* { kind: "storyai-director-project", version: 1, name, savedAt, project }
|
||||
* 同时兼容「裸 DirectorProject」的旧导出文件(早期版本直接导出 project 本身)。
|
||||
*
|
||||
* 模型二进制的处理:
|
||||
* - 导出:GLB/GLTF 在运行时存于 IndexedDB(工程里只留 binaryId),导出时把它内联成 data URL,
|
||||
* 让文件自包含、换机器也能打开。
|
||||
* - 导入:再把模型 data URL 落回 IndexedDB 并换回 binaryId,避免把大文件塞进 localStorage。
|
||||
*/
|
||||
|
||||
import type { DirectorProject } from "../schema/directorProject";
|
||||
import { loadModelBinary, saveModelBinary } from "../loaders/modelBinaryStore";
|
||||
|
||||
export const DIRECTOR_PROJECT_FILE_KIND = "storyai-director-project";
|
||||
export const DIRECTOR_PROJECT_FILE_VERSION = 1;
|
||||
export const DIRECTOR_PROJECT_FILE_EXTENSION = ".json";
|
||||
|
||||
export interface DirectorProjectFile {
|
||||
kind: typeof DIRECTOR_PROJECT_FILE_KIND;
|
||||
version: number;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
project: DirectorProject;
|
||||
}
|
||||
|
||||
export interface ParsedProjectFile {
|
||||
/** 文件里记录 / 由文件名推断的工程名;没有则为 null,由调用方决定默认名 */
|
||||
name: string | null;
|
||||
project: DirectorProject;
|
||||
}
|
||||
|
||||
/** 粗略校验「像一个工程」:版本 + 关键数组字段齐全即可,精确迁移交给 store */
|
||||
export function isPlausibleDirectorProject(value: unknown): value is DirectorProject {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
const project = value as Partial<DirectorProject>;
|
||||
const version = (value as { version?: unknown }).version;
|
||||
|
||||
return (
|
||||
(version === 1 || version === 2) &&
|
||||
Array.isArray(project.assets) &&
|
||||
Array.isArray(project.objects) &&
|
||||
Array.isArray(project.cameras) &&
|
||||
Boolean(project.scene) &&
|
||||
typeof project.scene?.backgroundColor === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** 把工程序列化成工程文件文本(信封格式) */
|
||||
export function buildProjectFileText(project: DirectorProject, name: string, savedAt = new Date()): string {
|
||||
const file: DirectorProjectFile = {
|
||||
kind: DIRECTOR_PROJECT_FILE_KIND,
|
||||
version: DIRECTOR_PROJECT_FILE_VERSION,
|
||||
name,
|
||||
savedAt: savedAt.toISOString(),
|
||||
project,
|
||||
};
|
||||
|
||||
return JSON.stringify(file, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析工程文件文本:既支持信封格式,也兼容早期直接导出的裸工程 JSON。
|
||||
* 解析不出可用工程时返回 null(由调用方给出错误提示)。
|
||||
*/
|
||||
export function parseProjectFile(text: string): ParsedProjectFile | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && (parsed as { kind?: unknown }).kind === DIRECTOR_PROJECT_FILE_KIND) {
|
||||
const file = parsed as Partial<DirectorProjectFile>;
|
||||
if (!isPlausibleDirectorProject(file.project)) return null;
|
||||
|
||||
const name = typeof file.name === "string" && file.name.trim() ? file.name.trim() : null;
|
||||
return { name, project: file.project };
|
||||
}
|
||||
|
||||
if (isPlausibleDirectorProject(parsed)) {
|
||||
return { name: null, project: parsed };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把工程名收敛成合法文件名:去掉路径分隔符等危险字符 */
|
||||
export function sanitizeProjectFileName(name: string): string {
|
||||
const trimmed = name.trim() || "未命名工程";
|
||||
const safe = trimmed.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_").replace(/\s+/g, " ").trim();
|
||||
|
||||
return safe.slice(0, 80) || "未命名工程";
|
||||
}
|
||||
|
||||
/** 由导入的文件名推断工程名(去掉扩展名) */
|
||||
export function deriveProjectNameFromFileName(fileName: string): string {
|
||||
const base = fileName.replace(/\.(json|txt)$/i, "").replace(/^storyai-director-/i, "").trim();
|
||||
return base || "未命名工程";
|
||||
}
|
||||
|
||||
/** 触发浏览器下载一段文本(真实下载,不用 window.open,避免被拦截 / 只读预览) */
|
||||
export function downloadTextFile(fileName: string, text: string, mimeType = "application/json"): void {
|
||||
const blob = new Blob([text], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.rel = "noopener";
|
||||
anchor.click();
|
||||
// 交给浏览器完成下载后再释放,避免同步 revoke 造成下载中断。
|
||||
if (typeof URL.revokeObjectURL === "function") {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** data URL → Blob(手动解码,不依赖 fetch,jsdom 下同样可用) */
|
||||
export function dataUrlToBlob(dataUrl: string): Blob | null {
|
||||
const match = /^data:([^;,]*)(;base64)?,([\s\S]*)$/.exec(dataUrl);
|
||||
if (!match) return null;
|
||||
|
||||
const [, mimeType, isBase64, data] = match;
|
||||
const type = mimeType || "application/octet-stream";
|
||||
|
||||
try {
|
||||
if (isBase64) {
|
||||
const binary = atob(data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new Blob([bytes], { type });
|
||||
}
|
||||
|
||||
return new Blob([decodeURIComponent(data)], { type });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE64_CHUNK_SIZE = 0x8000;
|
||||
|
||||
/** Blob → data URL(分块拼接,避免超长数组展开成参数列表爆栈) */
|
||||
export async function blobToDataUrl(blob: Blob): Promise<string | null> {
|
||||
try {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
return `data:${blob.type || "application/octet-stream"};base64,${btoa(binary)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否是「未内联」的本地模型资产(有 binaryId 但 url 为空或被清空) */
|
||||
function isModelAssetNeedingInline(asset: DirectorProject["assets"][number]) {
|
||||
return asset.sourceType === "model" && Boolean(asset.binaryId) && !asset.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出前把 IndexedDB 里的模型二进制内联进 url,让工程文件自包含。
|
||||
* 任何一步失败都保留原样(文件里会缺这个模型,但其它内容仍然可导入)。
|
||||
*/
|
||||
export async function inlineProjectModelBinaries(project: DirectorProject): Promise<DirectorProject> {
|
||||
const needsInline = project.assets.some(isModelAssetNeedingInline);
|
||||
if (!needsInline) return project;
|
||||
|
||||
const assets = await Promise.all(
|
||||
project.assets.map(async (asset) => {
|
||||
if (!isModelAssetNeedingInline(asset)) return asset;
|
||||
|
||||
try {
|
||||
const blob = await loadModelBinary(asset.binaryId as string);
|
||||
if (!blob) return asset;
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
if (!dataUrl) return asset;
|
||||
|
||||
const { binaryId: _binaryId, ...rest } = asset;
|
||||
return { ...rest, url: dataUrl };
|
||||
} catch {
|
||||
return asset;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { ...project, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入后把内联的模型 data URL 落回 IndexedDB,并换回 binaryId。
|
||||
* IndexedDB 不可用时保持 data URL 原样(功能可用,只是 localStorage 会偏大)。
|
||||
*/
|
||||
export async function rehydrateProjectModelBinaries(project: DirectorProject): Promise<DirectorProject> {
|
||||
const needsRehydrate = project.assets.some(
|
||||
(asset) => asset.sourceType === "model" && asset.url.startsWith("data:")
|
||||
);
|
||||
if (!needsRehydrate) return project;
|
||||
|
||||
const assets = await Promise.all(
|
||||
project.assets.map(async (asset) => {
|
||||
if (asset.sourceType !== "model" || !asset.url.startsWith("data:")) return asset;
|
||||
|
||||
const blob = dataUrlToBlob(asset.url);
|
||||
if (!blob) return asset;
|
||||
|
||||
try {
|
||||
await saveModelBinary(asset.id, blob);
|
||||
return { ...asset, binaryId: asset.id, url: "" };
|
||||
} catch {
|
||||
return asset;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { ...project, assets };
|
||||
}
|
||||
@@ -459,8 +459,9 @@ it("lists trajectory points under the owning character and selects one", async (
|
||||
|
||||
const trajectoryList = screen.getByLabelText("角色01轨迹");
|
||||
expect(trajectoryList).toHaveClass("object-trajectory-list");
|
||||
// 单行「轨迹」父行(1 个按钮)+ 其下每个轨迹点一行(选中 + 删除各 1 个按钮)
|
||||
expect(within(trajectoryList).getAllByRole("button")).toHaveLength(5);
|
||||
// 「轨迹」父行 4 个按钮:展开 / 可见性 / 锁定 / 删除
|
||||
// 每个轨迹点一行 3 个按钮:选中 / 锁定 / 删除
|
||||
expect(within(trajectoryList).getAllByRole("button")).toHaveLength(4 + 2 * 3);
|
||||
|
||||
// 默认展开:两个轨迹点都可见
|
||||
expect(screen.getByLabelText("角色01 轨迹点 1")).toBeInTheDocument();
|
||||
|
||||
107
src/editor/panels/ProjectManagerMenu.test.tsx
Normal file
107
src/editor/panels/ProjectManagerMenu.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, expect, it } from "vitest";
|
||||
import { ensureProjectCatalog } from "../io/projectCatalog";
|
||||
import { buildProjectFileText } from "../io/projectFile";
|
||||
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
|
||||
import { ProjectManagerMenu } from "./ProjectManagerMenu";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const catalog = ensureProjectCatalog();
|
||||
|
||||
useDirectorStore.setState({
|
||||
...useDirectorStore.getState(),
|
||||
...createInitialDirectorState(),
|
||||
clipboard: [],
|
||||
clipboardPasteCount: 0,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
});
|
||||
useDirectorStore.getState().openProject(catalog.activeProjectId);
|
||||
});
|
||||
|
||||
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole("button", { name: "工程" }));
|
||||
return screen.getByRole("dialog", { name: "工程管理" });
|
||||
}
|
||||
|
||||
it("lists the projects and shows the active one on the trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectManagerMenu />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "工程" })).toHaveTextContent("默认工程");
|
||||
|
||||
const dialog = await openMenu(user);
|
||||
|
||||
expect(within(dialog).getByText("默认工程")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a new project from the panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectManagerMenu />);
|
||||
const dialog = await openMenu(user);
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "新建工程" }));
|
||||
|
||||
expect(useDirectorStore.getState().projects).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("switches the active project by clicking its name", async () => {
|
||||
const firstId = useDirectorStore.getState().activeProjectId as string;
|
||||
useDirectorStore.getState().createProject("第二幕");
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectManagerMenu />);
|
||||
const dialog = await openMenu(user);
|
||||
|
||||
await user.click(within(dialog).getByText("默认工程"));
|
||||
|
||||
expect(useDirectorStore.getState().activeProjectId).toBe(firstId);
|
||||
});
|
||||
|
||||
it("renames a project inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectManagerMenu />);
|
||||
const dialog = await openMenu(user);
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "重命名 默认工程" }));
|
||||
|
||||
const input = within(dialog).getByLabelText("重命名 默认工程");
|
||||
await user.clear(input);
|
||||
await user.type(input, "第一幕");
|
||||
await user.click(within(dialog).getByRole("button", { name: "确认重命名" }));
|
||||
|
||||
expect(useDirectorStore.getState().projects.some((project) => project.name === "第一幕")).toBe(true);
|
||||
});
|
||||
|
||||
it("imports a project file as a new project", async () => {
|
||||
const text = buildProjectFileText(useDirectorStore.getState().project, "外部工程");
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectManagerMenu />);
|
||||
await openMenu(user);
|
||||
|
||||
await user.upload(
|
||||
screen.getByTestId("project-file-input") as HTMLInputElement,
|
||||
new File([text], "外部工程.json", { type: "application/json" })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDirectorStore.getState().projects.some((project) => project.name === "外部工程")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a project after an inline confirmation", async () => {
|
||||
const firstId = useDirectorStore.getState().activeProjectId as string;
|
||||
useDirectorStore.getState().createProject("第二幕");
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectManagerMenu />);
|
||||
const dialog = await openMenu(user);
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: "删除 默认工程" }));
|
||||
await user.click(within(dialog).getByRole("button", { name: "删除" }));
|
||||
|
||||
expect(useDirectorStore.getState().projects.map((project) => project.id)).not.toContain(firstId);
|
||||
});
|
||||
333
src/editor/panels/ProjectManagerMenu.tsx
Normal file
333
src/editor/panels/ProjectManagerMenu.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, Copy, Download, FileUp, FolderOpen, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useDirectorStore } from "../../editor/store/directorStore";
|
||||
|
||||
const ICON_PROPS = { "aria-hidden": true, size: 14, strokeWidth: 1.9 } as const;
|
||||
|
||||
function formatUpdatedAt(timestamp: number) {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶栏「工程」入口:集中管理多工程。
|
||||
* - 列表里点工程名 = 切换工程;每行可重命名 / 复制 / 保存到文件 / 删除
|
||||
* - 底部可新建工程、从工程文件导入
|
||||
*
|
||||
* 样式沿用「模型库」那套悬浮面板(不是下拉单选),因此没有套 InspectorDropdown;
|
||||
* 交互(点外部 / Esc 关闭)与模型库面板保持一致。
|
||||
*/
|
||||
export function ProjectManagerMenu() {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const projects = useDirectorStore((state) => state.projects);
|
||||
const activeProjectId = useDirectorStore((state) => state.activeProjectId);
|
||||
const createProject = useDirectorStore((state) => state.createProject);
|
||||
const openProject = useDirectorStore((state) => state.openProject);
|
||||
const renameProject = useDirectorStore((state) => state.renameProject);
|
||||
const duplicateProject = useDirectorStore((state) => state.duplicateProject);
|
||||
const deleteProject = useDirectorStore((state) => state.deleteProject);
|
||||
const saveProjectToFile = useDirectorStore((state) => state.saveProjectToFile);
|
||||
const importProjectFromFile = useDirectorStore((state) => state.importProjectFromFile);
|
||||
const refreshProjectCatalog = useDirectorStore((state) => state.refreshProjectCatalog);
|
||||
|
||||
const activeProject = projects.find((project) => project.id === activeProjectId) ?? null;
|
||||
const activeProjectName = activeProject?.name ?? "未命名工程";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
|
||||
// 打开时同步一次目录,避免外部(如宿主)改动后列表过期
|
||||
refreshProjectCatalog();
|
||||
setStatus(null);
|
||||
setPendingDeleteId(null);
|
||||
setRenamingId(null);
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (containerRef.current?.contains(event.target as Node)) return;
|
||||
if (importInputRef.current?.contains(event.target as Node)) return;
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setIsOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, refreshProjectCatalog]);
|
||||
|
||||
function handleOpenProject(projectId: string) {
|
||||
if (projectId !== activeProjectId) {
|
||||
openProject(projectId);
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleCreateProject() {
|
||||
const id = createProject();
|
||||
setStatus("已新建工程");
|
||||
setIsOpen(false);
|
||||
void id;
|
||||
}
|
||||
|
||||
function startRename(projectId: string, name: string) {
|
||||
setRenamingId(projectId);
|
||||
setRenameDraft(name);
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (!renamingId) return;
|
||||
renameProject(renamingId, renameDraft);
|
||||
setRenamingId(null);
|
||||
setRenameDraft("");
|
||||
}
|
||||
|
||||
function handleDuplicate(projectId: string) {
|
||||
const id = duplicateProject(projectId);
|
||||
setStatus(id ? "已复制工程" : "复制失败");
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
|
||||
async function handleExport(projectId: string) {
|
||||
try {
|
||||
await saveProjectToFile(projectId);
|
||||
setStatus("已保存到文件");
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(projectId: string) {
|
||||
deleteProject(projectId);
|
||||
setPendingDeleteId(null);
|
||||
setStatus("已删除工程");
|
||||
}
|
||||
|
||||
async function handleImportFile(file: File) {
|
||||
try {
|
||||
await importProjectFromFile(file);
|
||||
setStatus("已导入工程文件");
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "导入失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-menu" ref={containerRef}>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="工程"
|
||||
className="top-bar-action-button project-menu-trigger"
|
||||
title="工程:新建 / 切换 / 保存到文件 / 导入"
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<FolderOpen {...ICON_PROPS} />
|
||||
<span className="project-menu-trigger-name">{activeProjectName}</span>
|
||||
<ChevronDown {...ICON_PROPS} className="project-menu-trigger-chevron" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="project-menu-panel" role="dialog" aria-label="工程管理">
|
||||
<div className="project-menu-header">
|
||||
<h2 className="project-menu-title">工程</h2>
|
||||
<button
|
||||
aria-label="关闭工程管理"
|
||||
className="top-bar-action-button project-menu-close-button"
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X aria-hidden="true" size={16} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="project-menu-list" role="list" aria-label="工程列表">
|
||||
{projects.map((project) => {
|
||||
const isActive = project.id === activeProjectId;
|
||||
const isRenaming = renamingId === project.id;
|
||||
const isPendingDelete = pendingDeleteId === project.id;
|
||||
|
||||
return (
|
||||
<div className={`project-menu-row${isActive ? " is-active" : ""}`} key={project.id} role="listitem">
|
||||
{isRenaming ? (
|
||||
<form
|
||||
className="project-menu-rename"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
confirmRename();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={`重命名 ${project.name}`}
|
||||
autoFocus
|
||||
className="ui-field project-menu-rename-input"
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setRenamingId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button aria-label="确认重命名" className="project-menu-icon-button" type="submit">
|
||||
<Check {...ICON_PROPS} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="取消重命名"
|
||||
className="project-menu-icon-button"
|
||||
type="button"
|
||||
onClick={() => setRenamingId(null)}
|
||||
>
|
||||
<X {...ICON_PROPS} />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
className="project-menu-name"
|
||||
type="button"
|
||||
onClick={() => handleOpenProject(project.id)}
|
||||
>
|
||||
<span className="project-menu-name-text">{project.name}</span>
|
||||
<span className="project-menu-name-meta">{formatUpdatedAt(project.updatedAt)}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isRenaming ? (
|
||||
<div className="project-menu-row-actions">
|
||||
{isPendingDelete ? (
|
||||
<>
|
||||
<button
|
||||
className="project-menu-confirm-delete"
|
||||
type="button"
|
||||
onClick={() => handleDelete(project.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
aria-label="取消删除"
|
||||
className="project-menu-icon-button"
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
>
|
||||
<X {...ICON_PROPS} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
aria-label={`重命名 ${project.name}`}
|
||||
className="project-menu-icon-button"
|
||||
title="重命名"
|
||||
type="button"
|
||||
onClick={() => startRename(project.id, project.name)}
|
||||
>
|
||||
<Pencil {...ICON_PROPS} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`复制 ${project.name}`}
|
||||
className="project-menu-icon-button"
|
||||
title="复制工程"
|
||||
type="button"
|
||||
onClick={() => handleDuplicate(project.id)}
|
||||
>
|
||||
<Copy {...ICON_PROPS} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`保存 ${project.name} 到文件`}
|
||||
className="project-menu-icon-button"
|
||||
title="保存到文件"
|
||||
type="button"
|
||||
onClick={() => void handleExport(project.id)}
|
||||
>
|
||||
<Download {...ICON_PROPS} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`删除 ${project.name}`}
|
||||
className="project-menu-icon-button project-menu-icon-danger"
|
||||
disabled={projects.length <= 1}
|
||||
title={projects.length <= 1 ? "至少保留一个工程" : "删除工程"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingDeleteId(project.id);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 {...ICON_PROPS} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="project-menu-actions">
|
||||
<button
|
||||
aria-label="新建工程"
|
||||
className="project-menu-action-button"
|
||||
type="button"
|
||||
onClick={handleCreateProject}
|
||||
>
|
||||
<Plus {...ICON_PROPS} />
|
||||
新建工程
|
||||
</button>
|
||||
<button
|
||||
aria-label="导入工程文件"
|
||||
className="project-menu-action-button"
|
||||
type="button"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
<FileUp {...ICON_PROPS} />
|
||||
导入工程文件
|
||||
</button>
|
||||
</div>
|
||||
{status ? (
|
||||
<p className="project-menu-status" role="status">
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={importInputRef}
|
||||
aria-hidden="true"
|
||||
className="hidden-file-input"
|
||||
data-testid="project-file-input"
|
||||
tabIndex={-1}
|
||||
accept=".json,application/json"
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
void handleImportFile(file);
|
||||
}
|
||||
input.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -340,6 +340,41 @@ it("renders a second-based ruler and publishes the tick spacing for the grid lin
|
||||
expect(grid?.style.getPropertyValue("--timeline-tick-percent")).toBe("20%");
|
||||
});
|
||||
|
||||
it("keeps writing the playhead while dragging the ruler even when requestAnimationFrame never fires", () => {
|
||||
// 拖动播放头的写入必须与 rAF 解耦:rAF 在窗口被遮挡 / 页面在后台 / 被节流的 WebView 里
|
||||
// 会整段拖动都不回调,只靠它就会出现「拖动期间画布完全不动、松手才跳一下」。
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
const ruler = screen.getByRole("slider", { name: "时间指针" });
|
||||
// jsdom 不做布局(getBoundingClientRect 全是 0),这里给刻度尺一份固定几何:100px 起、1000px 宽 → 5s
|
||||
vi.spyOn(ruler, "getBoundingClientRect").mockReturnValue({
|
||||
x: 100,
|
||||
y: 0,
|
||||
top: 0,
|
||||
bottom: 22,
|
||||
left: 100,
|
||||
right: 1100,
|
||||
width: 1000,
|
||||
height: 22,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
fireEvent.pointerDown(ruler, { clientX: 100, pointerId: 1 });
|
||||
expect(usePlaybackStore.getState().playhead).toBe(0);
|
||||
|
||||
// 拖动期间(还没松手)就必须已经写进 playhead —— rAF 永远不回调也不影响
|
||||
fireEvent.pointerMove(ruler, { clientX: 600, pointerId: 1 });
|
||||
expect(usePlaybackStore.getState().playhead).toBeCloseTo(2.5, 5);
|
||||
|
||||
fireEvent.pointerMove(ruler, { clientX: 1000, pointerId: 1 });
|
||||
fireEvent.pointerUp(ruler, { clientX: 1000, pointerId: 1 });
|
||||
|
||||
// 松手时补齐最终位置,播放头指示器一并落到位
|
||||
expect(usePlaybackStore.getState().playhead).toBeCloseTo(4.5, 5);
|
||||
expect(container.querySelector(".timeline-playhead")?.getAttribute("style")).toContain("90%");
|
||||
});
|
||||
|
||||
it("stops playback and rewinds to the start from the transport controls", () => {
|
||||
usePlaybackStore.getState().setPlaying(true);
|
||||
usePlaybackStore.getState().setPlayhead(3);
|
||||
|
||||
@@ -237,6 +237,17 @@ const TRACK_DRAG_THRESHOLD_PX = 4;
|
||||
/** 时间滑块(片段 / 关键帧)吸附阈值(px):拖动的边缘进入其它片段边缘 / 播放头这么近就吸上去 */
|
||||
const SNAP_THRESHOLD_PX = 8;
|
||||
|
||||
/**
|
||||
* 拖动播放头时写 playhead 的最小间隔(ms):指针事件频率远高于刷新率(高回报率鼠标 / 触控板
|
||||
* 一秒几百条),逐条写会把帧预算全花在 React 同步重渲染上(playbackStore 走 useSyncExternalStore),
|
||||
* 所以同一个时间片内只保留最新位置写一次,约等于「一帧一次」。
|
||||
*
|
||||
* 刻意不用 requestAnimationFrame 来当这个节流时钟:rAF 在窗口被遮挡 / 页面在后台 / 被节流的
|
||||
* WebView 里会整段拖动都不回调,那样拖动期间一次都写不进 playhead —— 画布就会「完全不动,
|
||||
* 松手才跳一下」(松手走 pointerup 里的直接写入)。用 performance.now() 判断时间片不依赖任何回调。
|
||||
*/
|
||||
const SCRUB_APPLY_INTERVAL_MS = 16;
|
||||
|
||||
/** 轨道行的纵向几何(拖拽开始时量一次就缓存,移动中只查表) */
|
||||
interface TrackRowRect {
|
||||
top: number;
|
||||
@@ -1213,19 +1224,9 @@ export function TimelinePanel() {
|
||||
const rulerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rulerRectRef = useRef<DOMRect | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
// 拖动播放头:指针事件频率远高于刷新率(高回报率鼠标 / 触控板一秒几百次),
|
||||
// 逐条 setPlayhead 会把帧预算全花在 React 重渲染上。只记最新位置,按帧应用一次。
|
||||
// 拖动播放头:只记最新位置,按时间片写一次(见 SCRUB_APPLY_INTERVAL_MS 的注释)
|
||||
const scrubClientXRef = useRef<number | null>(null);
|
||||
const scrubFrameRef = useRef(0);
|
||||
|
||||
function cancelScrubFrame() {
|
||||
if (scrubFrameRef.current === 0) return;
|
||||
cancelAnimationFrame(scrubFrameRef.current);
|
||||
scrubFrameRef.current = 0;
|
||||
}
|
||||
|
||||
// 卸载时兜底:扔掉待执行的 scrub 帧,别在组件已经没了之后还去写 playhead
|
||||
useEffect(() => () => cancelScrubFrame(), []);
|
||||
const scrubLastApplyRef = useRef(0);
|
||||
|
||||
const rulerTicks = useMemo(() => getTimelineRulerTicks(duration), [duration]);
|
||||
const rulerMinorTicks = useMemo(
|
||||
@@ -1974,6 +1975,8 @@ export function TimelinePanel() {
|
||||
event.preventDefault();
|
||||
setIsScrubbing(true);
|
||||
setScrubbing(true);
|
||||
// 本次拖动第一次移动立刻生效,不要被上一次拖动残留的时间片挡住
|
||||
scrubLastApplyRef.current = 0;
|
||||
// 拖动播放头期间同样冻结全站毛玻璃:面板 / 画幅遮罩都带 backdrop-filter,
|
||||
// 它们背后是整张 WebGL 画面(机位视角下就是监视器那块大画面),只要被重绘就会
|
||||
// 整块重算背景模糊 —— 这正是「拖动轨道卡死」的同一条原因,拖播放头一样躲不掉。
|
||||
@@ -1987,9 +1990,8 @@ export function TimelinePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按帧应用一次拖动位置(指针事件可能一秒几百条,这里只保留最后一条) */
|
||||
/** 把当前指针位置换算成时间写进 playhead */
|
||||
function applyScrubFrame() {
|
||||
scrubFrameRef.current = 0;
|
||||
const clientX = scrubClientXRef.current;
|
||||
if (clientX === null) return;
|
||||
const time = getTimeFromPointer(clientX);
|
||||
@@ -1999,21 +2001,21 @@ export function TimelinePanel() {
|
||||
function handleRulerPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
// 只记最新位置;本时间片内已经写过就等下一次移动再写(末尾由 pointerup 补齐)——
|
||||
// 拖动期间必须真的写进去,画布才会实时预览,所以这里同步写、不挂在 rAF 上。
|
||||
scrubClientXRef.current = event.clientX;
|
||||
if (scrubFrameRef.current !== 0) return;
|
||||
scrubFrameRef.current = requestAnimationFrame(applyScrubFrame);
|
||||
const now = performance.now();
|
||||
if (now - scrubLastApplyRef.current < SCRUB_APPLY_INTERVAL_MS) return;
|
||||
scrubLastApplyRef.current = now;
|
||||
applyScrubFrame();
|
||||
}
|
||||
|
||||
function handleRulerPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
// 松手前先把最后一帧的位置补上,避免丢掉最后一次移动
|
||||
cancelScrubFrame();
|
||||
if (scrubClientXRef.current !== null) {
|
||||
const time = getTimeFromPointer(scrubClientXRef.current);
|
||||
if (time !== null) setPlayhead(time);
|
||||
scrubClientXRef.current = null;
|
||||
}
|
||||
applyScrubFrame();
|
||||
scrubClientXRef.current = null;
|
||||
setIsScrubbing(false);
|
||||
setScrubbing(false);
|
||||
document.body.classList.remove("is-timeline-dragging");
|
||||
|
||||
173
src/editor/store/directorStore.project.test.ts
Normal file
173
src/editor/store/directorStore.project.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
@@ -91,6 +91,30 @@ it("toggles the viewport side panel collapse flag in ui state", () => {
|
||||
expect((useDirectorStore.getState() as CollapseUiState).viewportPanelsCollapsed ?? false).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the left and right side panels independently", () => {
|
||||
useDirectorStore.setState(createInitialDirectorState());
|
||||
|
||||
useDirectorStore.getState().toggleLeftPanelVisible();
|
||||
|
||||
const state = useDirectorStore.getState();
|
||||
expect(state.leftPanelVisible).toBe(false);
|
||||
// 只收起左侧,右侧不受影响
|
||||
expect(state.rightPanelVisible).toBe(true);
|
||||
// 单侧收起不改变「专注模式」标记
|
||||
expect(state.viewportPanelsCollapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves focus mode and keeps only the toggled side when a panel switch is used", () => {
|
||||
useDirectorStore.setState({ ...createInitialDirectorState(), viewportPanelsCollapsed: true });
|
||||
|
||||
useDirectorStore.getState().toggleRightPanelVisible();
|
||||
|
||||
const state = useDirectorStore.getState();
|
||||
expect(state.viewportPanelsCollapsed).toBe(false);
|
||||
expect(state.leftPanelVisible).toBe(false);
|
||||
expect(state.rightPanelVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("routes the right panel by object type and view mode", () => {
|
||||
const state = createInitialDirectorState();
|
||||
const characterId = state.project.objects.find((item) => item.kind === "character")!.id;
|
||||
@@ -892,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 },
|
||||
@@ -949,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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -140,7 +166,12 @@ export interface DirectorUiState {
|
||||
transformMode: TransformMode;
|
||||
viewportAspectRatio: ViewportAspectRatio;
|
||||
viewportRuleOfThirdsEnabled: boolean;
|
||||
/** 「专注模式」:一次性收起左右两侧栏。与左右各自的显隐开关相与后决定是否渲染。 */
|
||||
viewportPanelsCollapsed: boolean;
|
||||
/** 左侧「场景」面板是否显示(专注模式下会被一并收起) */
|
||||
leftPanelVisible: boolean;
|
||||
/** 右侧「属性」面板是否显示(专注模式下会被一并收起) */
|
||||
rightPanelVisible: boolean;
|
||||
/** 悬浮时间轴(录制面板)是否显示,由视口快捷工具条的「录制」按钮切换 */
|
||||
timelineVisible: boolean;
|
||||
/** 用户自定义的时间轴轨道顺序(轨道 key 列表)。
|
||||
@@ -176,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;
|
||||
@@ -183,6 +224,10 @@ export interface DirectorActions {
|
||||
setViewportRuleOfThirdsEnabled: (enabled: boolean) => void;
|
||||
toggleViewportPanelsCollapsed: () => void;
|
||||
setViewportPanelsCollapsed: (collapsed: boolean) => void;
|
||||
/** 单独收起 / 展开左侧「场景」面板 */
|
||||
toggleLeftPanelVisible: () => void;
|
||||
/** 单独收起 / 展开右侧「属性」面板 */
|
||||
toggleRightPanelVisible: () => void;
|
||||
toggleTimelineVisible: () => void;
|
||||
setTimelineVisible: (visible: boolean) => void;
|
||||
/** 覆盖时间轴轨道顺序(传入解析后的完整顺序) */
|
||||
@@ -372,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<void>;
|
||||
/** 从工程文件导入为一个新工程并切换到它,返回新工程 id;格式不合法时抛错 */
|
||||
importProjectFromFile: (file: File) => Promise<string>;
|
||||
/** 重新从 localStorage 同步工程目录(外部改动后刷新 UI 用) */
|
||||
refreshProjectCatalog: () => void;
|
||||
}
|
||||
|
||||
type DirectorRuntimeState = DirectorState & DirectorInternalState;
|
||||
type DirectorRuntimeState = DirectorState & DirectorInternalState & DirectorProjectLibraryState;
|
||||
|
||||
export type DirectorStore = DirectorRuntimeState & DirectorActions;
|
||||
|
||||
@@ -420,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,
|
||||
@@ -435,6 +494,8 @@ const DEFAULT_UI_STATE: DirectorUiState = {
|
||||
viewportAspectRatio: "auto",
|
||||
viewportRuleOfThirdsEnabled: false,
|
||||
viewportPanelsCollapsed: false,
|
||||
leftPanelVisible: true,
|
||||
rightPanelVisible: true,
|
||||
timelineVisible: true,
|
||||
timelineTrackOrder: [],
|
||||
timelineCollapsedTracks: [],
|
||||
@@ -442,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) {
|
||||
@@ -917,6 +983,8 @@ function extractPersistedDirectorState(state: DirectorRuntimeState): DirectorSta
|
||||
viewportAspectRatio: state.viewportAspectRatio,
|
||||
viewportRuleOfThirdsEnabled: state.viewportRuleOfThirdsEnabled,
|
||||
viewportPanelsCollapsed: state.viewportPanelsCollapsed,
|
||||
leftPanelVisible: state.leftPanelVisible,
|
||||
rightPanelVisible: state.rightPanelVisible,
|
||||
timelineVisible: state.timelineVisible,
|
||||
timelineTrackOrder: state.timelineTrackOrder,
|
||||
timelineCollapsedTracks: state.timelineCollapsedTracks,
|
||||
@@ -931,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.
|
||||
}
|
||||
@@ -981,6 +1051,8 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo
|
||||
viewportAspectRatio: state.viewportAspectRatio ?? "auto",
|
||||
viewportRuleOfThirdsEnabled: Boolean(state.viewportRuleOfThirdsEnabled),
|
||||
viewportPanelsCollapsed: Boolean(state.viewportPanelsCollapsed),
|
||||
leftPanelVisible: state.leftPanelVisible === false ? false : true,
|
||||
rightPanelVisible: state.rightPanelVisible === false ? false : true,
|
||||
timelineVisible: state.timelineVisible === false ? false : true,
|
||||
timelineTrackOrder: readPersistedStringArray(state.timelineTrackOrder),
|
||||
timelineCollapsedTracks: readPersistedStringArray(state.timelineCollapsedTracks),
|
||||
@@ -997,6 +1069,7 @@ function readPersistedDirectorState(options: DirectorStateOptions = {}): Directo
|
||||
|
||||
function createRuntimeStateFromPersistedState(state: DirectorState): DirectorRuntimeState {
|
||||
const snapshot = cloneJsonValue(state);
|
||||
const catalog = readProjectCatalog();
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
@@ -1007,6 +1080,9 @@ function createRuntimeStateFromPersistedState(state: DirectorState): DirectorRun
|
||||
undoBatchDepth: 0,
|
||||
undoBatchSnapshot: null,
|
||||
undoBatchHasTrackedChanges: false,
|
||||
// 工程目录是独立于场景的一份数据:这里重新读一遍,保证 UI 列表与 localStorage 一致。
|
||||
projects: catalog?.projects ?? [],
|
||||
activeProjectId: catalog?.activeProjectId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2160,7 +2236,27 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
commitUiMutation((state) => ({
|
||||
...state,
|
||||
viewportPanelsCollapsed: collapsed,
|
||||
// 退出专注模式时把两侧还原成可见,避免「点了展开却没反应」
|
||||
leftPanelVisible: collapsed ? state.leftPanelVisible : true,
|
||||
rightPanelVisible: collapsed ? state.rightPanelVisible : true,
|
||||
})),
|
||||
toggleLeftPanelVisible: () =>
|
||||
commitUiMutation((state) => {
|
||||
// 专注模式下切换某一侧:退出专注模式,只保留这一侧,避免「按了没反应」
|
||||
if (state.viewportPanelsCollapsed) {
|
||||
return { ...state, viewportPanelsCollapsed: false, leftPanelVisible: true, rightPanelVisible: false };
|
||||
}
|
||||
|
||||
return { ...state, leftPanelVisible: !state.leftPanelVisible };
|
||||
}),
|
||||
toggleRightPanelVisible: () =>
|
||||
commitUiMutation((state) => {
|
||||
if (state.viewportPanelsCollapsed) {
|
||||
return { ...state, viewportPanelsCollapsed: false, leftPanelVisible: false, rightPanelVisible: true };
|
||||
}
|
||||
|
||||
return { ...state, rightPanelVisible: !state.rightPanelVisible };
|
||||
}),
|
||||
toggleTimelineVisible: () =>
|
||||
commitUiMutation((state) => ({
|
||||
...state,
|
||||
@@ -4133,5 +4229,216 @@ export const useDirectorStore = create<DirectorStore>((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,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -18,10 +18,14 @@
|
||||
--right-sidebar-width: 300px;
|
||||
--left-sidebar-content-width: 260px;
|
||||
--right-sidebar-content-width: 260px;
|
||||
/* 悬浮卡片:与视口边缘的间距、以及让开顶部工具条的起始高度 */
|
||||
/* 悬浮卡片与视口边缘的间距(左右、以及相对时间轴的上下留白都用它) */
|
||||
--floating-inset: 16px;
|
||||
--floating-top: 76px;
|
||||
/* 悬浮卡片可用高度上限:扣掉顶部工具条、上下内缩、以及悬浮时间轴占用的高度 */
|
||||
/* 顶栏高度:顶栏绝对定位在视口顶部铺满整行,左格放了「工程」入口 */
|
||||
--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 会按实测值覆盖),供悬浮时间轴让位使用 */
|
||||
--viewport-toolbar-height: 46px;
|
||||
@@ -133,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;
|
||||
@@ -379,7 +384,9 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.director-shell-fullbleed.is-sidebars-collapsed > .left-sidebar,
|
||||
.director-shell-fullbleed.is-sidebars-collapsed > .right-sidebar {
|
||||
.director-shell-fullbleed.is-sidebars-collapsed > .right-sidebar,
|
||||
.director-shell-fullbleed.is-left-collapsed > .left-sidebar,
|
||||
.director-shell-fullbleed.is-right-collapsed > .right-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3576,6 +3583,15 @@ button.inspector-dropdown-option.is-selected {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 窄屏下右侧栏本就强制隐藏,连它的手柄一起去掉,免得出现「点了没反应」的控件 */
|
||||
.panel-edge-handle-right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-edge-handle-left {
|
||||
left: calc(var(--floating-inset) + min(220px, 28vw) - 11px);
|
||||
}
|
||||
|
||||
.viewport-toolbar {
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
@@ -5035,9 +5051,9 @@ body.is-timeline-dragging * {
|
||||
}
|
||||
|
||||
/* ===== 全屏画布 + 悬浮卡片 =====
|
||||
画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。
|
||||
1) 顶部工具条改为绝对定位的透明浮层,只有标题/视角切换/关闭按钮可点击,其余区域让给画布。
|
||||
2) 右侧栏脱离第 1 行,直接落在 1fr 行上,从而占满整屏高度。 */
|
||||
画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。
|
||||
1) 顶部工具条改为绝对定位的透明浮层,只有视角切换按钮可点击,其余区域让给画布。
|
||||
2) 右侧栏脱离第 1 行,直接落在 1fr 行上,从而占满整屏高度。 */
|
||||
.app-shell > .director-shell {
|
||||
grid-row: 2;
|
||||
}
|
||||
@@ -5053,8 +5069,13 @@ body.is-timeline-dragging * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.top-bar-left,
|
||||
/* 只有视角切换按钮需要接管点击:整格可点会连带吃掉画布顶部中段、
|
||||
以及延伸到顶部的侧栏边缘,所以这里收到按钮本身 */
|
||||
.top-bar-center {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.top-bar-center .mode-toggle {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -5066,7 +5087,7 @@ body.is-timeline-dragging * {
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
}
|
||||
|
||||
/* 左右侧栏:从贴边全高面板改成内缩、按内容包裹的圆角悬浮卡片
|
||||
/* 左右侧栏:从贴边全高面板改成内缩、按内容包裹的悬浮卡片,顶部延伸到视口顶端
|
||||
bottom 置为 auto + max-height 封顶 → 内容少时卡片只有内容那么高,内容多时卡片内部滚动 */
|
||||
.director-shell-fullbleed .left-sidebar,
|
||||
.director-shell-fullbleed .right-sidebar {
|
||||
@@ -5096,6 +5117,53 @@ body.is-timeline-dragging * {
|
||||
right: var(--floating-inset);
|
||||
}
|
||||
|
||||
/* 侧栏折叠手柄:展开时半嵌在卡片外缘(落在卡片 20px 内边距里,不压内容),
|
||||
收起后缩到视口边上,只占一条 22px 的窄边,把画布整块让出来。
|
||||
z-index 高于侧栏(25) / 时间轴(30),低于顶部条(45)。 */
|
||||
.panel-edge-handle {
|
||||
position: absolute;
|
||||
top: calc(var(--floating-top) + 18px);
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(var(--border-rgb) / 0.42);
|
||||
border-radius: 999px;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
background: rgb(var(--panel-rgb) / 0.9);
|
||||
box-shadow: var(--ui-shadow-panel);
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.panel-edge-handle:hover,
|
||||
.panel-edge-handle:focus-visible {
|
||||
color: rgb(var(--text-rgb));
|
||||
background: rgb(var(--panel-rgb) / 0.98);
|
||||
}
|
||||
|
||||
.panel-edge-handle-left {
|
||||
left: calc(var(--floating-inset) + var(--left-sidebar-width) - 11px);
|
||||
}
|
||||
|
||||
.panel-edge-handle-right {
|
||||
right: calc(var(--floating-inset) + var(--right-sidebar-width) - 11px);
|
||||
}
|
||||
|
||||
.director-shell-fullbleed.is-left-collapsed > .panel-edge-handle-left {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.director-shell-fullbleed.is-right-collapsed > .panel-edge-handle-right {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
/* 卡片自身半透明,内部面板背景需让位,避免把悬浮磨砂底压成不透明;
|
||||
同时把内层依赖父级高度的 100% 显式改成跟随内容,配合卡片「按内容包裹」 */
|
||||
.director-shell-fullbleed .left-sidebar .object-tree-panel {
|
||||
@@ -5397,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user