fix(canvas): 远程媒体 - 统一资源地址并校验视频缓存
This commit is contained in:
@@ -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<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
@@ -13,11 +12,13 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "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<HTMLSpanElement>(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<HTMLImageElement, Event>) => {
|
||||
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<UploadedFile> {
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<BackendGenerationResult["video"]>, 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<MaterializeGenerationTaskOutput>[0], scope: string): Promise<NewAsset> {
|
||||
throwIfAborted(input.signal);
|
||||
const result = generationTaskResult(input.task);
|
||||
@@ -305,15 +326,7 @@ async function generationOutputAsset(input: Parameters<MaterializeGenerationTask
|
||||
const video = result.video;
|
||||
if (!video) throw new Error("生成任务缺少视频输出");
|
||||
const stored = video.storageKey
|
||||
? {
|
||||
url: await resolveMediaUrl(video.storageKey, video.dataUrl),
|
||||
storageKey: video.storageKey,
|
||||
width: video.width || 0,
|
||||
height: video.height || 0,
|
||||
durationMs: video.durationMs,
|
||||
bytes: video.bytes || 0,
|
||||
mimeType: video.mimeType || "video/mp4",
|
||||
}
|
||||
? await cachedRemoteGenerationVideo(video, input.signal)
|
||||
: await storedGenerationMedia(
|
||||
video.dataUrl,
|
||||
input.effectKey,
|
||||
|
||||
Reference in New Issue
Block a user