import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react"; import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey, type ResourceAccessVariant } from "@/services/api/resources"; import { getActiveUserScope } from "@/lib/user-scope"; import { resolveImageUrl } from "@/services/image-storage"; import { prepareCanvasImage } from "@/services/canvas-image-loader"; type CachedResourceImageProps = Omit, "src"> & { storageKey?: string; src?: string; fallback?: ReactNode; loadingFallback?: ReactNode; eager?: boolean; variant?: ResourceAccessVariant; }; /** * 远程资源图片统一使用 OSS/CDN 授权地址。 * Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把 * `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。 */ export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, variant = "original", onError, ...props }: CachedResourceImageProps) { const resourceId = resourceIdFromStorageKey(storageKey); const remoteResource = Boolean(resourceId); const localImageResource = Boolean(storageKey && storageKey.startsWith("image:")); const scope = getActiveUserScope(); const identity = `${scope}:${resourceId || storageKey || src}`; const targetRef = useRef(null); const [nearViewport, setNearViewport] = useState(eager || !remoteResource); const [displayed, setDisplayed] = useState<{ identity: string; src: string } | null>(null); const displayedRef = useRef(displayed); displayedRef.current = displayed; const [failedIdentity, setFailedIdentity] = useState(""); const cachedSrc = displayed?.identity === identity ? displayed.src : ""; 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(() => { if (remoteResource && !nearViewport) return; const controller = new AbortController(); let cancelled = false; const isCurrent = () => !cancelled && !controller.signal.aborted && getActiveUserScope() === scope; const load = async () => { let candidate = src; if (remoteResource && resourceId) { try { const access = await getResourceAccess(storageKey, "display", variant); candidate = resolveResourceAccessURL(access.url); } catch (error) { if (variant !== "thumbnail") throw error; const access = await getResourceAccess(storageKey, "display", "original"); candidate = resolveResourceAccessURL(access.url); } } else if (localImageResource && storageKey) { candidate = (await resolveImageUrl(storageKey, src)) || src; } if (!candidate) throw new Error("图片地址为空"); try { await prepareCanvasImage(candidate, controller.signal); } catch (error) { if (!remoteResource || variant !== "thumbnail") { if (candidate !== src && src) await prepareCanvasImage(src, controller.signal); else throw error; } else { const access = await getResourceAccess(storageKey, "display", "original"); const originalURL = resolveResourceAccessURL(access.url); if (!originalURL || originalURL === candidate) throw error; await prepareCanvasImage(originalURL, controller.signal); candidate = originalURL; } } if (isCurrent()) { setDisplayed({ identity, src: candidate }); setFailedIdentity(""); } }; void load().catch(() => { if (isCurrent() && displayedRef.current?.identity !== identity) setFailedIdentity(identity); }); return () => { cancelled = true; controller.abort(); }; }, [identity, localImageResource, nearViewport, remoteResource, resourceId, scope, src, storageKey, variant]); const handleImgError = (e: React.SyntheticEvent) => { if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) { void resolveImageUrl(storageKey) .then((url) => { if (url && url !== cachedSrc) { const controller = new AbortController(); void prepareCanvasImage(url, controller.signal).then(() => setDisplayed({ identity, src: url })).catch(() => { setFailedIdentity(identity); onError?.(e); }); return; } setFailedIdentity(identity); onError?.(e); }) .catch(() => { setFailedIdentity(identity); onError?.(e); }); return; } setFailedIdentity(identity); onError?.(e); }; if (failedIdentity === identity && fallback) return <>{fallback}; if (!remoteResource) return ; return ( {cachedSrc ? : failedIdentity === identity ? fallback : loadingFallback} ); }