xiaolongmr commited on
Commit ·
36b3bb0
1
Parent(s): 9340872
shan chu tupian yuchong xiazai, chidu gengxin an zhen he bing
Browse files
web/src/app/batch/components/image-results.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { Clock3, Download, LoaderCircle, Pencil, RotateCcw, ScanSearch, Sparkles
|
|
| 6 |
import { ImageShareEvidenceDialog } from "@/app/batch/components/image-share-evidence-dialog";
|
| 7 |
import { ChatGptIcon } from "@/components/chatgpt-icon";
|
| 8 |
import { Button } from "@/components/ui/button";
|
| 9 |
-
import { downloadImageSrc, normalizeImageUrl
|
| 10 |
import { cn } from "@/lib/utils";
|
| 11 |
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
| 12 |
|
|
@@ -395,9 +395,9 @@ export function ImageResults({
|
|
| 395 |
event.currentTarget.naturalWidth,
|
| 396 |
event.currentTarget.naturalHeight,
|
| 397 |
);
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
}}
|
| 402 |
/>
|
| 403 |
{shareEvidenceEnabled && image.conversationId ? (
|
|
|
|
| 6 |
import { ImageShareEvidenceDialog } from "@/app/batch/components/image-share-evidence-dialog";
|
| 7 |
import { ChatGptIcon } from "@/components/chatgpt-icon";
|
| 8 |
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { downloadImageSrc, normalizeImageUrl } from "@/lib/image-url";
|
| 10 |
import { cn } from "@/lib/utils";
|
| 11 |
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
| 12 |
|
|
|
|
| 395 |
event.currentTarget.naturalWidth,
|
| 396 |
event.currentTarget.naturalHeight,
|
| 397 |
);
|
| 398 |
+
// ⚠️ 同 image-results.tsx:不要在这里 warmImageBlob。
|
| 399 |
+
// 跨域直链会被 fetch 的 CORS 拦下并回落到后端 /api/images/proxy,
|
| 400 |
+
// 让容器替每个访客把整张图重下一遍,图片反而更慢。
|
| 401 |
}}
|
| 402 |
/>
|
| 403 |
{shareEvidenceEnabled && image.conversationId ? (
|
web/src/app/batch/page.tsx
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
| 48 |
type RedeemCode,
|
| 49 |
type ThirdPartyImageModelOption,
|
| 50 |
} from "@/lib/api";
|
| 51 |
-
import { fetchImageBlob, releaseImageBlob
|
| 52 |
import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
|
| 53 |
import { useAuthGuard } from "@/lib/use-auth-guard";
|
| 54 |
import { Input } from "@/components/ui/input";
|
|
@@ -541,11 +541,8 @@ function taskDataToStoredImages(image: StoredImage, task: ImageTask): StoredImag
|
|
| 541 |
durationMs: task.duration_ms,
|
| 542 |
}];
|
| 543 |
}
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
warmImageBlob(item.url);
|
| 547 |
-
}
|
| 548 |
-
});
|
| 549 |
return resultItems.map((item, index) => ({
|
| 550 |
...image,
|
| 551 |
id: index === 0 ? image.id : `${image.id}-extra-${index}`,
|
|
|
|
| 48 |
type RedeemCode,
|
| 49 |
type ThirdPartyImageModelOption,
|
| 50 |
} from "@/lib/api";
|
| 51 |
+
import { fetchImageBlob, releaseImageBlob } from "@/lib/image-url";
|
| 52 |
import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
|
| 53 |
import { useAuthGuard } from "@/lib/use-auth-guard";
|
| 54 |
import { Input } from "@/components/ui/input";
|
|
|
|
| 541 |
durationMs: task.duration_ms,
|
| 542 |
}];
|
| 543 |
}
|
| 544 |
+
// ⚠️ 同 image/page.tsx:不要在这里预热 warmImageBlob(item.url)。
|
| 545 |
+
// 预热会与 <img> 抢并发连接,跨域失败还会让容器走 /api/images/proxy 重下一遍。
|
|
|
|
|
|
|
|
|
|
| 546 |
return resultItems.map((item, index) => ({
|
| 547 |
...image,
|
| 548 |
id: index === 0 ? image.id : `${image.id}-extra-${index}`,
|
web/src/app/image/components/image-results.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { Clock3, Download, LoaderCircle, Pencil, RotateCcw, ScanSearch, Sparkles
|
|
| 6 |
import { ImageShareEvidenceDialog } from "@/app/image/components/image-share-evidence-dialog";
|
| 7 |
import { ChatGptIcon } from "@/components/chatgpt-icon";
|
| 8 |
import { Button } from "@/components/ui/button";
|
| 9 |
-
import { downloadImageSrc, normalizeImageUrl
|
| 10 |
import { cn } from "@/lib/utils";
|
| 11 |
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
| 12 |
|
|
@@ -166,6 +166,11 @@ export function ImageResults({
|
|
| 166 |
}, []);
|
| 167 |
|
| 168 |
const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
const [continueEditLoadingImageIds, setContinueEditLoadingImageIds] = useState<string[]>([]);
|
| 170 |
const [annotationLoadingImageIds, setAnnotationLoadingImageIds] = useState<string[]>([]);
|
| 171 |
const [now, setNow] = useState(() => Date.now());
|
|
@@ -186,9 +191,36 @@ export function ImageResults({
|
|
| 186 |
|
| 187 |
const updateImageDimensions = (id: string, width: number, height: number) => {
|
| 188 |
const dimensions = formatImageDimensions(width, height);
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
};
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
const continueEdit = async (
|
| 194 |
conversationId: string,
|
|
@@ -395,9 +427,14 @@ export function ImageResults({
|
|
| 395 |
event.currentTarget.naturalWidth,
|
| 396 |
event.currentTarget.naturalHeight,
|
| 397 |
);
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
}}
|
| 402 |
/>
|
| 403 |
{shareEvidenceEnabled && image.conversationId ? (
|
|
|
|
| 6 |
import { ImageShareEvidenceDialog } from "@/app/image/components/image-share-evidence-dialog";
|
| 7 |
import { ChatGptIcon } from "@/components/chatgpt-icon";
|
| 8 |
import { Button } from "@/components/ui/button";
|
| 9 |
+
import { downloadImageSrc, normalizeImageUrl } from "@/lib/image-url";
|
| 10 |
import { cn } from "@/lib/utils";
|
| 11 |
import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceImage } from "@/store/image-conversations";
|
| 12 |
|
|
|
|
| 166 |
}, []);
|
| 167 |
|
| 168 |
const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
|
| 169 |
+
// 把「每张图 onLoad 各触发一次 setState」合并成「每帧最多一次」。
|
| 170 |
+
// 一份会话常有几十张结果图,若逐张 setState,每加载完一张就要重渲染整份轮次列表
|
| 171 |
+
// (几十个子树 diff),主线程被占满,视觉上就是「图一直在转、点哪都迟钝」。
|
| 172 |
+
const pendingDimensionsRef = useRef<Record<string, string>>({});
|
| 173 |
+
const dimensionsRafRef = useRef<number | null>(null);
|
| 174 |
const [continueEditLoadingImageIds, setContinueEditLoadingImageIds] = useState<string[]>([]);
|
| 175 |
const [annotationLoadingImageIds, setAnnotationLoadingImageIds] = useState<string[]>([]);
|
| 176 |
const [now, setNow] = useState(() => Date.now());
|
|
|
|
| 191 |
|
| 192 |
const updateImageDimensions = (id: string, width: number, height: number) => {
|
| 193 |
const dimensions = formatImageDimensions(width, height);
|
| 194 |
+
pendingDimensionsRef.current[id] = dimensions;
|
| 195 |
+
if (dimensionsRafRef.current !== null) {
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
dimensionsRafRef.current = window.requestAnimationFrame(() => {
|
| 199 |
+
dimensionsRafRef.current = null;
|
| 200 |
+
const pending = pendingDimensionsRef.current;
|
| 201 |
+
pendingDimensionsRef.current = {};
|
| 202 |
+
setImageDimensions((current) => {
|
| 203 |
+
let next: Record<string, string> | null = null;
|
| 204 |
+
for (const [key, value] of Object.entries(pending)) {
|
| 205 |
+
if (current[key] !== value) {
|
| 206 |
+
next = next || { ...current };
|
| 207 |
+
next[key] = value;
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
return next || current;
|
| 211 |
+
});
|
| 212 |
+
});
|
| 213 |
};
|
| 214 |
|
| 215 |
+
useEffect(() => {
|
| 216 |
+
return () => {
|
| 217 |
+
if (dimensionsRafRef.current !== null) {
|
| 218 |
+
window.cancelAnimationFrame(dimensionsRafRef.current);
|
| 219 |
+
dimensionsRafRef.current = null;
|
| 220 |
+
}
|
| 221 |
+
};
|
| 222 |
+
}, []);
|
| 223 |
+
|
| 224 |
|
| 225 |
const continueEdit = async (
|
| 226 |
conversationId: string,
|
|
|
|
| 427 |
event.currentTarget.naturalWidth,
|
| 428 |
event.currentTarget.naturalHeight,
|
| 429 |
);
|
| 430 |
+
// ⚠️ 这里**不要**再加 `warmImageBlob(image.url)`。
|
| 431 |
+
// 图片是 ChatGPT 官网签名直链(跨域),<img> 已经把它完整下载过一遍;
|
| 432 |
+
// 再 fetch 一次会走 fetchImageBlobUncached,跨域被 CORS 拦下后回落到
|
| 433 |
+
// 后端 `/api/images/proxy`,让只有 2 核的容器替每个访客把整张图重下一遍。
|
| 434 |
+
// 一屏 6~9 张图 ⇒ 雪崩式排队,且浏览器对同域名的 6 条并发连接被这些
|
| 435 |
+
// 无用请求占满,<img> 反而要等——这就是「画图页图片加载慢、右键新标签页
|
| 436 |
+
// 打开却很快」的直接原因(新标签页不跑这段 JS,也不与轮询抢连接)。
|
| 437 |
+
// 下载 / 加入参考都是用户主动点击,届时 fetchImageBlob 自己会取,功能不变。
|
| 438 |
}}
|
| 439 |
/>
|
| 440 |
{shareEvidenceEnabled && image.conversationId ? (
|
web/src/app/image/page.tsx
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
| 48 |
type RedeemCode,
|
| 49 |
type ThirdPartyImageModelOption,
|
| 50 |
} from "@/lib/api";
|
| 51 |
-
import { fetchImageBlob, releaseImageBlob
|
| 52 |
import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
|
| 53 |
import { useAuthGuard } from "@/lib/use-auth-guard";
|
| 54 |
import { Input } from "@/components/ui/input";
|
|
@@ -534,11 +534,11 @@ function taskDataToStoredImages(image: StoredImage, task: ImageTask): StoredImag
|
|
| 534 |
durationMs: task.duration_ms,
|
| 535 |
}];
|
| 536 |
}
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
return resultItems.map((item, index) => ({
|
| 543 |
...image,
|
| 544 |
id: index === 0 ? image.id : `${image.id}-extra-${index}`,
|
|
|
|
| 48 |
type RedeemCode,
|
| 49 |
type ThirdPartyImageModelOption,
|
| 50 |
} from "@/lib/api";
|
| 51 |
+
import { fetchImageBlob, releaseImageBlob } from "@/lib/image-url";
|
| 52 |
import { formatChinaDateTime, formatChinaShortDateTime } from "@/lib/time";
|
| 53 |
import { useAuthGuard } from "@/lib/use-auth-guard";
|
| 54 |
import { Input } from "@/components/ui/input";
|
|
|
|
| 534 |
durationMs: task.duration_ms,
|
| 535 |
}];
|
| 536 |
}
|
| 537 |
+
// ⚠️ 这里**不要**再加「任务成功就 warmImageBlob(item.url)」的预热。
|
| 538 |
+
// 结果图是 ChatGPT 官网签名直链(跨域),预热会与紧接着的 <img> 请求**同时**发车:
|
| 539 |
+
// 跨域 fetch 被 CORS 拦下后回落到后端 `/api/images/proxy`,等于让容器替每个访客
|
| 540 |
+
// 把整张图重下一遍,还占掉浏览器对同域名的并发连接。渲染时 <img> 自己会拉,
|
| 541 |
+
// 用户点「下载 / 加入参考」时 fetchImageBlob 也会拉,预热纯属多余负担。
|
| 542 |
return resultItems.map((item, index) => ({
|
| 543 |
...image,
|
| 544 |
id: index === 0 ? image.id : `${image.id}-extra-${index}`,
|
web/src/lib/image-url.ts
CHANGED
|
@@ -169,9 +169,30 @@ export async function fetchImageBlob(src: string) {
|
|
| 169 |
return entry.promise;
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
export function warmImageBlob(src: string) {
|
| 173 |
if (!String(src || "").trim()) return;
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
export function releaseImageBlob(src: string) {
|
|
|
|
| 169 |
return entry.promise;
|
| 170 |
}
|
| 171 |
|
| 172 |
+
/**
|
| 173 |
+
* 预取图片为 Blob,供「下载 / 加入参考」等需要字节流的操作用。
|
| 174 |
+
*
|
| 175 |
+
* ⚠️⚠️ **绝对不要在图片列表的渲染 / onLoad 路径里批量调用它**(2026-10-16 事故)。
|
| 176 |
+
* 结果图是 ChatGPT 官网签名直链(跨域),`<img>` 本来就要完整下载一遍;这里再 fetch
|
| 177 |
+
* 一次,跨域被 CORS 拦下后会回落到后端 `/api/images/proxy`,让只有 2 核的容器替**每个
|
| 178 |
+
* 访客**把整张图(1~3MB)重下一遍;同时这些无用请求会占满浏览器对同域名仅有的 6 条
|
| 179 |
+
* 并发连接,`<img>` 反而要排队——症状就是「画图页图片加载慢,右键新标签页打开却很快」。
|
| 180 |
+
* 下载/加入参考都是用户主动点击,届时 fetchImageBlob 自己会取并缓存,无需预热。
|
| 181 |
+
*
|
| 182 |
+
* 保留在飞上限作为兜底:万一将来又被挂到批量路径上,最多只并行 2 个,不会雪崩。
|
| 183 |
+
*/
|
| 184 |
+
const MAX_INFLIGHT_WARMS = 2;
|
| 185 |
+
let inflightWarms = 0;
|
| 186 |
+
|
| 187 |
export function warmImageBlob(src: string) {
|
| 188 |
if (!String(src || "").trim()) return;
|
| 189 |
+
if (inflightWarms >= MAX_INFLIGHT_WARMS) return;
|
| 190 |
+
inflightWarms += 1;
|
| 191 |
+
void fetchImageBlob(src)
|
| 192 |
+
.catch(() => undefined)
|
| 193 |
+
.finally(() => {
|
| 194 |
+
inflightWarms = Math.max(0, inflightWarms - 1);
|
| 195 |
+
});
|
| 196 |
}
|
| 197 |
|
| 198 |
export function releaseImageBlob(src: string) {
|