Files
ttstd-infinite-canvas/web/src/stores/use-asset-store.ts

483 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>;
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<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[];
folderId?: string;
category?: AssetCategory;
status?: AssetStatus;
primaryVersionId?: string;
source?: string;
note?: string;
arkAssetId?: string;
portraitCertified?: boolean;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
};
type AssetStore = {
hydrated: boolean;
assets: Asset[];
addAsset: (asset: NewAsset) => string;
addGenerationAsset: (effectKey: string, asset: NewAsset, signal?: AbortSignal) => Promise<string>;
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
removeAsset: (id: string) => Promise<void>;
removeAssets: (ids: string[]) => Promise<void>;
replaceAssets: (assets: Asset[]) => void;
cleanupImages: (extra?: unknown) => Promise<void>;
};
export const ASSET_STORE_KEY = "yingce:asset_store";
type PersistedAssetState = Pick<AssetStore, "assets">;
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<string, PersistedAssetState>();
const observedAssetPersists = new Map<string, ObservedAssetPersist>();
const queuedAssetPersists = new Map<string, QueuedAssetPersist>();
const assetPersistTokens = new Map<string, number>();
const assetOperations = new Set<Promise<unknown>>();
const generationAssetFailures = new Map<string, unknown>();
const generationAssetDefaults = new Map<string, Map<string, Asset>>();
// 只记录本次实际创建的生成输入,不能把重读的缓存(可能已编辑)当作默认值。
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<T>(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<T>(operation: Promise<T>) {
assetOperations.add(operation);
const cleanup = () => assetOperations.delete(operation);
void operation.then(cleanup, cleanup);
return operation;
}
function persistAssetState(name: string, value: StorageValue<AssetStore>) {
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<T>(scope: string, effectKey: string, operation: Promise<T>) {
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<AssetStore> = {
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<AssetStore>;
},
setItem: persistAssetState,
removeItem: (name) => {
const scope = getActiveUserScope();
return localForageStorageForScope(scope).removeItem(name);
},
};
async function normalizePersistedAsset(asset: Asset): Promise<Asset> {
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<AssetStore>()(
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<Asset>({
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<string, Asset>();
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<void>((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<AssetStore>["state"],
onRehydrateStorage: () => () => {
useAssetStore.setState({ hydrated: true });
},
},
),
);