Download web/src/lib/image-url.ts from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 7.13 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/web/src/lib/image-url.ts
- Command line
-
hf download hf://spaces/zlcy/study/web/src/lib/image-url.ts
-
curl -L -o image-url.ts https://huggingface.co/spaces/zlcy/study/resolve/main/web/src/lib/image-url.ts
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); | |
| } | |