fix(canvas): keep uploaded resources in asset library (#388)

This commit is contained in:
MasterChiefCN
2026-09-02 20:18:49 +08:00
committed by GitHub
parent 58defbc3b9
commit 16913ee72c
22 changed files with 1154 additions and 34 deletions

View 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;
}

View File

@@ -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 };
}

View File

@@ -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);