From 04a1e19def339d2afcbc97aada1c6b25fe75f8e9 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 19 Sep 2026 22:29:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E8=BF=9C=E7=A8=8B=E5=AA=92?= =?UTF-8?q?=E4=BD=93=20-=20=E7=BB=9F=E4=B8=80=E8=B5=84=E6=BA=90=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=B9=B6=E6=A0=A1=E9=AA=8C=E8=A7=86=E9=A2=91=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/cached-resource-image.tsx | 32 ++++---------- .../canvas/canvas-subtitle-dialog.tsx | 25 ++--------- .../canvas/canvas-timeline-preview.tsx | 24 ++--------- .../canvas/canvas-video-frame-dialog.tsx | 19 +------- .../canvas/canvas-video-segment-dialog.tsx | 19 +------- .../lib/canvas/canvas-generation-task-sync.ts | 43 ++++++++++++++----- web/src/services/canvas-audio-playback.ts | 9 ++-- web/src/services/file-storage.ts | 6 +-- web/src/services/image-storage.ts | 9 +--- web/src/services/project-asset-sync.ts | 31 +++++++++---- 10 files changed, 83 insertions(+), 134 deletions(-) diff --git a/web/src/components/cached-resource-image.tsx b/web/src/components/cached-resource-image.tsx index aef38261b..cb2e845bd 100644 --- a/web/src/components/cached-resource-image.tsx +++ b/web/src/components/cached-resource-image.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; -import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; +import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { resolveImageUrl } from "@/services/image-storage"; type CachedResourceImageProps = Omit, "src"> & { @@ -13,11 +12,13 @@ type CachedResourceImageProps = Omit, "src"> }; /** - * 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。 - * 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。 + * 远程资源图片统一使用稳定的资源文件地址。 + * Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把 + * `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。 */ export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) { - const remoteResource = Boolean(resourceIdFromStorageKey(storageKey)); + const resourceId = resourceIdFromStorageKey(storageKey); + const remoteResource = Boolean(resourceId); const localImageResource = Boolean(storageKey && storageKey.startsWith("image:")); const targetRef = useRef(null); const [nearViewport, setNearViewport] = useState(eager || !remoteResource); @@ -51,29 +52,14 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa let cancelled = false; setCacheFailed(false); - if (remoteResource && storageKey) { + if (remoteResource && resourceId) { if (!nearViewport) { setCachedSrc(""); return () => { cancelled = true; }; } - setCachedSrc(""); - const resolve = cacheResourceObjectUrl(storageKey); - void resolve - .then((url) => { - if (!cancelled) { - setCachedSrc(url || src); - setCacheFailed(!url && !src); - } - }) - .catch(() => { - if (!cancelled) { - // 缓存读取失败时仍允许原始地址加载,真正的解码失败再显示占位。 - setCacheFailed(!src); - setCachedSrc(src); - } - }); + setCachedSrc(resourceFileUrl(resourceId)); return () => { cancelled = true; }; @@ -96,7 +82,7 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa return () => { cancelled = true; }; - }, [localImageResource, nearViewport, remoteResource, src, storageKey]); + }, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]); const handleImgError = (e: React.SyntheticEvent) => { if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) { diff --git a/web/src/components/canvas/canvas-subtitle-dialog.tsx b/web/src/components/canvas/canvas-subtitle-dialog.tsx index 56fe0eea7..cbd4ee500 100644 --- a/web/src/components/canvas/canvas-subtitle-dialog.tsx +++ b/web/src/components/canvas/canvas-subtitle-dialog.tsx @@ -9,8 +9,6 @@ import { canvasThemes } from "@/lib/canvas-theme"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useConfigStore, type AiConfig } from "@/stores/use-config-store"; import { resolveMediaUrl } from "@/services/file-storage"; -import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; import { parseSrt, serializeSrtEntries } from "@/lib/timeline/srt-parser"; import { DEFAULT_MAX_CHARS_PER_ENTRY, MAX_CHARS_PER_ENTRY_LIMIT, MIN_CHARS_PER_ENTRY, resegmentSrtEntries, splitLongEntry } from "@/lib/timeline/srt-resegment"; import { buildFallbackHighlights, remapHighlightsAfterResegment } from "@/lib/timeline/subtitle-highlights"; @@ -62,8 +60,7 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o return () => abortRef.current?.abort(); }, []); - // 打开弹窗时解析视频地址,用于字幕叠加预览。 - // 远端资源优先走节点同款缓存下载(对象 URL),失败再退回资源代理地址。 + // 打开弹窗时解析视频地址,用于字幕叠加预览;远端资源统一走稳定的云端地址。 useEffect(() => { if (!open) return; let cancelled = false; @@ -72,25 +69,9 @@ export function CanvasSubtitleDialog({ node, open, projectId, config, onClose, o setVideoError(false); const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; - const applyUrl = (url: string) => { + void resolveMediaUrl(storageKey, fallback).then((url) => { if (!cancelled) setVideoUrl(url); - }; - if (resourceIdFromStorageKey(storageKey)) { - void cacheResourceObjectUrl(storageKey) - .then((cached) => { - if (cancelled) return; - if (cached) { - setVideoUrl(cached); - } else { - void resolveMediaUrl(storageKey, fallback).then(applyUrl); - } - }) - .catch(() => { - if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl); - }); - } else { - void resolveMediaUrl(storageKey, fallback).then(applyUrl); - } + }); return () => { cancelled = true; }; diff --git a/web/src/components/canvas/canvas-timeline-preview.tsx b/web/src/components/canvas/canvas-timeline-preview.tsx index 2b0f262fe..820b5c5da 100644 --- a/web/src/components/canvas/canvas-timeline-preview.tsx +++ b/web/src/components/canvas/canvas-timeline-preview.tsx @@ -3,8 +3,6 @@ import { Pause, Play } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { resolveMediaUrl } from "@/services/file-storage"; -import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; import { formatTimelineTime } from "@/lib/timeline/timeline-view"; import { createDefaultSubtitleStyle } from "@/types/timeline"; import type { TimelineClip } from "@/types/timeline"; @@ -57,28 +55,12 @@ export function CanvasTimelinePreview({ clips, nodes, playheadMs, playing, theme setVideoSize(null); if (!node && !media) return; let cancelled = false; - // 直连媒体片段(directMedia,不落画布)与画布节点走同一套缓存/回退解析策略 + // 直连媒体片段(directMedia,不落画布)与画布节点走同一套稳定资源地址解析策略。 const storageKey = node?.metadata?.storageKey || media?.storageKey || ""; const fallback = node?.metadata?.content || media?.url || ""; - const applyUrl = (url: string) => { + void resolveMediaUrl(storageKey, fallback).then((url) => { if (!cancelled) setVideoUrl(url); - }; - if (resourceIdFromStorageKey(storageKey)) { - void cacheResourceObjectUrl(storageKey) - .then((cached) => { - if (cancelled) return; - if (cached) { - setVideoUrl(cached); - } else { - void resolveMediaUrl(storageKey, fallback).then(applyUrl); - } - }) - .catch(() => { - if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl); - }); - } else { - void resolveMediaUrl(storageKey, fallback).then(applyUrl); - } + }); return () => { cancelled = true; }; diff --git a/web/src/components/canvas/canvas-video-frame-dialog.tsx b/web/src/components/canvas/canvas-video-frame-dialog.tsx index c6bb1f6eb..0641ed7e7 100644 --- a/web/src/components/canvas/canvas-video-frame-dialog.tsx +++ b/web/src/components/canvas/canvas-video-frame-dialog.tsx @@ -5,9 +5,7 @@ import { nanoid } from "nanoid"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatVideoFrameTime, normalizeVideoFrameTimes } from "@/lib/canvas/canvas-video-frame"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; import { resolveMediaUrl } from "@/services/file-storage"; -import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; @@ -49,22 +47,9 @@ export function CanvasVideoFrameDialog({ node, open, onClose, onConfirm }: Canva setFrames([]); const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; - const applyUrl = (url: string) => { + void resolveMediaUrl(storageKey, fallback).then((url) => { if (!cancelled) setVideoUrl(url); - }; - if (resourceIdFromStorageKey(storageKey)) { - void cacheResourceObjectUrl(storageKey) - .then((cached) => { - if (cancelled) return; - if (cached) setVideoUrl(cached); - else void resolveMediaUrl(storageKey, fallback).then(applyUrl); - }) - .catch(() => { - if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl); - }); - } else { - void resolveMediaUrl(storageKey, fallback).then(applyUrl); - } + }); return () => { cancelled = true; }; diff --git a/web/src/components/canvas/canvas-video-segment-dialog.tsx b/web/src/components/canvas/canvas-video-segment-dialog.tsx index eeae10690..4f9498dea 100644 --- a/web/src/components/canvas/canvas-video-segment-dialog.tsx +++ b/web/src/components/canvas/canvas-video-segment-dialog.tsx @@ -12,8 +12,6 @@ import { modelRequestOptions, resolveCompatibleModel, type ModelRequirements } f import { navigateToSettings } from "@/lib/settings-navigation"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { resolveMediaUrl } from "@/services/file-storage"; -import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; import { modelDisplayName, type AiConfig } from "@/stores/use-config-store"; import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas"; import type { TimelineProject } from "@/types/timeline"; @@ -82,22 +80,9 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode, setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend"); const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; - const applyUrl = (url: string) => { + void resolveMediaUrl(storageKey, fallback).then((url) => { if (!cancelled) setVideoUrl(url); - }; - if (resourceIdFromStorageKey(storageKey)) { - void cacheResourceObjectUrl(storageKey) - .then((cached) => { - if (cancelled) return; - if (cached) setVideoUrl(cached); - else void resolveMediaUrl(storageKey, fallback).then(applyUrl); - }) - .catch(() => { - if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl); - }); - } else { - void resolveMediaUrl(storageKey, fallback).then(applyUrl); - } + }); return () => { cancelled = true; }; diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts index f5fe44eea..fd74ffd05 100644 --- a/web/src/lib/canvas/canvas-generation-task-sync.ts +++ b/web/src/lib/canvas/canvas-generation-task-sync.ts @@ -7,6 +7,7 @@ import { parseBackendGenerationResult } from "@/services/api/generation-task"; import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center"; import { resolveMediaUrl, type UploadedFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; +import { getCachedResourceBlob } from "@/services/resource-blob-cache"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useAssetStore } from "@/stores/use-asset-store"; import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe"; @@ -161,17 +162,9 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: } if (mode === "video") { - if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频"); + if (!result.video?.storageKey && !result.video?.dataUrl) throw new Error("后端任务没有返回视频"); const video = result.video.storageKey - ? { - url: await resolveMediaUrl(result.video.storageKey, result.video.dataUrl), - storageKey: result.video.storageKey, - width: result.video.width, - height: result.video.height, - durationMs: result.video.durationMs, - bytes: result.video.bytes || 0, - mimeType: result.video.mimeType || "video/mp4", - } + ? await cacheGeneratedRemoteVideo({ ...result.video, storageKey: result.video.storageKey }) : await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" }); const videoSize = fitNodeSize(video.width || node.width || VIDEO_NODE_MAX_SIZE.width, video.height || node.height || VIDEO_NODE_MAX_SIZE.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height); const geometry = node.metadata?.locked @@ -205,6 +198,36 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: }; } +type GeneratedVideoResult = { + dataUrl: string; + storageKey?: string; + width?: number; + height?: number; + durationMs?: number; + bytes?: number; + mimeType?: string; +}; + +/** + * 生成任务的远程视频只有在浏览器已拿到可复用的 Blob 后才进入成功态。 + * 节点仍保存稳定的资源文件地址,Blob 只作为本地缓存和后续字节处理的加速层。 + */ +async function cacheGeneratedRemoteVideo(result: GeneratedVideoResult & { storageKey: string }): Promise { + const blob = await getCachedResourceBlob(result.storageKey); + if (!blob) throw new Error("生成视频资源缓存失败,未标记为成功"); + const url = await resolveMediaUrl(result.storageKey, result.dataUrl || ""); + if (!url) throw new Error("生成视频资源地址为空,未标记为成功"); + return { + url, + storageKey: result.storageKey, + width: result.width, + height: result.height, + durationMs: result.durationMs, + bytes: result.bytes || blob.size, + mimeType: result.mimeType || blob.type || "video/mp4", + }; +} + export async function applyGenerationTaskResultToNodes(nodes: CanvasNodeData[], task: GenerationTask, targetNodeId?: string) { const node = findGenerationTaskNode(nodes, task, targetNodeId); if (!node) return { nodes, updated: false, nodeId: "", node: null }; diff --git a/web/src/services/canvas-audio-playback.ts b/web/src/services/canvas-audio-playback.ts index ad0b33d27..8b2fcfd5f 100644 --- a/web/src/services/canvas-audio-playback.ts +++ b/web/src/services/canvas-audio-playback.ts @@ -1,5 +1,4 @@ -import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; +import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; export type CanvasAudioSource = { nodeId: string; @@ -255,9 +254,9 @@ function sameSource(left: CanvasAudioSource, right: CanvasAudioSource) { } async function resolveAudioSource(source: CanvasAudioSource) { - if (source.storageKey && resourceIdFromStorageKey(source.storageKey)) { - const cached = await cacheResourceObjectUrl(source.storageKey).catch(() => ""); - return cached || source.content; + if (source.storageKey) { + const resourceId = resourceIdFromStorageKey(source.storageKey); + if (resourceId) return resourceFileUrl(resourceId); } return source.content; } diff --git a/web/src/services/file-storage.ts b/web/src/services/file-storage.ts index 1795c28cc..265f63694 100644 --- a/web/src/services/file-storage.ts +++ b/web/src/services/file-storage.ts @@ -5,7 +5,7 @@ import { getActiveUserScope } from "@/lib/user-scope"; import { captureVideoPoster, detectVideoAudioTrackFromBlob } from "@/lib/video-poster"; import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; import { uploadImage, type UploadedImage } from "@/services/image-storage"; -import { getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache"; +import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; export type UploadedFile = { url: string; @@ -115,8 +115,8 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") { if (!storageKey) return fallback; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { - const cached = await getCachedResourceObjectUrl(storageKey).catch(() => ""); - return cached || resourceFileUrl(resourceId); + // 远程资源展示统一走稳定的云端/资源文件地址;Blob 缓存仅服务于字节读取和媒体处理。 + return resourceFileUrl(resourceId); } const cached = objectUrls.get(storageKey); if (cached) return cached; diff --git a/web/src/services/image-storage.ts b/web/src/services/image-storage.ts index f259e40dc..832c1980c 100644 --- a/web/src/services/image-storage.ts +++ b/web/src/services/image-storage.ts @@ -4,7 +4,7 @@ import { nanoid } from "nanoid"; import { readImageMeta } from "@/lib/image-utils"; import { getActiveUserScope } from "@/lib/user-scope"; import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; -import { cacheResourceObjectUrl, getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache"; +import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; export type UploadedImage = { url: string; @@ -81,12 +81,7 @@ export async function resolveImageUrl(storageKey?: string, fallback = "", option if (!storageKey) return fallback; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { - const cached = await getCachedResourceObjectUrl(storageKey).catch(() => ""); - if (cached) return cached; - if (options?.cacheMiss) { - const populated = await cacheResourceObjectUrl(storageKey).catch(() => ""); - if (populated) return populated; - } + // 远程资源展示不返回 blob: URL,确保节点、素材库和弹窗都使用云端稳定地址。 return resourceFileUrl(resourceId); } const cached = objectUrls.get(storageKey); diff --git a/web/src/services/project-asset-sync.ts b/web/src/services/project-asset-sync.ts index 70f5380cc..35468d552 100644 --- a/web/src/services/project-asset-sync.ts +++ b/web/src/services/project-asset-sync.ts @@ -12,6 +12,7 @@ import { uploadGeneratedAssetToConfiguredSources } from "@/services/external-ass import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage"; import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink"; import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects"; +import { getCachedResourceBlob } from "@/services/resource-blob-cache"; import { saveRemoteUserDataNow } from "@/services/user-data-sync"; import { getActiveUserScope } from "@/lib/user-scope"; import { normalizeAssetCategory } from "@/lib/asset-category"; @@ -265,6 +266,26 @@ async function storedGenerationMedia(dataUrl: string, effectKey: string, mediaTy }; } +async function cachedRemoteGenerationVideo(video: NonNullable, signal?: AbortSignal) { + if (!video.storageKey) throw new Error("生成视频缺少云端资源标识"); + throwIfAborted(signal); + const blob = await getCachedResourceBlob(video.storageKey); + throwIfAborted(signal); + if (!blob) throw new Error("生成视频资源缓存失败,未标记为成功"); + const url = await resolveMediaUrl(video.storageKey, video.dataUrl); + throwIfAborted(signal); + if (!url) throw new Error("生成视频资源地址为空,未标记为成功"); + return { + url, + storageKey: video.storageKey, + width: video.width || 0, + height: video.height || 0, + durationMs: video.durationMs, + bytes: video.bytes || blob.size, + mimeType: video.mimeType || blob.type || "video/mp4", + }; +} + async function generationOutputAsset(input: Parameters[0], scope: string): Promise { throwIfAborted(input.signal); const result = generationTaskResult(input.task); @@ -305,15 +326,7 @@ async function generationOutputAsset(input: Parameters