xiaolongmr commited on
Commit ·
06e320b
1
Parent(s): 46e362d
优化
Browse files- api/system.py +5 -1
- web/src/app/image/components/image-results.tsx +35 -6
- web/src/app/image/page.tsx +19 -16
- web/src/lib/image-url.ts +75 -3
api/system.py
CHANGED
|
@@ -321,7 +321,11 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 321 |
content_type = response.headers.get("content-type") or "image/png"
|
| 322 |
if not content_type.lower().startswith("image/"):
|
| 323 |
content_type = "image/png"
|
| 324 |
-
return Response(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
@router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
|
| 327 |
async def get_image_thumbnail(image_path: str):
|
|
|
|
| 321 |
content_type = response.headers.get("content-type") or "image/png"
|
| 322 |
if not content_type.lower().startswith("image/"):
|
| 323 |
content_type = "image/png"
|
| 324 |
+
return Response(
|
| 325 |
+
content=response.content,
|
| 326 |
+
media_type=content_type,
|
| 327 |
+
headers={"Cache-Control": "private, max-age=3600"},
|
| 328 |
+
)
|
| 329 |
|
| 330 |
@router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
|
| 331 |
async def get_image_thumbnail(image_path: str):
|
web/src/app/image/components/image-results.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { memo, useEffect, useRef, useState } from "react";
|
|
| 4 |
import { Clock3, Download, LoaderCircle, RotateCcw, ScanSearch, Sparkles, Trash2 } from "lucide-react";
|
| 5 |
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
-
import { downloadImageSrc, normalizeImageUrl } from "@/lib/image-url";
|
| 8 |
import { cn } from "@/lib/utils";
|
| 9 |
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
| 10 |
|
|
@@ -46,7 +46,7 @@ export type ImageLightboxItem = {
|
|
| 46 |
type ImageResultsProps = {
|
| 47 |
selectedConversation: ImageConversation | null;
|
| 48 |
onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
|
| 49 |
-
onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void;
|
| 50 |
onReversePrompt: (conversationId: string, turnId: string, image: StoredImage) => void | Promise<void>;
|
| 51 |
reversePromptLoadingImageIds: string[];
|
| 52 |
onDeletePrompt: (conversationId: string, turnId: string) => void;
|
|
@@ -154,6 +154,7 @@ export function ImageResults({
|
|
| 154 |
}, []);
|
| 155 |
|
| 156 |
const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
|
|
|
|
| 157 |
const [now, setNow] = useState(() => Date.now());
|
| 158 |
const hasPendingTurns = selectedConversation?.turns.some(
|
| 159 |
(turn) => !turn.resultsDeleted && (turn.status === "queued" || turn.status === "generating"),
|
|
@@ -172,6 +173,20 @@ export function ImageResults({
|
|
| 172 |
setImageDimensions((current) => current[id] === dimensions ? current : { ...current, [id]: dimensions });
|
| 173 |
};
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if (!selectedConversation) {
|
| 176 |
return (
|
| 177 |
<div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
|
|
@@ -284,9 +299,14 @@ export function ImageResults({
|
|
| 284 |
variant="outline"
|
| 285 |
size="sm"
|
| 286 |
className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
|
| 287 |
-
onClick={() =>
|
|
|
|
| 288 |
>
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
加入编辑
|
| 291 |
</Button>
|
| 292 |
</div>
|
|
@@ -330,6 +350,9 @@ export function ImageResults({
|
|
| 330 |
event.currentTarget.naturalWidth,
|
| 331 |
event.currentTarget.naturalHeight,
|
| 332 |
);
|
|
|
|
|
|
|
|
|
|
| 333 |
}}
|
| 334 |
/>
|
| 335 |
<div className="flex flex-col gap-1 px-0 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-0 sm:py-3 sm:text-xs">
|
|
@@ -358,10 +381,15 @@ export function ImageResults({
|
|
| 358 |
variant="outline"
|
| 359 |
size="sm"
|
| 360 |
className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
|
| 361 |
-
onClick={() =>
|
|
|
|
| 362 |
aria-label="加入编辑"
|
| 363 |
>
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
<span className="hidden sm:inline">加入编辑</span>
|
| 366 |
</Button>
|
| 367 |
<Button
|
|
@@ -545,6 +573,7 @@ function getProgressLabel(progress?: string) {
|
|
| 545 |
generation_success: "生成成功",
|
| 546 |
generating: "正在生成中",
|
| 547 |
};
|
|
|
|
| 548 |
return labels[progress || ""] || "正在生成中";
|
| 549 |
}
|
| 550 |
|
|
|
|
| 4 |
import { Clock3, Download, LoaderCircle, RotateCcw, ScanSearch, Sparkles, Trash2 } from "lucide-react";
|
| 5 |
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { downloadImageSrc, normalizeImageUrl, warmImageBlob } from "@/lib/image-url";
|
| 8 |
import { cn } from "@/lib/utils";
|
| 9 |
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
| 10 |
|
|
|
|
| 46 |
type ImageResultsProps = {
|
| 47 |
selectedConversation: ImageConversation | null;
|
| 48 |
onOpenLightbox: (images: ImageLightboxItem[], index: number) => void;
|
| 49 |
+
onContinueEdit: (conversationId: string, image: StoredImage | StoredReferenceImage) => void | Promise<void>;
|
| 50 |
onReversePrompt: (conversationId: string, turnId: string, image: StoredImage) => void | Promise<void>;
|
| 51 |
reversePromptLoadingImageIds: string[];
|
| 52 |
onDeletePrompt: (conversationId: string, turnId: string) => void;
|
|
|
|
| 154 |
}, []);
|
| 155 |
|
| 156 |
const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
|
| 157 |
+
const [continueEditLoadingImageIds, setContinueEditLoadingImageIds] = useState<string[]>([]);
|
| 158 |
const [now, setNow] = useState(() => Date.now());
|
| 159 |
const hasPendingTurns = selectedConversation?.turns.some(
|
| 160 |
(turn) => !turn.resultsDeleted && (turn.status === "queued" || turn.status === "generating"),
|
|
|
|
| 173 |
setImageDimensions((current) => current[id] === dimensions ? current : { ...current, [id]: dimensions });
|
| 174 |
};
|
| 175 |
|
| 176 |
+
|
| 177 |
+
const continueEdit = async (
|
| 178 |
+
conversationId: string,
|
| 179 |
+
image: StoredImage | StoredReferenceImage,
|
| 180 |
+
loadingId: string,
|
| 181 |
+
) => {
|
| 182 |
+
setContinueEditLoadingImageIds((current) => current.includes(loadingId) ? current : [...current, loadingId]);
|
| 183 |
+
try {
|
| 184 |
+
await onContinueEdit(conversationId, image);
|
| 185 |
+
} finally {
|
| 186 |
+
setContinueEditLoadingImageIds((current) => current.filter((item) => item !== loadingId));
|
| 187 |
+
}
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
if (!selectedConversation) {
|
| 191 |
return (
|
| 192 |
<div className="flex h-full min-h-[260px] items-center justify-center text-center sm:min-h-[420px]">
|
|
|
|
| 299 |
variant="outline"
|
| 300 |
size="sm"
|
| 301 |
className="rounded-full border-stone-200 bg-white text-stone-700 hover:bg-stone-50"
|
| 302 |
+
onClick={() => void continueEdit(selectedConversation.id, image, `${turn.id}-reference-${index}`)}
|
| 303 |
+
disabled={continueEditLoadingImageIds.includes(`${turn.id}-reference-${index}`)}
|
| 304 |
>
|
| 305 |
+
{continueEditLoadingImageIds.includes(`${turn.id}-reference-${index}`) ? (
|
| 306 |
+
<LoaderCircle className="size-4 animate-spin" />
|
| 307 |
+
) : (
|
| 308 |
+
<Sparkles className="size-4" />
|
| 309 |
+
)}
|
| 310 |
加入编辑
|
| 311 |
</Button>
|
| 312 |
</div>
|
|
|
|
| 350 |
event.currentTarget.naturalWidth,
|
| 351 |
event.currentTarget.naturalHeight,
|
| 352 |
);
|
| 353 |
+
if (image.url && !image.b64_json) {
|
| 354 |
+
warmImageBlob(image.url);
|
| 355 |
+
}
|
| 356 |
}}
|
| 357 |
/>
|
| 358 |
<div className="flex flex-col gap-1 px-0 py-1 text-[10px] sm:flex-row sm:items-center sm:justify-between sm:gap-2 sm:px-0 sm:py-3 sm:text-xs">
|
|
|
|
| 381 |
variant="outline"
|
| 382 |
size="sm"
|
| 383 |
className="h-7 w-7 rounded-full border-stone-200 bg-white px-0 text-[10px] text-stone-700 hover:bg-stone-50 sm:h-8 sm:w-fit sm:px-3 sm:text-xs"
|
| 384 |
+
onClick={() => void continueEdit(selectedConversation.id, image, image.id)}
|
| 385 |
+
disabled={continueEditLoadingImageIds.includes(image.id)}
|
| 386 |
aria-label="加入编辑"
|
| 387 |
>
|
| 388 |
+
{continueEditLoadingImageIds.includes(image.id) ? (
|
| 389 |
+
<LoaderCircle className="size-3 animate-spin sm:size-4" />
|
| 390 |
+
) : (
|
| 391 |
+
<Sparkles className="size-3 sm:size-4" />
|
| 392 |
+
)}
|
| 393 |
<span className="hidden sm:inline">加入编辑</span>
|
| 394 |
</Button>
|
| 395 |
<Button
|
|
|
|
| 573 |
generation_success: "生成成功",
|
| 574 |
generating: "正在生成中",
|
| 575 |
};
|
| 576 |
+
|
| 577 |
return labels[progress || ""] || "正在生成中";
|
| 578 |
}
|
| 579 |
|
web/src/app/image/page.tsx
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
| 40 |
type RedeemCode,
|
| 41 |
type ThirdPartyImageModelOption,
|
| 42 |
} from "@/lib/api";
|
| 43 |
-
import { fetchImageBlob } from "@/lib/image-url";
|
| 44 |
import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
|
| 45 |
import { useAuthGuard } from "@/lib/use-auth-guard";
|
| 46 |
import { Input } from "@/components/ui/input";
|
|
@@ -433,15 +433,19 @@ async function buildReferenceImageFromStoredImage(image: StoredImage, fileName:
|
|
| 433 |
return null;
|
| 434 |
}
|
| 435 |
const file = await fetchImageAsFile(image.url, fileName);
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
}
|
| 446 |
|
| 447 |
function hasImageTaskResult(item: NonNullable<ImageTask["data"]>[number] | null | undefined) {
|
|
@@ -466,6 +470,11 @@ function taskDataToStoredImages(image: StoredImage, task: ImageTask): StoredImag
|
|
| 466 |
durationMs: task.duration_ms,
|
| 467 |
}];
|
| 468 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
return resultItems.map((item, index) => ({
|
| 470 |
...image,
|
| 471 |
id: index === 0 ? image.id : `${image.id}-extra-${index}`,
|
|
@@ -1753,7 +1762,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
|
|
| 1753 |
|
| 1754 |
setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
|
| 1755 |
setReferenceImageFiles((prev) => [...prev, nextReference.file]);
|
| 1756 |
-
setImagePrompt("");
|
| 1757 |
textareaRef.current?.focus();
|
| 1758 |
toast.success("已加入当前参考图,继续输入描述即可编辑");
|
| 1759 |
} catch (error) {
|
|
@@ -1773,11 +1781,6 @@ function ImagePageContent({ session }: { session: StoredAuthSession }) {
|
|
| 1773 |
|
| 1774 |
setSelectedConversationId(conversationId);
|
| 1775 |
setImagePrompt(turn.prompt);
|
| 1776 |
-
setReferenceImages([]);
|
| 1777 |
-
setReferenceImageFiles([]);
|
| 1778 |
-
if (fileInputRef.current) {
|
| 1779 |
-
fileInputRef.current.value = "";
|
| 1780 |
-
}
|
| 1781 |
textareaRef.current?.focus();
|
| 1782 |
toast.success("已复用这条提示词");
|
| 1783 |
}, []);
|
|
|
|
| 40 |
type RedeemCode,
|
| 41 |
type ThirdPartyImageModelOption,
|
| 42 |
} from "@/lib/api";
|
| 43 |
+
import { fetchImageBlob, releaseImageBlob, warmImageBlob } from "@/lib/image-url";
|
| 44 |
import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
|
| 45 |
import { useAuthGuard } from "@/lib/use-auth-guard";
|
| 46 |
import { Input } from "@/components/ui/input";
|
|
|
|
| 433 |
return null;
|
| 434 |
}
|
| 435 |
const file = await fetchImageAsFile(image.url, fileName);
|
| 436 |
+
try {
|
| 437 |
+
return {
|
| 438 |
+
referenceImage: {
|
| 439 |
+
name: file.name,
|
| 440 |
+
type: file.type || "image/png",
|
| 441 |
+
dataUrl: await readFileAsDataUrl(file),
|
| 442 |
+
source: "result" as const,
|
| 443 |
+
},
|
| 444 |
+
file,
|
| 445 |
+
};
|
| 446 |
+
} finally {
|
| 447 |
+
releaseImageBlob(image.url);
|
| 448 |
+
}
|
| 449 |
}
|
| 450 |
|
| 451 |
function hasImageTaskResult(item: NonNullable<ImageTask["data"]>[number] | null | undefined) {
|
|
|
|
| 470 |
durationMs: task.duration_ms,
|
| 471 |
}];
|
| 472 |
}
|
| 473 |
+
resultItems.forEach((item) => {
|
| 474 |
+
if (item.url && !item.b64_json) {
|
| 475 |
+
warmImageBlob(item.url);
|
| 476 |
+
}
|
| 477 |
+
});
|
| 478 |
return resultItems.map((item, index) => ({
|
| 479 |
...image,
|
| 480 |
id: index === 0 ? image.id : `${image.id}-extra-${index}`,
|
|
|
|
| 1762 |
|
| 1763 |
setReferenceImages((prev) => [...prev, nextReference.referenceImage]);
|
| 1764 |
setReferenceImageFiles((prev) => [...prev, nextReference.file]);
|
|
|
|
| 1765 |
textareaRef.current?.focus();
|
| 1766 |
toast.success("已加入当前参考图,继续输入描述即可编辑");
|
| 1767 |
} catch (error) {
|
|
|
|
| 1781 |
|
| 1782 |
setSelectedConversationId(conversationId);
|
| 1783 |
setImagePrompt(turn.prompt);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1784 |
textareaRef.current?.focus();
|
| 1785 |
toast.success("已复用这条提示词");
|
| 1786 |
}, []);
|
web/src/lib/image-url.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
| 1 |
import webConfig from "@/constants/common-env";
|
| 2 |
import { getStoredAuthKey } from "@/store/auth";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
function isBrowserHttps() {
|
| 5 |
return typeof window !== "undefined" && window.location.protocol === "https:";
|
| 6 |
}
|
|
@@ -74,11 +85,11 @@ function imageFetchCandidates(src: string) {
|
|
| 74 |
return Array.from(new Set(candidates.filter(Boolean)));
|
| 75 |
}
|
| 76 |
|
| 77 |
-
|
| 78 |
let lastError: unknown = null;
|
| 79 |
for (const candidate of imageFetchCandidates(src)) {
|
| 80 |
try {
|
| 81 |
-
const response = await fetch(candidate, { cache: "
|
| 82 |
if (response.ok) {
|
| 83 |
return response.blob();
|
| 84 |
}
|
|
@@ -94,7 +105,7 @@ export async function fetchImageBlob(src: string) {
|
|
| 94 |
const apiBase = webConfig.apiUrl.replace(/\/$/, "");
|
| 95 |
const authKey = await getStoredAuthKey();
|
| 96 |
const response = await fetch(`${apiBase}/api/images/proxy?url=${encodeURIComponent(parsed.toString())}`, {
|
| 97 |
-
cache: "
|
| 98 |
headers: authKey ? { Authorization: `Bearer ${authKey}` } : undefined,
|
| 99 |
});
|
| 100 |
if (response.ok) {
|
|
@@ -110,6 +121,67 @@ export async function fetchImageBlob(src: string) {
|
|
| 110 |
throw new Error(`读取图片失败${detail}`);
|
| 111 |
}
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
export async function downloadImageSrc(src: string, filename: string) {
|
| 114 |
const blob = await fetchImageBlob(src);
|
| 115 |
const objectUrl = URL.createObjectURL(blob);
|
|
|
|
| 1 |
import webConfig from "@/constants/common-env";
|
| 2 |
import { getStoredAuthKey } from "@/store/auth";
|
| 3 |
|
| 4 |
+
type ImageBlobCacheEntry = {
|
| 5 |
+
promise: Promise<Blob>;
|
| 6 |
+
size: number;
|
| 7 |
+
lastUsedAt: number;
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const imageBlobCache = new Map<string, ImageBlobCacheEntry>();
|
| 11 |
+
const MAX_IMAGE_BLOB_CACHE_ITEMS = 8;
|
| 12 |
+
const MAX_IMAGE_BLOB_CACHE_BYTES = 64 * 1024 * 1024;
|
| 13 |
+
let imageBlobCacheBytes = 0;
|
| 14 |
+
|
| 15 |
function isBrowserHttps() {
|
| 16 |
return typeof window !== "undefined" && window.location.protocol === "https:";
|
| 17 |
}
|
|
|
|
| 85 |
return Array.from(new Set(candidates.filter(Boolean)));
|
| 86 |
}
|
| 87 |
|
| 88 |
+
async function fetchImageBlobUncached(src: string) {
|
| 89 |
let lastError: unknown = null;
|
| 90 |
for (const candidate of imageFetchCandidates(src)) {
|
| 91 |
try {
|
| 92 |
+
const response = await fetch(candidate, { cache: "force-cache" });
|
| 93 |
if (response.ok) {
|
| 94 |
return response.blob();
|
| 95 |
}
|
|
|
|
| 105 |
const apiBase = webConfig.apiUrl.replace(/\/$/, "");
|
| 106 |
const authKey = await getStoredAuthKey();
|
| 107 |
const response = await fetch(`${apiBase}/api/images/proxy?url=${encodeURIComponent(parsed.toString())}`, {
|
| 108 |
+
cache: "force-cache",
|
| 109 |
headers: authKey ? { Authorization: `Bearer ${authKey}` } : undefined,
|
| 110 |
});
|
| 111 |
if (response.ok) {
|
|
|
|
| 121 |
throw new Error(`读取图片失败${detail}`);
|
| 122 |
}
|
| 123 |
|
| 124 |
+
function evictImageBlobCache(protectedKey = "") {
|
| 125 |
+
while (
|
| 126 |
+
imageBlobCache.size > MAX_IMAGE_BLOB_CACHE_ITEMS ||
|
| 127 |
+
imageBlobCacheBytes > MAX_IMAGE_BLOB_CACHE_BYTES
|
| 128 |
+
) {
|
| 129 |
+
const candidate = [...imageBlobCache.entries()]
|
| 130 |
+
.filter(([key]) => key !== protectedKey)
|
| 131 |
+
.sort(([, left], [, right]) => left.lastUsedAt - right.lastUsedAt)[0];
|
| 132 |
+
if (!candidate) return;
|
| 133 |
+
imageBlobCache.delete(candidate[0]);
|
| 134 |
+
imageBlobCacheBytes = Math.max(0, imageBlobCacheBytes - candidate[1].size);
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
export async function fetchImageBlob(src: string) {
|
| 139 |
+
const key = normalizeImageUrl(src);
|
| 140 |
+
const cached = imageBlobCache.get(key);
|
| 141 |
+
if (cached) {
|
| 142 |
+
cached.lastUsedAt = Date.now();
|
| 143 |
+
return cached.promise;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const entry: ImageBlobCacheEntry = {
|
| 147 |
+
promise: Promise.resolve(new Blob()),
|
| 148 |
+
size: 0,
|
| 149 |
+
lastUsedAt: Date.now(),
|
| 150 |
+
};
|
| 151 |
+
entry.promise = fetchImageBlobUncached(key)
|
| 152 |
+
.then((blob) => {
|
| 153 |
+
if (imageBlobCache.get(key) === entry) {
|
| 154 |
+
entry.size = blob.size;
|
| 155 |
+
entry.lastUsedAt = Date.now();
|
| 156 |
+
imageBlobCacheBytes += blob.size;
|
| 157 |
+
evictImageBlobCache(key);
|
| 158 |
+
}
|
| 159 |
+
return blob;
|
| 160 |
+
})
|
| 161 |
+
.catch((error) => {
|
| 162 |
+
if (imageBlobCache.get(key) === entry) {
|
| 163 |
+
imageBlobCache.delete(key);
|
| 164 |
+
}
|
| 165 |
+
throw error;
|
| 166 |
+
});
|
| 167 |
+
imageBlobCache.set(key, entry);
|
| 168 |
+
evictImageBlobCache(key);
|
| 169 |
+
return entry.promise;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
export function warmImageBlob(src: string) {
|
| 173 |
+
if (!String(src || "").trim()) return;
|
| 174 |
+
void fetchImageBlob(src).catch(() => undefined);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
export function releaseImageBlob(src: string) {
|
| 178 |
+
const key = normalizeImageUrl(src);
|
| 179 |
+
const cached = imageBlobCache.get(key);
|
| 180 |
+
if (!cached) return;
|
| 181 |
+
imageBlobCache.delete(key);
|
| 182 |
+
imageBlobCacheBytes = Math.max(0, imageBlobCacheBytes - cached.size);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
export async function downloadImageSrc(src: string, filename: string) {
|
| 186 |
const blob = await fetchImageBlob(src);
|
| 187 |
const objectUrl = URL.createObjectURL(blob);
|