From 21b47751d66aa3aa3fb1416576d7a9c7b610993a Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 14:24:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=A0=E5=9B=BE=E6=8C=89=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=8F=90=E4=BA=A4=E5=B9=B6=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=A2=84=E5=A4=84=E7=90=86=E9=87=8D=E5=A4=8D=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/scene-fusion/route.ts | 140 +----------------- components/image-annotator.tsx | 8 +- .../video-image-functions.ts | 1 + lib/ai/rmbg/service.ts | 16 +- .../direct-agent-image-edit-executor.ts | 1 + lib/canvas/capabilities/headless-executor.ts | 1 + lib/canvas/operations/rmbg.ts | 17 ++- lib/canvas/scale-adjust-product-lock.ts | 20 +-- lib/scene-fusion/preprocess-product.ts | 37 +++++ .../routes/canvas-scale-adjust-intent.test.ts | 2 - tests/routes/rmbg-delivery.test.ts | 81 +++++++++- 11 files changed, 155 insertions(+), 169 deletions(-) create mode 100644 lib/scene-fusion/preprocess-product.ts diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 063c6eb96..a64e5560a 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -6,6 +6,7 @@ */ import { NextResponse } from "next/server"; +import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; @@ -69,7 +70,6 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350; const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000; const SCENE_FUSION_AGENT_MAX_WORKERS = 3; const SCENE_FUSION_PROCESS_MAX_WORKERS = 4; -const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000; const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; @@ -236,41 +236,6 @@ function isSceneFusionReviewRejectError(message: string): boolean { ); } -function buildPreRmbgCandidateUrls(request: Request): string[] { - const urls: string[] = []; - const requestUrl = new URL(request.url); - const requestHost = requestUrl.hostname.toLowerCase(); - const requestProtocol = requestUrl.protocol.replace(":", ""); - if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) { - urls.push(new URL("/api/rmbg", request.url).toString()); - } - - const host = request.headers.get("host")?.trim(); - const protoHint = request.headers - .get("x-forwarded-proto") - ?.split(",")[0] - ?.trim() - .toLowerCase(); - const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", ""); - - if (host) { - const normalizedHost = host.split(":")[0]?.toLowerCase() || ""; - const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1"; - if (!(protocol === "https" && isLocalHost)) { - urls.push(`${protocol}://${host}/api/rmbg`); - } - if (isLocalHost) { - urls.push(`http://${host}/api/rmbg`); - } - } - - const localPort = requestUrl.port || process.env.PORT || "3125"; - urls.push(`http://127.0.0.1:${localPort}/api/rmbg`); - urls.push(`http://localhost:${localPort}/api/rmbg`); - - return Array.from(new Set(urls)); -} - function normalizeUrlForCompare(input: string): string { try { const parsed = new URL(input); @@ -988,102 +953,6 @@ async function getSceneFusionConfig() { }; } -async function preprocessProductImageWithRmbg( - request: Request, - requestId: string, - productImageUrl: string, - preRmbgEnabled: boolean -): Promise<{ - productImageUrl: string; - usedPreRmbg: boolean; - provider?: string; - reason?: string; -}> { - if (!preRmbgEnabled) { - return { - productImageUrl, - usedPreRmbg: false, - reason: "disabled_by_config", - }; - } - - const cookie = request.headers.get("cookie"); - if (!cookie) { - return { - productImageUrl, - usedPreRmbg: false, - reason: "missing_cookie", - }; - } - - const candidateUrls = buildPreRmbgCandidateUrls(request); - let lastReason = "no_candidate_url"; - - logger.info("pre_rmbg_started", { - requestId, - candidateCount: candidateUrls.length, - }); - - for (const rmbgUrl of candidateUrls) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS); - try { - const response = await fetch(rmbgUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - cookie, - }, - body: JSON.stringify({ - imageUrl: productImageUrl, - prompt: "场景裂变预处理-抠图", - skipGallerySave: true, - }), - signal: controller.signal, - }); - - const payload = await response.json().catch(() => null) as Record | null; - - if (!response.ok) { - lastReason = `request_failed_${response.status}`; - logger.warn("pre_rmbg_candidate_failed", { - requestId, - status: response.status, - detail: payload?.detail || payload?.error || "unknown", - }); - continue; - } - - const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim(); - if (!cleanedUrl || cleanedUrl === productImageUrl) { - lastReason = cleanedUrl ? "same_as_source" : "empty_result"; - logger.warn("pre_rmbg_candidate_invalid", { - requestId, - reason: lastReason, - }); - continue; - } - - return { - productImageUrl: cleanedUrl, - usedPreRmbg: true, - provider: String(payload?.provider || "unknown"), - }; - } catch (error) { - lastReason = error instanceof Error ? error.message : "exception"; - logger.warn("pre_rmbg_candidate_exception", error, { requestId }); - } finally { - clearTimeout(timeoutId); - } - } - - return { - productImageUrl, - usedPreRmbg: false, - reason: lastReason, - }; -} - /** * 创建缩略图 */ @@ -2436,12 +2305,13 @@ export async function POST(request: Request) { productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product"); } const shouldForcePreRmbg = resolvedSceneMode === "random-bg"; - const preprocessedProduct = await preprocessProductImageWithRmbg( + const preprocessedProduct = await preprocessProductImageWithRmbg({ request, requestId, + userId: session.user.id, productImageUrl, - shouldForcePreRmbg ? true : config.preRmbgEnabled - ); + preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled, + }); const fusionProductImageUrl = preprocessedProduct.productImageUrl; logger.info("pre_rmbg_resolved", { requestId, diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 37cea6c88..4088ab926 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -918,9 +918,9 @@ export function ImageAnnotator({ setIsRemovingBg(true); try { - // 使用原图URL获取原图数据,而不是canvas的预览图 - const base64Data = await imageUrlToBase64(originalImageUrl); - logger.debug("rmbg_source_prepared", { base64Length: base64Data.length }); + const input = /^https?:\/\//i.test(originalImageUrl) + ? { imageUrl: originalImageUrl } + : { image_base64: await imageUrlToBase64(originalImageUrl) }; // 调用抠图API const response = await fetch("/api/rmbg", { @@ -929,7 +929,7 @@ export function ImageAnnotator({ "Content-Type": "application/json", }, body: JSON.stringify({ - image_base64: base64Data, + ...input, prompt: "画图标注-抠图处理", // 可选:添加提示词 }), }); diff --git a/lib/ai/canvas-agent-runtime/video-image-functions.ts b/lib/ai/canvas-agent-runtime/video-image-functions.ts index bd48b4672..003f94697 100644 --- a/lib/ai/canvas-agent-runtime/video-image-functions.ts +++ b/lib/ai/canvas-agent-runtime/video-image-functions.ts @@ -72,6 +72,7 @@ export async function processVideoCanvasImage( const result = await executeRmbgJob({ userId: params.userId, imageBuffer: await fetchImageBufferFromUrl(imageUrl), + inputImageUrl: imageUrl, sourceImageUrl: imageUrl, prompt: params.prompt, }); diff --git a/lib/ai/rmbg/service.ts b/lib/ai/rmbg/service.ts index 859e41691..77dae6530 100644 --- a/lib/ai/rmbg/service.ts +++ b/lib/ai/rmbg/service.ts @@ -36,6 +36,8 @@ export type ExecuteRmbgJobParams = { chatId?: string | null; messageId?: string | null; skipGallerySave?: boolean; + // 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。 + inputImageUrl?: string | null; sourceImageUrl?: string | null; }; @@ -378,7 +380,7 @@ async function processWithAosSegmentation( foregroundType?: "" | "person" | "object" | "stamp"; crop?: 0 | 1; format?: "png" | "jpg"; - sourceImageUrl?: string | null; + inputImageUrl?: string | null; requestId?: string; } ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { @@ -393,9 +395,9 @@ async function processWithAosSegmentation( try { const createForm = new FormData(); let publicSourceUrl: string | null = null; - if (config.sourceImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) { + if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) { try { - const source = new URL(config.sourceImageUrl); + const source = new URL(config.inputImageUrl); const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || ""); if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) { publicSourceUrl = source.toString(); @@ -654,7 +656,7 @@ export async function executeRmbgJob( params: ExecuteRmbgJobParams ): Promise { const { - requestId, + requestId = crypto.randomUUID(), userId, imageBuffer, imageBase64 = imageBuffer.toString("base64"), @@ -663,11 +665,13 @@ export async function executeRmbgJob( chatId = null, messageId = null, skipGallerySave = false, + inputImageUrl = null, sourceImageUrl = null, } = params; const { provider, config } = await getValidatedRmbgConfig(); logger.info("job_started", { + requestId, hasChatId: Boolean(chatId), hasMessageId: Boolean(messageId), hasSourceImageUrl: Boolean(sourceImageUrl), @@ -711,7 +715,7 @@ export async function executeRmbgJob( foregroundType: foregroundType ?? config.aosForegroundType ?? "", crop: config.aosCrop === 1 ? 1 : 0, format: config.aosFormat === "jpg" ? "jpg" : "png", - sourceImageUrl, + inputImageUrl, requestId, }); @@ -849,6 +853,7 @@ export async function executeRmbgJob( } logger.info("job_completed", { + requestId, generatedImageId: savedImage.id, mediaAssetId, processingSeconds: Number(processingTime.toFixed(2)), @@ -871,6 +876,7 @@ export async function executeRmbgJob( } logger.info("job_completed", { + requestId, processingSeconds: Number(processingTime.toFixed(2)), provider, savedToGallery: false, diff --git a/lib/ai/runtime/direct-agent-image-edit-executor.ts b/lib/ai/runtime/direct-agent-image-edit-executor.ts index 3ac2c787e..d60ec2b3f 100644 --- a/lib/ai/runtime/direct-agent-image-edit-executor.ts +++ b/lib/ai/runtime/direct-agent-image-edit-executor.ts @@ -516,6 +516,7 @@ export async function executeDirectAgentImageEditTool( const result = await executeRmbgJob({ userId: params.userId, imageBuffer: sourceBuffer, + inputImageUrl: params.sourceImageUrl, prompt: params.requestTextContent || "抠图处理", chatId: params.chatId, messageId: params.messageId, diff --git a/lib/canvas/capabilities/headless-executor.ts b/lib/canvas/capabilities/headless-executor.ts index 3a451ae76..ebf0890fb 100644 --- a/lib/canvas/capabilities/headless-executor.ts +++ b/lib/canvas/capabilities/headless-executor.ts @@ -131,6 +131,7 @@ async function executeForTarget( requestId: command.requestId, userId: params.userId, imageBuffer, + inputImageUrl: target.sourceImageUrl, sourceImageUrl: target.sourceImageUrl, prompt: prompt || "抠图处理", skipGallerySave: false, diff --git a/lib/canvas/operations/rmbg.ts b/lib/canvas/operations/rmbg.ts index 8c4aae232..96802bf36 100644 --- a/lib/canvas/operations/rmbg.ts +++ b/lib/canvas/operations/rmbg.ts @@ -9,9 +9,11 @@ import { import { getPointsSystemConfig } from "@/lib/db/queries"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { getImageToolCreditCost } from "@/lib/points/config"; +import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) { + const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); try { const session = { user: { id: trustedUserId } }; if (!session?.user?.id) { @@ -27,6 +29,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI let messageId: string | null = null; let skipGallerySave = false; let sourceImageUrl: string | null = null; + let inputImageUrl: string | null = null; if (contentType.includes("application/json")) { const body = await request.json(); @@ -40,6 +43,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI if (imageUrl) { sourceImageUrl = String(imageUrl); + inputImageUrl = sourceImageUrl; imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl); } else if (body.image_base64) { imageBase64 = String(body.image_base64); @@ -94,6 +98,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI } const result = await executeRmbgJob({ + requestId, userId: session.user.id, imageBuffer, imageBase64, @@ -101,6 +106,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI chatId, messageId, skipGallerySave, + inputImageUrl, sourceImageUrl, }); @@ -114,22 +120,29 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI return NextResponse.json({ success: true, + requestId, ...result, }); } catch (error) { if (error instanceof RmbgServiceError) { + const failure = getRequestFailureDetails(error); return NextResponse.json( { - error: error.message, + success: false, + error: formatRequestFailure(failure, requestId), detail: error.detail || error.message, + code: failure.code, + failure, + requestId, }, { status: error.status } ); } console.error("[RMBG API] 处理错误:", error); + const failure = getRequestFailureDetails(error, "抠图请求未能完成"); return NextResponse.json( - { error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" }, + { success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId }, { status: 500 } ); } diff --git a/lib/canvas/scale-adjust-product-lock.ts b/lib/canvas/scale-adjust-product-lock.ts index 21185eeac..f0971471d 100644 --- a/lib/canvas/scale-adjust-product-lock.ts +++ b/lib/canvas/scale-adjust-product-lock.ts @@ -40,22 +40,6 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } -async function executeScaleAdjustRmbgJob( - params: Parameters[0] -) { - let lastError: unknown; - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - return await executeRmbgJob(params); - } catch (error) { - lastError = error; - if (attempt === 3) break; - await new Promise((resolve) => setTimeout(resolve, attempt * 800)); - } - } - throw lastError; -} - function rectToPixelBox( rect: CanvasScaleAdjustRect, sourceWidth: number, @@ -220,7 +204,7 @@ async function buildPersonExclusionAlpha(params: { userId: string; }) { try { - const person = await executeScaleAdjustRmbgJob({ + const person = await executeRmbgJob({ foregroundType: "person", imageBuffer: params.sourceBuffer, prompt: "比例调整人物保护蒙版", @@ -330,7 +314,7 @@ export async function buildScaleAdjustProductLockAssets(params: { .png({ compressionLevel: 6 }) .toBuffer(); semanticSourceCropBuffers.push(cropBuffer); - const subject = await executeScaleAdjustRmbgJob({ + const subject = await executeRmbgJob({ imageBuffer: cropBuffer, prompt: "比例调整完整产品蒙版", skipGallerySave: true, diff --git a/lib/scene-fusion/preprocess-product.ts b/lib/scene-fusion/preprocess-product.ts new file mode 100644 index 000000000..d3252006a --- /dev/null +++ b/lib/scene-fusion/preprocess-product.ts @@ -0,0 +1,37 @@ +import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg"; +import { createLogger } from "@/lib/observability/logger"; + +const logger = createLogger("scene-fusion"); + +export async function preprocessProductImageWithRmbg(params: { + request: Request; + requestId: string; + userId: string; + productImageUrl: string; + preRmbgEnabled: boolean; +}): Promise<{ productImageUrl: string; usedPreRmbg: boolean; provider?: string; reason?: string }> { + const { request, requestId, userId, productImageUrl, preRmbgEnabled } = params; + if (!preRmbgEnabled) return { productImageUrl, usedPreRmbg: false, reason: "disabled_by_config" }; + + logger.info("pre_rmbg_started", { requestId }); + try { + const response = await executeRemoveBackgroundPOST(new Request(new URL("/api/rmbg", request.url), { + method: "POST", + headers: { "Content-Type": "application/json", "X-Request-ID": `${requestId}:pre-rmbg` }, + body: JSON.stringify({ imageUrl: productImageUrl, prompt: "场景裂变预处理-抠图", skipGallerySave: true }), + }), userId); + const payload = await response.json(); + if (!response.ok) { + logger.warn("pre_rmbg_failed", { requestId, status: response.status, code: payload?.failure?.code }); + return { productImageUrl, usedPreRmbg: false, reason: String(payload?.error || `request_failed_${response.status}`) }; + } + const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim(); + if (!cleanedUrl || cleanedUrl === productImageUrl) { + return { productImageUrl, usedPreRmbg: false, reason: cleanedUrl ? "same_as_source" : "empty_result" }; + } + return { productImageUrl: cleanedUrl, usedPreRmbg: true, provider: String(payload?.provider || "unknown") }; + } catch (error) { + logger.warn("pre_rmbg_failed", error, { requestId }); + return { productImageUrl, usedPreRmbg: false, reason: error instanceof Error ? error.message : "exception" }; + } +} diff --git a/tests/routes/canvas-scale-adjust-intent.test.ts b/tests/routes/canvas-scale-adjust-intent.test.ts index a56373de7..61e1b94ec 100644 --- a/tests/routes/canvas-scale-adjust-intent.test.ts +++ b/tests/routes/canvas-scale-adjust-intent.test.ts @@ -236,8 +236,6 @@ test.describe("canvas scale adjust product lock", () => { const rmbg = readSource("lib/ai/rmbg/service.ts"); expect(productLock).toContain('foregroundType: "person"'); - expect(productLock).toContain("attempt <= 3"); - expect(productLock).toContain("executeScaleAdjustRmbgJob({"); expect(productLock).toContain("regionPersonExclusionAlpha"); expect(productLock).toContain("overlapPixelCount >= overlapLimit"); expect(productLock).toContain("目标产品被人物遮挡"); diff --git a/tests/routes/rmbg-delivery.test.ts b/tests/routes/rmbg-delivery.test.ts index 2c1f44d09..7c376f6e4 100644 --- a/tests/routes/rmbg-delivery.test.ts +++ b/tests/routes/rmbg-delivery.test.ts @@ -33,7 +33,7 @@ test("AOS uses a public Qiniu original URL without uploading the image file agai 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 }); + const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true }); expect(creates).toBe(1); expect(result.provider).toBe("aoscdn"); expect(result.original_url).toContain(cdn); @@ -55,10 +55,85 @@ test("non-CDN sources upload the original file without changing its bytes or dim 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 }); + await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true }); } }); +test("a cropped input uses its own bytes instead of the original provenance URL", async () => { + const original = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#abcdef" } }).png().toBuffer(); + const cropped = await sharp(original).extract({ left: 20, top: 10, width: 40, height: 20 }).png().toBuffer(); + let submitted!: FormData; + const service = rmbgLoader(async (input, options) => { + if (options?.method === "POST") { submitted = options.body as FormData; return Response.json({ data: { task_id: "cropped-task" } }); } + if (String(input).endsWith("/cropped-task")) return Response.json({ data: { state: 1, image: "https://output.example/cropped.png" } }); + return new Response(cropped, { headers: { "content-type": "image/png" } }); + }); + await service.executeRmbgJob({ userId: "test", imageBuffer: cropped, sourceImageUrl: `${cdn}/original.png`, skipGallerySave: true }); + expect(submitted.has("image_url")).toBe(false); + const bytes = Buffer.from(await (submitted.get("image_file") as File).arrayBuffer()); + expect(bytes.equals(cropped)).toBe(true); + expect(await sharp(bytes).metadata()).toMatchObject({ width: 40, height: 20 }); +}); + +test("scale adjustment does not start a new matting job after an unconfirmed result", async () => { + let creates = 0; + const image = await sharp({ create: { width: 64, height: 64, channels: 3, background: "white" } }).png().toBuffer(); + const scale = serverLoader({ + "@/lib/ai/rmbg/service": { executeRmbgJob: async () => { creates++; throw new Error("AOS查询任务超时,结果尚未确认"); } }, + setTimeout: (fn: () => void) => setTimeout(fn, 0), + })("lib/canvas/scale-adjust-product-lock.ts"); + await expect(scale.buildScaleAdjustProductLockAssets({ userId: "test", sourceBuffer: image, sourceWidth: 64, sourceHeight: 64, sourceImageUrl: `${cdn}/source.png`, regions: [{ index: 0, rect: { x: 0.1, y: 0.1, width: 0.5, height: 0.5 }, targetRect: { x: 0.2, y: 0.2, width: 0.5, height: 0.5 } }] })).rejects.toThrow("结果尚未确认"); + expect(creates).toBe(1); +}); + +test("scene preprocessing invokes matting once even when its result is unconfirmed or the call is interrupted", async () => { + for (const interrupted of [false, true]) { + let calls = 0, networkCalls = 0; + const scene = serverLoader({ + "@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async (request: Request, userId: string) => { + calls++; + expect(userId).toBe("user-a"); + expect(request.headers.get("x-request-id")).toBe("scene-a:pre-rmbg"); + expect(await request.json()).toMatchObject({ imageUrl: `${cdn}/source.png`, skipGallerySave: true }); + if (interrupted) throw new TypeError("fetch failed"); + return Response.json({ error: "抠图已完成,但结果图片下载失败,请核对原任务。", failure: { outcome: "unknown" } }, { status: 500 }); + } }, + fetch: async () => { networkCalls++; throw new Error("Unexpected HTTP resubmission"); }, + })("lib/scene-fusion/preprocess-product.ts"); + const result = await scene.preprocessProductImageWithRmbg({ request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png`, preRmbgEnabled: true }); + expect(result).toMatchObject({ productImageUrl: `${cdn}/source.png`, usedPreRmbg: false }); + expect({ calls, networkCalls }).toEqual({ calls: 1, networkCalls: 0 }); + } +}); + +test("scene preprocessing retains the disabled setting and delivers a successful original result", async () => { + let calls = 0; + const scene = serverLoader({ "@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async () => { calls++; return Response.json({ original_url: `${cdn}/result.png`, provider: "aoscdn" }); } } })("lib/scene-fusion/preprocess-product.ts"); + const params = { request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png` }; + expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: false })).toMatchObject({ usedPreRmbg: false }); + expect(calls).toBe(0); + expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: true })).toMatchObject({ usedPreRmbg: true, productImageUrl: `${cdn}/result.png` }); + expect(calls).toBe(1); +}); + +test("the standalone matting API returns the limit, recommendation and request ID", async () => { + class InputError extends Error { status = 400; } + let submitted: any; + const operation = serverLoader({ + "@/lib/ai/rmbg/service": { RmbgServiceError: InputError, fetchImageBufferFromUrl: async () => Buffer.from("original"), executeRmbgJob: async (params: any) => { submitted = params; throw new InputError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); } }, + "@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: false }) }, + "@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) }, + })("lib/canvas/operations/rmbg.ts"); + const response = await operation.executeRemoveBackgroundPOST(new Request("https://example.com/api/rmbg", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "request-a" }, body: JSON.stringify({ imageUrl: `${cdn}/source.png` }) }), "user-a"); + const result = await response.json(); + expect(response.status).toBe(400); + expect(submitted).toMatchObject({ requestId: "request-a", inputImageUrl: `${cdn}/source.png` }); + expect(result).toMatchObject({ success: false, requestId: "request-a", failure: { code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" } }); + expect(result.error).toContain("5056×3392"); + expect(result.error).toContain("本次未提交上游抠图任务"); + expect(result.error).toContain("请求编号:request-a"); +}); + 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(); @@ -93,7 +168,7 @@ test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submit 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 }); + await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, inputImageUrl: `${cdn}/boundary.png`, 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 }); }