Compare commits

..

15 Commits

Author SHA1 Message Date
3f52e24076 Merge pull request '抠图取结果中断时重试原任务读取' (#41) from codex/rmbg-result-recovery-20261105 into main 2026-11-05 12:04:22 +08:00
0b74e71a33 Retry background removal result reads without recreating tasks 2026-11-05 12:04:11 +08:00
4f8c1a5d5d Merge pull request '减少抠图大图上传并保留超时原因' (#40) from codex/rmbg-large-image-timeout-20261105 into main 2026-11-05 11:33:30 +08:00
33662210dd Use archived source URLs for background removal and preserve timeout causes 2026-11-05 11:33:20 +08:00
71413c173e Merge pull request '修复阿里模型结果预览 403 和重复读取大图' (#39) from codex/alibaba-image-preview-20261105 into main 2026-11-05 11:03:39 +08:00
dbe96db34d Serve model result previews through shared image bytes and archived copies 2026-11-05 11:03:32 +08:00
dfaeb9f09d Merge pull request '图片预览即时显示并按屏幕加载高清' (#38) from codex/image-preview-latency-20261105 into main 2026-11-05 10:53:56 +08:00
46abb8259a Show cached images immediately while fullscreen previews load 2026-11-05 10:53:48 +08:00
3b3b5bc78a Merge pull request '历史图片回退保持归档入口和正确媒体类型' (#37) from codex/legacy-image-fallback-20261105 into main 2026-11-05 09:57:48 +08:00
db63f38b9c 历史图片回退保持归档入口并正确识别图片元数据 2026-11-05 09:57:38 +08:00
955ad91ae8 Merge pull request '旧图片预览保留七牛归档入口' (#36) from codex/legacy-preview-source-20261105 into main 2026-11-05 09:50:20 +08:00
36ac3def8f 旧图片预览保持归档入口并停止回退失效源 2026-11-05 09:49:20 +08:00
1d40053c7a Merge pull request '旧阿里存储通过七牛归档副本提供访问' (#35) from codex/legacy-oss-qiniu-recovery-20261105 into main 2026-11-05 09:33:15 +08:00
07451f5558 旧阿里存储链接优先使用七牛副本并停止失效源重试 2026-11-05 09:32:27 +08:00
13684505a6 Merge pull request '失效图片保持容器宽度和下载入口' (#34) from codex/image-preview-error-width-20261104 into main 2026-11-04 20:18:44 +08:00
28 changed files with 819 additions and 146 deletions

View File

@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -182,6 +182,18 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
} }
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
try {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
} catch (error) {
if (error instanceof Error && error.message.includes("HTTP 410")) {
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
throw error;
}
}
if (!isAllowedModelHost(targetUrl.hostname)) { if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
} }

View File

@ -1,8 +1,9 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error"; import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
export const runtime = "nodejs"; export const runtime = "nodejs";
const logger = createLogger("image.download"); const logger = createLogger("image.download");
@ -242,6 +243,7 @@ function resolveDirectImageTarget(
requestedQuality: number | null, requestedQuality: number | null,
requestedFormat: string | null requestedFormat: string | null
): URL | null { ): URL | null {
if (isAlibabaModelImageUrl(target.toString())) return null;
if (!isBrowserImageRequest(request)) { if (!isBrowserImageRequest(request)) {
return null; return null;
} }
@ -298,6 +300,24 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 }); return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
} }
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
try {
if (searchParams.get("original") === "1") {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
}
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
} catch (error) {
if (searchParams.get("original") === "1") return downloadFailure(error);
const failure = getDownloadFailure(error);
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
}
}
if (isBlockedHost(target.hostname)) { if (isBlockedHost(target.hostname)) {
return NextResponse.json({ error: "Blocked target host" }, { status: 403 }); return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
} }
@ -318,6 +338,25 @@ export async function GET(request: NextRequest) {
); );
} }
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]);
const archived = mappings[target.toString()];
if (archived) {
const preview = buildImageProxyUrl(archived, {
width: width || undefined,
quality,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
target = new URL(archived);
}
} catch (error) {
logger.warn("preview_archive_lookup_failed", error, { requestId });
}
}
if (original && searchParams.get("download") === "resolve") { if (original && searchParams.get("download") === "resolve") {
try { try {
const { buildImageProxyUrl } = await import("@/lib/image-delivery"); const { buildImageProxyUrl } = await import("@/lib/image-delivery");
@ -370,7 +409,7 @@ export async function GET(request: NextRequest) {
const fetchSignal = AbortSignal.any([ const fetchSignal = AbortSignal.any([
request.signal, request.signal,
AbortSignal.timeout(original ? 60_000 : width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS), AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
]); ]);
const upstreamFailure = (error: unknown) => { const upstreamFailure = (error: unknown) => {
if (request.signal.aborted) return new NextResponse(null, { status: 499 }); if (request.signal.aborted) return new NextResponse(null, { status: 499 });
@ -381,6 +420,22 @@ export async function GET(request: NextRequest) {
{ status: timedOut ? 504 : 502 }, { status: timedOut ? 504 : 502 },
); );
}; };
let contentType: string;
let originalBuffer: Buffer;
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
originalBuffer = data.buffer;
contentType = data.contentType;
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
if (!contentType.toLowerCase().startsWith("image/")) {
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
} catch (error) {
return upstreamFailure(error);
}
} else {
let upstreamResponse: Response; let upstreamResponse: Response;
try { try {
upstreamResponse = await fetch(target.toString(), { upstreamResponse = await fetch(target.toString(), {
@ -400,19 +455,17 @@ export async function GET(request: NextRequest) {
{ status: 502 } { status: 502 }
); );
} }
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) { if (!contentType.toLowerCase().startsWith("image/")) {
if (original) return downloadFailure("Source is not an image"); if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }
let originalBuffer: Buffer;
try { try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) { } catch (error) {
return upstreamFailure(error); return upstreamFailure(error);
} }
}
if (original) { if (original) {
return buildImageResponse(originalBuffer, { return buildImageResponse(originalBuffer, {
contentType, contentType,

View File

@ -81,7 +81,8 @@ import { canvasService } from "@/services/canvas";
import { import {
inferOriginalImageUrl, inferOriginalImageUrl,
} from "@/lib/image-url"; } from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
@ -3434,6 +3435,7 @@ export function CanvasChatPanel({
const [fullscreenPreview, setFullscreenPreview] = useState<{ const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string; url: string;
alt?: string; alt?: string;
preview: LoadedImagePreview;
} | null>(null); } | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
@ -3471,16 +3473,6 @@ export function CanvasChatPanel({
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState(""); const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState(""); const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null); const downloadAbortControllerRef = useRef<AbortController | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [fullscreenPreview]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (composerUploadClearTimeoutRef.current) { if (composerUploadClearTimeoutRef.current) {
@ -15812,8 +15804,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) { if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || ""; event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
} }
}} }}
/> />
@ -16131,8 +16123,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== attachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = attachment.url; event.currentTarget.src = buildImageFallbackUrl(attachment.url);
} }
}} }}
/> />
@ -16208,8 +16200,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== attachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = attachment.url; event.currentTarget.src = buildImageFallbackUrl(attachment.url);
} }
}} }}
/> />
@ -16394,8 +16386,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== tag.iconUrl) { if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
event.currentTarget.src = tag.iconUrl || ""; event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
} }
}} }}
/> />
@ -16695,8 +16687,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== attachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
event.currentTarget.src = attachment.url; event.currentTarget.src = buildImageFallbackUrl(attachment.url);
} }
}} }}
/> />
@ -17032,15 +17024,23 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== url) { if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
event.currentTarget.src = url; event.currentTarget.src = buildImageFallbackUrl(url);
} }
}} }}
onClick={() => setFullscreenPreview({ url })} onClick={(event) => setFullscreenPreview({ url, preview: {
src: event.currentTarget.currentSrc || event.currentTarget.src,
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
} })}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setFullscreenPreview({ url }); setFullscreenPreview({ url, preview: {
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
} });
}} }}
/> />
<div className="absolute bottom-2 right-2 flex items-center gap-1.5"> <div className="absolute bottom-2 right-2 flex items-center gap-1.5">
@ -17286,7 +17286,7 @@ export function CanvasChatPanel({
onClick={() => setFullscreenPreview(null)} onClick={() => setFullscreenPreview(null)}
> >
<button <button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors" className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreview(null)} onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览" aria-label="关闭预览"
> >
@ -17295,7 +17295,7 @@ export function CanvasChatPanel({
</svg> </svg>
</button> </button>
<button <button
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65" className="absolute top-4 right-16 z-10 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@ -17312,13 +17312,11 @@ export function CanvasChatPanel({
{isDownloadingOriginalImage ? "下载中" : "下载原图"} {isDownloadingOriginalImage ? "下载中" : "下载原图"}
</button> </button>
<img <ProgressiveImagePreview
src={fullscreenPreviewSrc} src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
preview={fullscreenPreview.preview}
alt={fullscreenPreview.alt || "图片预览"} alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain" className="max-w-[95vw] max-h-[95vh] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
/> />
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
@ -17885,8 +17883,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = slotAttachment.url; event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
} }
}} }}
/> />
@ -18210,8 +18208,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) { if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
event.currentTarget.src = activeClawdSkillTag.iconUrl || ""; event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
} }
}} }}
/> />
@ -18470,8 +18468,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== item.url) { if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
event.currentTarget.src = item.url; event.currentTarget.src = buildImageFallbackUrl(item.url);
} }
}} }}
/> />
@ -18655,8 +18653,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = slotAttachment.url; event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
} }
}} }}
/> />
@ -18723,8 +18721,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) { if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
event.currentTarget.src = slotAttachment.url; event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
} }
}} }}
/> />
@ -19423,8 +19421,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== tagPreview.url) { if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
event.currentTarget.src = tagPreview.url; event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
} }
}} }}
/> />

