fix(canvas): 远程媒体 - 统一资源地址并校验视频缓存

This commit is contained in:
ddCat
2026-09-19 22:29:09 +08:00
parent cb68476e36
commit 04a1e19def
10 changed files with 83 additions and 134 deletions

View File

@@ -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:")) {

View File

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

View File

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

View File

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

View File

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