import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { nanoid } from "nanoid"; import type { AssetCategory } from "@/lib/asset-category"; import { parseAssetRecord } from "@/lib/asset-record"; import { parseAssetStorageDocumentRecovering, rebaseAssetSnapshot, serializeAssetStorageDocument, type AssetStorageDocument } from "@/lib/asset-storage-revision"; import { parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision"; import { localForageStorageForScope } from "@/lib/localforage-storage"; import { getActiveUserScope } from "@/lib/user-scope"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { cleanupUnusedImages, collectImageStorageKeys, resolveImageUrl, uploadImage } from "@/services/image-storage"; import { cleanupUnusedMedia, collectMediaStorageKeys, resolveMediaUrl } from "@/services/file-storage"; import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGenerationArtifactCommitLock, withGenerationAssetStorageLock } from "@/services/generation-asset-repository"; import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store"; import { readAllCanvasSyncDrafts } from "@/services/canvas-sync-drafts"; export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity"; export type { AssetCategory } from "@/lib/asset-category"; 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; hasAudio?: boolean; 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; version?: number; coverStorageKey?: string; voiceName?: string; voiceSampleStorageKey?: string; visualStatus?: string; voiceStatus?: string; }; }; 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[]; folderId?: string; category?: AssetCategory; status?: AssetStatus; primaryVersionId?: string; source?: string; note?: string; arkAssetId?: string; portraitCertified?: boolean; createdAt: string; updatedAt: string; metadata?: Record; }; type AssetStore = { hydrated: boolean; assets: Asset[]; addAsset: (asset: NewAsset) => string; addGenerationAsset: (effectKey: string, asset: NewAsset, signal?: AbortSignal) => Promise; updateAsset: (id: string, patch: Partial>) => void; removeAsset: (id: string) => Promise; removeAssets: (ids: string[]) => Promise; replaceAssets: (assets: Asset[]) => void; cleanupImages: (extra?: unknown) => Promise; }; export const ASSET_STORE_KEY = "yingce:asset_store"; type PersistedAssetState = Pick; type ObservedAssetPersist = { assets: Asset[]; revision: number; }; type QueuedAssetPersist = { name: string; scope: string; baseAssets: Asset[]; baseRevision: number; assets: Asset[]; token: number; }; let suppressAssetStorePersistence = 0; const assetMemoryStates = new Map(); const observedAssetPersists = new Map(); const queuedAssetPersists = new Map(); const assetPersistTokens = new Map(); const assetOperations = new Set>(); const generationAssetFailures = new Map(); const generationAssetDefaults = new Map>(); // 只记录本次实际创建的生成输入,不能把重读的缓存(可能已编辑)当作默认值。 export function getGenerationAssetDefaults(id: string): Asset | undefined { return generationAssetDefaults.get(getActiveUserScope())?.get(id); } function recordAssetStorageDocument(scope: string, document: AssetStorageDocument) { observedAssetPersists.set(scope, { assets: document.state.assets, revision: document.storageRevision, }); } export function withAssetStorePersistenceSuppressed(operation: () => T) { suppressAssetStorePersistence += 1; try { return operation(); } finally { suppressAssetStorePersistence -= 1; } } async function commitPendingAssetStorePersistenceLocked(scope: string) { const storage = localForageStorageForScope(scope); let committed: AssetStorageDocument | null = null; while (true) { const queued = queuedAssetPersists.get(scope); if (!queued) return committed; // 读取旧版本时允许隔离历史坏记录;真正写回前,queued.assets 已经由 persistAssetState 严格校验。 // 这样既不会用伪造默认值掩盖坏数据,也不会让一条旧记录阻断整库的后续写入。 const recovery = parseAssetStorageDocumentRecovering(await storage.getItem(queued.name), queued.baseAssets); if (recovery.invalid.length) { console.warn("素材本地缓存包含无法恢复的历史记录,写回时将隔离这些记录", { scope, invalid: recovery.invalid, }); } const durable = recovery.document; const rebased = rebaseAssetSnapshot({ document: durable, baseAssets: queued.baseAssets, localAssets: queued.assets, baseRevision: queued.baseRevision, }); await storage.setItem(queued.name, serializeAssetStorageDocument(rebased)); committed = rebased; recordAssetStorageDocument(scope, rebased); const latest = queuedAssetPersists.get(scope); if (!latest || latest.token === queued.token) { if (latest?.token === queued.token) queuedAssetPersists.delete(scope); return committed; } latest.baseAssets = queued.assets; latest.baseRevision = rebased.storageRevision; } } async function writeQueuedAssetPersist(scope: string, _token: number) { await withGenerationAssetStorageLock(scope, () => commitPendingAssetStorePersistenceLocked(scope)); } async function readPersistedAssetDocumentForScope(scope: string) { const recovery = parseAssetStorageDocumentRecovering(await localForageStorageForScope(scope).getItem(ASSET_STORE_KEY)); if (recovery.invalid.length) { console.warn("素材本地缓存包含无法恢复的历史记录,已隔离并保留其诊断信息", { scope, invalid: recovery.invalid, }); } return recovery.document; } /** * 登记一个素材写操作,供登出、切换账号和页面卸载前统一冲刷。 * * 这里不能直接对 finally 返回的 Promise 放任不管:原操作失败时,finally 派生的 * Promise 也会拒绝并产生未处理拒绝。用 then 的成功/失败分支做同一个清理动作, * 既保留原 Promise 给调用方观察真实错误,也不会制造第二条未处理错误链。 */ function trackAssetOperation(operation: Promise) { assetOperations.add(operation); const cleanup = () => assetOperations.delete(operation); void operation.then(cleanup, cleanup); return operation; } function persistAssetState(name: string, value: StorageValue) { const scope = getActiveUserScope(); const nextAssets = value.state.assets.map(parseAssetRecord); const queued = queuedAssetPersists.get(scope); const observed = observedAssetPersists.get(scope); const baseAssets = assetMemoryStates.get(scope)?.assets ?? observed?.assets ?? []; assetMemoryStates.set(scope, { assets: nextAssets }); if (suppressAssetStorePersistence) return; const token = (assetPersistTokens.get(scope) ?? 0) + 1; assetPersistTokens.set(scope, token); queuedAssetPersists.set(scope, { name, scope, baseAssets: queued?.baseAssets ?? baseAssets, baseRevision: queued?.baseRevision ?? observed?.revision ?? 0, assets: nextAssets, token, }); return trackAssetOperation(writeQueuedAssetPersist(scope, token)); } function generationAssetFailureKey(scope: string, effectKey: string) { return `${scope}\0${effectKey}`; } function trackGenerationAssetOperation(scope: string, effectKey: string, operation: Promise) { const failureKey = generationAssetFailureKey(scope, effectKey); return trackAssetOperation( operation.then( (value) => { generationAssetFailures.delete(failureKey); return value; }, (error) => { throw error; }, ), ); } export async function flushAssetStorePersistence() { while (true) { if (assetOperations.size) { await Promise.all([...assetOperations]); continue; } const writes = [...queuedAssetPersists.values()].map(({ scope, token }) => writeQueuedAssetPersist(scope, token)); if (writes.length) { await Promise.all(writes); continue; } await flushGenerationAssetStorageLocks(); if (!assetOperations.size && !queuedAssetPersists.size) break; } const failure = generationAssetFailures.values().next(); if (!failure.done) throw failure.value; } const assetStorage: PersistStorage = { getItem: async (name) => { const scope = getActiveUserScope(); const value = await localForageStorageForScope(scope).getItem(name); if (!value) { assetMemoryStates.set(scope, { assets: [] }); observedAssetPersists.set(scope, { assets: [], revision: 0 }); return null; } const recovery = parseAssetStorageDocumentRecovering(value); if (recovery.invalid.length) { console.warn("素材本地缓存包含无法恢复的历史记录,已隔离并保留其诊断信息", { scope, invalid: recovery.invalid, }); } const document = recovery.document; // 持久化恢复只恢复结构化记录,不能为每个 resource: key 逐条读取资源元数据或 Blob。 // 远程资源直接使用受鉴权的 file URL;本地 legacy key 仍恢复为 Blob URL,避免兼容性回归。 const assets = await Promise.all(document.state.assets.map((asset) => normalizePersistedAsset(asset))); const hydratedDocument = { ...document, state: { assets } }; assetMemoryStates.set(scope, { assets }); recordAssetStorageDocument(scope, hydratedDocument); return hydratedDocument as unknown as StorageValue; }, setItem: persistAssetState, removeItem: (name) => { const scope = getActiveUserScope(); return localForageStorageForScope(scope).removeItem(name); }, }; async function normalizePersistedAsset(asset: Asset): Promise { asset = parseAssetRecord(asset); const storageKey = "data" in asset && asset.data && "storageKey" in asset.data ? asset.data.storageKey : undefined; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { const url = resourceFileUrl(resourceId); if (asset.kind === "video" || asset.kind === "audio" || asset.kind === "model") return { ...asset, data: { ...asset.data, url } } as Asset; if (asset.kind === "image") return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, dataUrl: url } }; } // 非 resource: key 是早期本地存储格式,必须继续从 localForage 恢复, // 但只在确有本地 key 时读取,不让远程资源重新走逐条网络查询。 if (asset.kind === "video" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } }; if (asset.kind === "audio" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } }; if (asset.kind === "model" && storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(storageKey, asset.data.url) } }; if (asset.kind !== "image") return asset; if (storageKey) { return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(storageKey, asset.coverUrl) : asset.coverUrl, data: { ...asset.data, dataUrl: await resolveImageUrl(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 } }; } async function generationAssetId(effectKey: string) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(effectKey)); return `generation_${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; } export const useAssetStore = create()( persist( (set, get) => ({ hydrated: false, assets: [], addAsset: (asset) => { const now = new Date().toISOString(); const id = nanoid(); set((state) => ({ assets: [parseAssetRecord({ ...asset, id, createdAt: now, updatedAt: now }), ...state.assets] })); return id; }, addGenerationAsset: (effectKey, asset, signal) => { const scope = getActiveUserScope(); const publicationBase = observedAssetPersists.get(scope) ?? { assets: get().assets, revision: 0 }; return trackGenerationAssetOperation( scope, effectKey, (async () => { const id = await generationAssetId(effectKey); let persistedDocument: AssetStorageDocument | null = null; return insertOrReturnGenerationAsset({ storageScope: scope, effectKey, assetId: id, createAsset: () => { const now = new Date().toISOString(); const created = parseAssetRecord({ ...asset, id, createdAt: now, updatedAt: now, metadata: { ...asset.metadata, generationEffectKey: effectKey }, }); const defaults = generationAssetDefaults.get(scope) ?? new Map(); defaults.set(id, structuredClone(created)); generationAssetDefaults.set(scope, defaults); return created; }, updateAssets: (updater) => { withAssetStorePersistenceSuppressed(() => { set((state) => { if (!persistedDocument) return { assets: updater(state.assets) }; const liveDocument = rebaseAssetSnapshot({ document: persistedDocument, baseAssets: publicationBase.assets, localAssets: state.assets, baseRevision: publicationBase.revision, }); const generationAssets = updater(persistedDocument.state.assets); const published = rebaseAssetSnapshot({ document: liveDocument, baseAssets: persistedDocument.state.assets, localAssets: generationAssets, baseRevision: persistedDocument.storageRevision, }); return { assets: published.state.assets }; }); }); }, readAssets: () => get().assets, readPersistedAssets: async () => { try { await commitPendingAssetStorePersistenceLocked(scope); persistedDocument = await readPersistedAssetDocumentForScope(scope); recordAssetStorageDocument(scope, persistedDocument); return persistedDocument.state.assets; } catch (error) { generationAssetFailures.set(generationAssetFailureKey(scope, effectKey), error); throw error; } }, isAssetDeleted: () => Boolean(persistedDocument?.tombstones.assets[id]), requireCrossRealmLock: true, signal, persistAssets: async (assets) => { const durable = persistedDocument ?? (await readPersistedAssetDocumentForScope(scope)); const nextDocument: AssetStorageDocument = { ...durable, state: { assets }, storageRevision: durable.storageRevision + 1, }; try { await localForageStorageForScope(scope).setItem(ASSET_STORE_KEY, serializeAssetStorageDocument(nextDocument)); } catch (error) { generationAssetFailures.set(generationAssetFailureKey(scope, effectKey), error); throw error; } persistedDocument = nextDocument; recordAssetStorageDocument(scope, nextDocument); }, }); })(), ); }, updateAsset: (id, patch) => set((state) => ({ assets: state.assets.map((asset) => (asset.id === id ? parseAssetRecord({ ...asset, ...patch, updatedAt: new Date().toISOString() }) : asset)), })), removeAsset: async (id) => get().removeAssets([id]), removeAssets: async (ids) => { const removedIds = new Set(ids); for (const id of ids) generationAssetDefaults.get(getActiveUserScope())?.delete(id); let remainingAssets: Asset[] = []; let hasLocalMedia = false; set((state) => { const assets = state.assets.filter((asset) => { if (!removedIds.has(asset.id)) return true; hasLocalMedia ||= !!collectImageStorageKeys(asset).size || !!collectMediaStorageKeys(asset).size; return false; }); remainingAssets = assets; return { assets }; }); // 没有本地媒体定位时没有需要由该删除动作回收的 Blob;跳过全库扫描, // 避免纯文本/远程资源删除依赖浏览器 IndexedDB 驱动。 if (!hasLocalMedia) return; await get().cleanupImages({ assets: remainingAssets }); }, replaceAssets: (assets) => set({ assets: assets.map(parseAssetRecord) }), cleanupImages: async (extra) => { const scope = getActiveUserScope(); const frozenExtraImageKeys = collectImageStorageKeys(extra); const frozenExtraMediaKeys = collectMediaStorageKeys(extra); await new Promise((resolve, reject) => { window.setTimeout(() => withGenerationArtifactCommitLock(scope, async () => { const syncDrafts = await readAllCanvasSyncDrafts(scope); // 固定锁序:artifact -> Canvas(释放)-> Asset,避免跨 store 锁重入。 const canvasProjects = await withCanvasStorePersistenceLock(scope, async () => { await commitPendingCanvasStorePersistenceLocked(scope); const durableCanvas = parseCanvasStorageDocument(await localForageStorageForScope(scope).getItem(CANVAS_STORE_KEY)); return pendingCanvasStorePersistence(scope)?.projects ?? durableCanvas.state.projects; }); await withGenerationAssetStorageLock(scope, async () => { await commitPendingAssetStorePersistenceLocked(scope); const durableAssets = (await readPersistedAssetDocumentForScope(scope)).state.assets; const references = { projects: canvasProjects, assets: durableAssets, syncDrafts }; const imageKeys = new Set([...frozenExtraImageKeys, ...collectImageStorageKeys(references)]); const mediaKeys = new Set([...frozenExtraMediaKeys, ...collectMediaStorageKeys(references)]); await cleanupUnusedImages( [...imageKeys].map((storageKey) => ({ storageKey })), scope, ); await cleanupUnusedMedia( [...mediaKeys].map((storageKey) => ({ storageKey })), scope, ); }); }).then(resolve, reject), ); }); }, }), { name: ASSET_STORE_KEY, storage: assetStorage, partialize: (state) => ({ assets: state.assets }) as StorageValue["state"], onRehydrateStorage: () => () => { useAssetStore.setState({ hydrated: true }); }, }, ), );