672 lines
32 KiB
TypeScript
672 lines
32 KiB
TypeScript
import { getMediaBlob } from "@/services/file-storage";
|
||
import { getImageBlob } from "@/services/image-storage";
|
||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteAsset, getRemoteAssetsByIds, getRemoteCanvasProject, getRemoteUserDataSnapshot, listRemoteAssetsPage, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||
import { appQueryClient } from "@/lib/query-client";
|
||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
|
||
import type { Asset } from "@/stores/use-asset-store";
|
||
import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||
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";
|
||
|
||
let remoteUserDataPhase: RemoteUserDataPhase = "inactive";
|
||
let syncTimer: number | null = null;
|
||
let syncPromise: Promise<void> | null = null;
|
||
let syncQueued = false;
|
||
let remoteOperationTail: Promise<void> = Promise.resolve();
|
||
let subscriptionsInstalled = false;
|
||
let acknowledgedAssets = new Map<string, Asset>();
|
||
let acknowledgedProjects = new Map<string, CanvasProject>();
|
||
let incrementalSession = false;
|
||
let sessionEpoch = 0;
|
||
const verifiedProjects = new Set<string>();
|
||
const verifiedAssets = new Set<string>();
|
||
|
||
export async function initializeRemoteUserDataSession(userId: string) {
|
||
await withRemoteUserDataSyncExclusive(async () => {
|
||
resetRemoteUserDataSync();
|
||
activeRemoteUserId = userId;
|
||
incrementalSession = true;
|
||
acknowledgedProjects = new Map(useCanvasStore.getState().projects.map((project) => [project.id, project]));
|
||
acknowledgedAssets = new Map(useAssetStore.getState().assets.map((asset) => [asset.id, asset]));
|
||
remoteUserDataPhase = "ready";
|
||
});
|
||
}
|
||
|
||
export async function loadCanvasProjectForEditing(id: string) {
|
||
const epoch = sessionEpoch;
|
||
return withRemoteUserDataSyncExclusive(async () => {
|
||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新打开画布");
|
||
const local = useCanvasStore.getState().projects.find((project) => project.id === id);
|
||
if (!activeRemoteUserId || verifiedProjects.has(id)) return local;
|
||
const { project } = await getRemoteCanvasProject(id);
|
||
await loadReferencedAssets(collectAssetIds(project));
|
||
const current = useCanvasStore.getState().projects.find((candidate) => candidate.id === id);
|
||
if (current && !sameEntitySnapshot(acknowledgedProjects.get(id), current)) {
|
||
const baseline = acknowledgedProjects.get(id);
|
||
if (baseline && Date.parse(baseline.updatedAt) !== Date.parse(project.updatedAt)) throw new Error("画布已在其他端修改,请先导出本地修改再重新加载");
|
||
verifiedProjects.add(id);
|
||
return current;
|
||
}
|
||
acknowledgedProjects.set(id, project);
|
||
verifiedProjects.add(id);
|
||
useCanvasStore.setState((state) => ({ projects: [...state.projects.filter((candidate) => candidate.id !== id), project] }));
|
||
return project;
|
||
});
|
||
}
|
||
|
||
export async function loadAssetLibraryPage(options: Parameters<typeof listRemoteAssetsPage>[0]) {
|
||
const epoch = sessionEpoch;
|
||
const result = await listRemoteAssetsPage(options);
|
||
await withRemoteUserDataSyncExclusive(async () => {
|
||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新读取素材");
|
||
acceptRemoteAssets(result.assets);
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function acceptRemoteAssets(assets: Asset[]) {
|
||
const current = new Map(useAssetStore.getState().assets.map((asset) => [asset.id, asset]));
|
||
for (const asset of assets) {
|
||
const local = current.get(asset.id);
|
||
if (local && !sameEntitySnapshot(acknowledgedAssets.get(asset.id), local)) continue;
|
||
acknowledgedAssets.set(asset.id, asset);
|
||
verifiedAssets.add(asset.id);
|
||
current.set(asset.id, asset);
|
||
}
|
||
useAssetStore.setState({ assets: [...current.values()] });
|
||
}
|
||
|
||
function collectAssetIds(value: unknown, ids = new Set<string>()): Set<string> {
|
||
if (!value || typeof value !== "object") return ids;
|
||
for (const [key, child] of Object.entries(value)) {
|
||
if (key === "assetId" && typeof child === "string" && child) ids.add(child);
|
||
else if (child && typeof child === "object") collectAssetIds(child, ids);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
async function loadReferencedAssets(ids: Iterable<string>) {
|
||
const pending = [...new Set(ids)].filter((id) => !verifiedAssets.has(id));
|
||
for (let offset = 0; offset < pending.length; offset += 100) {
|
||
const { assets } = await getRemoteAssetsByIds(pending.slice(offset, offset + 100));
|
||
acceptRemoteAssets(assets);
|
||
}
|
||
}
|
||
|
||
export async function loadAssetsForUse(ids: Iterable<string>) {
|
||
const epoch = sessionEpoch;
|
||
const requestedIds = [...new Set(ids)];
|
||
await withRemoteUserDataSyncExclusive(async () => {
|
||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新读取素材");
|
||
if (activeRemoteUserId) await loadReferencedAssets(requestedIds);
|
||
const available = new Set(useAssetStore.getState().assets.map((asset) => asset.id));
|
||
if (requestedIds.some((id) => !available.has(id) || (activeRemoteUserId && !verifiedAssets.has(id)))) throw new Error("部分素材不存在或无权访问,请重新选择素材");
|
||
});
|
||
}
|
||
|
||
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 () => {
|
||
incrementalSession = false;
|
||
activeRemoteUserId = userId || "";
|
||
acknowledgedProjects.clear();
|
||
acknowledgedAssets.clear();
|
||
if (!activeRemoteUserId) {
|
||
remoteUserDataPhase = "inactive";
|
||
return;
|
||
}
|
||
remoteUserDataPhase = "hydrating";
|
||
try {
|
||
// 登录只拉一次聚合快照。摘要列表再逐条请求详情会把 N 条数据放大成 2N+2 个请求,
|
||
// 并且会在登录阶段同时触发大量媒体解析,任何一项失败都会污染登录结果。
|
||
const snapshot = await getRemoteUserDataSnapshot();
|
||
// 登录时服务端是实体真相。浏览器 IndexedDB 只作为首屏缓存,不能把服务端已删除的记录补回去。
|
||
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 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]));
|
||
remoteUserDataPhase = "ready";
|
||
} catch (error) {
|
||
remoteUserDataPhase = "failed";
|
||
throw error;
|
||
}
|
||
});
|
||
if (repairedCanvasAssets) await saveRemoteUserDataNow();
|
||
}
|
||
|
||
export function installRemoteUserDataAutoSync() {
|
||
if (subscriptionsInstalled) return;
|
||
subscriptionsInstalled = true;
|
||
useCanvasStore.subscribe((state, previous) => {
|
||
if (state.projects !== previous.projects) scheduleRemoteUserDataSync();
|
||
});
|
||
useAssetStore.subscribe((state, previous) => {
|
||
if (state.assets !== previous.assets) scheduleRemoteUserDataSync();
|
||
});
|
||
}
|
||
|
||
export function resetRemoteUserDataSync() {
|
||
sessionEpoch += 1;
|
||
incrementalSession = false;
|
||
verifiedProjects.clear();
|
||
verifiedAssets.clear();
|
||
activeRemoteUserId = "";
|
||
remoteUserDataPhase = "inactive";
|
||
acknowledgedAssets.clear();
|
||
acknowledgedProjects.clear();
|
||
if (syncTimer) {
|
||
window.clearTimeout(syncTimer);
|
||
syncTimer = null;
|
||
}
|
||
syncQueued = false;
|
||
useSyncProgressStore.getState().clearAll();
|
||
}
|
||
|
||
export function hasRemoteUserDataSyncSession() {
|
||
return Boolean(activeRemoteUserId) && remoteUserDataPhase === "ready";
|
||
}
|
||
|
||
export function withRemoteUserDataSyncExclusive<T>(operation: () => Promise<T>): Promise<T> {
|
||
const pending = remoteOperationTail.catch(() => undefined).then(operation);
|
||
remoteOperationTail = pending.then(
|
||
() => undefined,
|
||
() => undefined,
|
||
);
|
||
return pending;
|
||
}
|
||
|
||
export function scheduleRemoteUserDataSync() {
|
||
if (!activeRemoteUserId || remoteUserDataPhase !== "ready") return;
|
||
if (syncPromise) {
|
||
syncQueued = true;
|
||
return;
|
||
}
|
||
if (syncTimer) window.clearTimeout(syncTimer);
|
||
syncTimer = window.setTimeout(() => {
|
||
syncTimer = null;
|
||
void saveRemoteUserDataNow().catch((error) => console.warn("云端自动同步失败", error));
|
||
}, 1200);
|
||
}
|
||
|
||
export async function createCanvasProjectWithRemoteSync(title: string, projectId?: string, initialContent?: Partial<Pick<CanvasProject, "nodes" | "connections">>) {
|
||
const id = useCanvasStore.getState().createProject(title, projectId);
|
||
if (initialContent) useCanvasStore.getState().updateProject(id, initialContent);
|
||
if (!activeRemoteUserId) return { id, syncError: new Error("尚未建立云端同步会话") };
|
||
try {
|
||
await saveRemoteUserDataNow();
|
||
return { id };
|
||
} catch (syncError) {
|
||
scheduleRemoteUserDataSync();
|
||
return { id, syncError };
|
||
}
|
||
}
|
||
|
||
export async function deleteAssetWithRemoteSync(id: string) {
|
||
const epoch = sessionEpoch;
|
||
const assetId = id.trim();
|
||
if (!assetId) throw new Error("素材 ID 不能为空");
|
||
await withRemoteUserDataSyncExclusive(async () => {
|
||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的素材");
|
||
if (activeRemoteUserId) {
|
||
requireRemoteUserDataBaseline();
|
||
await deleteRemoteAsset(assetId);
|
||
acknowledgedAssets.delete(assetId);
|
||
}
|
||
await useAssetStore.getState().removeAsset(assetId);
|
||
await flushAssetStorePersistence();
|
||
});
|
||
}
|
||
|
||
export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||
const epoch = sessionEpoch;
|
||
const projectIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||
if (!projectIds.length) return;
|
||
if (incrementalSession) {
|
||
for (const id of projectIds) await loadCanvasProjectForEditing(id);
|
||
}
|
||
await withRemoteUserDataSyncExclusive(async () => {
|
||
if (epoch !== sessionEpoch) throw new Error("账号已切换,请重新选择要删除的画布");
|
||
if (activeRemoteUserId) requireRemoteUserDataBaseline();
|
||
const currentProjects = useCanvasStore.getState().projects;
|
||
const projectById = new Map(currentProjects.map((project) => [project.id, project]));
|
||
const deletedProjectIds: string[] = [];
|
||
const deletedProjectObjects: CanvasProject[] = [];
|
||
let deletionError: unknown;
|
||
for (const id of projectIds) {
|
||
try {
|
||
if (activeRemoteUserId) {
|
||
await deleteRemoteCanvasProject(id);
|
||
acknowledgedProjects.delete(id);
|
||
}
|
||
useCanvasStore.getState().deleteProjects([id]);
|
||
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
|
||
await flushCanvasStorePersistence();
|
||
deletedProjectIds.push(id);
|
||
const project = projectById.get(id);
|
||
if (project) deletedProjectObjects.push(project);
|
||
} catch (error) {
|
||
deletionError = error;
|
||
break;
|
||
}
|
||
}
|
||
if (deletedProjectObjects.length > 0) useCanvasHistoryStore.getState().recordDeletedProjects(deletedProjectObjects);
|
||
|
||
if (incrementalSession) {
|
||
void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
|
||
if (deletionError) throw deletionError;
|
||
return;
|
||
}
|
||
|
||
// 将属于被删除画布的所有媒体节点安全归档至素材库回收站 (status = "archived")
|
||
const currentAssets = useAssetStore.getState().assets;
|
||
const remainingProjects = useCanvasStore.getState().projects;
|
||
const activeAssetIds = new Set<string>();
|
||
for (const proj of remainingProjects) {
|
||
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
|
||
const assetsToArchive = currentAssets.filter((asset) => {
|
||
const canvasId = asset.metadata?.canvasId as string | undefined;
|
||
return canvasId && deletedProjectIds.includes(canvasId) && !activeAssetIds.has(asset.id) && asset.status !== "archived";
|
||
});
|
||
for (const asset of assetsToArchive) {
|
||
useAssetStore.getState().updateAsset(asset.id, { status: "archived" });
|
||
assetChanged = true;
|
||
}
|
||
|
||
// 2. 对于画布中尚未入库的媒体节点,直接归档为回收站素材
|
||
for (const project of deletedProjectObjects) {
|
||
for (const node of project.nodes || []) {
|
||
const isMedia = node.type === "image" || node.type === "video" || node.type === "audio";
|
||
const content = typeof node.metadata?.content === "string" ? node.metadata.content : "";
|
||
const storageKey = typeof node.metadata?.storageKey === "string" ? node.metadata.storageKey : undefined;
|
||
if (!isMedia || (!content && !storageKey)) continue;
|
||
|
||
const existingAsset = node.metadata?.assetId ? currentAssets.find((a) => a.id === node.metadata?.assetId) : undefined;
|
||
if (existingAsset) {
|
||
const owningCanvasId = existingAsset.metadata?.canvasId as string | undefined;
|
||
if (owningCanvasId === project.id && !activeAssetIds.has(existingAsset.id) && existingAsset.status !== "archived") {
|
||
useAssetStore.getState().updateAsset(existingAsset.id, { status: "archived" });
|
||
assetChanged = true;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const title = node.title || `${project.title} - ${node.type === "image" ? "图片" : node.type === "video" ? "视频" : "音频"}`;
|
||
const prompt = typeof node.metadata?.prompt === "string" ? node.metadata.prompt : "";
|
||
if (node.type === "image") {
|
||
useAssetStore.getState().addAsset({
|
||
kind: "image",
|
||
title,
|
||
coverUrl: content || "",
|
||
tags: prompt ? [prompt.slice(0, 16)] : ["画布生成"],
|
||
category: "other",
|
||
status: "archived",
|
||
source: `已删除画布:${project.title}`,
|
||
data: {
|
||
dataUrl: content || "",
|
||
storageKey,
|
||
width: Number(node.width) || 1024,
|
||
height: Number(node.height) || 1024,
|
||
bytes: Number(node.metadata?.bytes) || 0,
|
||
mimeType: (node.metadata?.mimeType as string) || "image/png",
|
||
},
|
||
metadata: {
|
||
canvasId: project.id,
|
||
sourceNodeId: node.id,
|
||
},
|
||
});
|
||
assetChanged = true;
|
||
} else if (node.type === "video") {
|
||
useAssetStore.getState().addAsset({
|
||
kind: "video",
|
||
title,
|
||
coverUrl: content || "",
|
||
tags: prompt ? [prompt.slice(0, 16)] : ["画布视频"],
|
||
category: "other",
|
||
status: "archived",
|
||
source: `已删除画布:${project.title}`,
|
||
data: {
|
||
url: content || "",
|
||
storageKey,
|
||
width: Number(node.width) || 1280,
|
||
height: Number(node.height) || 720,
|
||
durationMs: Number(node.metadata?.durationMs) || 0,
|
||
bytes: Number(node.metadata?.bytes) || 0,
|
||
mimeType: (node.metadata?.mimeType as string) || "video/mp4",
|
||
},
|
||
metadata: {
|
||
canvasId: project.id,
|
||
sourceNodeId: node.id,
|
||
},
|
||
});
|
||
assetChanged = true;
|
||
} else if (node.type === "audio") {
|
||
useAssetStore.getState().addAsset({
|
||
kind: "audio",
|
||
title,
|
||
coverUrl: "",
|
||
tags: ["画布音频"],
|
||
category: "other",
|
||
status: "archived",
|
||
source: `已删除画布:${project.title}`,
|
||
data: {
|
||
url: content || "",
|
||
storageKey,
|
||
durationMs: Number(node.metadata?.durationMs) || 0,
|
||
bytes: Number(node.metadata?.bytes) || 0,
|
||
mimeType: (node.metadata?.mimeType as string) || "audio/mpeg",
|
||
},
|
||
metadata: {
|
||
canvasId: project.id,
|
||
sourceNodeId: node.id,
|
||
},
|
||
});
|
||
assetChanged = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (assetChanged) {
|
||
await flushAssetStorePersistence();
|
||
if (activeRemoteUserId) {
|
||
try {
|
||
await drainRemoteUserDataChanges();
|
||
} catch (syncErr) {
|
||
scheduleRemoteUserDataSync();
|
||
console.warn("回收站素材云端同步警告:", syncErr);
|
||
}
|
||
}
|
||
}
|
||
if (deletionError) throw deletionError;
|
||
});
|
||
}
|
||
|
||
export async function saveRemoteUserDataNow() {
|
||
const epoch = sessionEpoch;
|
||
if (!activeRemoteUserId) return;
|
||
requireRemoteUserDataBaseline();
|
||
if (syncPromise) {
|
||
syncQueued = true;
|
||
return syncPromise;
|
||
}
|
||
syncPromise = withRemoteUserDataSyncExclusive(async () => {
|
||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
|
||
requireRemoteUserDataBaseline();
|
||
await drainRemoteUserDataChanges();
|
||
});
|
||
try {
|
||
await syncPromise;
|
||
} finally {
|
||
syncPromise = null;
|
||
}
|
||
}
|
||
|
||
async function drainRemoteUserDataChanges() {
|
||
const uploaded = new Map<string, string>();
|
||
do {
|
||
syncQueued = false;
|
||
await saveRemoteUserDataBatch(uploaded);
|
||
} while (syncQueued);
|
||
}
|
||
|
||
async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
|
||
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
|
||
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
|
||
const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id));
|
||
repairMissingCanvasAssets(incrementalSession ? changedProjectIds : undefined, incrementalSession);
|
||
const currentProjects = useCanvasStore.getState().projects;
|
||
const currentAssets = useAssetStore.getState().assets;
|
||
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
|
||
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
|
||
if (!dirtyProjects.length && !dirtyAssets.length) return;
|
||
|
||
if (incrementalSession) {
|
||
for (const source of dirtyProjects) {
|
||
const baseline = acknowledgedProjects.get(source.id);
|
||
if (!baseline || verifiedProjects.has(source.id)) continue;
|
||
const { project } = await getRemoteCanvasProject(source.id);
|
||
if (Date.parse(project.updatedAt) !== Date.parse(baseline.updatedAt)) throw new Error("画布远端版本已变化,已停止覆盖,请重新打开画布");
|
||
verifiedProjects.add(source.id);
|
||
}
|
||
for (const source of dirtyAssets) {
|
||
const baseline = acknowledgedAssets.get(source.id);
|
||
if (!baseline || verifiedAssets.has(source.id)) continue;
|
||
const { asset } = await getRemoteAsset(source.id);
|
||
if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) throw new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库");
|
||
verifiedAssets.add(source.id);
|
||
}
|
||
}
|
||
|
||
// 转换后的 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);
|
||
verifiedAssets.add(source.id);
|
||
}
|
||
for (const source of dirtyProjects) {
|
||
const keysToUpload = collectLocalMediaKeys(source);
|
||
const total = keysToUpload.length;
|
||
if (total > 0) {
|
||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||
projectId: source.id,
|
||
total,
|
||
completed: 0,
|
||
phase: "uploading",
|
||
message: "正在同步媒体至云端",
|
||
});
|
||
}
|
||
const onMediaUploaded = () => {
|
||
if (total > 0) {
|
||
useSyncProgressStore.getState().incrementProjectCompleted(source.id);
|
||
}
|
||
};
|
||
try {
|
||
const remotePayload = await ensureRemoteResourceReferences(source, uploaded, onMediaUploaded);
|
||
if (total > 0) {
|
||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||
phase: "saving",
|
||
message: "正在保存画布结构",
|
||
});
|
||
}
|
||
await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
|
||
acknowledgedProjects.set(source.id, source);
|
||
verifiedProjects.add(source.id);
|
||
if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null);
|
||
} catch (error) {
|
||
if (total > 0) {
|
||
useSyncProgressStore.getState().setProjectProgress(source.id, {
|
||
phase: "error",
|
||
message: error instanceof Error ? error.message : "云端同步失败,等待重试",
|
||
});
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
if (dirtyProjects.length) void appQueryClient.invalidateQueries({ queryKey: ["canvas-library"] });
|
||
}
|
||
|
||
function collectLocalMediaKeys(value: unknown, set = new Set<string>()): string[] {
|
||
if (!value || typeof value !== "object") return [...set];
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) collectLocalMediaKeys(item, set);
|
||
return [...set];
|
||
}
|
||
const record = value as Record<string, unknown>;
|
||
const storageKey = typeof record.storageKey === "string" ? record.storageKey : "";
|
||
if (isLocalStorageKey(storageKey) && !resourceIdFromStorageKey(storageKey)) {
|
||
set.add(storageKey);
|
||
} else {
|
||
const inline = inlineMediaDataUrl(record);
|
||
if (inline) set.add(`${inline.length}:${inline.slice(0, 64)}:${inline.slice(-64)}`);
|
||
}
|
||
for (const child of Object.values(record)) {
|
||
collectLocalMediaKeys(child, set);
|
||
}
|
||
return [...set];
|
||
}
|
||
|
||
async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>(), onUploaded?: () => void): Promise<T> {
|
||
if (!value || typeof value !== "object") return value;
|
||
if (Array.isArray(value)) {
|
||
const result: unknown[] = [];
|
||
for (const item of value) result.push(await ensureRemoteResourceReferences(item, uploaded, onUploaded));
|
||
return result as T;
|
||
}
|
||
|
||
const next: Record<string, unknown> = {};
|
||
for (const [key, child] of Object.entries(value)) {
|
||
next[key] = await ensureRemoteResourceReferences(child, uploaded, onUploaded);
|
||
}
|
||
|
||
const storageKey = typeof next.storageKey === "string" ? next.storageKey : "";
|
||
const remoteResourceId = resourceIdFromStorageKey(storageKey);
|
||
if (remoteResourceId) return applyResourceReference(next, storageKey) as T;
|
||
|
||
if (!isLocalStorageKey(storageKey)) {
|
||
const inline = inlineMediaDataUrl(next);
|
||
if (!inline) return next as T;
|
||
const identity = await inlineMediaUploadIdentity(inline);
|
||
const cached = uploaded.get(identity);
|
||
if (cached) return applyResourceReference(next, cached) as T;
|
||
const resourceStorage = await uploadInlineDataUrl(inline, identity);
|
||
uploaded.set(identity, resourceStorage);
|
||
onUploaded?.();
|
||
return applyResourceReference(next, resourceStorage) as T;
|
||
}
|
||
|
||
const cached = uploaded.get(storageKey);
|
||
if (cached) return applyResourceReference(next, cached) as T;
|
||
const resourceStorage = await uploadLocalStorageKey(storageKey, next);
|
||
uploaded.set(storageKey, resourceStorage);
|
||
onUploaded?.();
|
||
return applyResourceReference(next, resourceStorage) as T;
|
||
}
|
||
|
||
function applyResourceReference(payload: Record<string, unknown>, storageKey: string) {
|
||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||
if (!resourceId) {
|
||
throw new Error(`远端资源引用无效:${storageKey}`);
|
||
}
|
||
const url = resourceFileUrl(resourceId);
|
||
payload.storageKey = storageKey;
|
||
for (const key of ["content", "dataUrl", "url", "coverUrl"]) {
|
||
if (typeof payload[key] === "string") payload[key] = url;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function inlineMediaDataUrl(payload: Record<string, unknown>) {
|
||
for (const key of ["dataUrl", "content", "url", "coverUrl"]) {
|
||
const value = payload[key];
|
||
if (typeof value === "string" && /^data:(image|video|audio)\//i.test(value)) return value;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function uploadInlineDataUrl(dataUrl: string, identity: string) {
|
||
const response = await fetch(dataUrl);
|
||
if (!response.ok) throw new Error("内嵌媒体读取失败");
|
||
const blob = await response.blob();
|
||
const kind: "image" | "video" | "audio" | "file" = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
|
||
const resource = await uploadResourceFile(blob, kind, { idempotencyKey: identity });
|
||
return resourceStorageKey(resource.id);
|
||
}
|
||
|
||
async function inlineMediaUploadIdentity(dataUrl: string) {
|
||
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(dataUrl));
|
||
return `inline:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
||
}
|
||
|
||
async function uploadLocalStorageKey(storageKey: string, payload: Record<string, unknown>) {
|
||
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
|
||
if (!blob) throw new Error(`本地媒体不存在,无法同步:${storageKey}`);
|
||
const kind = blob.type.startsWith("image/") ? "image" : blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
|
||
const resource = await uploadResourceFile(blob, kind, {
|
||
width: numberValue(payload.naturalWidth) || numberValue(payload.width),
|
||
height: numberValue(payload.naturalHeight) || numberValue(payload.height),
|
||
durationMs: numberValue(payload.durationMs),
|
||
idempotencyKey: storageKey,
|
||
});
|
||
return resourceStorageKey(resource.id);
|
||
}
|
||
|
||
function requireRemoteUserDataBaseline() {
|
||
if (remoteUserDataPhase !== "ready") throw new Error("云端数据基线尚未建立,已停止写入");
|
||
}
|
||
|
||
function sameEntitySnapshot<T>(acknowledged: T | undefined, current: T) {
|
||
return acknowledged !== undefined && (acknowledged === current || JSON.stringify(acknowledged) === JSON.stringify(current));
|
||
}
|
||
|
||
function isLocalStorageKey(value: string) {
|
||
return LOCAL_STORAGE_KEY_PATTERN.test(value) && !resourceIdFromStorageKey(value);
|
||
}
|
||
|
||
function numberValue(value: unknown) {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) && number > 0 ? number : undefined;
|
||
}
|
||
|
||
function sanitizeCanvasProjectForRemoteSync<T>(project: T): T {
|
||
if (!project || typeof project !== "object") return project;
|
||
const clone = { ...(project as Record<string, unknown>) };
|
||
if (Array.isArray(clone.chatSessions)) {
|
||
clone.chatSessions = clone.chatSessions.map((session) => {
|
||
if (!session || typeof session !== "object") return session;
|
||
const s = { ...(session as Record<string, unknown>) };
|
||
if (Array.isArray(s.messages)) {
|
||
s.messages = s.messages.map((message) => {
|
||
if (!message || typeof message !== "object" || !message.detail) return message;
|
||
const m = { ...(message as Record<string, unknown>) };
|
||
if (m.detail && typeof m.detail === "object") {
|
||
const d = { ...(m.detail as Record<string, unknown>) };
|
||
if (Array.isArray(d.results)) {
|
||
d.results = d.results.map((r) => {
|
||
if (!r || typeof r !== "object") return r;
|
||
const res = { ...(r as Record<string, unknown>) };
|
||
if (res.result && typeof res.result === "object") {
|
||
const inner = { ...(res.result as Record<string, unknown>) };
|
||
if (inner.data && typeof inner.data === "object") {
|
||
const { snapshot: _s, before: _b, after: _a, ...restData } = inner.data as Record<string, unknown>;
|
||
inner.data = restData;
|
||
}
|
||
res.result = inner;
|
||
}
|
||
return res;
|
||
});
|
||
}
|
||
m.detail = d;
|
||
}
|
||
return m;
|
||
});
|
||
}
|
||
return s;
|
||
});
|
||
}
|
||
return clone as T;
|
||
}
|