feat(canvas): 画布资源 - 缩略图字段与缓存加载支持

This commit is contained in:
ddcat666
2026-09-27 22:26:32 +08:00
parent bf2682c38e
commit f9b8c5a43e
9 changed files with 312 additions and 101 deletions

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey } from "@/services/api/resources";
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<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
storageKey?: string;
@@ -9,6 +11,7 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
fallback?: ReactNode;
loadingFallback?: ReactNode;
eager?: boolean;
variant?: ResourceAccessVariant;
};
/**
@@ -16,14 +19,19 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
*/
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
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<HTMLSpanElement>(null);
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src);
const [cacheFailed, setCacheFailed] = useState(false);
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) {
@@ -49,75 +57,83 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
}, [eager, remoteResource]);
useEffect(() => {
if (remoteResource && !nearViewport) return;
const controller = new AbortController();
let cancelled = false;
setCacheFailed(false);
if (remoteResource && resourceId) {
if (!nearViewport) {
setCachedSrc("");
return () => {
cancelled = true;
};
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;
}
void getResourceAccess(storageKey, "display")
.then((access) => {
if (!cancelled) setCachedSrc(resolveResourceAccessURL(access.url));
})
.catch(() => {
if (!cancelled) setCacheFailed(true);
});
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);
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();
};
}, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]);
}, [identity, localImageResource, nearViewport, remoteResource, resourceId, scope, src, storageKey, variant]);
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
void resolveImageUrl(storageKey)
.then((url) => {
if (url && url !== cachedSrc) {
setCachedSrc(url);
const controller = new AbortController();
void prepareCanvasImage(url, controller.signal).then(() => setDisplayed({ identity, src: url })).catch(() => {
setFailedIdentity(identity);
onError?.(e);
});
return;
}
setCacheFailed(true);
setFailedIdentity(identity);
onError?.(e);
})
.catch(() => {
setCacheFailed(true);
setFailedIdentity(identity);
onError?.(e);
});
return;
}
setCacheFailed(true);
setFailedIdentity(identity);
onError?.(e);
};
if (!remoteResource) {
if (cacheFailed && fallback) return <>{fallback}</>;
return <img {...props} src={cachedSrc} onError={handleImgError} />;
}
if (failedIdentity === identity && fallback) return <>{fallback}</>;
if (!remoteResource) return <img {...props} src={cachedSrc || undefined} onError={handleImgError} />;
return (
<span ref={targetRef} className="cached-resource-image-shell">
{cachedSrc && !cacheFailed ? <img {...props} src={cachedSrc} onError={handleImgError} /> : cacheFailed ? fallback : loadingFallback}
{cachedSrc ? <img {...props} src={cachedSrc} onError={handleImgError} /> : failedIdentity === identity ? fallback : loadingFallback}
</span>
);
}