feat(project): 新增多工程管理与工程文件导入导出
This commit is contained in:
1 parent
ada16fa3ec
commit
a036c0d917
18 files changed
+1894
-26
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user