study / web /src /lib /image-url.ts
xiaolongmr
shan chu tupian yuchong xiazai, chidu gengxin an zhen he bing
36b3bb0
Raw History Blame Contribute Delete
7.13 kB
import webConfig from "@/constants/common-env";
import { getStoredAuthKey } from "@/store/auth";
type ImageBlobCacheEntry = {
promise: Promise<Blob>;
size: number;
lastUsedAt: number;
};
const imageBlobCache = new Map<string, ImageBlobCacheEntry>();
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 官网签名直链(跨域),`<img>` 本来就要完整下载一遍;这里再 fetch
* 一次,跨域被 CORS 拦下后会回落到后端 `/api/images/proxy`,让只有 2 核的容器替**每个
* 访客**把整张图(1~3MB)重下一遍;同时这些无用请求会占满浏览器对同域名仅有的 6 条
* 并发连接,`<img>` 反而要排队——症状就是「画图页图片加载慢,右键新标签页打开却很快」。
* 下载/加入参考都是用户主动点击,届时 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);
}