fix(canvas): keep uploaded resources in asset library (#388)
This commit is contained in:
118
web/src/services/canvas-asset-repair.ts
Normal file
118
web/src/services/canvas-asset-repair.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { TimelineDirectMedia } from "@/types/timeline";
|
||||
|
||||
export type CanvasAssetRepairResult = {
|
||||
createdAssets: number;
|
||||
updatedProjects: number;
|
||||
};
|
||||
|
||||
export function repairMissingCanvasAssets(): CanvasAssetRepairResult {
|
||||
const assetStore = useAssetStore.getState();
|
||||
const canvasStore = useCanvasStore.getState();
|
||||
const assetIdByStorageKey = new Map<string, string>();
|
||||
const storageKeyByAssetId = new Map<string, string>();
|
||||
const knownAssetIds = new Set<string>();
|
||||
for (const asset of assetStore.assets) {
|
||||
knownAssetIds.add(asset.id);
|
||||
const storageKey = assetStorageKey(asset);
|
||||
if (storageKey) {
|
||||
storageKeyByAssetId.set(asset.id, storageKey);
|
||||
if (!assetIdByStorageKey.has(storageKey)) assetIdByStorageKey.set(storageKey, asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
let createdAssets = 0;
|
||||
let updatedProjects = 0;
|
||||
for (const project of canvasStore.projects) {
|
||||
const repaired = repairProject(project, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId, (node) => {
|
||||
const input = canvasNodeToAsset(node, { canvasId: project.id, source: "canvas-upload" });
|
||||
if (!input) return undefined;
|
||||
const assetId = useAssetStore.getState().addAsset(input);
|
||||
knownAssetIds.add(assetId);
|
||||
const storageKey = node.metadata?.storageKey;
|
||||
if (storageKey) {
|
||||
assetIdByStorageKey.set(storageKey, assetId);
|
||||
storageKeyByAssetId.set(assetId, storageKey);
|
||||
}
|
||||
createdAssets += 1;
|
||||
return assetId;
|
||||
});
|
||||
if (!repaired) continue;
|
||||
canvasStore.updateProject(project.id, repaired);
|
||||
updatedProjects += 1;
|
||||
}
|
||||
return { createdAssets, updatedProjects };
|
||||
}
|
||||
|
||||
function repairProject(
|
||||
project: CanvasProject,
|
||||
knownAssetIds: Set<string>,
|
||||
assetIdByStorageKey: Map<string, string>,
|
||||
storageKeyByAssetId: Map<string, string>,
|
||||
createAsset: (node: CanvasNodeData) => string | undefined,
|
||||
): Pick<CanvasProject, "nodes" | "timeline"> | null {
|
||||
let changed = false;
|
||||
const nodes = project.nodes.map((node) => {
|
||||
if (!isDurableMediaNode(node)) return node;
|
||||
const assetId = matchingAssetId(node.metadata?.assetId, node.metadata?.storageKey, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId) || createAsset(node);
|
||||
if (!assetId || node.metadata?.assetId === assetId) return node;
|
||||
changed = true;
|
||||
return { ...node, metadata: { ...node.metadata, assetId } };
|
||||
});
|
||||
const timeline = project.timeline
|
||||
? {
|
||||
...project.timeline,
|
||||
clips: project.timeline.clips.map((clip) => {
|
||||
const media = clip.directMedia;
|
||||
if (!media || media.kind === "text" || !mediaContent(media)) return clip;
|
||||
const assetId = matchingAssetId(media.assetId, media.storageKey, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId) || createAsset(timelineMediaNode(media));
|
||||
if (!assetId || media.assetId === assetId) return clip;
|
||||
changed = true;
|
||||
return { ...clip, directMedia: { ...media, assetId } };
|
||||
}),
|
||||
}
|
||||
: project.timeline;
|
||||
return changed ? { nodes, timeline } : null;
|
||||
}
|
||||
|
||||
function matchingAssetId(explicitId: string | undefined, storageKey: string | undefined, knownAssetIds: Set<string>, assetIdByStorageKey: Map<string, string>, storageKeyByAssetId: Map<string, string>) {
|
||||
if (explicitId && knownAssetIds.has(explicitId) && (!storageKey || storageKeyByAssetId.get(explicitId) === storageKey)) return explicitId;
|
||||
return storageKey ? assetIdByStorageKey.get(storageKey) : undefined;
|
||||
}
|
||||
|
||||
function isDurableMediaNode(node: CanvasNodeData) {
|
||||
const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
|
||||
return isMedia && Boolean(node.metadata?.content || node.metadata?.storageKey);
|
||||
}
|
||||
|
||||
function timelineMediaNode(media: TimelineDirectMedia): CanvasNodeData {
|
||||
const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image;
|
||||
return {
|
||||
id: media.id,
|
||||
type,
|
||||
title: media.title,
|
||||
position: { x: 0, y: 0 },
|
||||
width: media.width || 320,
|
||||
height: media.height || (type === CanvasNodeType.Audio ? 120 : 240),
|
||||
metadata: {
|
||||
content: mediaContent(media),
|
||||
storageKey: media.storageKey,
|
||||
naturalWidth: media.width,
|
||||
naturalHeight: media.height,
|
||||
durationMs: media.durationMs,
|
||||
bytes: media.bytes,
|
||||
mimeType: media.mimeType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mediaContent(media: TimelineDirectMedia) {
|
||||
return media.url || media.dataUrl || media.content || "";
|
||||
}
|
||||
|
||||
function assetStorageKey(asset: Asset) {
|
||||
return asset.kind === "text" || asset.kind === "entity" ? undefined : asset.data.storageKey;
|
||||
}
|
||||
@@ -71,7 +71,12 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr
|
||||
store.updateAsset(asset.id, { category: declaredCategory });
|
||||
asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset;
|
||||
}
|
||||
if (!options.domainProjectId) return { assetId: asset.id, created, linkedToProject: false };
|
||||
if (!options.domainProjectId) {
|
||||
// 个人画布也必须在返回成功前把素材提交到服务端,不能只依赖延迟自动同步。
|
||||
await saveRemoteUserDataNow();
|
||||
throwIfAborted(options.signal);
|
||||
return { assetId: asset.id, created, linkedToProject: false };
|
||||
}
|
||||
await syncAssetToProject(asset.id, options.domainProjectId, declaredCategory, options.folderId, options.signal);
|
||||
return { assetId: asset.id, created, linkedToProject: true };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { repairMissingCanvasAssets } from "@/services/canvas-asset-repair";
|
||||
|
||||
let activeRemoteUserId = "";
|
||||
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
|
||||
@@ -25,6 +26,7 @@ let acknowledgedProjects = new Map<string, CanvasProject>();
|
||||
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
|
||||
|
||||
export async function syncRemoteUserData(userId?: string | null) {
|
||||
let repairedCanvasAssets = false;
|
||||
await withRemoteUserDataSyncExclusive(async () => {
|
||||
activeRemoteUserId = userId || "";
|
||||
acknowledgedProjects.clear();
|
||||
@@ -42,6 +44,8 @@ export async function syncRemoteUserData(userId?: string | null) {
|
||||
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
|
||||
useCanvasStore.getState().replaceProjects(snapshot.projects);
|
||||
useAssetStore.getState().replaceAssets(snapshot.assets);
|
||||
const repair = repairMissingCanvasAssets();
|
||||
repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0;
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
|
||||
acknowledgedAssets = new Map(snapshot.assets.map((asset) => [asset.id, asset]));
|
||||
@@ -51,6 +55,7 @@ export async function syncRemoteUserData(userId?: string | null) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (repairedCanvasAssets) await saveRemoteUserDataNow();
|
||||
}
|
||||
|
||||
export function installRemoteUserDataAutoSync() {
|
||||
@@ -167,6 +172,9 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
for (const node of proj.nodes) {
|
||||
if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId);
|
||||
}
|
||||
for (const clip of proj.timeline?.clips || []) {
|
||||
if (clip.directMedia?.assetId) activeAssetIds.add(clip.directMedia.assetId);
|
||||
}
|
||||
}
|
||||
let assetChanged = false;
|
||||
// 1. 已存在的关联素材标记为 archived
|
||||
@@ -314,6 +322,9 @@ async function drainRemoteUserDataChanges() {
|
||||
}
|
||||
|
||||
async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
|
||||
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
|
||||
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
|
||||
repairMissingCanvasAssets();
|
||||
const currentProjects = useCanvasStore.getState().projects;
|
||||
const currentAssets = useAssetStore.getState().assets;
|
||||
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
|
||||
@@ -326,6 +337,13 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
|
||||
|
||||
// 转换后的 resource: 引用只属于发往服务端的 payload,不能反写整份实时 store。
|
||||
// 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。
|
||||
// 素材先于画布提交。这样画布中的 resource: 引用一旦成为远端事实,
|
||||
// 对应 Asset 已经存在,刷新或换设备不会出现只占容量、不见素材的窗口。
|
||||
for (const source of dirtyAssets) {
|
||||
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
|
||||
await upsertRemoteAsset(remotePayload);
|
||||
acknowledgedAssets.set(source.id, source);
|
||||
}
|
||||
for (const source of dirtyProjects) {
|
||||
const keysToUpload = collectLocalMediaKeys(source);
|
||||
const total = keysToUpload.length;
|
||||
@@ -364,11 +382,6 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
for (const source of dirtyAssets) {
|
||||
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
|
||||
await upsertRemoteAsset(remotePayload);
|
||||
acknowledgedAssets.set(source.id, source);
|
||||
}
|
||||
for (const id of deletedProjectIds) {
|
||||
await deleteRemoteCanvasProject(id);
|
||||
acknowledgedProjects.delete(id);
|
||||
|
||||
Reference in New Issue
Block a user