* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP * feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环 Refs #30 * fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置 * feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环 Refs #30 --------- Co-authored-by: ddCat <ddcat666@88.com>
122 lines
6.1 KiB
TypeScript
122 lines
6.1 KiB
TypeScript
import { create } from "zustand";
|
|
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
|
|
|
|
import { nanoid } from "nanoid";
|
|
import { localForageStorage } from "@/lib/localforage-storage";
|
|
import { cleanupUnusedImages, resolveImageUrl, uploadImage } from "@/services/image-storage";
|
|
import { cleanupUnusedMedia, resolveMediaUrl } from "@/services/file-storage";
|
|
|
|
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
|
|
export type AssetCategory = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
|
|
export type AssetStatus = "draft" | "review" | "confirmed" | "archived";
|
|
export type TextAsset = AssetBase<"text"> & { data: { content: string } };
|
|
export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
|
|
export type VideoAsset = AssetBase<"video"> & { data: { url: string; storageKey?: string; width: number; height: number; durationMs?: number; bytes: number; mimeType: string } };
|
|
export type AudioAsset = AssetBase<"audio"> & { data: { url: string; storageKey?: string; durationMs?: number; bytes: number; mimeType: string } };
|
|
export type ModelAsset = AssetBase<"model"> & { data: { url: string; storageKey?: string; bytes: number; mimeType: string; fileName: string } };
|
|
export type EntityAsset = AssetBase<"entity"> & { data: { definition: Record<string, unknown> } };
|
|
export type Asset = TextAsset | ImageAsset | VideoAsset | AudioAsset | ModelAsset | EntityAsset;
|
|
export type NewAsset =
|
|
| Omit<TextAsset, "id" | "createdAt" | "updatedAt">
|
|
| Omit<ImageAsset, "id" | "createdAt" | "updatedAt">
|
|
| Omit<VideoAsset, "id" | "createdAt" | "updatedAt">
|
|
| Omit<AudioAsset, "id" | "createdAt" | "updatedAt">
|
|
| Omit<ModelAsset, "id" | "createdAt" | "updatedAt">
|
|
| Omit<EntityAsset, "id" | "createdAt" | "updatedAt">;
|
|
|
|
type AssetBase<T extends AssetKind> = {
|
|
id: string;
|
|
kind: T;
|
|
title: string;
|
|
coverUrl: string;
|
|
tags: string[];
|
|
category?: AssetCategory;
|
|
status?: AssetStatus;
|
|
primaryVersionId?: string;
|
|
source?: string;
|
|
note?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
type AssetStore = {
|
|
hydrated: boolean;
|
|
assets: Asset[];
|
|
addAsset: (asset: NewAsset) => string;
|
|
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
|
|
removeAsset: (id: string) => void;
|
|
replaceAssets: (assets: Asset[]) => void;
|
|
cleanupImages: (extra?: unknown) => void;
|
|
};
|
|
|
|
export const ASSET_STORE_KEY = "infinite-canvas:asset_store";
|
|
|
|
const assetStorage: PersistStorage<AssetStore> = {
|
|
getItem: async (name) => {
|
|
const value = await localForageStorage.getItem(name);
|
|
if (!value) return null;
|
|
const parsed = JSON.parse(value) as StorageValue<AssetStore>;
|
|
parsed.state.assets = await Promise.all(
|
|
parsed.state.assets.map(async (asset) => {
|
|
if ((asset.kind === "video" || asset.kind === "audio") && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
|
if (asset.kind === "model" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
|
if (asset.kind !== "image") return asset;
|
|
if (asset.data.storageKey)
|
|
return {
|
|
...asset,
|
|
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl,
|
|
data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) },
|
|
};
|
|
if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
|
|
const image = await uploadImage(asset.data.dataUrl);
|
|
return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } };
|
|
}),
|
|
);
|
|
return parsed;
|
|
},
|
|
setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)),
|
|
removeItem: (name) => localForageStorage.removeItem(name),
|
|
};
|
|
|
|
export const useAssetStore = create<AssetStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
hydrated: false,
|
|
assets: [],
|
|
addAsset: (asset) => {
|
|
const now = new Date().toISOString();
|
|
const id = nanoid();
|
|
set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] }));
|
|
return id;
|
|
},
|
|
updateAsset: (id, patch) =>
|
|
set((state) => ({
|
|
assets: state.assets.map((asset) => (asset.id === id ? ({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
|
|
})),
|
|
removeAsset: (id) =>
|
|
set((state) => {
|
|
const assets = state.assets.filter((asset) => asset.id !== id);
|
|
get().cleanupImages({ assets });
|
|
return { assets };
|
|
}),
|
|
replaceAssets: (assets) => set({ assets }),
|
|
cleanupImages: (extra) => {
|
|
window.setTimeout(async () => {
|
|
const { useCanvasStore } = await import("@/stores/canvas/use-canvas-store");
|
|
await cleanupUnusedImages({ assets: get().assets, projects: useCanvasStore.getState().projects, extra });
|
|
await cleanupUnusedMedia({ assets: get().assets, projects: useCanvasStore.getState().projects, extra });
|
|
}, 0);
|
|
},
|
|
}),
|
|
{
|
|
name: ASSET_STORE_KEY,
|
|
storage: assetStorage,
|
|
partialize: (state) => ({ assets: state.assets }) as StorageValue<AssetStore>["state"],
|
|
onRehydrateStorage: () => () => {
|
|
useAssetStore.setState({ hydrated: true });
|
|
},
|
|
},
|
|
),
|
|
);
|