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 } }; export type Asset = TextAsset | ImageAsset | VideoAsset | AudioAsset | ModelAsset | EntityAsset; export type NewAsset = | Omit | Omit | Omit | Omit | Omit | Omit; type AssetBase = { 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; }; type AssetStore = { hydrated: boolean; assets: Asset[]; addAsset: (asset: NewAsset) => string; updateAsset: (id: string, patch: Partial>) => 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 = { getItem: async (name) => { const value = await localForageStorage.getItem(name); if (!value) return null; const parsed = JSON.parse(value) as StorageValue; 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()( 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["state"], onRehydrateStorage: () => () => { useAssetStore.setState({ hydrated: true }); }, }, ), );