View File

@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
} }
} }
const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi; const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
while ((match = markdownLinkPattern.exec(text)) !== null) { while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || ""); const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label) const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
.replace(/<audio[\s\S]*?<\/audio>/gi, "") .replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "") .replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "") .replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => { .replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test( const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "") String(label || "")
); );

View File

@ -60,6 +60,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar"; import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import { import {
areImageAdjustmentsEqual, areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle, buildImageAdjustmentFilterStyle,
@ -186,7 +187,7 @@ import {
normalizeDetailUiSubjectAnalysis, normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis"; } from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId, getDefaultThreeDModelAliasId,
@ -1711,6 +1712,7 @@ export function ImageNode({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null); const downloadAbortControllerRef = useRef<AbortController | null>(null);
@ -8466,8 +8468,8 @@ export function ImageNode({
draggable={false} draggable={false}
loading="lazy" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== data.src) { if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = data.src; event.currentTarget.src = buildImageFallbackUrl(data.src);
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -8495,8 +8497,8 @@ export function ImageNode({
draggable={false} draggable={false}
loading="lazy" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== compareSource) { if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
event.currentTarget.src = compareSource; event.currentTarget.src = buildImageFallbackUrl(compareSource);
} }
}} }}
src={displayCompareSrc} src={displayCompareSrc}
@ -8559,12 +8561,17 @@ export function ImageNode({
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) { if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation(); e.stopPropagation();
setFullscreenPreviewImage({
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
});
setFullscreenPreviewOpen(true); setFullscreenPreviewOpen(true);
} }
}} }}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== data.src) { if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = data.src; event.currentTarget.src = buildImageFallbackUrl(data.src);
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -11964,6 +11971,7 @@ export function ImageNode({
{/* 双击放大预览弹窗 */} {/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen && {fullscreenPreviewOpen &&
data.src && data.src &&
fullscreenPreviewImage &&
createPortal( createPortal(
<div <div
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90" className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
@ -11971,7 +11979,7 @@ export function ImageNode({
> >
{/* 关闭按钮 */} {/* 关闭按钮 */}
<button <button
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black" className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
onClick={() => setFullscreenPreviewOpen(false)} onClick={() => setFullscreenPreviewOpen(false)}
> >
<svg <svg
@ -11989,7 +11997,7 @@ export function ImageNode({
</svg> </svg>
</button> </button>
<button <button
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60" className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={isDownloadingOriginalImage} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@ -12008,13 +12016,11 @@ export function ImageNode({
</button> </button>
{/* 图片 */} {/* 图片 */}
<img <ProgressiveImagePreview
alt={data.prompt || "图片预览"} alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain" className="max-h-[95vh] max-w-[95vw] object-contain"
decoding="async" src={resolveOriginalImageUrl(data.src) || data.src}
draggable={false} preview={fullscreenPreviewImage}
onClick={(e) => e.stopPropagation()}
src={fullscreenPreviewSrc}
style={activeImageAdjustmentsStyle} style={activeImageAdjustmentsStyle}
/> />

View File

@ -15,7 +15,7 @@ import {
splitOpenClawChartBlocks, splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card"; } from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download"; import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments"; import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming"; import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error"; import { getGatewayErrorPageMessage } from "@/lib/request-error";
@ -442,8 +442,8 @@ function MarkdownTableImagePreview({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== src) { if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
event.currentTarget.src = src; event.currentTarget.src = buildImageFallbackUrl(src);
} }
}} }}
/> />
@ -473,8 +473,8 @@ function MarkdownTableImagePreview({
decoding="async" decoding="async"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== originalSrc) { if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
event.currentTarget.src = originalSrc; event.currentTarget.src = buildImageFallbackUrl(originalSrc);
} }
}} }}
/> />

View File

@ -1,5 +1,5 @@
"use client"; "use client";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
@ -313,7 +313,7 @@ export function ImagePreview({
event.currentTarget.alt = "Generated Image"; event.currentTarget.alt = "Generated Image";
}} }}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== (thumbnail || original)) { if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (thumbnail || original)) {
event.currentTarget.src = thumbnail || original; event.currentTarget.src = thumbnail || original;
} else if (!compactGrid) { } else if (!compactGrid) {
event.currentTarget.style.aspectRatio = "1"; event.currentTarget.style.aspectRatio = "1";
@ -610,7 +610,7 @@ export function ImagePreview({
onClick={(e: React.MouseEvent) => e.stopPropagation()} onClick={(e: React.MouseEvent) => e.stopPropagation()}
> >
<img <img
src={original} src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original}
alt="Generated Image - Original" alt="Generated Image - Original"
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${ className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
isAnnotationMode ? "cursor-crosshair" : "" isAnnotationMode ? "cursor-crosshair" : ""

View File

@ -0,0 +1,74 @@
"use client";
import { useEffect, useState, type CSSProperties, type MouseEventHandler } from "react";
import { buildImageProxyUrl, getFullscreenPreviewSize } from "@/lib/image-delivery";
export type LoadedImagePreview = { src: string; width: number; height: number };
/** Keep the already displayed image visible while its larger preview loads. */
export function ProgressiveImagePreview({
src,
preview,
alt,
className,
style,
onClick,
}: {
src: string;
preview: LoadedImagePreview;
alt: string;
className?: string;
style?: CSSProperties;
onClick?: MouseEventHandler<HTMLImageElement>;
}) {
const size = getFullscreenPreviewSize({
imageWidth: preview.width,
imageHeight: preview.height,
viewportWidth: typeof window === "undefined" ? 1280 : window.innerWidth,
viewportHeight: typeof window === "undefined" ? 900 : window.innerHeight,
pixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio,
});
const fullSrc = buildImageProxyUrl(src, {
delivery: "proxy",
width: size.requestWidth,
quality: 86,
format: "webp",
});
const [loadedSrc, setLoadedSrc] = useState("");
const [failedSrc, setFailedSrc] = useState("");
const ready = fullSrc === preview.src || loadedSrc === fullSrc;
const failed = failedSrc === fullSrc;
useEffect(() => {
if (fullSrc === preview.src) return;
const image = new Image();
image.decoding = "async";
image.onload = () => setLoadedSrc(fullSrc);
image.onerror = () => setFailedSrc(fullSrc);
image.src = fullSrc;
return () => {
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
};
}, [fullSrc, preview.src]);
return (
<div className="relative flex items-center justify-center" onClick={(event) => event.stopPropagation()}>
<img
alt={alt}
className={className}
decoding="async"
draggable={false}
onClick={onClick}
src={ready ? fullSrc : preview.src}
style={{ width: size.displayWidth, ...style }}
/>
{!ready && (
<div role="status" className="pointer-events-none absolute bottom-4 max-w-full rounded-lg bg-black/80 px-3 py-2 text-center text-sm text-white">
{failed ? "高清预览加载失败,已保留当前预览;可关闭后重试或下载原图" : "正在加载高清预览…"}
</div>
)}
</div>
);
}

View File

@ -14,6 +14,7 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
import { import {
isAlibabaQwenImage3ModelId, isAlibabaQwenImage3ModelId,
resolveAlibabaImageAsyncEndpoint, resolveAlibabaImageAsyncEndpoint,
@ -129,11 +130,16 @@ async function fetchImageBufferWithRetry(
if (!match) throw new Error("输入图片数据格式无效"); if (!match) throw new Error("输入图片数据格式无效");
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
} }
let sourceUrl = imageUrl;
if (normalizeLegacyAliyunStorageUrl(sourceUrl)) {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
sourceUrl = await resolveLegacyAliyunStorageUrl(sourceUrl);
}
let lastError: unknown = null; let lastError: unknown = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) { for (let attempt = 1; attempt <= attempts; attempt += 1) {
const ctx = createAbortSignal(timeoutMs); const ctx = createAbortSignal(timeoutMs);
try { try {
const response = await fetch(imageUrl, { const response = await fetch(sourceUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",
signal: ctx.signal, signal: ctx.signal,

View File

@ -27,6 +27,7 @@ type RmbgConfig = {
}; };
export type ExecuteRmbgJobParams = { export type ExecuteRmbgJobParams = {
requestId?: string;
userId: string; userId: string;
imageBuffer: Buffer; imageBuffer: Buffer;
imageBase64?: string; imageBase64?: string;
@ -53,7 +54,7 @@ export class RmbgServiceError extends Error {
detail?: string; detail?: string;
constructor(message: string, status = 500, detail?: string) { constructor(message: string, status = 500, detail?: string) {
super(message); super(detail && detail !== message ? `${message}:${detail}` : message);
this.name = "RmbgServiceError"; this.name = "RmbgServiceError";
this.status = status; this.status = status;
this.detail = detail; this.detail = detail;
@ -147,6 +148,18 @@ const withTimeout = async <T,>(
} }
}; };
async function readAosResult<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await withTimeout(read, timeoutMs);
} catch (error) {
const name = error instanceof Error ? error.name : "";
if (attempt >= 2 || (name !== "TypeError" && name !== "AbortError")) throw error;
await sleep(1000 * (attempt + 1));
}
}
}
function getAosFriendlyErrorMessage(detail: string): string | null { function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i); const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) { if (resolutionMatch) {
@ -163,7 +176,7 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
async function prepareImageForAos( async function prepareImageForAos(
imageBuffer: Buffer imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { ): Promise<{ buffer: Buffer; mimeType: string; filename: string; resized: boolean }> {
try { try {
const sharp = await import("sharp"); const sharp = await import("sharp");
const image = sharp.default(imageBuffer); const image = sharp.default(imageBuffer);
@ -189,6 +202,7 @@ async function prepareImageForAos(
buffer: imageBuffer, buffer: imageBuffer,
mimeType, mimeType,
filename: `image.${filenameExt}`, filename: `image.${filenameExt}`,
resized: false,
}; };
} }
@ -203,10 +217,11 @@ async function prepareImageForAos(
buffer: resizedBuffer, buffer: resizedBuffer,
mimeType, mimeType,
filename: `image.${filenameExt}`, filename: `image.${filenameExt}`,
resized: true,
}; };
} catch (error) { } catch (error) {
logger.warn("aos_image_preprocess_failed", error); logger.warn("aos_image_preprocess_failed", error);
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" }; return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png", resized: true };
} }
} }
@ -369,20 +384,38 @@ async function processWithAosSegmentation(
foregroundType?: "" | "person" | "object" | "stamp"; foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1; crop?: 0 | 1;
format?: "png" | "jpg"; format?: "png" | "jpg";
sourceImageUrl?: string | null;
requestId?: string;
} }
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now(); const startTime = Date.now();
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, ""); const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`; const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
const headers = { "X-API-KEY": config.apiKey }; const headers = { "X-API-KEY": config.apiKey };
let stage = "create";
let providerTaskId: string | undefined;
try { try {
const normalizedImage = await prepareImageForAos(imageBuffer); const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData(); const createForm = new FormData();
let publicSourceUrl: string | null = null;
if (!normalizedImage.resized && config.sourceImageUrl) {
try {
const source = new URL(config.sourceImageUrl);
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
publicSourceUrl = source.toString();
}
} catch { /* Uploaded files and other sources use the file API. */ }
}
if (publicSourceUrl) {
createForm.append("image_url", publicSourceUrl);
} else {
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
type: normalizedImage.mimeType, type: normalizedImage.mimeType,
}); });
createForm.append("image_file", imageBlob, normalizedImage.filename); createForm.append("image_file", imageBlob, normalizedImage.filename);
}
createForm.append("sync", "0"); createForm.append("sync", "0");
createForm.append("return_type", "1"); createForm.append("return_type", "1");
createForm.append("output_type", "2"); createForm.append("output_type", "2");
@ -392,6 +425,9 @@ async function processWithAosSegmentation(
createForm.append("type", config.foregroundType); createForm.append("type", config.foregroundType);
} }
const createStartedAt = Date.now();
logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : normalizedImage.buffer.length });
const createResponse = await withTimeout( const createResponse = await withTimeout(
(signal) => (signal) =>
fetch(createUrl, { fetch(createUrl, {
@ -416,23 +452,32 @@ async function processWithAosSegmentation(
} }
const taskId = createPayload.data.task_id as string; const taskId = createPayload.data.task_id as string;
providerTaskId = taskId;
logger.info("aos_task_created", { requestId: config.requestId, taskId, createMs: Date.now() - createStartedAt });
const taskUrl = `${createUrl}/${taskId}`; const taskUrl = `${createUrl}/${taskId}`;
const maxPollCount = 60; const maxPollCount = 60;
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) { for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
await sleep(1000); await sleep(1000);
stage = "poll";
const pollResponse = await withTimeout( const pollResponse = await readAosResult(
(signal) => async (signal) => {
fetch(taskUrl, { const response = await fetch(taskUrl, {
method: "GET", method: "GET",
headers, headers,
signal, signal,
}), });
const payload = await response.json().catch((error) => {
if (error instanceof Error && error.name !== "SyntaxError") throw error;
return null;
});
return { ok: response.ok, status: response.status, statusText: response.statusText, payload };
},
AOS_POLL_TIMEOUT_MS AOS_POLL_TIMEOUT_MS
); );
const pollPayload = await pollResponse.json().catch(() => null); const pollPayload = pollResponse.payload;
if (!pollResponse.ok || !pollPayload?.data) { if (!pollResponse.ok || !pollPayload?.data) {
logger.warn("aos_task_poll_failed", { logger.warn("aos_task_poll_failed", {
hasPayload: pollPayload != null, hasPayload: pollPayload != null,
@ -454,12 +499,15 @@ async function processWithAosSegmentation(
pollCount, pollCount,
taskId, taskId,
}); });
const imageResponse = await withTimeout( stage = "download";
(signal) => const imageResponse = await readAosResult(
fetch(imageUrl, { async (signal) => {
const response = await fetch(imageUrl, {
method: "GET", method: "GET",
signal, signal,
}), });
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
},
AOS_DOWNLOAD_TIMEOUT_MS AOS_DOWNLOAD_TIMEOUT_MS
); );
@ -474,7 +522,7 @@ async function processWithAosSegmentation(
}; };
} }
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer()); const resultBuffer = imageResponse.buffer!;
return { return {
success: true, success: true,
imageBuffer: resultBuffer, imageBuffer: resultBuffer,
@ -499,10 +547,21 @@ async function processWithAosSegmentation(
return { success: false, error: "AOS任务超时(最多60秒)" }; return { success: false, error: "AOS任务超时(最多60秒)" };
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
return { success: false, error: "AOS请求超时,请稍后重试" }; logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
const message = stage === "create"
? "AOS创建任务超时(120秒),结果尚未确认"
: stage === "poll"
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
return { success: false, error: message };
} }
logger.warn("aos_provider_request_error", error); logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
return { success: false, error: error instanceof Error ? error.message : "未知错误" }; const detail = error instanceof Error ? error.message : "未知错误";
return { success: false, error: stage === "create"
? `AOS创建任务连接失败,结果尚未确认:${detail}`
: stage === "poll"
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
} }
} }
@ -601,6 +660,7 @@ export async function executeRmbgJob(
params: ExecuteRmbgJobParams params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> { ): Promise<ExecuteRmbgJobResult> {
const { const {
requestId,
userId, userId,
imageBuffer, imageBuffer,
imageBase64 = imageBuffer.toString("base64"), imageBase64 = imageBuffer.toString("base64"),
@ -657,6 +717,8 @@ export async function executeRmbgJob(
foregroundType: foregroundType ?? config.aosForegroundType ?? "", foregroundType: foregroundType ?? config.aosForegroundType ?? "",
crop: config.aosCrop === 1 ? 1 : 0, crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png", format: config.aosFormat === "jpg" ? "jpg" : "png",
sourceImageUrl,
requestId,
}); });
if (!result.success || !result.imageBuffer) { if (!result.success || !result.imageBuffer) {

View File

@ -128,6 +128,7 @@ async function executeForTarget(
if (command.capabilityId === "image.remove_background") { if (command.capabilityId === "image.remove_background") {
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl); const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId: command.requestId,
userId: params.userId, userId: params.userId,
imageBuffer, imageBuffer,
sourceImageUrl: target.sourceImageUrl, sourceImageUrl: target.sourceImageUrl,

View File

@ -45,6 +45,7 @@ import {
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { isProviderBalanceError } from "@/lib/request-error"; import { isProviderBalanceError } from "@/lib/request-error";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
export interface CanvasLocalEditCompositeRegion { export interface CanvasLocalEditCompositeRegion {
x: number; x: number;
@ -4632,10 +4633,10 @@ function findCanvasFailoverModel(params: {
} }
export async function generateCanvasImage( export async function generateCanvasImage(
request: CanvasGenerateImageRequest input: CanvasGenerateImageRequest
): Promise<CanvasGenerateImageResult> { ): Promise<CanvasGenerateImageResult> {
const startedAt = Date.now(); const startedAt = Date.now();
if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false }; const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input;
const prompt = String(request.prompt || "").trim(); const prompt = String(request.prompt || "").trim();
if (!prompt) { if (!prompt) {
throw new Error("请输入提示词"); throw new Error("请输入提示词");
@ -4873,11 +4874,16 @@ export async function generateCanvasImage(
typeof request.ownerKey === "string" && request.ownerKey.trim() typeof request.ownerKey === "string" && request.ownerKey.trim()
? request.ownerKey.trim() ? request.ownerKey.trim()
: undefined; : undefined;
const referenceImage = const resolveReference = async (url: string) => {
typeof request.referenceImage === "string" ? request.referenceImage : undefined; if (!normalizeLegacyAliyunStorageUrl(url)) return url;
const referenceImages = Array.isArray(request.referenceImages) const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
return resolveLegacyAliyunStorageUrl(url);
};
const referenceImage = typeof request.referenceImage === "string"
? await resolveReference(request.referenceImage) : undefined;
const referenceImages = await Promise.all((Array.isArray(request.referenceImages)
? request.referenceImages ? request.referenceImages
: []; : []).map(resolveReference));
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image"; const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
const debugMeta = const debugMeta =
request.debugMeta && typeof request.debugMeta === "object" request.debugMeta && typeof request.debugMeta === "object"

View File

@ -1,5 +1,17 @@
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy"; const IMAGE_PROXY_PATH = "/api/image-proxy";
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string {
const source = value || "";
const legacy = normalizeLegacyAliyunStorageUrl(source);
const modelImage = isAlibabaModelImageUrl(source);
if (!legacy && !modelImage) return source;
const proxy = buildImageProxyUrl(legacy || source, modelImage ? { width: 960, quality: 86, format: "webp" } : {});
return typeof window === "undefined" ? proxy : new URL(proxy, window.location.origin).toString();
}
type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png"; type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png";
export type ImageProxyOptions = { export type ImageProxyOptions = {
@ -9,6 +21,26 @@ export type ImageProxyOptions = {
delivery?: "display" | "proxy" | "original"; delivery?: "display" | "proxy" | "original";
}; };
export function getFullscreenPreviewSize({
imageWidth,
imageHeight,
viewportWidth,
viewportHeight,
pixelRatio = 1,
}: {
imageWidth: number;
imageHeight: number;
viewportWidth: number;
viewportHeight: number;
pixelRatio?: number;
}) {
const ratio = imageWidth > 0 && imageHeight > 0 ? imageWidth / imageHeight : 1;
const displayWidth = Math.min(viewportWidth * 0.95, viewportHeight * 0.95 * ratio);
const requiredWidth = displayWidth * Math.max(1, pixelRatio || 1);
const requestWidth = [640, 960, 1280, 1600, 2048, 2560, 3200, 4096].find((width) => width >= requiredWidth) || 4096;
return { displayWidth, requestWidth };
}
/** Preserve the actual response format when naming an original download. */ /** Preserve the actual response format when naming an original download. */
export function getImageDownloadExtension(contentType: string) { export function getImageDownloadExtension(contentType: string) {
const mime = contentType.split(";")[0]?.trim().toLowerCase(); const mime = contentType.split(";")[0]?.trim().toLowerCase();
@ -59,6 +91,15 @@ function isAliyunOssImageHost(hostname: string): boolean {
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-"); return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
} }
/** Model result buckets allow downloading objects but deny OSS image processing. */
export function isAlibabaModelImageUrl(value: string): boolean {
try {
return /^dashscope(?:-[a-z0-9-]+)?\.oss-[a-z0-9-]+\.aliyuncs\.com$/i.test(new URL(value).hostname);
} catch {
return false;
}
}
function isTrustedDirectStorageImageHost(hostname: string): boolean { function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase(); const normalized = hostname.trim().toLowerCase();
let configuredCdnHost = ""; let configuredCdnHost = "";
@ -200,7 +241,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
return trustedDirectStorageUrl; return trustedDirectStorageUrl;
} }
if (options.delivery !== "proxy") { if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source) && !isAlibabaModelImageUrl(source)) {
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options); const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
if (directDisplayUrl) { if (directDisplayUrl) {
return directDisplayUrl; return directDisplayUrl;

View File

@ -231,6 +231,24 @@ export function getRequestFailureDetails(
if (isGatewayResponseTimeout(error)) { if (isGatewayResponseTimeout(error)) {
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown"); return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
} }
if (/AOS创建任务超时/.test(text)) {
return detail("RMBG_CREATE_TIMEOUT", "抠图服务创建任务超时(120秒),结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown");
}
if (/AOS创建任务连接失败/.test(text)) {
return detail("RMBG_CREATE_CONNECTION_FAILED", "抠图服务创建任务时连接中断,结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown");
}
if (/AOS查询任务连接失败/.test(text)) {
return detail("RMBG_POLL_CONNECTION_FAILED", "抠图服务查询任务时连接中断,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown");
}
if (/AOS查询任务超时/.test(text)) {
return detail("RMBG_POLL_TIMEOUT", "抠图服务查询任务超时,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown");
}
if (/AOS结果下载连接失败/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", "抠图已完成,但结果图片传输中断。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if (/AOS结果下载超时/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) { if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown"); return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
} }
@ -243,6 +261,9 @@ export function getRequestFailureDetails(
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) { if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。"); return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
} }
if (/旧存储已停用|storage_retired/i.test(text)) {
return detail("SOURCE_STORAGE_RETIRED", "参考文件所在的旧存储已停用,且没有可用的归档副本。", "请重新上传原始文件后再提交;切换模型或重复提交无法恢复该文件。");
}
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) { if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown"); return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
} }

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl } from "./retired-storage"; import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { import {
@ -22,6 +22,8 @@ export function getImageArchiveDisplayHoldMs() {
} }
export function imageArchiveSourceKey(source: string) { export function imageArchiveSourceKey(source: string) {
const legacy = normalizeLegacyAliyunStorageUrl(source);
if (legacy) return legacy;
if (!source.startsWith("data:")) return source; if (!source.startsWith("data:")) return source;
const configured = const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL; process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
@ -39,7 +41,7 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}) { }) {
const sourceUrl = String(params.sourceUrl || "").trim(); const sourceUrl = String(params.sourceUrl || "").trim();
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null; if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return null;
const now = new Date(); const now = new Date();
const archiveAfterAt = params.archiveAfterAt || now; const archiveAfterAt = params.archiveAfterAt || now;
@ -312,6 +314,18 @@ export async function getArchivedImageUrlMappings(
return replacements; return replacements;
} }
/** Legacy OSS is unavailable; only an existing durable copy can serve its URLs. */
export async function resolveLegacyAliyunStorageUrl(source: string): Promise<string> {
const legacy = normalizeLegacyAliyunStorageUrl(source);
if (!legacy) return source;
const mappings = await getArchivedImageUrlMappings([source, legacy]);
const target = mappings[source] || mappings[legacy];
if (!target || normalizeLegacyAliyunStorageUrl(target) || isRetiredStorageUrl(target)) {
throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
}
return target;
}
// Normalize late writes too: an archive can finish before a task/canvas save arrives. // Normalize late writes too: an archive can finish before a task/canvas save arrives.
export async function resolveArchivedImageUrls<T>( export async function resolveArchivedImageUrls<T>(
value: T, value: T,

View File

@ -1,5 +1,6 @@
// Shared by the server and canvas; no storage or network work happens here. // Shared by the server and canvas; no storage or network work happens here.
export type ArchivedImageUrlMap = Record<string, string>; export type ArchivedImageUrlMap = Record<string, string>;
const legacyOssUrlPattern = /https?:\/\/demo-sparkai\.oss-cn-shanghai\.aliyuncs\.com\/[^\s"'<>`\\)\]}|]+/gi;
export function isGeneratedImageSource(value: string) { export function isGeneratedImageSource(value: string) {
return ( return (
@ -11,6 +12,7 @@ export function collectImageArchiveSources(value: unknown): string[] {
const urls = new Set<string>(); const urls = new Set<string>();
const visit = (item: unknown) => { const visit = (item: unknown) => {
if (typeof item === "string") { if (typeof item === "string") {
for (const match of item.matchAll(legacyOssUrlPattern)) urls.add(match[0].replace(/,+$/, ""));
if (isGeneratedImageSource(item)) urls.add(item); if (isGeneratedImageSource(item)) urls.add(item);
else else
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
@ -35,6 +37,10 @@ export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImag
.map((part) => replacements[part] || part) .map((part) => replacements[part] || part)
.join("|||"); .join("|||");
return `![${nextAlt}](${replacements[url] || url})`; return `![${nextAlt}](${replacements[url] || url})`;
}).replace(legacyOssUrlPattern, (url) => {
if (replacements[url]) return replacements[url];
const source = url.replace(/,+$/, "");
return (replacements[source] || source) + url.slice(source.length);
}) as T; }) as T;
} }
if (!value || typeof value !== "object" || value instanceof Date) return value; if (!value || typeof value !== "object" || value instanceof Date) return value;

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises"; import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@ -83,13 +83,18 @@ function isMediaResponse(data: ImageBytes) {
} }
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) { export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`); if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => { let sourceUrl = url;
if (url.startsWith("data:")) { if (normalizeLegacyAliyunStorageUrl(url)) {
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i); const { resolveLegacyAliyunStorageUrl } = await import("./image-archive-db");
sourceUrl = await resolveLegacyAliyunStorageUrl(url);
}
return onceImageBytes(`source:${options.scope || ""}:${sourceUrl}`, async () => {
if (sourceUrl.startsWith("data:")) {
const match = sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) throw new Error("Invalid inline generated image"); if (!match) throw new Error("Invalid inline generated image");
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
} }
const response = await fetch(url, { const response = await fetch(sourceUrl, {
headers: options.headers, cache: "no-store", redirect: "follow", headers: options.headers, cache: "no-store", redirect: "follow",
// The deadline covers the body as well as the response headers. // The deadline covers the body as well as the response headers.
signal: options.signal signal: options.signal

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl } from "./retired-storage"; import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, normalizeLegacyAliyunStorageUrl } from "./retired-storage";
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -19,6 +19,7 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
config.isEnabled && config.isEnabled &&
!isRetiredStorageUrl(config.endpoint) && !isRetiredStorageUrl(config.endpoint) &&
!isRetiredStorageUrl(config.publicUrl) && !isRetiredStorageUrl(config.publicUrl) &&
!isLegacyAliyunStorageConfig(config) &&
String(config.endpoint || "").trim() && String(config.endpoint || "").trim() &&
String(config.bucket || "").trim() && String(config.bucket || "").trim() &&
String(config.accessKeyId || "").trim() && String(config.accessKeyId || "").trim() &&
@ -83,7 +84,7 @@ export async function enqueueStorageMigrationTask({
migrateAfterAt: Date; migrateAfterAt: Date;
cleanupAfterAt: Date; cleanupAfterAt: Date;
}) { }) {
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return; if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return;
await db await db
.insert(storageMigrationTask) .insert(storageMigrationTask)

View File

@ -2,6 +2,29 @@
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)'; export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。'; export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
export function normalizeLegacyAliyunStorageUrl(value: string | null | undefined): string | null {
if (!value) return null;
try {
let url = new URL(value, 'https://local.invalid');
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
url = new URL(url.searchParams.get('url') || '');
}
if (url.hostname !== 'demo-sparkai.oss-cn-shanghai.aliyuncs.com') return null;
url.protocol = 'https:';
url.hash = '';
for (const name of ['Signature', 'OSSAccessKeyId', 'Expires', 'security-token']) url.searchParams.delete(name);
url.searchParams.sort();
return url.toString();
} catch { return null; }
}
export function isLegacyAliyunStorageConfig(config: { bucket?: string | null; endpoint?: string | null; publicUrl?: string | null }): boolean {
if (normalizeLegacyAliyunStorageUrl(config.publicUrl)) return true;
try {
return config.bucket === 'demo-sparkai' && new URL(config.endpoint || '').hostname.endsWith('.aliyuncs.com');
} catch { return false; }
}
export function isRetiredStorageUrl(value: string | null | undefined): boolean { export function isRetiredStorageUrl(value: string | null | undefined): boolean {
if (!value) return false; if (!value) return false;
try { try {

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob"; import { put } from "@vercel/blob";
@ -150,7 +150,7 @@ export function getContentType(filename: string): string {
} }
export function createS3Client(config: S3UploadConfig): S3Client { export function createS3Client(config: S3UploadConfig): S3Client {
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) { if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl) || isLegacyAliyunStorageConfig(config)) {
throw new Error(RETIRED_STORAGE_MESSAGE); throw new Error(RETIRED_STORAGE_MESSAGE);
} }
const normalizedEndpoint = normalizeEndpoint(config.endpoint); const normalizedEndpoint = normalizeEndpoint(config.endpoint);
@ -343,6 +343,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
!value.isEnabled || !value.isEnabled ||
isRetiredStorageUrl(endpoint) || isRetiredStorageUrl(endpoint) ||
isRetiredStorageUrl(String(value.publicUrl || "")) || isRetiredStorageUrl(String(value.publicUrl || "")) ||
isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: String(value.publicUrl || "") }) ||
!endpoint || !endpoint ||
!bucket || !bucket ||
!accessKeyId || !accessKeyId ||
@ -370,7 +371,8 @@ function buildEnvS3Config(): S3UploadConfig | null {
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? ""; const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? ""; const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) { if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) &&
!isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: process.env.S3_PUBLIC_URL }) && bucket && accessKeyId && secretAccessKey) {
return { return {
endpoint, endpoint,
region: process.env.S3_REGION?.trim() || "auto", region: process.env.S3_REGION?.trim() || "auto",

View File

@ -3,6 +3,9 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/rmbg-delivery\.test\.ts/,
/routes\/legacy-oss-recovery\.test\.ts/,
/routes\/retired-storage\.test\.ts/,
/routes\/original-image-download\.test\.ts/, /routes\/original-image-download\.test\.ts/,
/routes\/canvas-download-speed\.test\.ts/, /routes\/canvas-download-speed\.test\.ts/,
/routes\/canvas-download-recovery\.test\.ts/, /routes\/canvas-download-recovery\.test\.ts/,

View File

@ -53,6 +53,7 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
ReadableStream, ReadableStream,
Headers, Headers,
Blob, Blob,
FormData,
Request, Request,
Date: mocks.Date || Date, Date: mocks.Date || Date,
document: mocks.document, document: mocks.document,

View File

@ -1,4 +1,15 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("image metadata containing video or audio paths remains an image", () => {
for (const folder of ["video-canvas-demo", "audio-preview"]) {
const url = `https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/${folder}/cover.png`;
const markdown = `![旧链接图片|||${url}|||2K](${url})`;
expect(extractVideoUrls(markdown)).toEqual([]);
expect(extractAudioUrls(markdown)).toEqual([]);
expect(extractAssistantImageUrls(markdown)).toContain(url);
expect(stripMediaMarkup(markdown)).toBe(markdown);
}
});
import { import {
collectMessageImageUrls, collectMessageImageUrls,
extractAssistantImageUrls, extractAssistantImageUrls,

View File

@ -2,9 +2,77 @@ import { expect, test } from "@playwright/test";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import sharp from "sharp"; import sharp from "sharp";
import { GET } from "@/app/api/image-proxy/route"; import { GET } from "@/app/api/image-proxy/route";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => { test.describe("Qiniu image delivery", () => {
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");
expect(preview.pathname).toBe("/api/image-proxy");
expect(preview.searchParams.get("url")).toBe(source);
expect(preview.searchParams.has("x-oss-process")).toBe(false);
expect(buildImageProxyUrl("https://other-bucket.oss-cn-shanghai.aliyuncs.com/image.png", { width: 960 })).toContain("x-oss-process=");
});
test("a completed archive serves old model-result references from Qiniu immediately", async () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/archived.png";
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://yybb.top/archived.png" }) },
fetch: () => { throw new Error("Must use the completed archive"); },
})("app/api/image-proxy/route.ts");
const request = new URL("https://app.example/api/image-proxy");
request.searchParams.set("url", source); request.searchParams.set("w", "960"); request.searchParams.set("f", "webp");
const response = await route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://yybb.top/archived.png?imageView2/2/w/960/format/webp/q/82");
});
test("unarchived model previews share one original fetch with archiving and return resized WebP", async () => {
const source = `https://dashscope-a717.oss-accelerate.aliyuncs.com/${crypto.randomUUID()}.png`;
const original = await sharp({ create: { width: 1024, height: 1024, channels: 3, background: "#345678" } }).png().toBuffer();
let requests = 0;
const load = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({}) },
fetch: async (url: string) => {
expect(url).toBe(source); requests++;
await new Promise(resolve => setTimeout(resolve, 30));
return new Response(original, { headers: { "content-type": "image/png" } });
},
});
const route = load("app/api/image-proxy/route.ts");
const archiveRead = load("lib/storage/image-byte-cache.ts").fetchImageBytes(source);
const responses = await Promise.all([100, 200].map(width => {
const request = new URL("https://app.example/api/image-proxy");
request.searchParams.set("url", source); request.searchParams.set("w", String(width)); request.searchParams.set("f", "webp");
return route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
}));
expect((await archiveRead).buffer.equals(original)).toBe(true);
expect(requests).toBe(1);
for (let i = 0; i < responses.length; i++) {
expect(responses[i].status).toBe(200);
expect(responses[i].headers.get("content-type")).toBe("image/webp");
expect((await sharp(Buffer.from(await responses[i].arrayBuffer())).metadata()).width).toBe([100, 200][i]);
}
});
test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => {
const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 });
expect(size.displayWidth).toBeCloseTo(839.8);
expect(size.requestWidth).toBe(960);
expect(buildImageProxyUrl("https://yybb.top/a.png", { width: size.requestWidth, quality: 86, format: "webp" }))
.toBe(buildImageProxyUrl("https://yybb.top/a.png", { width: 960, quality: 86, format: "webp" }));
});
test("preserves screen density and image aspect ratio without requesting oversized fixed previews", () => {
expect(getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884, pixelRatio: 2 }).requestWidth).toBe(2048);
const portrait = getFullscreenPreviewSize({ imageWidth: 1000, imageHeight: 2000, viewportWidth: 1440, viewportHeight: 900, pixelRatio: 2 });
expect(portrait.displayWidth).toBeCloseTo(427.5);
expect(portrait.requestWidth).toBe(960);
expect(getFullscreenPreviewSize({ imageWidth: 4000, imageHeight: 1000, viewportWidth: 5000, viewportHeight: 2000, pixelRatio: 2 }).requestWidth).toBe(4096);
});
test("reports a timeout while reading image bytes as a retryable response", async () => { test("reports a timeout while reading image bytes as a retryable response", async () => {
const previousFetch = globalThis.fetch; const previousFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(new ReadableStream({ globalThis.fetch = async () => new Response(new ReadableStream({

View File

@ -17,6 +17,7 @@ test("known provider and platform failures explain their cause and next action",
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"], ["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"], ["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"], ["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
["HTTP 410: 旧存储已停用,源文件已无法恢复,请重新上传。", "SOURCE_STORAGE_RETIRED", "重新上传"],
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"], ["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"], ["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"], ["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],

View File

@ -0,0 +1,137 @@
import { test, expect } from "@playwright/test";
import { NextRequest, NextResponse } from "next/server";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { normalizeLegacyAliyunStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { collectImageArchiveSources, replaceArchivedImageUrls } from "@/lib/storage/image-archive-urls";
import { buildImageProxyUrl } from "@/lib/image-delivery";
const legacy = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/uploads/product.png";
const permanent = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/uploads/product.png";
test("legacy identity preserves image variants while dropping expired OSS credentials", () => {
const signed = legacy.replace("https:", "http:") + "?Signature=expired&Expires=1&OSSAccessKeyId=old&x-oss-process=image%2Fresize%2Cw_100#preview";
expect(normalizeLegacyAliyunStorageUrl(signed)).toBe(legacy + "?x-oss-process=image%2Fresize%2Cw_100");
expect(normalizeLegacyAliyunStorageUrl("/api/image-proxy?url=" + encodeURIComponent(signed))).toBe(normalizeLegacyAliyunStorageUrl(signed));
for (const source of [permanent, "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/image.png", "https://other-bucket.oss-cn-shanghai.aliyuncs.com/a.png"]) {
expect(normalizeLegacyAliyunStorageUrl(source)).toBeNull();
}
expect(isLegacyAliyunStorageConfig({ bucket: "demo-sparkai", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(true);
expect(isLegacyAliyunStorageConfig({ bucket: "other-bucket", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(false);
});
test("late canvas saves replace legacy URLs embedded in SVG, HTML and Markdown originals", () => {
const content = { svg: `<svg><image href="${legacy}"/></svg>`, html: `<img src='${legacy}'>`, text: `![thumb|||${legacy}|||2K](${permanent})` };
expect(collectImageArchiveSources(content)).toContain(legacy);
const result = replaceArchivedImageUrls(content, { [legacy]: permanent });
expect(JSON.stringify(result)).not.toContain("https://demo-sparkai.");
expect(result.svg).toContain(permanent);
expect(result.text).toBe(`![thumb|||${permanent}|||2K](${permanent})`);
});
test("legacy URLs in comma-separated reference descriptions keep sentence punctuation", () => {
const text = `Reference: ${legacy}, next reference`;
expect(collectImageArchiveSources(text)).toContain(legacy);
expect(replaceArchivedImageUrls(text, { [legacy]: permanent })).toBe(`Reference: ${permanent}, next reference`);
});
test("legacy display URLs go through archive resolution while other Alibaba buckets keep working", () => {
const url = buildImageProxyUrl(legacy, { width: 400 });
expect(url).toMatch(/^\/api\/image-proxy\?/);
expect(new URL(url, "https://app.example").searchParams.get("url")).toBe(legacy);
expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toMatch(/^\/api\/image-proxy\?/);
});
test("browser image fallbacks remain on one stable archive URL instead of retrying the old source", () => {
const delivery = serverLoader({ window: { location: { origin: "https://app.example" } } })("lib/image-delivery.ts");
const fallback = delivery.buildImageFallbackUrl(legacy);
expect(fallback).toMatch(/^https:\/\/app\.example\/api\/image-proxy\?/);
expect(delivery.buildImageFallbackUrl(fallback)).toBe(fallback);
expect(delivery.buildImageFallbackUrl(permanent)).toBe(permanent);
});
function archiveLoader(rows: Array<{ sourceUrl: string; targetUrl: string | null }>) {
return serverLoader({
"@/lib/db": { db: { select: () => ({ from: () => ({ where: async () => rows }) }), insert: () => { throw new Error("Legacy sources must not be queued"); } } },
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "source", targetUrl: "target" } },
})("lib/storage/image-archive-db.ts");
}
test("archive lookup serves signed legacy links from the durable Qiniu alias", async () => {
const archive = archiveLoader([{ sourceUrl: legacy, targetUrl: permanent }]);
expect(await archive.resolveLegacyAliyunStorageUrl(legacy + "?Expires=1&Signature=expired")).toBe(permanent);
expect(await archive.resolveLegacyAliyunStorageUrl(permanent)).toBe(permanent);
expect(await archive.enqueueImageArchiveTask({ sourceUrl: legacy })).toBeNull();
});
test("legacy files without a durable copy fail permanently instead of fetching the old bucket", async () => {
const archive = archiveLoader([]);
await expect(archive.resolveLegacyAliyunStorageUrl(legacy)).rejects.toThrow("HTTP 410");
let fetched = 0;
const bytes = serverLoader({
"./image-archive-db": { resolveLegacyAliyunStorageUrl: archive.resolveLegacyAliyunStorageUrl },
fetch: () => { fetched++; throw new Error("Unexpected old-bucket request"); },
})("lib/storage/image-byte-cache.ts");
await expect(bytes.fetchImageBytes(legacy)).rejects.toThrow(RETIRED_STORAGE_MESSAGE);
expect(fetched).toBe(0);
});
test("byte reads use the resolved Qiniu object and preserve its bytes", async () => {
const seen: string[] = [];
const target = permanent + "?acceptance=" + crypto.randomUUID();
const original = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1]);
const bytes = serverLoader({
"./image-archive-db": { resolveLegacyAliyunStorageUrl: async () => target },
fetch: async (url: string) => { seen.push(url); return new Response(original, { headers: { "content-type": "image/png" } }); },
})("lib/storage/image-byte-cache.ts");
expect((await bytes.fetchImageBytes(legacy)).buffer.equals(original)).toBe(true);
expect(seen).toEqual([target]);
});
test("preview requests redirect to Qiniu after resolving a legacy alias", async () => {
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent },
fetch: () => { throw new Error("Legacy previews must not fetch the old bucket"); },
})("app/api/image-proxy/route.ts");
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?url=" + encodeURIComponent(legacy), { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(permanent);
});
test("original downloads resolve the archived file without reading the legacy source", async () => {
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent },
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async (url: string) => {
expect(url).toBe(permanent);
return { delivery: "cdn", downloadUrl: permanent + "?attname=original.png", filename: "original.png" };
} },
fetch: () => { throw new Error("Unexpected source request"); },
})("app/api/image-proxy/route.ts");
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?original=1&download=resolve&url=" + encodeURIComponent(legacy)));
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ delivery: "cdn", filename: "original.png" });
});
test("unrecoverable preview and download requests return a cause and recommendation immediately", async () => {
let fetched = 0;
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => { throw new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE); } },
fetch: () => { fetched++; throw new Error("Unexpected source request"); },
})("app/api/image-proxy/route.ts");
for (const query of ["", "original=1&download=resolve&"]) {
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?" + query + "url=" + encodeURIComponent(legacy)));
const data = await response.json();
expect(response.status).toBe(410);
expect(data.failure).toMatchObject({ code: "STORAGE_RETIRED", retryable: false });
expect(data.failure.recommendation).toContain("重新上传");
expect(data.error).toContain(data.requestId);
}
expect(fetched).toBe(0);
});
test("retired model inputs are reported as missing files rather than a model-service failure", async () => {
const { getRequestFailureDetails } = await import("@/lib/request-error");
expect(getRequestFailureDetails(new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE))).toMatchObject({ code: "SOURCE_STORAGE_RETIRED", outcome: "failed" });
});

