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

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

View File

@@ -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 模型已加入场景和素材库");

View File

@@ -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) => {

View File

@@ -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({

View File

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

View File

@@ -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,

View File

@@ -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)上传并创建为新的视频节点放回画布,
// 复用上传/持久化/选中逻辑,新节点可继续编辑字幕与样式。

View File

@@ -0,0 +1,118 @@
import { canvasNodeToAsset } from "@/lib/canvas/canvas-node-asset";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { TimelineDirectMedia } from "@/types/timeline";
export type CanvasAssetRepairResult = {
createdAssets: number;
updatedProjects: number;
};
export function repairMissingCanvasAssets(): CanvasAssetRepairResult {
const assetStore = useAssetStore.getState();
const canvasStore = useCanvasStore.getState();
const assetIdByStorageKey = new Map<string, string>();
const storageKeyByAssetId = new Map<string, string>();
const knownAssetIds = new Set<string>();
for (const asset of assetStore.assets) {
knownAssetIds.add(asset.id);
const storageKey = assetStorageKey(asset);
if (storageKey) {
storageKeyByAssetId.set(asset.id, storageKey);
if (!assetIdByStorageKey.has(storageKey)) assetIdByStorageKey.set(storageKey, asset.id);
}
}
let createdAssets = 0;
let updatedProjects = 0;
for (const project of canvasStore.projects) {
const repaired = repairProject(project, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId, (node) => {
const input = canvasNodeToAsset(node, { canvasId: project.id, source: "canvas-upload" });
if (!input) return undefined;
const assetId = useAssetStore.getState().addAsset(input);
knownAssetIds.add(assetId);
const storageKey = node.metadata?.storageKey;
if (storageKey) {
assetIdByStorageKey.set(storageKey, assetId);
storageKeyByAssetId.set(assetId, storageKey);
}
createdAssets += 1;
return assetId;
});
if (!repaired) continue;
canvasStore.updateProject(project.id, repaired);
updatedProjects += 1;
}
return { createdAssets, updatedProjects };
}
function repairProject(
project: CanvasProject,
knownAssetIds: Set<string>,
assetIdByStorageKey: Map<string, string>,
storageKeyByAssetId: Map<string, string>,
createAsset: (node: CanvasNodeData) => string | undefined,
): Pick<CanvasProject, "nodes" | "timeline"> | null {
let changed = false;
const nodes = project.nodes.map((node) => {
if (!isDurableMediaNode(node)) return node;
const assetId = matchingAssetId(node.metadata?.assetId, node.metadata?.storageKey, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId) || createAsset(node);
if (!assetId || node.metadata?.assetId === assetId) return node;
changed = true;
return { ...node, metadata: { ...node.metadata, assetId } };
});
const timeline = project.timeline
? {
...project.timeline,
clips: project.timeline.clips.map((clip) => {
const media = clip.directMedia;
if (!media || media.kind === "text" || !mediaContent(media)) return clip;
const assetId = matchingAssetId(media.assetId, media.storageKey, knownAssetIds, assetIdByStorageKey, storageKeyByAssetId) || createAsset(timelineMediaNode(media));
if (!assetId || media.assetId === assetId) return clip;
changed = true;
return { ...clip, directMedia: { ...media, assetId } };
}),
}
: project.timeline;
return changed ? { nodes, timeline } : null;
}
function matchingAssetId(explicitId: string | undefined, storageKey: string | undefined, knownAssetIds: Set<string>, assetIdByStorageKey: Map<string, string>, storageKeyByAssetId: Map<string, string>) {
if (explicitId && knownAssetIds.has(explicitId) && (!storageKey || storageKeyByAssetId.get(explicitId) === storageKey)) return explicitId;
return storageKey ? assetIdByStorageKey.get(storageKey) : undefined;
}
function isDurableMediaNode(node: CanvasNodeData) {
const isMedia = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
return isMedia && Boolean(node.metadata?.content || node.metadata?.storageKey);
}
function timelineMediaNode(media: TimelineDirectMedia): CanvasNodeData {
const type = media.kind === "audio" ? CanvasNodeType.Audio : media.kind === "video" ? CanvasNodeType.Video : CanvasNodeType.Image;
return {
id: media.id,
type,
title: media.title,
position: { x: 0, y: 0 },
width: media.width || 320,
height: media.height || (type === CanvasNodeType.Audio ? 120 : 240),
metadata: {
content: mediaContent(media),
storageKey: media.storageKey,
naturalWidth: media.width,
naturalHeight: media.height,
durationMs: media.durationMs,
bytes: media.bytes,
mimeType: media.mimeType,
},
};
}
function mediaContent(media: TimelineDirectMedia) {
return media.url || media.dataUrl || media.content || "";
}
function assetStorageKey(asset: Asset) {
return asset.kind === "text" || asset.kind === "entity" ? undefined : asset.data.storageKey;
}

View File

@@ -71,7 +71,12 @@ async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Pr
store.updateAsset(asset.id, { category: declaredCategory });
asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset;
}
if (!options.domainProjectId) return { assetId: asset.id, created, linkedToProject: false };
if (!options.domainProjectId) {
// 个人画布也必须在返回成功前把素材提交到服务端,不能只依赖延迟自动同步。
await saveRemoteUserDataNow();
throwIfAborted(options.signal);
return { assetId: asset.id, created, linkedToProject: false };
}
await syncAssetToProject(asset.id, options.domainProjectId, declaredCategory, options.folderId, options.signal);
return { assetId: asset.id, created, linkedToProject: true };
}

