Compare commits

...

3 Commits

Author SHA1 Message Date
a036c0d917 feat(project): 新增多工程管理与工程文件导入导出 2026-09-21 11:50:32 +08:00
ada16fa3ec fix(timeline): 修复拖动播放头时画布不实时更新
拖动期间改用 performance.now() 时间片节流替代 requestAnimationFrame,避免在窗口被遮挡或后台节流时整段拖动不写 playhead。同时草稿档不再跳过景深渲染,仅降低模糊采样点数以保留移焦与变焦预览。
2026-09-21 10:19:43 +08:00
341ad949e1 feat(viewport): 支持左右侧栏独立折叠与专注模式
- 新增 leftPanelVisible / rightPanelVisible 独立状态,可与专注模式叠加
- 添加侧栏折叠手柄及 [ / ] / \ 快捷键
- 画布安全区与悬浮控件按单侧可见性动态贴合边缘
- 顶部工具条「全屏」按钮更名为「专注模式」并更新提示
- 调整悬浮卡片顶部留白,统一视口四周间距
2026-09-21 00:57:17 +08:00
27 changed files with 2235 additions and 88 deletions

View File

@@ -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 匹配为空不会报错,属于预期行为。相关测试在目录缺失时会自动跳过。

View File

@@ -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);

View File

@@ -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"

View File

@@ -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 />);

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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 />);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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");

View File

@@ -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)}

View File

@@ -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

View File

@@ -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: "添加轨迹",

View File

@@ -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) {

View 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);
});

View 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;
}
}

View 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();
});

View 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 };
}

View File

@@ -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();

View 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);
});

View 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>
);
}

View File

@@ -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);

View File

@@ -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");

View 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();
});

View File

@@ -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 },

View File

@@ -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,
});
},
};
});

View File

@@ -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;
}
}