Files
ttstd-infinite-canvas/web/src/stores/use-asset-store.ts
VicoHu 15e5b4a8bf feat(assets): 素材库 - 方舟素材 ID 引用与人像认证标记
- 素材编辑弹窗新增「方舟素材 ID」(asset- 前缀校验)与「人像认证」开关,
  卡片封面显示人像认证徽章,详情抽屉展示方舟素材 ID
- 画布图片节点绑定的素材录入方舟素材 ID 后,方舟视频渠道(volcengine-ark-video
  或 Ark Plan BaseURL)生成时参考图直接提交 asset:// 引用,跳过可信素材上传同步;
  浏览器直连路径同样优先 asset://;非方舟渠道与本地回退路径不受影响
- 素材字段随 payload_json 透传,AssetBase 与 parseAssetRecord 白名单同步扩展
2026-09-11 21:41:43 +08:00

453 lines
22 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";
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> } };
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>;
replaceAssets: (assets: Asset[]) => void;
cleanupImages: (extra?: unknown) => Promise<void>;
};
export const ASSET_STORE_KEY = "infinite-canvas: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>();
function recordAssetStorageDocument(scope: string, document: AssetStorageDocument) {
observedAssetPersists.set(scope, {
assets: document.state.assets,
revision: document.storageRevision,
});
}
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();
return parseAssetRecord({
...asset,
id,
createdAt: now,
updatedAt: now,
metadata: { ...asset.metadata, generationEffectKey: effectKey },
});
},
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) => {
let remainingAssets: Asset[] = [];
let removedAsset: Asset | undefined;
set((state) => {
removedAsset = state.assets.find((asset) => asset.id === id);
const assets = state.assets.filter((asset) => asset.id !== id);
remainingAssets = assets;
return { assets };
});
// 没有本地媒体定位时没有需要由该删除动作回收的 Blob;跳过全库扫描,
// 避免纯文本/远程资源删除依赖浏览器 IndexedDB 驱动。
if (!removedAsset || (!collectImageStorageKeys(removedAsset).size && !collectMediaStorageKeys(removedAsset).size)) 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 () => {
// 固定锁序: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 };
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 });
},
},
),
);