View File

@@ -9,6 +9,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { repairMissingCanvasAssets } from "@/services/canvas-asset-repair";
let activeRemoteUserId = "";
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
@@ -25,6 +26,7 @@ let acknowledgedProjects = new Map<string, CanvasProject>();
const LOCAL_STORAGE_KEY_PATTERN = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncRemoteUserData(userId?: string | null) {
let repairedCanvasAssets = false;
await withRemoteUserDataSyncExclusive(async () => {
activeRemoteUserId = userId || "";
acknowledgedProjects.clear();
@@ -42,6 +44,8 @@ export async function syncRemoteUserData(userId?: string | null) {
// 这里只替换结构化记录,不在登录阶段解析图片/视频/音频 URL;媒体由实际使用方按需解析。
useCanvasStore.getState().replaceProjects(snapshot.projects);
useAssetStore.getState().replaceAssets(snapshot.assets);
const repair = repairMissingCanvasAssets();
repairedCanvasAssets = repair.createdAssets > 0 || repair.updatedProjects > 0;
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
acknowledgedProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
acknowledgedAssets = new Map(snapshot.assets.map((asset) => [asset.id, asset]));
@@ -51,6 +55,7 @@ export async function syncRemoteUserData(userId?: string | null) {
throw error;
}
});
if (repairedCanvasAssets) await saveRemoteUserDataNow();
}
export function installRemoteUserDataAutoSync() {
@@ -167,6 +172,9 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
for (const node of proj.nodes) {
if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId);
}
for (const clip of proj.timeline?.clips || []) {
if (clip.directMedia?.assetId) activeAssetIds.add(clip.directMedia.assetId);
}
}
let assetChanged = false;
// 1. 已存在的关联素材标记为 archived
@@ -314,6 +322,9 @@ async function drainRemoteUserDataChanges() {
}
async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
repairMissingCanvasAssets();
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((project) => !sameEntitySnapshot(acknowledgedProjects.get(project.id), project));
@@ -326,6 +337,13 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
// 转换后的 resource: 引用只属于发往服务端的 payload,不能反写整份实时 store。
// 已确认快照记录的是本次上传所依据的本地实体;上传期间的新编辑会在下一轮继续提交。
// 素材先于画布提交。这样画布中的 resource: 引用一旦成为远端事实,
// 对应 Asset 已经存在,刷新或换设备不会出现只占容量、不见素材的窗口。
for (const source of dirtyAssets) {
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
await upsertRemoteAsset(remotePayload);
acknowledgedAssets.set(source.id, source);
}
for (const source of dirtyProjects) {
const keysToUpload = collectLocalMediaKeys(source);
const total = keysToUpload.length;
@@ -364,11 +382,6 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
throw error;
}
}
for (const source of dirtyAssets) {
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
await upsertRemoteAsset(remotePayload);
acknowledgedAssets.set(source.id, source);
}
for (const id of deletedProjectIds) {
await deleteRemoteCanvasProject(id);
acknowledgedProjects.delete(id);

View File

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