Files
ttstd-infinite-canvas/web/src/lib/asset-storage-revision.ts

181 lines
8.8 KiB
TypeScript

import type { Asset } from "@/stores/use-asset-store";
import { defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
export type AssetStorageDocument = {
state: { assets: Asset[] };
version: number;
storageRevision: number;
tombstones: { assets: Record<string, number> };
};
export function normalizeAssetRecord(asset: Asset): Asset {
const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind));
const folderId = typeof asset.folderId === "string" ? asset.folderId.trim() : "";
const normalizedData = normalizeAssetData(asset);
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category && (asset.folderId || "") === folderId && normalizedData === asset.data) return asset;
return {
...asset,
tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [],
folderId: folderId || undefined,
category,
data: normalizedData,
} as Asset;
}
function normalizeAssetData(asset: Asset): Asset["data"] {
const raw = asset.data && typeof asset.data === "object" ? asset.data as Record<string, unknown> : {};
const numberValue = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
if (asset.kind === "text") return typeof raw.content === "string" ? asset.data : { content: "" };
if (asset.kind === "image") {
if (typeof raw.dataUrl === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data;
return { dataUrl: typeof raw.dataUrl === "string" ? raw.dataUrl : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "image/*" };
}
if (asset.kind === "video") {
if (typeof raw.url === "string" && typeof raw.width === "number" && typeof raw.height === "number") return asset.data;
return { url: typeof raw.url === "string" ? raw.url : asset.coverUrl || "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, width: numberValue(raw.width), height: numberValue(raw.height), durationMs: numberValue(raw.durationMs), hasAudio: raw.hasAudio === true, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "video/*" };
}
if (asset.kind === "audio") {
if (typeof raw.url === "string") return asset.data;
return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, durationMs: numberValue(raw.durationMs), bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "audio/*" };
}
if (asset.kind === "model") {
if (typeof raw.url === "string" && typeof raw.fileName === "string") return asset.data;
return { url: "", storageKey: typeof raw.storageKey === "string" ? raw.storageKey : undefined, bytes: numberValue(raw.bytes), mimeType: typeof raw.mimeType === "string" ? raw.mimeType : "model/*", fileName: typeof raw.fileName === "string" ? raw.fileName : asset.title || "未命名模型" };
}
return asset.data;
}
function normalizeAssets(assets: Asset[]) {
return assets.map(normalizeAssetRecord);
}
export function parseAssetStorageDocument(value: string | null, fallback: Asset[] = []): AssetStorageDocument {
if (!value) {
return {
state: { assets: normalizeAssets(fallback) },
version: 0,
storageRevision: 0,
tombstones: { assets: {} },
};
}
const parsed = JSON.parse(value) as {
state?: { assets?: unknown };
version?: unknown;
storageRevision?: unknown;
tombstones?: { assets?: unknown };
};
if (!Array.isArray(parsed.state?.assets)) throw new Error("素材持久状态无效");
const rawTombstones = parsed.tombstones?.assets;
const tombstones =
rawTombstones && typeof rawTombstones === "object" && !Array.isArray(rawTombstones) ? Object.fromEntries(Object.entries(rawTombstones).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))) : {};
return {
state: { assets: normalizeAssets(parsed.state.assets as Asset[]) },
version: typeof parsed.version === "number" ? parsed.version : 0,
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
tombstones: { assets: tombstones },
};
}
export function serializeAssetStorageDocument(document: AssetStorageDocument) {
return JSON.stringify(document);
}
function deepEqual(left: unknown, right: unknown) {
if (Object.is(left, right)) return true;
return JSON.stringify(left) === JSON.stringify(right);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function mergeValue(base: unknown, local: unknown, durable: unknown): unknown {
if (deepEqual(local, base)) return durable;
if (deepEqual(durable, base)) return local;
if (isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord(base, local, durable);
if (!isRecord(base) && isRecord(local) && isRecord(durable)) return mergeRecord({}, local, durable);
return durable;
}
function mergeRecord(base: Record<string, unknown>, local: Record<string, unknown>, durable: Record<string, unknown>) {
const merged: Record<string, unknown> = {};
for (const key of new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(durable)])) {
const baseHas = Object.prototype.hasOwnProperty.call(base, key);
const localHas = Object.prototype.hasOwnProperty.call(local, key);
const durableHas = Object.prototype.hasOwnProperty.call(durable, key);
if (!localHas && baseHas) {
if (durableHas && !deepEqual(durable[key], base[key])) merged[key] = durable[key];
continue;
}
if (!localHas) {
if (durableHas) merged[key] = durable[key];
continue;
}
if (!baseHas) {
merged[key] = durableHas ? mergeValue(undefined, local[key], durable[key]) : local[key];
continue;
}
if (!durableHas) {
if (!deepEqual(local[key], base[key])) merged[key] = local[key];
continue;
}
merged[key] = mergeValue(base[key], local[key], durable[key]);
}
return merged;
}
export function rebaseAssetSnapshot(input: { document: AssetStorageDocument; baseAssets: Asset[]; localAssets: Asset[]; baseRevision: number }) {
const nextRevision = input.document.storageRevision + 1;
const tombstones = { assets: { ...input.document.tombstones.assets } };
const baseById = new Map(input.baseAssets.map((asset) => [asset.id, asset]));
const localById = new Map(input.localAssets.map((asset) => [asset.id, asset]));
const durableById = new Map(input.document.state.assets.map((asset) => [asset.id, asset]));
const assets = [...input.document.state.assets];
const positions = new Map(assets.map((asset, index) => [asset.id, index]));
const remove = (id: string) => {
const index = positions.get(id);
if (index === undefined) return;
assets.splice(index, 1);
positions.clear();
assets.forEach((asset, position) => positions.set(asset.id, position));
};
const set = (asset: Asset) => {
const index = positions.get(asset.id);
if (index === undefined) {
positions.set(asset.id, assets.length);
assets.push(asset);
} else {
assets[index] = asset;
}
};
for (const id of new Set([...baseById.keys(), ...localById.keys()])) {
const base = baseById.get(id);
const local = localById.get(id);
const durable = durableById.get(id);
if (base && !local) {
remove(id);
tombstones.assets[id] = nextRevision;
continue;
}
if (!local || (base && deepEqual(base, local))) continue;
if (!durable) {
if (base || (tombstones.assets[id] ?? 0) > input.baseRevision) continue;
delete tombstones.assets[id];
set(local);
continue;
}
delete tombstones.assets[id];
set(mergeRecord((base || {}) as unknown as Record<string, unknown>, local as unknown as Record<string, unknown>, durable as unknown as Record<string, unknown>) as unknown as Asset);
}
return {
state: { assets },
version: input.document.version,
storageRevision: nextRevision,
tombstones,
} satisfies AssetStorageDocument;
}