import webConfig from "@/constants/common-env"; import { getStoredAuthKey } from "@/store/auth"; type ImageBlobCacheEntry = { promise: Promise; size: number; lastUsedAt: number; }; const imageBlobCache = new Map(); const MAX_IMAGE_BLOB_CACHE_ITEMS = 8; const MAX_IMAGE_BLOB_CACHE_BYTES = 64 * 1024 * 1024; let imageBlobCacheBytes = 0; function isBrowserHttps() { return typeof window !== "undefined" && window.location.protocol === "https:"; } export function normalizeImageUrl(value: string) { const trimmed = String(value || "").trim(); if (!trimmed) { return ""; } try { const url = new URL(trimmed, typeof window !== "undefined" ? window.location.href : undefined); if (url.protocol === "http:" && (url.hostname.endsWith(".hf.space") || isBrowserHttps())) { url.protocol = "https:"; } return url.toString(); } catch { return trimmed; } } function isHuggingFaceHost(hostname: string) { const host = hostname.toLowerCase(); return host === "huggingface.co" || host.endsWith(".hf.space") || host.endsWith(".hf.co"); } function unproxyHuggingFaceUrl(value: string) { try { const url = new URL(value, typeof window !== "undefined" ? window.location.href : undefined); const marker = "/redirect_to_"; const markerIndex = url.pathname.indexOf(marker); if (markerIndex < 0) { return ""; } const target = url.pathname.slice(markerIndex + marker.length); const slashIndex = target.indexOf("/"); if (slashIndex <= 0) { return ""; } const hostname = target.slice(0, slashIndex); if (!isHuggingFaceHost(hostname)) { return ""; } return `https://${hostname}${target.slice(slashIndex)}${url.search}`; } catch { return ""; } } function imageFetchCandidates(src: string) { const normalizedSrc = normalizeImageUrl(src); const candidates = [normalizedSrc]; const fallbackSrc = unproxyHuggingFaceUrl(normalizedSrc); if (fallbackSrc && fallbackSrc !== normalizedSrc) { candidates.push(fallbackSrc); } try { const url = new URL(normalizedSrc, typeof window !== "undefined" ? window.location.href : undefined); if (url.hostname === "localhost") { url.hostname = "127.0.0.1"; candidates.push(url.toString()); } else if (url.hostname === "127.0.0.1") { url.hostname = "localhost"; candidates.push(url.toString()); } } catch { // Keep the original candidate list for non-URL values. } return Array.from(new Set(candidates.filter(Boolean))); } async function fetchImageBlobUncached(src: string) { let lastError: unknown = null; for (const candidate of imageFetchCandidates(src)) { try { const response = await fetch(candidate, { cache: "force-cache" }); if (response.ok) { return response.blob(); } lastError = new Error(`HTTP ${response.status}`); } catch (error) { lastError = error; } } try { const parsed = new URL(normalizeImageUrl(src), typeof window !== "undefined" ? window.location.href : undefined); if (parsed.protocol === "http:" || parsed.protocol === "https:") { const apiBase = webConfig.apiUrl.replace(/\/$/, ""); const authKey = await getStoredAuthKey(); const response = await fetch(`${apiBase}/api/images/proxy?url=${encodeURIComponent(parsed.toString())}`, { cache: "force-cache", headers: authKey ? { Authorization: `Bearer ${authKey}` } : undefined, }); if (response.ok) { return response.blob(); } lastError = new Error(`HTTP ${response.status}`); } } catch (error) { lastError = error; } const detail = lastError instanceof Error && lastError.message ? `:${lastError.message}` : ""; throw new Error(`读取图片失败${detail}`); } function evictImageBlobCache(protectedKey = "") { while ( imageBlobCache.size > MAX_IMAGE_BLOB_CACHE_ITEMS || imageBlobCacheBytes > MAX_IMAGE_BLOB_CACHE_BYTES ) { const candidate = [...imageBlobCache.entries()] .filter(([key]) => key !== protectedKey) .sort(([, left], [, right]) => left.lastUsedAt - right.lastUsedAt)[0]; if (!candidate) return; imageBlobCache.delete(candidate[0]); imageBlobCacheBytes = Math.max(0, imageBlobCacheBytes - candidate[1].size); } } export async function fetchImageBlob(src: string) { const key = normalizeImageUrl(src); const cached = imageBlobCache.get(key); if (cached) { cached.lastUsedAt = Date.now(); return cached.promise; } const entry: ImageBlobCacheEntry = { promise: Promise.resolve(new Blob()), size: 0, lastUsedAt: Date.now(), }; entry.promise = fetchImageBlobUncached(key) .then((blob) => { if (imageBlobCache.get(key) === entry) { entry.size = blob.size; entry.lastUsedAt = Date.now(); imageBlobCacheBytes += blob.size; evictImageBlobCache(key); } return blob; }) .catch((error) => { if (imageBlobCache.get(key) === entry) { imageBlobCache.delete(key); } throw error; }); imageBlobCache.set(key, entry); evictImageBlobCache(key); return entry.promise; } /** * 预取图片为 Blob,供「下载 / 加入参考」等需要字节流的操作用。 * * ⚠️⚠️ **绝对不要在图片列表的渲染 / onLoad 路径里批量调用它**(2026-10-16 事故)。 * 结果图是 ChatGPT 官网签名直链(跨域),`` 本来就要完整下载一遍;这里再 fetch * 一次,跨域被 CORS 拦下后会回落到后端 `/api/images/proxy`,让只有 2 核的容器替**每个 * 访客**把整张图(1~3MB)重下一遍;同时这些无用请求会占满浏览器对同域名仅有的 6 条 * 并发连接,`` 反而要排队——症状就是「画图页图片加载慢,右键新标签页打开却很快」。 * 下载/加入参考都是用户主动点击,届时 fetchImageBlob 自己会取并缓存,无需预热。 * * 保留在飞上限作为兜底:万一将来又被挂到批量路径上,最多只并行 2 个,不会雪崩。 */ const MAX_INFLIGHT_WARMS = 2; let inflightWarms = 0; export function warmImageBlob(src: string) { if (!String(src || "").trim()) return; if (inflightWarms >= MAX_INFLIGHT_WARMS) return; inflightWarms += 1; void fetchImageBlob(src) .catch(() => undefined) .finally(() => { inflightWarms = Math.max(0, inflightWarms - 1); }); } export function releaseImageBlob(src: string) { const key = normalizeImageUrl(src); const cached = imageBlobCache.get(key); if (!cached) return; imageBlobCache.delete(key); imageBlobCacheBytes = Math.max(0, imageBlobCacheBytes - cached.size); } export async function downloadImageSrc(src: string, filename: string) { const blob = await fetchImageBlob(src); const objectUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(objectUrl); }