From ae9354f747cd31baf224c24b598b06569eb781f1 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 15:27:01 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=E6=A1=86=E9=80=89=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8E=9F=E4=BB=BB=E5=8A=A1=E5=B9=B6=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/canvas/box-cutout/route.ts | 99 ++--------------- components/canvas/nodes/ImageNode.tsx | 104 ++++-------------- lib/ai/box-cutout/service.ts | 20 +++- lib/canvas/capabilities/headless-executor.ts | 41 ++++--- lib/canvas/operations/box-cutout.ts | 96 ++++++++++++++++ ...as-agent-runtime-image-preferences.test.ts | 2 +- .../routes/canvas-toolbar-capability.test.ts | 27 +++++ tests/routes/rmbg-delivery.test.ts | 85 ++++++++++++++ 8 files changed, 280 insertions(+), 194 deletions(-) create mode 100644 lib/canvas/operations/box-cutout.ts diff --git a/app/api/canvas/box-cutout/route.ts b/app/api/canvas/box-cutout/route.ts index 86773fc3e..bacb1f070 100644 --- a/app/api/canvas/box-cutout/route.ts +++ b/app/api/canvas/box-cutout/route.ts @@ -1,100 +1,15 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; -import { - BoxCutoutServiceError, - executeBoxCutoutJob, -} from "@/lib/ai/box-cutout/service"; -import { RmbgServiceError } from "@/lib/ai/rmbg/service"; -import { getPointsSystemConfig } from "@/lib/db/queries"; -import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; -import { getImageToolCreditCost } from "@/lib/points/config"; +import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout"; export const runtime = "nodejs"; export const maxDuration = 60; -type BoxCutoutRequest = { - imageUrl?: string; - cropRect?: { - x?: number; - y?: number; - width?: number; - height?: number; - }; - paddingRatio?: number; - prompt?: string; - chatId?: string | null; - messageId?: string | null; - skipGallerySave?: boolean; - sourceNodeId?: string; -}; - -export async function POST(request: NextRequest) { - try { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); - } - - const body = (await request.json()) as BoxCutoutRequest; - const rawImageUrl = String(body.imageUrl || "").trim(); - if (!rawImageUrl) { - return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 }); - } - - const resolvedImageUrl = new URL(rawImageUrl, request.url).toString(); - const pointsConfig = await getPointsSystemConfig(); - const pointsCharge = await prepareFeaturePointsCharge({ - userId: session.user.id, - unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"), - reason: "image_box_cutout", - modelId: "box-cutout", - note: "框选抠图消耗积分", - pointsConfig, - }); - if (!pointsCharge.ok) { - return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); - } - - const result = await executeBoxCutoutJob({ - userId: session.user.id, - imageUrl: resolvedImageUrl, - cropRect: body.cropRect || {}, - paddingRatio: body.paddingRatio, - prompt: body.prompt || "框选抠图", - chatId: body.chatId || null, - messageId: body.messageId || null, - skipGallerySave: body.skipGallerySave === true, - sourceNodeId: body.sourceNodeId, - }); - - if (pointsCharge.chargeable) { - try { - await pointsCharge.consume(); - } catch (pointsError) { - console.error("[Points] Failed to consume box cutout points:", pointsError); - } - } - - return NextResponse.json(result); - } catch (error) { - if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) { - return NextResponse.json( - { - success: false, - error: error.detail || error.message, - }, - { status: error.status }, - ); - } - - console.error("[BoxCutout] 处理失败:", error); - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : "框选抠图失败", - }, - { status: 500 }, - ); +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); } + return executeBoxCutoutPOST(request, session.user.id); } diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 09e7a83ee..db71d9c7e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -4683,106 +4683,44 @@ export function ImageNode({ return; } - setIsCreatingDetachedResultNode(true); + if (!projectId || !runCanvasRemoteOperation) { + toast.error("画布还在加载,请稍后再试"); + return; + } + + setIsCreatingDetachedResultNode(false); setIsProcessing(true); const actionName = "框选抠图"; - const newNodeId = addNode({ - type: "image", - position: { - x: node.position.x + node.size.width + 20, - y: node.position.y, - }, - size: { width: node.size.width, height: node.size.height }, - data: { - src: "", - status: "generating", - prompt: `${actionName}处理中...`, - title: `${displayTitle}-${actionName}`, - originalSrc: data.src, - metadata: { - cropRect: boxCutoutSelection, - paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, - }, - }, - connections: [node.id], - }); try { - const result = await imageGenerationService.boxCutout({ - imageUrl: data.src, - cropRect: boxCutoutSelection, - paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, - sourceNodeId: node.id, - }); - - if (result.success && result.data?.url) { - updateNode(newNodeId, { - data: { - src: result.data.url, - status: "completed", - prompt: `${actionName}结果`, - title: `${displayTitle}-${actionName}`, - originalSrc: data.src, - metadata: { - cropRect: boxCutoutSelection, - cropRectApplied: result.data.cropRectApplied, - paddedCropRectApplied: result.data.paddedCropRectApplied, - paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, - editMode: "box-cutout", - }, - }, - }); - toast.success(`${actionName}完成`); - } else { - updateNode(newNodeId, { - data: { - src: "", - status: "failed", - prompt: `${actionName}失败`, - title: `${displayTitle}-${actionName}-失败`, - originalSrc: data.src, - metadata: { - cropRect: boxCutoutSelection, - paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, - }, - }, - }); - toast.error(result.error || `${actionName}失败`); - } - } catch (error) { - console.error("框选抠图失败:", error); - updateNode(newNodeId, { - data: { - src: "", - status: "failed", - prompt: `${actionName}失败`, - title: `${displayTitle}-${actionName}-失败`, - originalSrc: data.src, - metadata: { - cropRect: boxCutoutSelection, - paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, - }, + const command = createCanvasCapabilityClientCommand({ + capabilityId: "image.box_cutout", + nodeIds: [node.id], + projectId, + source: "toolbar", + args: { + ...boxCutoutSelection, + paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, }, }); - toast.error(`${actionName}失败,请重试`); + await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command)); + toast.success(`${actionName}完成`); + } catch (error) { + console.error("框选抠图失败:", error); + toast.error(error instanceof Error ? error.message : `${actionName}失败`); } finally { setIsCreatingDetachedResultNode(false); setIsProcessing(false); resetBoxCutoutState(); } }, [ - addNode, boxCutoutSelection, data.src, - displayTitle, isProcessing, node.id, - node.position.x, - node.position.y, - node.size.height, - node.size.width, + projectId, resetBoxCutoutState, - updateNode, + runCanvasRemoteOperation, ]); const animateAddToChat = useCallback(async (previewUrl?: string) => { diff --git a/lib/ai/box-cutout/service.ts b/lib/ai/box-cutout/service.ts index 2c7888821..3c38ecad9 100644 --- a/lib/ai/box-cutout/service.ts +++ b/lib/ai/box-cutout/service.ts @@ -4,6 +4,9 @@ import { executeRmbgJob, fetchImageBufferFromUrl, } from "@/lib/ai/rmbg/service"; +import { createLogger } from "@/lib/observability/logger"; + +const logger = createLogger("box-cutout-service"); export type NormalizedCropRect = { x: number; @@ -13,6 +16,7 @@ export type NormalizedCropRect = { }; export interface ExecuteBoxCutoutJobParams { + requestId?: string; userId: string; imageUrl: string; cropRect: Partial; @@ -143,6 +147,7 @@ function applyPadding( export async function executeBoxCutoutJob( params: ExecuteBoxCutoutJobParams, ): Promise { + const requestId = params.requestId || crypto.randomUUID(); const imageUrl = String(params.imageUrl || "").trim(); if (!imageUrl) { throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400); @@ -150,7 +155,7 @@ export async function executeBoxCutoutJob( const cropRect = sanitizeNormalizedCropRect(params.cropRect); if (!cropRect) { - throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400); + throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400); } const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio); @@ -170,7 +175,7 @@ export async function executeBoxCutoutJob( const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight); if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) { - throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400); + throw new BoxCutoutServiceError("框选区域过小,宽、高均须至少 24 像素,请扩大框选范围后重新提交。", 400); } const paddedCropRect = applyPadding(cropRect, paddingRatio); @@ -184,7 +189,18 @@ export async function executeBoxCutoutJob( }) .toBuffer(); + logger.info("crop_prepared", { + requestId, + sourceNodeId: params.sourceNodeId, + sourceWidth, + sourceHeight, + cropRectPixels, + paddedCropRectPixels, + inputBytes: croppedBuffer.length, + }); + const result = await executeRmbgJob({ + requestId, userId: params.userId, imageBuffer: croppedBuffer, imageBase64: croppedBuffer.toString("base64"), diff --git a/lib/canvas/capabilities/headless-executor.ts b/lib/canvas/capabilities/headless-executor.ts index ebf0890fb..cdd5f6b23 100644 --- a/lib/canvas/capabilities/headless-executor.ts +++ b/lib/canvas/capabilities/headless-executor.ts @@ -1,16 +1,15 @@ -import { - executeBoxCutoutJob, -} from "@/lib/ai/box-cutout/service"; import { executeExtendJob } from "@/lib/ai/extend/service"; import { executeLayerSplitJob } from "@/lib/ai/layer-split/service"; import { executeResizeJob } from "@/lib/ai/resize/service"; import { executeRmbgJob, fetchImageBufferFromUrl, + RmbgServiceError, } from "@/lib/ai/rmbg/service"; import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context"; import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint"; +import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout"; import { generateCanvasImage } from "@/lib/canvas/generate-image"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { @@ -187,25 +186,35 @@ async function executeForTarget( } if (command.capabilityId === "image.box_cutout") { - const result = await executeBoxCutoutJob({ - userId: params.userId, - imageUrl: target.sourceImageUrl, - sourceNodeId: target.nodeId, - cropRect: { - x: asFiniteNumber(command.args.x), - y: asFiniteNumber(command.args.y), - width: asFiniteNumber(command.args.width), - height: asFiniteNumber(command.args.height), - }, - paddingRatio: asFiniteNumber(command.args.paddingRatio), - prompt: prompt || "框选抠图", - }); + const response = await executeBoxCutoutPOST(new Request(params.requestUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Request-ID": command.requestId }, + body: JSON.stringify({ + imageUrl: target.sourceImageUrl, + sourceNodeId: target.nodeId, + cropRect: { + x: asFiniteNumber(command.args.x), + y: asFiniteNumber(command.args.y), + width: asFiniteNumber(command.args.width), + height: asFiniteNumber(command.args.height), + }, + paddingRatio: asFiniteNumber(command.args.paddingRatio), + prompt: prompt || "框选抠图", + }), + }), params.userId); + const result = await response.json(); + if (!response.ok || !result.success) { + throw new RmbgServiceError(result.detail || result.message || result.error || "框选抠图未能完成", response.status); + } return [{ ...common, assetId: result.mediaAssetId || undefined, metadata: { + editMode: "box-cutout", cropRectApplied: result.cropRectApplied, paddedCropRectApplied: result.paddedCropRectApplied, + cropRectPixels: result.cropRectPixels, + paddedCropRectPixels: result.paddedCropRectPixels, }, title: `${target.title}-框选抠图`, url: result.original_url, diff --git a/lib/canvas/operations/box-cutout.ts b/lib/canvas/operations/box-cutout.ts new file mode 100644 index 000000000..45d7beb5a --- /dev/null +++ b/lib/canvas/operations/box-cutout.ts @@ -0,0 +1,96 @@ +import "server-only"; +import { NextResponse } from "next/server"; + +import { + BoxCutoutServiceError, + executeBoxCutoutJob, +} from "@/lib/ai/box-cutout/service"; +import { RmbgServiceError } from "@/lib/ai/rmbg/service"; +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"; + +type BoxCutoutRequest = { + imageUrl?: string; + cropRect?: { + x?: number; + y?: number; + width?: number; + height?: number; + }; + paddingRatio?: number; + prompt?: string; + chatId?: string | null; + messageId?: string | null; + skipGallerySave?: boolean; + sourceNodeId?: string; +}; + +export async function executeBoxCutoutPOST(request: Request, userId: string) { + const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); + try { + if (!userId) { + return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); + } + + const body = (await request.json()) as BoxCutoutRequest; + const rawImageUrl = String(body.imageUrl || "").trim(); + if (!rawImageUrl) { + throw new BoxCutoutServiceError("缺少原图链接,请重新选择图片后提交。", 400); + } + + const resolvedImageUrl = new URL(rawImageUrl, request.url).toString(); + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId, + unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"), + reason: "image_box_cutout", + modelId: "box-cutout", + note: "框选抠图消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + + const result = await executeBoxCutoutJob({ + requestId, + userId, + imageUrl: resolvedImageUrl, + cropRect: body.cropRect || {}, + paddingRatio: body.paddingRatio, + prompt: body.prompt || "框选抠图", + chatId: body.chatId || null, + messageId: body.messageId || null, + skipGallerySave: body.skipGallerySave === true, + sourceNodeId: body.sourceNodeId, + }); + + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume(); + } catch (pointsError) { + console.error("[Points] Failed to consume box cutout points:", pointsError); + } + } + + return NextResponse.json({ ...result, requestId }); + } catch (error) { + const failure = getRequestFailureDetails(error, "框选抠图未能完成"); + const payload = { + success: false, + error: formatRequestFailure(failure, requestId), + detail: error instanceof Error ? error.message : "框选抠图未能完成", + code: failure.code, + failure, + requestId, + }; + if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) { + return NextResponse.json(payload, { status: error.status }); + } + + console.error("[BoxCutout] 处理失败:", error); + return NextResponse.json(payload, { status: 500 }); + } +} diff --git a/tests/routes/canvas-agent-runtime-image-preferences.test.ts b/tests/routes/canvas-agent-runtime-image-preferences.test.ts index 8406aa84f..53aa39b1e 100644 --- a/tests/routes/canvas-agent-runtime-image-preferences.test.ts +++ b/tests/routes/canvas-agent-runtime-image-preferences.test.ts @@ -102,7 +102,7 @@ test("mixed-model batches bill and label only the delivered images using their a test("headless image features cannot substitute their default when a pinned model disappears", async () => { let generated = 0; const mocks: Record = { - "@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {}, + "@/lib/canvas/operations/box-cutout": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {}, "@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {}, "@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } }, "@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null }, diff --git a/tests/routes/canvas-toolbar-capability.test.ts b/tests/routes/canvas-toolbar-capability.test.ts index 38d49dbd0..3f178df53 100644 --- a/tests/routes/canvas-toolbar-capability.test.ts +++ b/tests/routes/canvas-toolbar-capability.test.ts @@ -103,6 +103,33 @@ test("a recovered matting rejection shows its dimensions and advice without chan expect(useCanvasStore.getState().canvasRevision).toBe("r1"); }); +test("a lost box-cutout response recovers the same selection into its original canvas with one submission", async () => { + const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.box_cutout", nodeIds: ["source"], source: "toolbar", args: { x: 0.25, y: 0.2, width: 0.4, height: 0.5, paddingRatio: 0.08 } }); + let posts = 0, polls = 0; + const selected = { ...node("cutout"), data: { src: "https://example.com/cutout.png", metadata: { editMode: "box-cutout", cropRectApplied: submitted.args } } }; + globalThis.fetch = (async (url, options) => { + if (options?.method === "POST") { + posts++; + expect(JSON.parse(String(options.body))).toMatchObject({ capabilityId: "image.box_cutout", requestId: submitted.requestId, args: submitted.args }); + useCanvasStore.getState().moveNode("source", { x: 20, y: 30 }); + return new Response(null, { status: 524 }); + } + if (String(url).includes("/capabilities?")) { + polls++; + expect(String(url)).toContain(encodeURIComponent(submitted.requestId)); + return Response.json({ success: true, task: { status: "succeeded" } }); + } + return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("source"), selected], edges: [] } } }); + }) as typeof fetch; + await executeCanvasToolbarCapability(submitted); + expect({ posts, polls }).toEqual({ posts: 1, polls: 1 }); + const state = useCanvasStore.getState(); + expect(state.projectId).toBe("project-a"); + expect(state.nodes.map(n => n.id)).toEqual(["source", "cutout"]); + expect(state.nodes[0].position).toEqual({ x: 20, y: 30 }); + expect(state.nodes[1].data).toEqual(selected.data); +}); + test("switching projects during processing does not load the old result into the new project", async () => { globalThis.fetch = (async (url) => { if (String(url).endsWith("/capabilities")) { diff --git a/tests/routes/rmbg-delivery.test.ts b/tests/routes/rmbg-delivery.test.ts index 7c376f6e4..d850364cf 100644 --- a/tests/routes/rmbg-delivery.test.ts +++ b/tests/routes/rmbg-delivery.test.ts @@ -5,6 +5,91 @@ import { getRequestFailureDetails } from "@/lib/request-error"; const cdn = "https://scqnycdn.jiansun.vip"; +test("box cutout submits exactly the padded selection and keeps the original URL as provenance", async () => { + const pixels = Buffer.alloc(120 * 80 * 3); + for (let y = 0; y < 80; y++) for (let x = 0; x < 120; x++) { + const offset = (y * 120 + x) * 3; + pixels[offset] = x * 2; pixels[offset + 1] = y * 3; pixels[offset + 2] = x + y; + } + const original = await sharp(pixels, { raw: { width: 120, height: 80, channels: 3 } }).png().toBuffer(); + let submitted: any; + const box = serverLoader({ + "@/lib/ai/rmbg/service": { + fetchImageBufferFromUrl: async () => original, + executeRmbgJob: async (params: any) => { submitted = params; return { original_url: `${cdn}/selected.png` }; }, + }, + })("lib/ai/box-cutout/service.ts"); + const result = await box.executeBoxCutoutJob({ userId: "user-a", requestId: "box-a", sourceNodeId: "source", imageUrl: `${cdn}/original.png`, cropRect: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, paddingRatio: 0.1 }); + expect(result.cropRectPixels).toEqual({ left: 30, top: 20, width: 60, height: 40 }); + expect(result.paddedCropRectPixels).toEqual({ left: 24, top: 16, width: 72, height: 48 }); + expect(submitted).toMatchObject({ requestId: "box-a", sourceImageUrl: `${cdn}/original.png` }); + expect(submitted.inputImageUrl).toBeUndefined(); + const expected = await sharp(original).extract(result.paddedCropRectPixels).raw().toBuffer(); + expect((await sharp(submitted.imageBuffer).raw().toBuffer()).equals(expected)).toBe(true); +}); + +test("box cutout shares the configured charge once after success and never charges a rejected input", async () => { + class InputError extends Error { status = 400; } + for (const rejected of [false, true]) { + let creates = 0, charges = 0; + const operation = serverLoader({ + "@/lib/ai/box-cutout/service": { BoxCutoutServiceError: InputError, executeBoxCutoutJob: async (params: any) => { + creates++; + expect(params.requestId).toBe("box-a"); + if (rejected) throw new InputError("抠图参考图分辨率超限(5000×3000 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); + return { success: true, original_url: `${cdn}/selected.png` }; + } }, + "@/lib/ai/rmbg/service": { RmbgServiceError: InputError }, + "@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: true, creditCostPerImageBoxCutout: 7 }) }, + "@/lib/points/feature-charge": { prepareFeaturePointsCharge: async (params: any) => { + expect(params).toMatchObject({ userId: "user-a", unitCost: 7, reason: "image_box_cutout", modelId: "box-cutout" }); + return { ok: true, chargeable: true, consume: async () => { charges++; } }; + } }, + })("lib/canvas/operations/box-cutout.ts"); + const response = await operation.executeBoxCutoutPOST(new Request("https://example.com/api/canvas/box-cutout", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "box-a" }, body: JSON.stringify({ imageUrl: `${cdn}/original.png`, cropRect: { x: 0, y: 0, width: 0.5, height: 0.5 } }) }), "user-a"); + const result = await response.json(); + expect({ creates, charges }).toEqual({ creates: 1, charges: rejected ? 0 : 1 }); + expect(response.status).toBe(rejected ? 400 : 200); + if (rejected) { + expect(result.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" }); + expect(result.error).toContain("5000×3000"); + expect(result.error).toContain("本次未提交上游抠图任务"); + expect(result.error).toContain("请求编号:box-a"); + } + } +}); + +test("headless box cutout keeps its task identity, pixel bounds and input-rejection status", async () => { + class InputError extends Error { + status: number; + constructor(message: string, status: number) { super(message); this.status = status; } + } + let calls = 0; + let rejectInput = false; + const bounds = { left: 20, top: 30, width: 200, height: 300 }; + const executor = serverLoader({ + "@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async (request: Request, userId: string) => { + calls++; + expect(userId).toBe("user-a"); + expect(request.headers.get("x-request-id")).toBe("box-a"); + expect(await request.json()).toMatchObject({ sourceNodeId: "source", cropRect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, paddingRatio: 0.08 }); + return rejectInput ? Response.json({ success: false, detail: "抠图参考图分辨率超限(5000×3000 像素)" }, { status: 400 }) + : Response.json({ success: true, original_url: `${cdn}/cutout.png`, paddedCropRectPixels: bounds }); + } }, + "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, + "@/lib/ai/rmbg/service": { RmbgServiceError: InputError }, + "@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {}, + "@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {}, + })("lib/canvas/capabilities/headless-executor.ts"); + const params = { userId: "user-a", requestUrl: "https://example.com/api/canvas/project-a/capabilities", requestHeaders: new Headers(), command: { requestId: "box-a", capabilityId: "image.box_cutout", args: { x: 0.1, y: 0.2, width: 0.3, height: 0.4, paddingRatio: 0.08 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/original.png` }] }; + const results = await executor.executeCanvasCapabilityHeadlessly(params); + expect(calls).toBe(1); + expect(results).toMatchObject([{ sourceNodeId: "source", url: `${cdn}/cutout.png`, metadata: { editMode: "box-cutout", paddedCropRectPixels: bounds } }]); + rejectInput = true; + await expect(executor.executeCanvasCapabilityHeadlessly(params)).rejects.toMatchObject({ status: 400 }); + expect(calls).toBe(2); +}); + function rmbgLoader(fetch: typeof globalThis.fetch, timer?: typeof setTimeout) { return serverLoader({ "@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) }, -- 2.47.2