import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react"; import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; import { resolveImageUrl } from "@/services/image-storage"; type CachedResourceImageProps = Omit, "src"> & { storageKey?: string; src?: string; fallback?: ReactNode; loadingFallback?: ReactNode; eager?: boolean; }; /** * 远程资源图片统一使用稳定的资源文件地址。 * Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把 * `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。 */ export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) { 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); const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src); const [cacheFailed, setCacheFailed] = useState(false); useEffect(() => { if (!remoteResource || eager) { setNearViewport(true); return; } const image = targetRef.current; if (!image || typeof IntersectionObserver === "undefined") { setNearViewport(true); return; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNearViewport(true); observer.disconnect(); } }, { rootMargin: "240px" }, ); observer.observe(image); return () => observer.disconnect(); }, [eager, remoteResource]); useEffect(() => { let cancelled = false; setCacheFailed(false); if (remoteResource && resourceId) { if (!nearViewport) { setCachedSrc(""); return () => { cancelled = true; }; } setCachedSrc(resourceFileUrl(resourceId)); return () => { cancelled = true; }; } if (localImageResource && storageKey) { void resolveImageUrl(storageKey, src) .then((url) => { if (!cancelled) setCachedSrc(url || src); }) .catch(() => { if (!cancelled) setCachedSrc(src); }); return () => { cancelled = true; }; } setCachedSrc(src); return () => { cancelled = true; }; }, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]); const handleImgError = (e: React.SyntheticEvent) => { if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) { void resolveImageUrl(storageKey) .then((url) => { if (url && url !== cachedSrc) { setCachedSrc(url); return; } setCacheFailed(true); onError?.(e); }) .catch(() => { setCacheFailed(true); onError?.(e); }); return; } setCacheFailed(true); onError?.(e); }; if (!remoteResource) { if (cacheFailed && fallback) return <>{fallback}; return ; } return ( {cachedSrc && !cacheFailed ? : cacheFailed ? fallback : loadingFallback} ); }