Compare commits
No commits in common. "main" and "codex/legacy-preview-source-20261105" have entirely different histories.
main
...
codex/lega
@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
|
|||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
|
||||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||||
@ -91,7 +90,7 @@ export async function POST(
|
|||||||
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
||||||
{ status: error instanceof RmbgServiceError ? error.status : 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_M
|
|||||||
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");
|
||||||
@ -243,7 +242,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -338,25 +336,6 @@ 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");
|
||||||
@ -409,7 +388,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const fetchSignal = AbortSignal.any([
|
const fetchSignal = AbortSignal.any([
|
||||||
request.signal,
|
request.signal,
|
||||||
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
AbortSignal.timeout(original ? 60_000 : 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 });
|
||||||
@ -420,22 +399,6 @@ 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(), {
|
||||||
@ -455,17 +418,19 @@ 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,
|
||||||
|
|||||||
@ -81,8 +81,7 @@ import { canvasService } from "@/services/canvas";
|
|||||||
import {
|
import {
|
||||||
inferOriginalImageUrl,
|
inferOriginalImageUrl,
|
||||||
} from "@/lib/image-url";
|
} from "@/lib/image-url";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
|
||||||
import {
|
import {
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
@ -3435,7 +3434,6 @@ 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);
|
||||||
@ -3473,6 +3471,16 @@ 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) {
|
||||||
@ -15804,8 +15812,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16123,8 +16131,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
if (event.currentTarget.src !== attachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
event.currentTarget.src = attachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16200,8 +16208,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
if (event.currentTarget.src !== attachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
event.currentTarget.src = attachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16386,8 +16394,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
|
if (event.currentTarget.src !== tag.iconUrl) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
|
event.currentTarget.src = tag.iconUrl || "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16687,8 +16695,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
if (event.currentTarget.src !== attachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
event.currentTarget.src = attachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -17024,23 +17032,15 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
|
if (event.currentTarget.src !== url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(url);
|
event.currentTarget.src = url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(event) => setFullscreenPreview({ url, preview: {
|
onClick={() => setFullscreenPreview({ url })}
|
||||||
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, preview: {
|
setFullscreenPreview({ url });
|
||||||
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 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
className="absolute top-4 right-4 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 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"
|
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"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -17312,11 +17312,13 @@ export function CanvasChatPanel({
|
|||||||
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ProgressiveImagePreview
|
<img
|
||||||
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
|
src={fullscreenPreviewSrc}
|
||||||
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">
|
||||||
@ -17883,8 +17885,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
if (event.currentTarget.src !== slotAttachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
event.currentTarget.src = slotAttachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18208,8 +18210,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18468,8 +18470,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
|
if (event.currentTarget.src !== item.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(item.url);
|
event.currentTarget.src = item.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18653,8 +18655,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
if (event.currentTarget.src !== slotAttachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
event.currentTarget.src = slotAttachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18721,8 +18723,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
if (event.currentTarget.src !== slotAttachment.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
event.currentTarget.src = slotAttachment.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -19421,8 +19423,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
|
if (event.currentTarget.src !== tagPreview.url) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
|
event.currentTarget.src = tagPreview.url;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 || "")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -60,7 +60,6 @@ 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,
|
||||||
@ -187,7 +186,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 { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||||
getDefaultThreeDModelAliasId,
|
getDefaultThreeDModelAliasId,
|
||||||
@ -1712,7 +1711,6 @@ 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);
|
||||||
@ -8468,8 +8466,8 @@ export function ImageNode({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
if (event.currentTarget.src !== data.src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = data.src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -8497,8 +8495,8 @@ export function ImageNode({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
if (event.currentTarget.src !== compareSource) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
event.currentTarget.src = compareSource;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
src={displayCompareSrc}
|
src={displayCompareSrc}
|
||||||
@ -8561,17 +8559,12 @@ 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 !== buildImageFallbackUrl(data.src)) {
|
if (event.currentTarget.src !== data.src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
event.currentTarget.src = data.src;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -11971,7 +11964,6 @@ 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"
|
||||||
@ -11979,7 +11971,7 @@ export function ImageNode({
|
|||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
||||||
onClick={() => setFullscreenPreviewOpen(false)}
|
onClick={() => setFullscreenPreviewOpen(false)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -11997,7 +11989,7 @@ export function ImageNode({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -12016,11 +12008,13 @@ export function ImageNode({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 图片 */}
|
{/* 图片 */}
|
||||||
<ProgressiveImagePreview
|
<img
|
||||||
alt={data.prompt || "图片预览"}
|
alt={data.prompt || "图片预览"}
|
||||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||||
src={resolveOriginalImageUrl(data.src) || data.src}
|
decoding="async"
|
||||||
preview={fullscreenPreviewImage}
|
draggable={false}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
src={fullscreenPreviewSrc}
|
||||||
style={activeImageAdjustmentsStyle}
|
style={activeImageAdjustmentsStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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 { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { 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 !== buildImageFallbackUrl(src)) {
|
if (event.currentTarget.src !== src) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(src);
|
event.currentTarget.src = 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 !== buildImageFallbackUrl(originalSrc)) {
|
if (event.currentTarget.src !== originalSrc) {
|
||||||
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
event.currentTarget.src = originalSrc;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -27,7 +27,6 @@ type RmbgConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecuteRmbgJobParams = {
|
export type ExecuteRmbgJobParams = {
|
||||||
requestId?: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
imageBuffer: Buffer;
|
imageBuffer: Buffer;
|
||||||
imageBase64?: string;
|
imageBase64?: string;
|
||||||
@ -54,7 +53,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(detail && detail !== message ? `${message}:${detail}` : message);
|
super(message);
|
||||||
this.name = "RmbgServiceError";
|
this.name = "RmbgServiceError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.detail = detail;
|
this.detail = detail;
|
||||||
@ -62,11 +61,7 @@ export class RmbgServiceError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
// https://picwish.cn/background-removal-api-doc
|
|
||||||
const AOS_MAX_RESOLUTION = 4096;
|
const AOS_MAX_RESOLUTION = 4096;
|
||||||
const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024;
|
|
||||||
// The shared AOS state -5 documents a separate limit for image_url downloads.
|
|
||||||
const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024;
|
|
||||||
const AOS_CREATE_TIMEOUT_MS = 120_000;
|
const AOS_CREATE_TIMEOUT_MS = 120_000;
|
||||||
const AOS_POLL_TIMEOUT_MS = 60_000;
|
const AOS_POLL_TIMEOUT_MS = 60_000;
|
||||||
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
|
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||||
@ -152,18 +147,6 @@ 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) {
|
||||||
@ -181,42 +164,50 @@ 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 }> {
|
||||||
if (imageBuffer.length > AOS_MAX_INPUT_BYTES) {
|
|
||||||
throw new RmbgServiceError(
|
|
||||||
`抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`,
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata: import("sharp").Metadata;
|
|
||||||
try {
|
try {
|
||||||
const sharp = await import("sharp");
|
const sharp = await import("sharp");
|
||||||
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
|
const image = sharp.default(imageBuffer);
|
||||||
} catch (error) {
|
const metadata = await image.metadata();
|
||||||
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
|
const width = metadata.width;
|
||||||
logger.warn("aos_image_metadata_unavailable", error);
|
const height = metadata.height;
|
||||||
return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" };
|
const format = (metadata.format || "png").toLowerCase();
|
||||||
}
|
|
||||||
|
|
||||||
const { width, height } = metadata;
|
|
||||||
if (width && height && (width > AOS_MAX_RESOLUTION || height > AOS_MAX_RESOLUTION)) {
|
|
||||||
throw new RmbgServiceError(
|
|
||||||
`抠图参考图分辨率超限(${width}×${height} 像素),当前 AOS 上游要求宽、高均不超过 ${AOS_MAX_RESOLUTION} 像素。`,
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const format = metadata.format || "";
|
|
||||||
if (["gif", "svg", "heif", "avif", "jp2", "jxl"].includes(format)) {
|
|
||||||
const name = format === "heif" ? "HEIF/AVIF" : format.toUpperCase();
|
|
||||||
throw new RmbgServiceError(`抠图参考图格式不支持(${name}),当前 AOS 上游不支持此格式。`, 400);
|
|
||||||
}
|
|
||||||
const ext = format === "jpeg" ? "jpg" : format;
|
const ext = format === "jpeg" ? "jpg" : format;
|
||||||
|
const filenameExt =
|
||||||
|
ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png";
|
||||||
|
const mimeType =
|
||||||
|
ext === "jpg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: ext === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: ext === "tiff"
|
||||||
|
? "image/tiff"
|
||||||
|
: "image/png";
|
||||||
|
|
||||||
|
if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) {
|
||||||
return {
|
return {
|
||||||
buffer: imageBuffer,
|
buffer: imageBuffer,
|
||||||
mimeType: format ? `image/${format}` : "application/octet-stream",
|
mimeType,
|
||||||
filename: ext ? `image.${ext}` : "image",
|
filename: `image.${filenameExt}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizedBuffer = await image
|
||||||
|
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: resizedBuffer,
|
||||||
|
mimeType,
|
||||||
|
filename: `image.${filenameExt}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("aos_image_preprocess_failed", error);
|
||||||
|
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processWithRMBG(
|
async function processWithRMBG(
|
||||||
@ -378,38 +369,20 @@ 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;
|
|
||||||
const sourceImage = await prepareImageForAos(imageBuffer);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const normalizedImage = await prepareImageForAos(imageBuffer);
|
||||||
const createForm = new FormData();
|
const createForm = new FormData();
|
||||||
let publicSourceUrl: string | null = null;
|
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
|
||||||
if (config.sourceImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
|
type: normalizedImage.mimeType,
|
||||||
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(sourceImage.buffer)], {
|
|
||||||
type: sourceImage.mimeType,
|
|
||||||
});
|
});
|
||||||
createForm.append("image_file", imageBlob, sourceImage.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");
|
||||||
@ -419,9 +392,6 @@ 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 : sourceImage.buffer.length });
|
|
||||||
|
|
||||||
const createResponse = await withTimeout(
|
const createResponse = await withTimeout(
|
||||||
(signal) =>
|
(signal) =>
|
||||||
fetch(createUrl, {
|
fetch(createUrl, {
|
||||||
@ -441,37 +411,28 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`,
|
error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 readAosResult(
|
const pollResponse = await withTimeout(
|
||||||
async (signal) => {
|
(signal) =>
|
||||||
const response = await fetch(taskUrl, {
|
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 = pollResponse.payload;
|
const pollPayload = await pollResponse.json().catch(() => null);
|
||||||
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,
|
||||||
@ -479,7 +440,7 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`,
|
error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,15 +454,12 @@ async function processWithAosSegmentation(
|
|||||||
pollCount,
|
pollCount,
|
||||||
taskId,
|
taskId,
|
||||||
});
|
});
|
||||||
stage = "download";
|
const imageResponse = await withTimeout(
|
||||||
const imageResponse = await readAosResult(
|
(signal) =>
|
||||||
async (signal) => {
|
fetch(imageUrl, {
|
||||||
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
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -512,11 +470,11 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`,
|
error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultBuffer = imageResponse.buffer!;
|
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imageBuffer: resultBuffer,
|
imageBuffer: resultBuffer,
|
||||||
@ -532,30 +490,19 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
|
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
|
logger.warn("aos_task_timeout", { maxPollCount });
|
||||||
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
|
return { success: false, error: "AOS任务超时(最多60秒)" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
|
return { success: false, error: "AOS请求超时,请稍后重试" };
|
||||||
const message = stage === "create"
|
|
||||||
? "AOS创建任务超时(120秒),结果尚未确认"
|
|
||||||
: stage === "poll"
|
|
||||||
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
|
|
||||||
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
|
|
||||||
return { success: false, error: message };
|
|
||||||
}
|
}
|
||||||
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
|
logger.warn("aos_provider_request_error", error);
|
||||||
const detail = error instanceof Error ? error.message : "未知错误";
|
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
|
||||||
return { success: false, error: stage === "create"
|
|
||||||
? `AOS创建任务连接失败,结果尚未确认:${detail}`
|
|
||||||
: stage === "poll"
|
|
||||||
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
|
|
||||||
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -654,7 +601,6 @@ 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"),
|
||||||
@ -711,8 +657,6 @@ 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) {
|
||||||
|
|||||||
@ -128,7 +128,6 @@ 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,
|
||||||
|
|||||||
@ -2,16 +2,6 @@ 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 = {
|
||||||
@ -21,26 +11,6 @@ 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();
|
||||||
@ -91,15 +61,6 @@ 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 = "";
|
||||||
@ -241,7 +202,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return trustedDirectStorageUrl;
|
return trustedDirectStorageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source) && !isAlibabaModelImageUrl(source)) {
|
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source)) {
|
||||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||||
if (directDisplayUrl) {
|
if (directDisplayUrl) {
|
||||||
return directDisplayUrl;
|
return directDisplayUrl;
|
||||||
|
|||||||
@ -228,71 +228,9 @@ export function getRequestFailureDetails(
|
|||||||
const status = getRequestErrorStatus(error);
|
const status = getRequestErrorStatus(error);
|
||||||
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
|
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
|
||||||
({ code, message, recommendation, outcome });
|
({ code, message, recommendation, outcome });
|
||||||
if (/抠图参考图文件大小超限/.test(text)) {
|
|
||||||
return detail("RMBG_INPUT_TOO_LARGE", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 50 MB 上限。", "请手动压缩至 50 MB 以内后重新提交。本次未提交上游抠图任务。");
|
|
||||||
}
|
|
||||||
if (/抠图参考图分辨率超限/.test(text)) {
|
|
||||||
return detail("RMBG_INPUT_RESOLUTION_EXCEEDED", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 4096 像素上限。", "请手动将宽、高均调整至 4096 像素以内,再重新提交。本次未提交上游抠图任务。");
|
|
||||||
}
|
|
||||||
if (/抠图参考图格式不支持/.test(text)) {
|
|
||||||
return detail("RMBG_INPUT_FORMAT_UNSUPPORTED", getRequestErrorMessage(error) || "AOS 不支持此参考图格式。", "请导出为 JPG、PNG 或 WebP 后重新提交。本次未提交上游抠图任务。");
|
|
||||||
}
|
|
||||||
const aosResolution = text.match(/(?:resolution excess,\s*width=(\d+),\s*height=(\d+)|图片分辨率超限((\d+)x(\d+)))/i);
|
|
||||||
if (aosResolution) {
|
|
||||||
return detail("RMBG_PROVIDER_RESOLUTION_EXCEEDED", `AOS 拒绝了参考图:分辨率为 ${aosResolution[1] || aosResolution[3]}×${aosResolution[2] || aosResolution[4]} 像素,宽、高均须不超过 4096 像素。`, "请手动调整尺寸后重新提交。");
|
|
||||||
}
|
|
||||||
if (/AOS.*invalid file|图片文件无法识别/i.test(text)) {
|
|
||||||
return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。");
|
|
||||||
}
|
|
||||||
const aosState = text.match(/AOS任务失败(状态 (-\d+),/);
|
|
||||||
if (aosState) {
|
|
||||||
const state = Number(aosState[1]);
|
|
||||||
if (state === -5) return detail("RMBG_PROVIDER_URL_TOO_LARGE", "AOS 拒绝读取参考图:链接取图大小超过上游的 30 MB 上限。", "请联系管理员核对文件提交方式;不要连续点击。上游文件上传接口允许最大 50 MB。");
|
|
||||||
if (state === -7) return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。");
|
|
||||||
if (state === -3) return detail("RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED", "AOS 下载参考图失败,未能读取原图。", "请确认原图链接可打开;若原图正常,请联系管理员检查上游取图线路,并提供请求编号。");
|
|
||||||
if (state === -2) return detail("RMBG_PROVIDER_RESULT_STORAGE_FAILED", "AOS 已完成处理,但上游保存结果图片失败。", "请联系管理员核对原任务结果,避免立即重复抠图。", "unknown");
|
|
||||||
if (state === -8) return detail("RMBG_PROVIDER_PROCESSING_TIMEOUT", "AOS 抠图处理超时,上游已返回失败状态。", "请稍后重试;若持续发生,请联系管理员并提供请求编号。");
|
|
||||||
if (state === -11) return detail("RMBG_PROVIDER_EMPTY_RESULT", "AOS 抠图结果为空,未能得到有效主体。", "请检查图片中的主体是否清晰,尝试使用主体更明确的图片。");
|
|
||||||
if ([-17, -16, -14, -10].includes(state)) return detail("RMBG_PROVIDER_INPUT_REJECTED", `AOS 拒绝处理参考图,图片内容未通过上游检查(状态 ${state})。`, "请更换符合上游要求的参考图;如认为判断有误,请联系管理员核对请求编号。");
|
|
||||||
if (state === -15) return detail("RMBG_PROVIDER_RESOURCE_UNAVAILABLE", "AOS 处理资源不足,未能完成本次抠图。", "请稍后再试;持续失败时联系管理员检查上游服务。");
|
|
||||||
return detail("RMBG_PROVIDER_TASK_FAILED", getRequestErrorMessage(error) || `AOS 抠图任务失败(状态 ${state})。`, "请联系管理员并提供请求编号,核对上游任务详情。");
|
|
||||||
}
|
|
||||||
if (/AOS任务创建失败(HTTP 413)/.test(text)) {
|
|
||||||
return detail("RMBG_PROVIDER_FILE_TOO_LARGE", "AOS 拒绝了参考图:文件超过上游大小限制(文件上传最大 50 MB)。", "请手动压缩文件后重新提交;若文件已小于限制,请联系管理员核对上游返回。");
|
|
||||||
}
|
|
||||||
if (/AOS任务创建失败(HTTP (400|401|404))/.test(text)) {
|
|
||||||
return detail("RMBG_PROVIDER_REQUEST_REJECTED", getRequestErrorMessage(error) || "AOS 拒绝了抠图请求。", "请联系管理员核对上游参数、服务权限及接口配置,并提供请求编号。");
|
|
||||||
}
|
|
||||||
if (/AOS任务创建失败(HTTP (200|5\d\d))/.test(text)) {
|
|
||||||
return detail("RMBG_PROVIDER_CREATE_UNCONFIRMED", getRequestErrorMessage(error) || "AOS 未能确认抠图任务是否创建成功。", "请联系管理员核对原请求,避免连续提交造成重复处理。", "unknown");
|
|
||||||
}
|
|
||||||
const aosReadFailure = text.match(/AOS(任务查询|结果下载)失败(HTTP (\d+)/);
|
|
||||||
if (aosReadFailure) {
|
|
||||||
return aosReadFailure[1] === "任务查询"
|
|
||||||
? detail("RMBG_PROVIDER_POLL_FAILED", `AOS 查询抠图任务失败(HTTP ${aosReadFailure[2]}),原任务结果尚未确认。`, "请联系管理员核对原任务,不要重复提交。", "unknown")
|
|
||||||
: detail("RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", `抠图已完成,但结果图片下载失败(HTTP ${aosReadFailure[2]})。`, "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
|
|
||||||
}
|
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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\/legacy-oss-recovery\.test\.ts/,
|
||||||
/routes\/retired-storage\.test\.ts/,
|
/routes\/retired-storage\.test\.ts/,
|
||||||
/routes\/original-image-download\.test\.ts/,
|
/routes\/original-image-download\.test\.ts/,
|
||||||
|
|||||||
@ -53,7 +53,6 @@ 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,
|
||||||
|
|||||||
@ -1,15 +1,4 @@
|
|||||||
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 = ``;
|
|
||||||
expect(extractVideoUrls(markdown)).toEqual([]);
|
|
||||||
expect(extractAudioUrls(markdown)).toEqual([]);
|
|
||||||
expect(extractAssistantImageUrls(markdown)).toContain(url);
|
|
||||||
expect(stripMediaMarkup(markdown)).toBe(markdown);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
import {
|
import {
|
||||||
collectMessageImageUrls,
|
collectMessageImageUrls,
|
||||||
extractAssistantImageUrls,
|
extractAssistantImageUrls,
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capa
|
|||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { canvasService } from "@/services/canvas";
|
import { canvasService } from "@/services/canvas";
|
||||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
|
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
|
||||||
@ -87,22 +86,6 @@ test("a lost submission response polls the original task and never resubmits", a
|
|||||||
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]);
|
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a recovered matting rejection shows its dimensions and advice without changing the canvas or resubmitting", async () => {
|
|
||||||
const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.remove_background", nodeIds: ["source"], source: "toolbar" });
|
|
||||||
const failure = getRequestFailureDetails("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。");
|
|
||||||
const message = formatRequestFailure(failure, submitted.requestId);
|
|
||||||
let posts = 0;
|
|
||||||
globalThis.fetch = (async (url, options) => {
|
|
||||||
if (options?.method === "POST") { posts++; return new Response(null, { status: 504 }); }
|
|
||||||
expect(String(url)).toContain(encodeURIComponent(submitted.requestId));
|
|
||||||
return Response.json({ success: true, task: { status: "failed", failure, error: message } });
|
|
||||||
}) as typeof fetch;
|
|
||||||
await expect(executeCanvasToolbarCapability(submitted)).rejects.toThrow(message);
|
|
||||||
expect(posts).toBe(1);
|
|
||||||
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]);
|
|
||||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching projects during processing does not load the old result into the new project", async () => {
|
test("switching projects during processing does not load the old result into the new project", async () => {
|
||||||
globalThis.fetch = (async (url) => {
|
globalThis.fetch = (async (url) => {
|
||||||
if (String(url).endsWith("/capabilities")) {
|
if (String(url).endsWith("/capabilities")) {
|
||||||
|
|||||||
@ -2,77 +2,9 @@ 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, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, 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({
|
||||||
|
|||||||
@ -38,15 +38,7 @@ test("legacy display URLs go through archive resolution while other Alibaba buck
|
|||||||
const url = buildImageProxyUrl(legacy, { width: 400 });
|
const url = buildImageProxyUrl(legacy, { width: 400 });
|
||||||
expect(url).toMatch(/^\/api\/image-proxy\?/);
|
expect(url).toMatch(/^\/api\/image-proxy\?/);
|
||||||
expect(new URL(url, "https://app.example").searchParams.get("url")).toBe(legacy);
|
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\?/);
|
expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toContain("x-oss-process=");
|
||||||
});
|
|
||||||
|
|
||||||
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 }>) {
|
function archiveLoader(rows: Array<{ sourceUrl: string; targetUrl: string | null }>) {
|
||||||
|
|||||||
@ -1,223 +0,0 @@
|
|||||||
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("non-CDN sources upload the original file without changing its bytes or dimensions", async () => {
|
|
||||||
for (const [width, source] of [[4096, "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;
|
|
||||||
const submitted = Buffer.from(await file.arrayBuffer());
|
|
||||||
expect(submitted.equals(image)).toBe(true);
|
|
||||||
expect((await sharp(submitted).metadata()).width).toBe(width);
|
|
||||||
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("AOS rejects either oversized dimension before creating a task and gives the exact dimensions", async () => {
|
|
||||||
for (const [width, height] of [[4097, 20], [20, 4097]]) {
|
|
||||||
const image = await sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer();
|
|
||||||
let creates = 0;
|
|
||||||
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
|
|
||||||
let failure: Error | undefined;
|
|
||||||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/large.png`, skipGallerySave: true }); }
|
|
||||||
catch (error) { failure = error as Error; }
|
|
||||||
expect(creates).toBe(0);
|
|
||||||
expect(failure).toMatchObject({ status: 400 });
|
|
||||||
expect(failure?.message).toContain(`${width}×${height}`);
|
|
||||||
const details = getRequestFailureDetails(failure);
|
|
||||||
expect(details).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
|
|
||||||
expect(details.message).toContain("4096");
|
|
||||||
expect(details.recommendation).toContain("本次未提交上游抠图任务");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submitting it", async () => {
|
|
||||||
const output = await sharp({ create: { width: 4096, height: 20, channels: 3, background: "white" } }).png().toBuffer();
|
|
||||||
const boundary = Buffer.alloc(50 * 1024 * 1024);
|
|
||||||
output.copy(boundary);
|
|
||||||
let creates = 0;
|
|
||||||
const service = rmbgLoader(async (input, options) => {
|
|
||||||
if (options?.method === "POST") {
|
|
||||||
creates++;
|
|
||||||
const form = options.body as FormData;
|
|
||||||
expect(form.has("image_url")).toBe(false);
|
|
||||||
expect((form.get("image_file") as File).size).toBe(boundary.length);
|
|
||||||
return Response.json({ data: { task_id: "boundary-task" } });
|
|
||||||
}
|
|
||||||
if (String(input).endsWith("/boundary-task")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
|
|
||||||
return new Response(output, { headers: { "content-type": "image/png" } });
|
|
||||||
});
|
|
||||||
await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, sourceImageUrl: `${cdn}/boundary.png`, skipGallerySave: true });
|
|
||||||
expect(creates).toBe(1);
|
|
||||||
let failure: Error | undefined;
|
|
||||||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: Buffer.alloc(boundary.length + 1), skipGallerySave: true }); }
|
|
||||||
catch (error) { failure = error as Error; }
|
|
||||||
expect(creates).toBe(1);
|
|
||||||
expect(failure).toMatchObject({ status: 400 });
|
|
||||||
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_INPUT_TOO_LARGE", outcome: "failed" });
|
|
||||||
expect(getRequestFailureDetails(failure).message).toContain("50 MB");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats unsupported by AOS are rejected explicitly without converting or submitting", async () => {
|
|
||||||
for (const format of ["gif", "avif"] as const) {
|
|
||||||
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).toFormat(format).toBuffer();
|
|
||||||
let creates = 0;
|
|
||||||
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
|
|
||||||
let failure: Error | undefined;
|
|
||||||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, skipGallerySave: true }); }
|
|
||||||
catch (error) { failure = error as Error; }
|
|
||||||
expect(creates).toBe(0);
|
|
||||||
const details = getRequestFailureDetails(failure);
|
|
||||||
expect(details).toMatchObject({ code: "RMBG_INPUT_FORMAT_UNSUPPORTED", outcome: "failed" });
|
|
||||||
expect(details.message).toContain(format.toUpperCase());
|
|
||||||
expect(details.recommendation).toContain("JPG、PNG 或 WebP");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("AOS input rejections retain their cause instead of becoming a generic processing failure", () => {
|
|
||||||
const resolution = getRequestFailureDetails("抠图处理失败:图片分辨率超限(5000x3000),AOS要求宽高都不超过 4096px。请缩小后重试。");
|
|
||||||
expect(resolution).toMatchObject({ code: "RMBG_PROVIDER_RESOLUTION_EXCEEDED", outcome: "failed" });
|
|
||||||
expect(resolution.message).toContain("5000×3000");
|
|
||||||
expect(getRequestFailureDetails("抠图处理失败:图片文件无法识别,请尝试转换为 JPG/PNG 后重试。")).toMatchObject({ code: "RMBG_PROVIDER_INVALID_FILE", outcome: "failed" });
|
|
||||||
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 413): Payload Too Large")).toMatchObject({ code: "RMBG_PROVIDER_FILE_TOO_LARGE", outcome: "failed" });
|
|
||||||
for (const [state, code] of [[-3, "RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED"], [-5, "RMBG_PROVIDER_URL_TOO_LARGE"], [-7, "RMBG_PROVIDER_INVALID_FILE"], [-8, "RMBG_PROVIDER_PROCESSING_TIMEOUT"], [-11, "RMBG_PROVIDER_EMPTY_RESULT"]] as const) {
|
|
||||||
const details = getRequestFailureDetails(`抠图处理失败:AOS任务失败(状态 ${state},任务 original-task): Failed`);
|
|
||||||
expect(details).toMatchObject({ code, outcome: "failed" });
|
|
||||||
expect(details.recommendation).not.toBe("");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the canvas API returns input-limit errors as HTTP 400 and preserves the same advice when polling", async () => {
|
|
||||||
const service = rmbgLoader(async () => { throw new Error("Unexpected provider call"); });
|
|
||||||
const error = new service.RmbgServiceError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。", 400);
|
|
||||||
const route = serverLoader({
|
|
||||||
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-a" } }) },
|
|
||||||
"@/lib/ai/rmbg/service": { RmbgServiceError: service.RmbgServiceError },
|
|
||||||
"@/lib/canvas/capabilities/client-command": { canvasCapabilityClientCommandSchema: { parse: (v: unknown) => v } },
|
|
||||||
"@/lib/canvas/capabilities/context": { CanvasCapabilityRuntimeError: class extends Error {} },
|
|
||||||
"@/lib/canvas/agent-mutations": { CanvasAgentMutationError: class extends Error {} },
|
|
||||||
"@/lib/canvas/capabilities/registry": { getCanvasCapabilityManifest: () => [] },
|
|
||||||
"@/lib/canvas/capabilities/runtime": { executeCanvasCapabilityRuntime: async () => { throw error; } },
|
|
||||||
"@/lib/canvas/capabilities/task-store": { getCanvasCapabilityTask: async () => ({ id: "task-a", status: "failed", error: error.message }) },
|
|
||||||
})("app/api/canvas/[id]/capabilities/route.ts");
|
|
||||||
const params = { params: Promise.resolve({ id: "project-a" }) };
|
|
||||||
const response = await route.POST(new Request("https://example.com/api/canvas/project-a/capabilities", { method: "POST", body: JSON.stringify({ requestId: "request-a" }) }), params);
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
const submitted = await response.json();
|
|
||||||
expect(submitted.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
|
|
||||||
expect(submitted.error).toContain("5056×3392");
|
|
||||||
expect(submitted.error).toContain("本次未提交上游抠图任务");
|
|
||||||
const polled = await (await route.GET(new Request("https://example.com/api/canvas/project-a/capabilities?requestId=request-a"), params)).json();
|
|
||||||
expect(polled.task.failure).toEqual(submitted.failure);
|
|
||||||
expect(polled.task.error).toContain(submitted.failure.recommendation);
|
|
||||||
});
|
|
||||||
|
|
||||||
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("取回原任务结果");
|
|
||||||
expect(getRequestFailureDetails("抠图处理失败:AOS任务查询失败(HTTP 404,任务 task-1): Not Found")).toMatchObject({ code: "RMBG_PROVIDER_POLL_FAILED", outcome: "unknown" });
|
|
||||||
expect(getRequestFailureDetails("抠图处理失败:AOS结果下载失败(HTTP 500,任务 task-1): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", outcome: "unknown" });
|
|
||||||
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 500): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_CREATE_UNCONFIRMED", outcome: "unknown" });
|
|
||||||
});
|
|
||||||
|
|
||||||
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" });
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user