diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 86ba5370a..07c1442e1 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -213,6 +213,19 @@ SEO 标题、描述、可选关键词会投影到浏览器 metadata、Open Graph - `web/src/lib/canvas/canvas-starter.ts`:空画布起点状态决策。 - `web/src/stores/canvas/use-canvas-store.ts`:按画布保存自由起点选择。 +## 画布手动保存与强制覆盖 + +画布左上角菜单提供「保存」与「强制覆盖保存」两个手动入口。「保存」与 ⌘/Ctrl+S 等价:先写入浏览器本地缓存,再触发云端同步;云端失败时保留本地结果并排队自动重试,适合平板等无键盘设备手动落盘。 + +「强制覆盖保存」用于云端同步反复失败(如提示“画布媒体与素材库记录不一致”)时的显式恢复,操作前有确认弹窗。执行时以本地画布为准覆盖云端版本:先按服务端素材记录校验所有画布媒体节点与时间线片段声明的素材绑定,不一致时重绑到引用同一资源的素材(缺失则按节点新建),再按“素材先于画布”的顺序整体推送;素材远端版本冲突时采纳远端为基线继续用本地覆盖。后端“画布资源必须被其素材记录引用”的不变式保持不变,强制覆盖通过前端对齐绑定完成,不绕过校验。 + +### 相关实现 + +- `web/src/services/canvas-asset-repair.ts`:媒体与素材的资源身份归一化、增量自动修复和强制重绑。 +- `web/src/services/user-data-sync.ts`:强制覆盖同步入口与素材冲突基线策略。 +- `web/src/pages/canvas/use-canvas-project-lifecycle.ts`:手动保存与强制保存流程。 +- `web/src/pages/canvas/canvas-project-top-bar.tsx`:菜单入口。 + ## 画布外观 底部工具栏的“画布外观”面板支持浅色、深色、自定义三种主题,以及点阵、线稿、空白三种网格。浅色画布使用比白色节点更沉的中性雾灰背景,深色画布使用比原深色节点更亮的中性灰背景,让画布底层与内容表面保持稳定区分,同时避免蓝灰背景与黑色工具浮层产生色温割裂。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 403035e86..05dad2f44 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -93,6 +93,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - 已补充后端回归用例,覆盖任务状态、输入引用、画布引用、删除事务 Outbox、历史记录保留和自动清理边界;测试包编译通过,本地数据库测试因 `CGO_ENABLED=0` 无法初始化 SQLite,尚未验证通过。 - 待部署验收:未绑定画布的生成图片可单个及批量彻底删除;被画布或其他任务输入引用时仍提示来源。删除后任务历史中的原文件不再可用,共享素材仍保留其资源。 +## 画布手动保存与强制覆盖保存 + +- 画布左上角菜单新增「保存」(与 ⌘/Ctrl+S 等价,本地落盘后同步云端,适配平板无键盘场景)与「强制覆盖保存」(确认弹窗后以本地内容覆盖云端,并自动修复画布媒体与素材库的绑定)。 +- 根因修复:同节点原地重生成(如视频节点改提示词后重新生成)走兜底写回路径时,节点保留旧 assetId 却指向新资源,后端画布媒体校验拒绝后,自动修复被节点自身声明掩盖,形成“自动重试但永远失败”。现节点声明不再覆盖素材库真实绑定,错误配对会在下一次自动同步时自愈。 +- 强制覆盖语义:按服务端素材记录(与本地缓存合并)校验所有画布媒体节点与时间线片段的素材绑定,不一致时重绑到引用同一资源的素材或按节点新建,再按“素材先于画布”整体推送;素材远端版本冲突时采纳远端为基线继续用本地覆盖。后端不变式与接口未改动。 +- 已通过 `web/test/canvas-asset-repair.test.ts`(8 例)与前端生产构建。待浏览器实测:改提示词原地重生成视频后不再出现“画布媒体与素材库记录不一致”的持续报错;出现历史遗留报错时「强制覆盖保存」能立即恢复且后续自动同步不再报错;平板设备上「保存」菜单可用。 + ## 欢迎页开放开关 - 管理员在 `/admin/settings/appearance` 的英文品牌标识下方切换欢迎页,立即保存,沿用功能配置保存及审计流程,默认开启;功能开放页不再编辑该字段。 diff --git a/web/src/pages/canvas/canvas-project-top-bar.tsx b/web/src/pages/canvas/canvas-project-top-bar.tsx index 167f4545d..94656aa20 100644 --- a/web/src/pages/canvas/canvas-project-top-bar.tsx +++ b/web/src/pages/canvas/canvas-project-top-bar.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Link } from "react-router"; -import { Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Share2, Trash2, Undo2, Upload } from "lucide-react"; +import { Clapperboard, CloudDownload, CloudUpload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react"; import { Button, Dropdown, Tooltip } from "antd"; import { useWalletBalance } from "@/hooks/use-wallet-balance"; @@ -25,6 +25,8 @@ type CanvasTopBarProps = { canRedo: boolean; onCreateProject: () => void; onDeleteProject: () => void; + onSave: () => void | Promise; + onForceSave: () => void; onImportImage: () => void; onImportLibTV: () => void; onImportTapNow: () => void; @@ -52,6 +54,8 @@ export function CanvasTopBar({ canRedo, onCreateProject, onDeleteProject, + onSave, + onForceSave, onImportImage, onImportLibTV, onImportTapNow, @@ -105,6 +109,8 @@ export function CanvasTopBar({ { type: "divider" }, { key: "new", icon: , label: "新建画布", onClick: onCreateProject }, { key: "delete", danger: true, icon: , label: "删除当前画布", onClick: onDeleteProject }, + { key: "save", icon: , label: , onClick: () => void onSave() }, + { key: "force-save", icon: , label: "强制覆盖保存", onClick: onForceSave }, { type: "divider" }, { key: "import", icon: , label: "导入素材", onClick: onImportImage }, { key: "search", icon: , label: , onClick: onOpenSearch }, diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index e1e262fdf..3620fcf97 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -416,7 +416,7 @@ function InfiniteCanvasPage() { [cleanupAssetImages, getHistoryCleanupContext], ); - const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, updateProject } = useCanvasProjectLifecycle({ + const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({ projectId, projectLoaded, nodes, @@ -445,6 +445,18 @@ function InfiniteCanvasPage() { cleanupCanvasFiles, }); + // 强制覆盖会改写云端版本并重绑媒体素材关联,必须让用户显式确认 destructive 语义。 + const confirmForceSaveCanvas = useCallback(() => { + modal.confirm({ + title: "用本地内容强制覆盖云端?", + content: "将把当前本地画布保存并覆盖云端版本,同时自动修复画布媒体与素材库的绑定(缺少素材记录时会按节点新建)。云端尚未同步到本地的改动会被覆盖。", + okText: "强制覆盖保存", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: () => forceSaveCanvasProject(), + }); + }, [forceSaveCanvasProject, modal]); + const applyLibTVImport = useCallback( async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { const previousNodes = nodesRef.current; @@ -2336,6 +2348,8 @@ function InfiniteCanvasPage() { canRedo={historyState.canRedo} onCreateProject={createAndOpenProject} onDeleteProject={deleteCurrentProject} + onSave={() => void saveCanvasProject()} + onForceSave={confirmForceSaveCanvas} onImportImage={() => handleUploadRequest()} onImportLibTV={() => setLibTVImportOpen(true)} onImportTapNow={() => setTapNowImportOpen(true)} diff --git a/web/src/pages/canvas/use-canvas-project-lifecycle.ts b/web/src/pages/canvas/use-canvas-project-lifecycle.ts index d443f86d0..485dbcdc8 100644 --- a/web/src/pages/canvas/use-canvas-project-lifecycle.ts +++ b/web/src/pages/canvas/use-canvas-project-lifecycle.ts @@ -9,7 +9,7 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps"; import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation"; import { listAddedSkills, type Skill } from "@/services/api/skills"; -import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; +import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync"; import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store"; import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -242,7 +242,7 @@ export function useCanvasProjectLifecycle({ renameProject(projectId, title); }, [projectId, renameProject]); - const saveCanvasProject = useCallback(async (): Promise => { + const persistCanvasSnapshot = useCallback(async (): Promise => { try { updateProject(projectId, { nodes: nodesRef.current, @@ -256,10 +256,15 @@ export function useCanvasProjectLifecycle({ directorScenes: currentProject?.directorScenes || [], }); await flushCanvasStorePersistence(); + return true; } catch { message.error("画布保存失败,请稍后重试"); return false; } + }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); + + const saveCanvasProject = useCallback(async (): Promise => { + if (!(await persistCanvasSnapshot())) return false; try { await saveRemoteUserDataNow(); message.success("画布布局和位置已保存"); @@ -268,7 +273,18 @@ export function useCanvasProjectLifecycle({ message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error)); } return true; - }, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]); + }, [message, persistCanvasSnapshot]); + + const forceSaveCanvasProject = useCallback(async (): Promise => { + if (!(await persistCanvasSnapshot())) return false; + try { + const result = await forceOverwriteRemoteCanvasSync(); + message.success(result.reboundNodes > 0 ? `已用本地内容覆盖云端,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "已用本地内容覆盖云端画布"); + } catch (error) { + message.error(`强制覆盖保存失败:${error instanceof Error ? error.message : "未知错误"}`); + } + return true; + }, [message, persistCanvasSnapshot]); const clearCanvasFiles = useCallback(() => { cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] }); @@ -285,6 +301,7 @@ export function useCanvasProjectLifecycle({ deleteCurrentProject, renameCurrentProject, saveCanvasProject, + forceSaveCanvasProject, updateProject, }; } diff --git a/web/src/services/canvas-asset-repair.ts b/web/src/services/canvas-asset-repair.ts index a350b2282..efdbf0418 100644 --- a/web/src/services/canvas-asset-repair.ts +++ b/web/src/services/canvas-asset-repair.ts @@ -1,5 +1,6 @@ import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset"; -import { useAssetStore, type Asset } from "@/stores/use-asset-store"; +import { resourceIdFromStorageKey } from "@/services/api/resources"; +import { useAssetStore, type Asset, type NewAsset } 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"; @@ -32,13 +33,15 @@ export function repairMissingCanvasAssets(projectIds?: Set, partialAsset for (const node of project.nodes) { if (node.metadata?.assetId) { knownAssetIds.add(node.metadata.assetId); - if (node.metadata.storageKey) storageKeyByAssetId.set(node.metadata.assetId, node.metadata.storageKey); + // 节点声明只用于补充本地未加载的素材映射,不能覆盖素材库的真实绑定: + // 原地重生成的节点可能保留旧 assetId 却指向新资源,覆盖会让修复永远放行错误配对。 + if (node.metadata.storageKey && !storageKeyByAssetId.has(node.metadata.assetId)) storageKeyByAssetId.set(node.metadata.assetId, node.metadata.storageKey); } } for (const clip of project.timeline?.clips || []) { if (clip.directMedia?.assetId) { knownAssetIds.add(clip.directMedia.assetId); - if (clip.directMedia.storageKey) storageKeyByAssetId.set(clip.directMedia.assetId, clip.directMedia.storageKey); + if (clip.directMedia.storageKey && !storageKeyByAssetId.has(clip.directMedia.assetId)) storageKeyByAssetId.set(clip.directMedia.assetId, clip.directMedia.storageKey); } } } @@ -99,8 +102,7 @@ function matchingAssetId(explicitId: string | undefined, storageKey: string | un } 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); + return isCanvasMediaNodeType(node) && Boolean(node.metadata?.content || node.metadata?.storageKey); } function timelineMediaNode(media: TimelineDirectMedia): CanvasNodeData { @@ -131,3 +133,145 @@ function mediaContent(media: TimelineDirectMedia) { function assetStorageKey(asset: Asset) { return asset.kind === "text" || asset.kind === "entity" ? undefined : asset.data.storageKey; } + +export type CanvasAssetRebindResult = { + reboundNodes: number; + createdAssets: number; + updatedProjects: number; +}; + +const LOCAL_MEDIA_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/; +const RESOURCE_URL_PATTERN = /\/resources\/([A-Za-z0-9_-]{1,80})\/file/; +const RESOURCE_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/; + +/** + * 强制对齐画布媒体与素材的绑定:以素材记录实际引用的资源为准校验节点声明的 + * (assetId, 资源) 对,不一致时重绑到引用同一资源的现有素材,缺失则按节点新建。 + * 服务端画布不变式只认「资源被 assetId 对应素材引用」,重绑后按素材先于画布 + * 推送即可在不破坏该不变式的前提下用本地画布覆盖云端。 + */ +export function rebindInconsistentCanvasAssets(records: Asset[], projectIds?: Set): CanvasAssetRebindResult { + const identitiesByAssetId = new Map>(); + const assetIdByIdentity = new Map(); + const registerAsset = (asset: Asset) => { + const identities = assetIdentities(asset); + identitiesByAssetId.set(asset.id, identities); + for (const identity of identities) { + if (!assetIdByIdentity.has(identity)) assetIdByIdentity.set(identity, asset.id); + } + }; + for (const asset of records) registerAsset(asset); + + let reboundNodes = 0; + let createdAssets = 0; + let updatedProjects = 0; + const canvasStore = useCanvasStore.getState(); + for (const project of canvasStore.projects) { + if (projectIds && !projectIds.has(project.id)) continue; + let projectChanged = false; + const resolveAssetId = (declaredId: string | undefined, identity: string, buildAsset: () => NewAsset | null): string | undefined => { + if (declaredId && identitiesByAssetId.get(declaredId)?.has(identity)) return declaredId; + const candidate = assetIdByIdentity.get(identity); + if (candidate) return candidate; + const input = buildAsset(); + if (!input) return declaredId; + const assetStore = useAssetStore.getState(); + const assetId = assetStore.addAsset(input); + identitiesByAssetId.set(assetId, new Set([identity])); + if (!assetIdByIdentity.has(identity)) assetIdByIdentity.set(identity, assetId); + createdAssets += 1; + return assetId; + }; + const nodes = project.nodes.map((node) => { + if (!isCanvasMediaNodeType(node)) return node; + const identity = mediaNodeIdentity(node.metadata?.storageKey, node.metadata?.content); + if (!identity) return node; + const assetId = resolveAssetId(node.metadata?.assetId, identity, () => canvasNodeToAsset(node, { canvasId: project.id, source: "canvas-manual" })); + if (!assetId || node.metadata?.assetId === assetId) return node; + reboundNodes += 1; + projectChanged = true; + return { ...node, metadata: { ...node.metadata, assetId } }; + }); + const timeline = project.timeline + ? { + ...project.timeline, + clips: project.timeline.clips.map((clip) => { + const media = clip.directMedia; + const identity = media && media.kind !== "text" ? mediaNodeIdentity(media.storageKey, mediaContent(media)) : undefined; + if (!media || !identity) return clip; + const assetId = resolveAssetId(media.assetId, identity, () => canvasNodeToAsset(timelineMediaNode(media), { canvasId: project.id, source: "canvas-manual" })); + if (!assetId || media.assetId === assetId) return clip; + reboundNodes += 1; + projectChanged = true; + return { ...clip, directMedia: { ...media, assetId } }; + }), + } + : project.timeline; + if (!projectChanged) continue; + canvasStore.updateProject(project.id, { nodes, timeline }); + updatedProjects += 1; + } + return { reboundNodes, createdAssets, updatedProjects }; +} + +/** 收集画布媒体节点与时间线片段声明过 assetId 的媒体引用,供强制覆盖前批量拉取服务端素材记录。 */ +export function collectCanvasMediaAssetIds(projects: CanvasProject[]): Set { + const ids = new Set(); + for (const project of projects) { + for (const node of project.nodes) { + if (isCanvasMediaNodeType(node) && node.metadata?.assetId && mediaNodeIdentity(node.metadata.storageKey, node.metadata.content)) ids.add(node.metadata.assetId); + } + for (const clip of project.timeline?.clips || []) { + const media = clip.directMedia; + if (media?.assetId && media.kind !== "text" && mediaNodeIdentity(media.storageKey, mediaContent(media))) ids.add(media.assetId); + } + } + return ids; +} + +function isCanvasMediaNodeType(node: CanvasNodeData) { + return node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio; +} + +function mediaNodeIdentity(storageKey: string | undefined, content: string | undefined) { + const fromKey = mediaIdentityFromValue(storageKey); + if (fromKey) return fromKey; + const resourceId = resourceIdFromStorageKey(content); + if (resourceId) return resourceIdFromIdentity(resourceId); + const urlMatch = RESOURCE_URL_PATTERN.exec(content || ""); + return urlMatch ? urlMatch[1] : undefined; +} + +function mediaIdentityFromValue(value: string | undefined) { + const key = value?.trim(); + if (!key || key.startsWith("data:") || key.startsWith("blob:")) return undefined; + const resourceId = resourceIdFromStorageKey(key); + if (resourceId) return resourceIdFromIdentity(resourceId); + const urlMatch = RESOURCE_URL_PATTERN.exec(key); + if (urlMatch) return urlMatch[1]; + // 本地存储 key(image:/video:…)上传时以 key 为幂等标识,同名必得同资源,可作稳定身份。 + return LOCAL_MEDIA_KEY_PATTERN.test(key) ? key : undefined; +} + +function resourceIdFromIdentity(candidate: string | undefined) { + const id = candidate?.trim() || ""; + return RESOURCE_ID_PATTERN.test(id) ? id : undefined; +} + +/** 镜像服务端 guard 的素材资源定位来源:storageKey、resourceKey 与 URL 形态的资源引用。 */ +function assetIdentities(asset: Asset) { + const identities = new Set(); + const data = asset.kind === "text" || asset.kind === "entity" ? undefined : asset.data; + const values = [ + typeof asset.metadata?.resourceKey === "string" ? asset.metadata.resourceKey : "", + assetStorageKey(asset) || "", + ...(data && "url" in data && typeof data.url === "string" ? [data.url] : []), + ...(data && "dataUrl" in data && typeof data.dataUrl === "string" ? [data.dataUrl] : []), + asset.coverUrl || "", + ]; + for (const value of values) { + const identity = mediaIdentityFromValue(value); + if (identity) identities.add(identity); + } + return identities; +} diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index a6f40c1f2..d617abda4 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -11,7 +11,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"; +import { repairMissingCanvasAssets, collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, type CanvasAssetRebindResult } from "@/services/canvas-asset-repair"; import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset"; import { sameAgentCanvasContent } from "@/lib/canvas/agent-canvas-snapshot"; import { applyAgentCanvasPatch, type AgentCanvasPatch } from "@/lib/canvas/agent-canvas-patch"; @@ -436,7 +436,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) { }); } -export async function saveRemoteUserDataNow() { +export async function saveRemoteUserDataNow(options: { force?: boolean } = {}) { // 这是远端写入的总闸门:只有 phase=ready 且已建立 acknowledged 基线时才能提交。 // 本地 Zustand/localForage 写成功不等于服务端写成功,任何同步异常都必须继续抛出给调用方。 const epoch = sessionEpoch; @@ -451,7 +451,7 @@ export async function saveRemoteUserDataNow() { syncPromise = withRemoteUserDataSyncExclusive(async () => { if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存"); requireRemoteUserDataBaseline(); - await drainRemoteUserDataChanges(); + await drainRemoteUserDataChanges(options); }); try { await syncPromise; @@ -460,15 +460,47 @@ export async function saveRemoteUserDataNow() { } } -async function drainRemoteUserDataChanges() { +/** + * 强制覆盖保存:以本地内容为准修复画布媒体与素材的绑定(重绑到引用同一资源的素材, + * 缺失则按节点新建),再走「素材先于画布」的整体推送覆盖云端。服务端画布不变式只认 + * 「节点资源被其 assetId 对应素材引用」,重绑后的本地画布可以在不破坏该不变式的前提下 + * 覆盖远端。素材远端版本冲突时采纳远端为基线继续覆盖,避免显式覆盖被冲突检测卡死。 + */ +export async function forceOverwriteRemoteCanvasSync(): Promise { + const epoch = sessionEpoch; + if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试"); + requireRemoteUserDataBaseline(); + await waitForRemoteProjectLoads(); + const rebind = await withRemoteUserDataSyncExclusive(async () => { + if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止强制覆盖"); + requireRemoteUserDataBaseline(); + const projects = useCanvasStore.getState().projects; + // 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。 + const claimedIds = [...collectCanvasMediaAssetIds(projects)]; + const remoteAssets: Asset[] = []; + for (let offset = 0; offset < claimedIds.length; offset += 100) { + const { assets } = await getRemoteAssetsByIds(claimedIds.slice(offset, offset + 100)); + remoteAssets.push(...assets); + } + const remoteById = new Map(remoteAssets.map((asset) => [asset.id, asset])); + const merged = [...remoteAssets, ...useAssetStore.getState().assets.filter((asset) => !remoteById.has(asset.id))]; + const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged)); + await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); + return result; + }); + await saveRemoteUserDataNow({ force: true }); + return rebind; +} + +async function drainRemoteUserDataChanges(options: { force?: boolean } = {}) { const uploaded = new Map(); do { syncQueued = false; - await saveRemoteUserDataBatch(uploaded); + await saveRemoteUserDataBatch(uploaded, options); } while (syncQueued); } -async function saveRemoteUserDataBatch(uploaded: Map) { +async function saveRemoteUserDataBatch(uploaded: Map, options: { force?: boolean } = {}) { // 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。 // 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。 const changedProjectIds = new Set(useCanvasStore.getState().projects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project)).map((project) => project.id)); @@ -495,7 +527,11 @@ async function saveRemoteUserDataBatch(uploaded: Map) { 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("素材远端版本已变化,已停止覆盖,请重新打开素材库"); + if (Date.parse(asset.updatedAt) !== Date.parse(baseline.updatedAt)) { + if (!options.force) throw new Error("素材远端版本已变化,已停止覆盖,请重新打开素材库"); + // 强制覆盖是用户显式指令:采纳远端版本为新基线后继续用本地内容覆盖。 + acknowledgedAssets.set(source.id, asset); + } verifiedAssets.add(source.id); } } diff --git a/web/test/canvas-asset-repair.test.ts b/web/test/canvas-asset-repair.test.ts new file mode 100644 index 000000000..57f905fc9 --- /dev/null +++ b/web/test/canvas-asset-repair.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, test } from "bun:test"; + +import { collectCanvasMediaAssetIds, rebindInconsistentCanvasAssets, repairMissingCanvasAssets } from "@/services/canvas-asset-repair"; +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 { TimelineClip } from "@/types/timeline"; + +function videoAsset(id: string, storageKey?: string, url = ""): Asset { + return { + id, + kind: "video", + title: `素材 ${id}`, + coverUrl: "", + tags: [], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + data: { url, ...(storageKey ? { storageKey } : {}), width: 1, height: 1, bytes: 1, mimeType: "video/mp4" }, + } as Asset; +} + +function videoNode(id: string, assetId: string | undefined, storageKey: string): CanvasNodeData { + return { + id, + type: CanvasNodeType.Video, + title: "镜头", + position: { x: 0, y: 0 }, + width: 320, + height: 240, + metadata: { assetId, storageKey, content: "blob:http://localhost/preview" }, + }; +} + +function project(id: string, nodes: CanvasNodeData[], clips: TimelineClip[] = []): CanvasProject { + return { + id, + title: `画布 ${id}`, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + nodes, + connections: [], + chatSessions: [], + activeChatId: null, + viewport: { x: 0, y: 0, k: 1 }, + ...(clips.length ? { timeline: { clips } } : {}), + }; +} + +function timelineClip(assetId: string | undefined, storageKey: string): TimelineClip { + return { id: "clip-1", kind: "video", directMedia: { id: "media-1", assetId, kind: "video", title: "片段", storageKey } } as TimelineClip; +} + +beforeEach(() => { + useCanvasStore.setState({ projects: [] }); + useAssetStore.setState({ assets: [] }); +}); + +describe("repairMissingCanvasAssets 增量会话", () => { + test("节点声明不能掩盖素材库的真实绑定,错误配对会被重绑", () => { + useAssetStore.setState({ assets: [videoAsset("asset-a", "resource:R1"), videoAsset("asset-c", "resource:R2")] }); + useCanvasStore.setState({ projects: [project("canvas-1", [videoNode("node-1", "asset-a", "resource:R2")])] }); + + const result = repairMissingCanvasAssets(new Set(["canvas-1"]), true); + + expect(result.createdAssets).toBe(0); + expect(useCanvasStore.getState().projects[0].nodes[0].metadata?.assetId).toBe("asset-c"); + }); + + test("本地未加载的素材仍按节点声明接受,不会为远端素材分叉新建", () => { + useCanvasStore.setState({ projects: [project("canvas-1", [videoNode("node-1", "remote-only", "resource:R9")])] }); + + const result = repairMissingCanvasAssets(new Set(["canvas-1"]), true); + + expect(result.createdAssets).toBe(0); + expect(useCanvasStore.getState().projects[0].nodes[0].metadata?.assetId).toBe("remote-only"); + }); +}); + +describe("rebindInconsistentCanvasAssets 强制对齐", () => { + test("声明的素材引用了其他资源时重绑到引用同一资源的素材", () => { + useCanvasStore.setState({ projects: [project("canvas-1", [videoNode("node-1", "asset-a", "resource:R2")])] }); + + const result = rebindInconsistentCanvasAssets([videoAsset("asset-a", "resource:R1"), videoAsset("asset-c", "resource:R2")]); + + expect(result).toMatchObject({ reboundNodes: 1, createdAssets: 0, updatedProjects: 1 }); + expect(useCanvasStore.getState().projects[0].nodes[0].metadata?.assetId).toBe("asset-c"); + }); + + test("没有素材引用节点资源时按节点新建素材并重绑", () => { + useCanvasStore.setState({ projects: [project("canvas-1", [videoNode("node-1", "asset-a", "resource:R2")])] }); + + const result = rebindInconsistentCanvasAssets([videoAsset("asset-a", "resource:R1")]); + + expect(result.reboundNodes).toBe(1); + expect(result.createdAssets).toBe(1); + const reboundId = useCanvasStore.getState().projects[0].nodes[0].metadata?.assetId; + const created = useAssetStore.getState().assets.find((asset) => asset.id === reboundId); + expect(created?.kind).toBe("video"); + expect(created && "storageKey" in created.data ? created.data.storageKey : undefined).toBe("resource:R2"); + }); + + test("时间线 directMedia 与节点走同一套绑定校验", () => { + useCanvasStore.setState({ projects: [project("canvas-1", [], [timelineClip("asset-a", "resource:R2")])] }); + + const result = rebindInconsistentCanvasAssets([videoAsset("asset-a", "resource:R1"), videoAsset("asset-c", "resource:R2")]); + + expect(result.reboundNodes).toBe(1); + expect(useCanvasStore.getState().projects[0].timeline?.clips[0].directMedia?.assetId).toBe("asset-c"); + }); + + test("素材以资源文件 URL 引用时与 resource: 前缀归一化匹配", () => { + useCanvasStore.setState({ projects: [project("canvas-1", [videoNode("node-1", "asset-a", "resource:R2")])] }); + + const result = rebindInconsistentCanvasAssets([videoAsset("asset-a", undefined, "/api/resources/R2/file")]); + + expect(result.reboundNodes).toBe(0); + expect(useCanvasStore.getState().projects[0].nodes[0].metadata?.assetId).toBe("asset-a"); + }); + + test("绑定的素材记录缺失(远端已删)时也按节点重建", () => { + useCanvasStore.setState({ projects: [project("canvas-1", [videoNode("node-1", "deleted-asset", "resource:R2")])] }); + + const result = rebindInconsistentCanvasAssets([]); + + expect(result.reboundNodes).toBe(1); + expect(result.createdAssets).toBe(1); + expect(useCanvasStore.getState().projects[0].nodes[0].metadata?.assetId).not.toBe("deleted-asset"); + }); +}); + +describe("collectCanvasMediaAssetIds", () => { + test("只收集有资源身份的媒体节点与时间线片段声明", () => { + const textNode: CanvasNodeData = { id: "node-text", type: CanvasNodeType.Text, title: "文本", position: { x: 0, y: 0 }, width: 200, height: 100, metadata: { content: "正文", assetId: "text-asset" } }; + const pendingNode = videoNode("node-pending", undefined, "resource:R3"); + const projects = [project("canvas-1", [videoNode("node-1", "asset-a", "resource:R1"), textNode, pendingNode], [timelineClip("asset-b", "resource:R2")])]; + + expect(collectCanvasMediaAssetIds(projects)).toEqual(new Set(["asset-a", "asset-b"])); + }); +});