feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
1 parent
8f2232feee
commit
57e1820f41
179 files changed
+49746
-1897
No files matched your search
@@ -2,9 +2,14 @@ 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";
|
||||
import { parseAssetStorageDocument, 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 { 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 = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
|
||||
@@ -44,6 +49,7 @@ 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) => void;
|
||||
replaceAssets: (assets: Asset[]) => void;
|
||||
@@ -52,13 +58,163 @@ type AssetStore = {
|
||||
|
||||
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;
|
||||
|
||||
const durable = parseAssetStorageDocument(await storage.getItem(queued.name), queued.baseAssets);
|
||||
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) {
|
||||
return parseAssetStorageDocument(await localForageStorageForScope(scope).getItem(ASSET_STORE_KEY));
|
||||
}
|
||||
|
||||
function trackAssetOperation<T>(operation: Promise<T>) {
|
||||
assetOperations.add(operation);
|
||||
void operation.finally(() => assetOperations.delete(operation)).catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
function persistAssetState(name: string, value: StorageValue<AssetStore>) {
|
||||
const scope = getActiveUserScope();
|
||||
const nextAssets = value.state.assets;
|
||||
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 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) => {
|
||||
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 document = parseAssetStorageDocument(value);
|
||||
const assets = await Promise.all(
|
||||
document.state.assets.map(async (asset) => {
|
||||
// 视频和音频的数据结构不同,分别缩窄以保持 Asset 判别联合关系。
|
||||
if (asset.kind === "video" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
||||
if (asset.kind === "audio" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
|
||||
@@ -75,12 +231,23 @@ const assetStorage: PersistStorage<AssetStore> = {
|
||||
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;
|
||||
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);
|
||||
},
|
||||
setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)),
|
||||
removeItem: (name) => localForageStorage.removeItem(name),
|
||||
};
|
||||
|
||||
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) => ({
|
||||
@@ -92,6 +259,85 @@ export const useAssetStore = create<AssetStore>()(
|
||||
set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...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 {
|
||||
...asset,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: { ...asset.metadata, generationEffectKey: effectKey },
|
||||
} as Asset;
|
||||
},
|
||||
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 ? ({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
|
||||
@@ -104,10 +350,33 @@ export const useAssetStore = create<AssetStore>()(
|
||||
}),
|
||||
replaceAssets: (assets) => set({ assets }),
|
||||
cleanupImages: (extra) => {
|
||||
const scope = getActiveUserScope();
|
||||
const frozenExtraImageKeys = collectImageStorageKeys(extra);
|
||||
const frozenExtraMediaKeys = collectMediaStorageKeys(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 });
|
||||
await 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user