fix(canvas): keep uploaded resources in asset library (#388)
This commit is contained in:
@@ -23,6 +23,7 @@ import { createDirectorActor, createDirectorBillboard, createDirectorCamera, cre
|
||||
import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring";
|
||||
import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
|
||||
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -418,6 +419,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
|
||||
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
|
||||
const uploaded = await uploadMediaFile(file, "model");
|
||||
const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "导演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "director" } });
|
||||
await saveRemoteUserDataNow();
|
||||
const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
|
||||
if (asset) addModelAsset(asset);
|
||||
message.success("3D 模型已加入场景和素材库");
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CanvasCreateCard } from "@/components/canvas/canvas-project-card";
|
||||
import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
|
||||
import { CanvasHistoryDrawer } from "@/components/canvas/canvas-history-drawer";
|
||||
import type { CanvasExportFile } from "@/types/canvas-export";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
||||
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
@@ -25,6 +25,7 @@ import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
|
||||
import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { primeResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
|
||||
export default function CanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
@@ -206,8 +207,69 @@ export default function CanvasPage() {
|
||||
};
|
||||
};
|
||||
|
||||
const remappedNodes = (item.project.nodes || []).map(remapNodeMedia);
|
||||
updateProject(importedProjectId, { nodes: remappedNodes });
|
||||
let remappedNodes = (item.project.nodes || []).map(remapNodeMedia);
|
||||
let remappedTimeline = item.project.timeline
|
||||
? {
|
||||
...item.project.timeline,
|
||||
clips: item.project.timeline.clips.map((clip) => {
|
||||
const directMedia = clip.directMedia;
|
||||
if (!directMedia?.storageKey) return clip;
|
||||
const mapped = storageKeyMap.get(directMedia.storageKey);
|
||||
return mapped
|
||||
? {
|
||||
...clip,
|
||||
directMedia: { ...directMedia, storageKey: mapped.storageKey, url: mapped.url, dataUrl: directMedia.dataUrl ? mapped.url : directMedia.dataUrl, content: directMedia.content ? mapped.url : directMedia.content },
|
||||
}
|
||||
: clip;
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
updateProject(importedProjectId, { nodes: remappedNodes, timeline: remappedTimeline });
|
||||
|
||||
const assetIdByStorageKey = new Map<string, string>();
|
||||
for (let index = 0; index < remappedNodes.length; index += 1) {
|
||||
const node = remappedNodes[index];
|
||||
const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
|
||||
if (!isMedia || !node.metadata?.content) continue;
|
||||
const storageKey = node.metadata.storageKey || "";
|
||||
let assetId = storageKey ? assetIdByStorageKey.get(storageKey) : undefined;
|
||||
if (!assetId) {
|
||||
const result = await ensureCanvasNodeAsset({ canvasId: importedProjectId, domainProjectId: item.project.projectId, node, source: "canvas-upload" });
|
||||
assetId = result.assetId;
|
||||
if (storageKey) assetIdByStorageKey.set(storageKey, assetId);
|
||||
}
|
||||
remappedNodes[index] = { ...node, metadata: { ...node.metadata, assetId } };
|
||||
}
|
||||
if (remappedTimeline) {
|
||||
const clips: typeof remappedTimeline.clips = [];
|
||||
for (const clip of remappedTimeline.clips) {
|
||||
const media = clip.directMedia;
|
||||
const content = media?.url || media?.dataUrl || media?.content || "";
|
||||
if (!media || media.assetId || !media.storageKey || !content || media.kind === "text") {
|
||||
clips.push(clip);
|
||||
continue;
|
||||
}
|
||||
let assetId = assetIdByStorageKey.get(media.storageKey);
|
||||
if (!assetId) {
|
||||
const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image;
|
||||
const node: CanvasNodeData = {
|
||||
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, storageKey: media.storageKey, naturalWidth: media.width, naturalHeight: media.height, durationMs: media.durationMs, bytes: media.bytes, mimeType: media.mimeType },
|
||||
};
|
||||
const result = await ensureCanvasNodeAsset({ canvasId: importedProjectId, domainProjectId: item.project.projectId, node, source: "canvas-upload" });
|
||||
assetId = result.assetId;
|
||||
assetIdByStorageKey.set(media.storageKey, assetId);
|
||||
}
|
||||
clips.push({ ...clip, directMedia: { ...media, assetId } });
|
||||
}
|
||||
remappedTimeline = { ...remappedTimeline, clips };
|
||||
}
|
||||
updateProject(importedProjectId, { nodes: remappedNodes, timeline: remappedTimeline });
|
||||
|
||||
await Promise.all(
|
||||
(item.drawingDocuments || []).map((document) => {
|
||||
|
||||
@@ -1346,11 +1346,13 @@ function InfiniteCanvasPage() {
|
||||
setNodes((current) => [...current, created]);
|
||||
setConnections((current) => [...current, connection]);
|
||||
setSelectedNodeIds(new Set([created.id]));
|
||||
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: created, source: "canvas-manual" });
|
||||
setNodes((current) => current.map((item) => item.id === created.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
|
||||
message.success("候选图片已添加到画布并连接到排查节点");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "候选图片添加失败");
|
||||
}
|
||||
}, [message, portraitClearanceNodeId, setConnections, setNodes, setSelectedNodeIds]);
|
||||
}, [currentProject?.projectId, message, portraitClearanceNodeId, projectId, setConnections, setNodes, setSelectedNodeIds]);
|
||||
const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null;
|
||||
const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content);
|
||||
|
||||
@@ -1462,6 +1464,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({
|
||||
projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
directorNodeId,
|
||||
directorScenes: currentProject?.directorScenes || [],
|
||||
nodesRef,
|
||||
@@ -1654,12 +1657,16 @@ function InfiniteCanvasPage() {
|
||||
message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 });
|
||||
try {
|
||||
let resourceID = resourceIdFromStorageKey(node.metadata.storageKey);
|
||||
let persistedNode = node;
|
||||
if (!resourceID) {
|
||||
const uploaded = await uploadImage(node.metadata.content);
|
||||
resourceID = resourceIdFromStorageKey(uploaded.storageKey);
|
||||
if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试");
|
||||
handleConfigNodeChange(node.id, imageMetadata(uploaded));
|
||||
persistedNode = { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded) } };
|
||||
}
|
||||
const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: persistedNode, source: "canvas-manual" });
|
||||
handleConfigNodeChange(node.id, { assetId: asset.assetId });
|
||||
await syncResourceToArkPrivateAsset(resourceID);
|
||||
message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 });
|
||||
} catch (error) {
|
||||
@@ -1667,7 +1674,7 @@ function InfiniteCanvasPage() {
|
||||
} finally {
|
||||
setArkPrivateAssetUploadNodeId((current) => current === node.id ? null : current);
|
||||
}
|
||||
}, [arkPrivateAssetUploadNodeId, handleConfigNodeChange, message]);
|
||||
}, [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId]);
|
||||
|
||||
const confirmUploadNodeImageToArkPrivateAsset = useCallback((node: CanvasNodeData) => {
|
||||
Modal.confirm({
|
||||
|
||||
@@ -9,12 +9,14 @@ import { createDirectorSceneFromTemplate, type DirectorTemplateId } from "@/lib/
|
||||
import { mergeDirectorOutputPreview, upsertDirectorSceneById } from "@/lib/canvas/director/director-session";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
|
||||
import type { DirectorScene, DirectorSceneOutput } from "@/types/director";
|
||||
|
||||
type UseCanvasDirectorOptions = {
|
||||
projectId: string;
|
||||
domainProjectId?: string;
|
||||
directorNodeId: string | null;
|
||||
directorScenes: DirectorScene[];
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
@@ -40,6 +42,7 @@ function currentDirectorScenes(projectId: string, fallback: DirectorScene[]) {
|
||||
}
|
||||
export function useCanvasDirector({
|
||||
projectId,
|
||||
domainProjectId,
|
||||
directorNodeId,
|
||||
directorScenes,
|
||||
nodesRef,
|
||||
@@ -196,13 +199,23 @@ export function useCanvasDirector({
|
||||
videoCameraMovePrompt: output.prompt,
|
||||
referenceAssetNodeIds,
|
||||
};
|
||||
const finalizedNodes = nextNodes.map((item) => item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, ...directorMetadata } } : item);
|
||||
const mediaNodes = nextNodes.filter((item) => item.id === previewId || Boolean(clayVideoId && item.id === clayVideoId));
|
||||
const assetIds = new Map<string, string>();
|
||||
for (const mediaNode of mediaNodes) {
|
||||
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: mediaNode, source: "canvas-manual" });
|
||||
assetIds.set(mediaNode.id, result.assetId);
|
||||
}
|
||||
const finalizedNodes = nextNodes.map((item) => {
|
||||
const assetId = assetIds.get(item.id);
|
||||
if (assetId) return { ...item, metadata: { ...item.metadata, assetId } };
|
||||
return item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, ...directorMetadata } } : item;
|
||||
});
|
||||
nodesRef.current = finalizedNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(finalizedNodes);
|
||||
setConnections(nextConnections);
|
||||
saveDirectorScene(mergedScene);
|
||||
}, [connectionsRef, directorNodeId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]);
|
||||
}, [connectionsRef, directorNodeId, domainProjectId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]);
|
||||
|
||||
return { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene };
|
||||
}
|
||||
|
||||
@@ -126,6 +126,25 @@ export function useCanvasMediaTools({
|
||||
}
|
||||
}, [message, nodesRef]);
|
||||
|
||||
const persistMediaNodes = useCallback(async (mediaNodes: CanvasNodeData[]) => {
|
||||
const assetIds = new Map<string, string>();
|
||||
for (const mediaNode of mediaNodes) {
|
||||
try {
|
||||
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node: mediaNode, source: "canvas-manual" });
|
||||
assetIds.set(mediaNode.id, result.assetId);
|
||||
} catch (error) {
|
||||
message.warning(`媒体节点已创建,但素材库写入失败:${error instanceof Error ? error.message : "未知错误"}`);
|
||||
}
|
||||
}
|
||||
if (assetIds.size > 0) {
|
||||
setNodes((current) => current.map((item) => {
|
||||
const assetId = assetIds.get(item.id);
|
||||
return assetId ? { ...item, metadata: { ...item.metadata, assetId } } : item;
|
||||
}));
|
||||
}
|
||||
return assetIds;
|
||||
}, [domainProjectId, message, projectId, setNodes]);
|
||||
|
||||
const createImageReversePromptNodes = useCallback((node: CanvasNodeData) => {
|
||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
|
||||
message.warning("图片节点为空,无法反推提示词");
|
||||
@@ -184,7 +203,8 @@ export function useCanvasMediaTools({
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setDialogNodeId(childId);
|
||||
setCropNodeId(null);
|
||||
}, [setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
|
||||
await persistMediaNodes([child]);
|
||||
}, [persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
|
||||
|
||||
const saveAnnotatedImageNode = useCallback(async (node: CanvasNodeData, dataUrl: string) => {
|
||||
const image = await uploadImage(dataUrl);
|
||||
@@ -197,8 +217,9 @@ export function useCanvasMediaTools({
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(null);
|
||||
setAnnotationNodeId(null);
|
||||
await persistMediaNodes([child]);
|
||||
message.success("标注图片已保存为新节点");
|
||||
}, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
}, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const openVideoFrameExtractor = useCallback((node: CanvasNodeData) => {
|
||||
if (!node.metadata?.content) {
|
||||
@@ -251,6 +272,7 @@ export function useCanvasMediaTools({
|
||||
setConnections(nextConnections);
|
||||
setSelectedNodeIds(selection);
|
||||
setSelectedConnectionId(null);
|
||||
await persistMediaNodes(frameNodes);
|
||||
const failedCount = captured.failures.length + uploadFailures.length;
|
||||
progress.done(failedCount ? `已提取 ${frameNodes.length} 帧,${failedCount} 帧失败` : `已提取 ${frameNodes.length} 帧并创建图片节点`);
|
||||
if (failedCount) message.warning(`${failedCount} 个时间点提取失败,其余画面已创建`);
|
||||
@@ -262,7 +284,7 @@ export function useCanvasMediaTools({
|
||||
extractingVideoFramesNodeIdRef.current = null;
|
||||
setExtractingVideoFramesNodeId(null);
|
||||
}
|
||||
}, [connectionsRef, message, nodesRef, selectedNodeIdsRef, setConnections, setNodes, setSelectedConnectionId, setSelectedNodeIds, startUploadStatus]);
|
||||
}, [connectionsRef, message, nodesRef, persistMediaNodes, selectedNodeIdsRef, setConnections, setNodes, setSelectedConnectionId, setSelectedNodeIds, startUploadStatus]);
|
||||
|
||||
const extractAudioFromVideo = useCallback((node: CanvasNodeData) => {
|
||||
if (!node.metadata?.content) {
|
||||
@@ -502,6 +524,7 @@ export function useCanvasMediaTools({
|
||||
setSelectedNodeIds(selection);
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(null);
|
||||
await persistMediaNodes([mergedNode]);
|
||||
setMergeVideoProgress({ phase: "encoding", progress: 100 });
|
||||
message.success(`已合并 ${videos.length} 段视频,成片节点已添加`);
|
||||
} catch (error) {
|
||||
@@ -510,7 +533,7 @@ export function useCanvasMediaTools({
|
||||
mergeVideoRunningRef.current = false;
|
||||
window.setTimeout(() => setMergeVideoProgress(null), 700);
|
||||
}
|
||||
}, [connectionsRef, message, nodesRef, selectedNodeIdsRef, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
}, [connectionsRef, message, nodesRef, persistMediaNodes, selectedNodeIdsRef, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const mergeSelectedVideos = useCallback(() => mergeVideosByIds(Array.from(selectedNodeIdsRef.current)), [mergeVideosByIds, selectedNodeIdsRef]);
|
||||
|
||||
@@ -539,8 +562,9 @@ export function useCanvasMediaTools({
|
||||
setSelectedNodeIds(new Set(childNodes.map((child) => child.id)));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(null);
|
||||
await persistMediaNodes(childNodes);
|
||||
message.success(`已切分为 ${childNodes.length} 个子节点`);
|
||||
}, [message, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
}, [message, persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
@@ -572,7 +596,11 @@ export function useCanvasMediaTools({
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } } : item));
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("局部编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
@@ -582,7 +610,7 @@ export function useCanvasMediaTools({
|
||||
finishGenerationRequest(childId, controller);
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
|
||||
if (!node.metadata?.content) return;
|
||||
@@ -596,7 +624,8 @@ export function useCanvasMediaTools({
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setDialogNodeId(childId);
|
||||
}, [setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
|
||||
await persistMediaNodes([child]);
|
||||
}, [persistMediaNodes, setConnections, setDialogNodeId, setNodes, setSelectedNodeIds]);
|
||||
|
||||
const generateAngleNode = useCallback(async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
|
||||
if (!node.metadata?.content) return;
|
||||
@@ -628,7 +657,11 @@ export function useCanvasMediaTools({
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } } : item));
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("视角生成节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
@@ -637,7 +670,7 @@ export function useCanvasMediaTools({
|
||||
finishGenerationRequest(childId, controller);
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const generateEmotionNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageEmotionPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
@@ -689,14 +722,18 @@ export function useCanvasMediaTools({
|
||||
const composited = await compositeEmotionImage(node.metadata.content, image.dataUrl, payload.editRegion, payload.faceBox);
|
||||
const uploaded = await uploadImage(composited);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } } : item));
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("表情编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
message.error(details);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally { finishGenerationRequest(childId, controller); setRunningNodeId(null); }
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
return {
|
||||
angleNodeId,
|
||||
|
||||
@@ -116,6 +116,31 @@ export function useCanvasUpload({
|
||||
}
|
||||
}, [canvasId, domainProjectId, message, queryClient, setNodes]);
|
||||
|
||||
const persistTimelineMedia = useCallback(async (media: TimelineDirectMedia) => {
|
||||
const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image;
|
||||
const defaults = NODE_DEFAULT_SIZE[type];
|
||||
const node: CanvasNodeData = {
|
||||
id: media.id,
|
||||
type,
|
||||
title: media.title,
|
||||
position: { x: 0, y: 0 },
|
||||
width: media.width || defaults.width,
|
||||
height: media.height || defaults.height,
|
||||
metadata: {
|
||||
content: media.url || media.dataUrl || media.content || "",
|
||||
storageKey: media.storageKey,
|
||||
naturalWidth: media.width,
|
||||
naturalHeight: media.height,
|
||||
durationMs: media.durationMs,
|
||||
bytes: media.bytes,
|
||||
mimeType: media.mimeType,
|
||||
},
|
||||
};
|
||||
const result = await ensureCanvasNodeAsset({ canvasId, domainProjectId, node, source: "canvas-upload" });
|
||||
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
|
||||
return result.assetId;
|
||||
}, [canvasId, domainProjectId, queryClient]);
|
||||
|
||||
const createImageFileNode = useCallback(async (file: File, position: Position) => {
|
||||
const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
|
||||
try {
|
||||
@@ -343,7 +368,7 @@ export function useCanvasUpload({
|
||||
try {
|
||||
if (isAudioFile(file)) {
|
||||
const audio = await uploadMediaFile(file, "audio");
|
||||
created.push({
|
||||
const media: TimelineDirectMedia = {
|
||||
id: `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
kind: "audio",
|
||||
title: file.name,
|
||||
@@ -352,10 +377,12 @@ export function useCanvasUpload({
|
||||
durationMs: audio.durationMs,
|
||||
bytes: audio.bytes,
|
||||
mimeType: audio.mimeType,
|
||||
});
|
||||
};
|
||||
media.assetId = await persistTimelineMedia(media);
|
||||
created.push(media);
|
||||
} else {
|
||||
const video = await uploadMediaFile(file, "video");
|
||||
created.push({
|
||||
const media: TimelineDirectMedia = {
|
||||
id: `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
kind: "video",
|
||||
title: file.name,
|
||||
@@ -366,7 +393,9 @@ export function useCanvasUpload({
|
||||
durationMs: video.durationMs,
|
||||
bytes: video.bytes,
|
||||
mimeType: video.mimeType,
|
||||
});
|
||||
};
|
||||
media.assetId = await persistTimelineMedia(media);
|
||||
created.push(media);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? `素材上传失败:${error.message}` : "素材上传失败");
|
||||
@@ -374,7 +403,7 @@ export function useCanvasUpload({
|
||||
}
|
||||
if (created.length) message.success(`已上传 ${created.length} 个素材到时间线`);
|
||||
return created;
|
||||
}, [message]);
|
||||
}, [message, persistTimelineMedia]);
|
||||
|
||||
// 组装能力闭环:把时间线合成结果(MP4 Blob)上传并创建为新的视频节点放回画布,
|
||||
// 复用上传/持久化/选中逻辑,新节点可继续编辑字幕与样式。
|
||||
|
||||
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);
|
||||
|
||||
@@ -68,6 +68,8 @@ export type TimelineClipKind = "video" | "audio" | "subtitle" | "text" | "image"
|
||||
export type TimelineDirectMedia = {
|
||||
/** 源素材 id(本地资产 id / 上传文件 id),用于生成时间线内唯一 nodeId 前缀 */
|
||||
id: string;
|
||||
/** 远端素材库记录;本地上传成功后必须存在。 */
|
||||
assetId?: string;
|
||||
kind: "video" | "audio" | "image" | "text";
|
||||
title: string;
|
||||
storageKey?: string;
|
||||
|
||||
Reference in New Issue
Block a user