View File

@ -18,16 +18,18 @@ test('retired URLs include stored proxy URLs but do not include live storage', (
test('original image proxy returns permanent failure without redirecting or fetching', async () => { test('original image proxy returns permanent failure without redirecting or fetching', async () => {
let fetches = 0; let fetches = 0;
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw Error('Unexpected network'); } })('app/api/image-proxy/route.ts'); const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw new Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}})); const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}}));
expect(response.status).toBe(410); expect(response.status).toBe(410);
expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE}); const data = await response.json();
expect(data.failure).toMatchObject({code:'STORAGE_RETIRED',retryable:false});
expect(data.error).toContain(data.requestId);
expect(fetches).toBe(0); expect(fetches).toBe(0);
}); });
test('byte cache never fetches retired resources', async () => { test('byte cache never fetches retired resources', async () => {
let fetches = 0; let fetches = 0;
const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts'); const module = serverLoader({ fetch: () => { fetches++; throw new Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410'); await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
expect(fetches).toBe(0); expect(fetches).toBe(0);
}); });
@ -35,7 +37,7 @@ test('byte cache never fetches retired resources', async () => {
test('retired sources cannot enter the archive queue', async () => { test('retired sources cannot enter the archive queue', async () => {
let inserts = 0; let inserts = 0;
const module = serverLoader({ const module = serverLoader({
'@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}}, '@/lib/db': {db:{insert:()=>{inserts++;throw new Error('Unexpected insert');}}},
'@/lib/db/schema': {}, '@/lib/db/schema': {},
})('lib/storage/image-archive-db.ts'); })('lib/storage/image-archive-db.ts');
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull(); expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();

View File

@ -0,0 +1,119 @@
import { expect, test } from "@playwright/test";
import sharp from "sharp";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { getRequestFailureDetails } from "@/lib/request-error";
const cdn = "https://scqnycdn.jiansun.vip";
function rmbgLoader(fetch: typeof globalThis.fetch, timer?: typeof setTimeout) {
return serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) },
"@/lib/db": { db: {} },
"@/lib/db/schema": { generatedImage: {} },
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `${cdn}/${name}` }) },
fetch,
setTimeout: timer || ((callback: () => void, ms: number) => setTimeout(callback, ms <= 2000 ? 0 : ms)),
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts");
}
test("AOS uses a public Qiniu original URL without uploading the image file again", async () => {
const image = await sharp({ create: { width: 1024, height: 768, channels: 3, background: "#abcdef" } }).png().toBuffer();
let creates = 0;
const source = `${cdn}/uploads/original.png`;
const service = rmbgLoader(async (input, options) => {
const url = String(input);
if (options?.method === "POST") {
creates++;
const form = options.body as FormData;
expect(form.get("image_url")).toBe(source);
expect(form.has("image_file")).toBe(false);
expect(form.get("sync")).toBe("0");
return Response.json({ data: { task_id: "task-url" } });
}
if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(image, { headers: { "content-type": "image/png" } });
});
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
expect(creates).toBe(1);
expect(result.provider).toBe("aoscdn");
expect(result.original_url).toContain(cdn);
});
test("oversized images and non-CDN sources keep the normalized file upload", async () => {
for (const [width, source] of [[5000, `${cdn}/large.png`], [1000, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) {
const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer();
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") {
const form = options.body as FormData;
expect(form.has("image_url")).toBe(false);
const file = form.get("image_file") as File;
expect((await sharp(Buffer.from(await file.arrayBuffer())).metadata()).width).toBe(Math.min(width, 4096));
return Response.json({ data: { task_id: "task-file" } });
}
if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(image, { headers: { "content-type": "image/png" } });
});
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
}
});
test("a create timeout preserves its cause and never creates another paid task", async () => {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0;
let abort: Error;
const service = rmbgLoader(async () => { creates++; throw abort; });
abort = new service.RmbgServiceError("request timed out");
abort.name = "AbortError";
let failure: Error | undefined;
try {
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
} catch (error) { failure = error as Error; }
expect(creates).toBe(1);
expect(failure?.message).toContain("AOS创建任务超时(120秒)");
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_CREATE_TIMEOUT", outcome: "unknown" });
});
test("poll and result download timeouts retain their original task and distinct advice", () => {
const poll = getRequestFailureDetails("抠图处理失败:AOS查询任务超时,结果尚未确认(任务 task-1)");
const download = getRequestFailureDetails("抠图处理失败:AOS结果下载超时,任务已完成(任务 task-1)");
expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" });
expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" });
expect(download.recommendation).toContain("取回原任务结果");
});
test("network and body interruptions retry only the original task reads", async () => {
const image = await sharp({ create: { width: 32, height: 32, channels: 4, background: "#abcdef00" } }).png().toBuffer();
let creates = 0, polls = 0, downloads = 0;
let networkError: Error;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "original-task" } }); }
if (String(input).endsWith("/original-task")) {
if (++polls === 1) throw networkError;
return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
}
if (++downloads === 1) return new Response(new ReadableStream({ start(controller) { controller.error(networkError); } }));
return new Response(image, { headers: { "content-type": "image/png" } });
});
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
expect(result.original_url).toContain(cdn);
expect({ creates, polls, downloads }).toEqual({ creates: 1, polls: 2, downloads: 2 });
});
test("exhausted result reads preserve the completed task and never re-create it", async () => {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0, downloads = 0;
let networkError: Error;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "completed-task" } }); }
if (String(input).endsWith("/completed-task")) return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
downloads++; throw networkError;
});
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect({ creates, downloads }).toEqual({ creates: 1, downloads: 3 });
expect(failure?.message).toContain("completed-task");
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", outcome: "unknown" });
});