Use archived source URLs for background removal and preserve timeout causes
This commit is contained in:
parent
71413c173e
commit
33662210dd
@ -27,6 +27,7 @@ type RmbgConfig = {
|
||||
};
|
||||
|
||||
export type ExecuteRmbgJobParams = {
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
imageBuffer: Buffer;
|
||||
imageBase64?: string;
|
||||
@ -53,7 +54,7 @@ export class RmbgServiceError extends Error {
|
||||
detail?: string;
|
||||
|
||||
constructor(message: string, status = 500, detail?: string) {
|
||||
super(message);
|
||||
super(detail && detail !== message ? `${message}:${detail}` : message);
|
||||
this.name = "RmbgServiceError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
@ -163,7 +164,7 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
|
||||
|
||||
async function prepareImageForAos(
|
||||
imageBuffer: Buffer
|
||||
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
|
||||
): Promise<{ buffer: Buffer; mimeType: string; filename: string; resized: boolean }> {
|
||||
try {
|
||||
const sharp = await import("sharp");
|
||||
const image = sharp.default(imageBuffer);
|
||||
@ -189,6 +190,7 @@ async function prepareImageForAos(
|
||||
buffer: imageBuffer,
|
||||
mimeType,
|
||||
filename: `image.${filenameExt}`,
|
||||
resized: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -203,10 +205,11 @@ async function prepareImageForAos(
|
||||
buffer: resizedBuffer,
|
||||
mimeType,
|
||||
filename: `image.${filenameExt}`,
|
||||
resized: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("aos_image_preprocess_failed", error);
|
||||
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
|
||||
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png", resized: true };
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,20 +372,38 @@ async function processWithAosSegmentation(
|
||||
foregroundType?: "" | "person" | "object" | "stamp";
|
||||
crop?: 0 | 1;
|
||||
format?: "png" | "jpg";
|
||||
sourceImageUrl?: string | null;
|
||||
requestId?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
|
||||
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
|
||||
const headers = { "X-API-KEY": config.apiKey };
|
||||
let stage = "create";
|
||||
let providerTaskId: string | undefined;
|
||||
|
||||
try {
|
||||
const normalizedImage = await prepareImageForAos(imageBuffer);
|
||||
const createForm = new FormData();
|
||||
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
|
||||
type: normalizedImage.mimeType,
|
||||
});
|
||||
createForm.append("image_file", imageBlob, normalizedImage.filename);
|
||||
let publicSourceUrl: string | null = null;
|
||||
if (!normalizedImage.resized && config.sourceImageUrl) {
|
||||
try {
|
||||
const source = new URL(config.sourceImageUrl);
|
||||
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
|
||||
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
|
||||
publicSourceUrl = source.toString();
|
||||
}
|
||||
} catch { /* Uploaded files and other sources use the file API. */ }
|
||||
}
|
||||
if (publicSourceUrl) {
|
||||
createForm.append("image_url", publicSourceUrl);
|
||||
} else {
|
||||
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
|
||||
type: normalizedImage.mimeType,
|
||||
});
|
||||
createForm.append("image_file", imageBlob, normalizedImage.filename);
|
||||
}
|
||||
createForm.append("sync", "0");
|
||||
createForm.append("return_type", "1");
|
||||
createForm.append("output_type", "2");
|
||||
@ -392,6 +413,9 @@ async function processWithAosSegmentation(
|
||||
createForm.append("type", config.foregroundType);
|
||||
}
|
||||
|
||||
const createStartedAt = Date.now();
|
||||
logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : normalizedImage.buffer.length });
|
||||
|
||||
const createResponse = await withTimeout(
|
||||
(signal) =>
|
||||
fetch(createUrl, {
|
||||
@ -416,11 +440,14 @@ async function processWithAosSegmentation(
|
||||
}
|
||||
|
||||
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 maxPollCount = 60;
|
||||
|
||||
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
|
||||
await sleep(1000);
|
||||
stage = "poll";
|
||||
|
||||
const pollResponse = await withTimeout(
|
||||
(signal) =>
|
||||
@ -454,6 +481,7 @@ async function processWithAosSegmentation(
|
||||
pollCount,
|
||||
taskId,
|
||||
});
|
||||
stage = "download";
|
||||
const imageResponse = await withTimeout(
|
||||
(signal) =>
|
||||
fetch(imageUrl, {
|
||||
@ -499,7 +527,13 @@ async function processWithAosSegmentation(
|
||||
return { success: false, error: "AOS任务超时(最多60秒)" };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return { success: false, error: "AOS请求超时,请稍后重试" };
|
||||
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
|
||||
const message = stage === "create"
|
||||
? "AOS创建任务超时(120秒),结果尚未确认"
|
||||
: stage === "poll"
|
||||
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
|
||||
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
|
||||
return { success: false, error: message };
|
||||
}
|
||||
logger.warn("aos_provider_request_error", error);
|
||||
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
|
||||
@ -601,6 +635,7 @@ export async function executeRmbgJob(
|
||||
params: ExecuteRmbgJobParams
|
||||
): Promise<ExecuteRmbgJobResult> {
|
||||
const {
|
||||
requestId,
|
||||
userId,
|
||||
imageBuffer,
|
||||
imageBase64 = imageBuffer.toString("base64"),
|
||||
@ -657,6 +692,8 @@ export async function executeRmbgJob(
|
||||
foregroundType: foregroundType ?? config.aosForegroundType ?? "",
|
||||
crop: config.aosCrop === 1 ? 1 : 0,
|
||||
format: config.aosFormat === "jpg" ? "jpg" : "png",
|
||||
sourceImageUrl,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (!result.success || !result.imageBuffer) {
|
||||
|
||||
@ -128,6 +128,7 @@ async function executeForTarget(
|
||||
if (command.capabilityId === "image.remove_background") {
|
||||
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
|
||||
const result = await executeRmbgJob({
|
||||
requestId: command.requestId,
|
||||
userId: params.userId,
|
||||
imageBuffer,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
|
||||
@ -231,6 +231,15 @@ export function getRequestFailureDetails(
|
||||
if (isGatewayResponseTimeout(error)) {
|
||||
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_POLL_TIMEOUT", "抠图服务查询任务超时,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "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)) {
|
||||
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/rmbg-delivery\.test\.ts/,
|
||||
/routes\/legacy-oss-recovery\.test\.ts/,
|
||||
/routes\/retired-storage\.test\.ts/,
|
||||
/routes\/original-image-download\.test\.ts/,
|
||||
|
||||
@ -53,6 +53,7 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
|
||||
ReadableStream,
|
||||
Headers,
|
||||
Blob,
|
||||
FormData,
|
||||
Request,
|
||||
Date: mocks.Date || Date,
|
||||
document: mocks.document,
|
||||
|
||||
82
tests/routes/rmbg-delivery.test.ts
Normal file
82
tests/routes/rmbg-delivery.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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 === 1000 ? 0 : ms)),
|
||||
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts");
|
||||
}
|
||||
|
||||
test("AOS uses a public Qiniu original URL without uploading the image file again", async () => {
|
||||
const image = await sharp({ create: { width: 1024, height: 768, channels: 3, background: "#abcdef" } }).png().toBuffer();
|
||||
let creates = 0;
|
||||
const source = `${cdn}/uploads/original.png`;
|
||||
const service = rmbgLoader(async (input, options) => {
|
||||
const url = String(input);
|
||||
if (options?.method === "POST") {
|
||||
creates++;
|
||||
const form = options.body as FormData;
|
||||
expect(form.get("image_url")).toBe(source);
|
||||
expect(form.has("image_file")).toBe(false);
|
||||
expect(form.get("sync")).toBe("0");
|
||||
return Response.json({ data: { task_id: "task-url" } });
|
||||
}
|
||||
if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
|
||||
return new Response(image, { headers: { "content-type": "image/png" } });
|
||||
});
|
||||
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
|
||||
expect(creates).toBe(1);
|
||||
expect(result.provider).toBe("aoscdn");
|
||||
expect(result.original_url).toContain(cdn);
|
||||
});
|
||||
|
||||
test("oversized images and non-CDN sources keep the normalized file upload", async () => {
|
||||
for (const [width, source] of [[5000, `${cdn}/large.png`], [1000, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) {
|
||||
const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer();
|
||||
const service = rmbgLoader(async (input, options) => {
|
||||
if (options?.method === "POST") {
|
||||
const form = options.body as FormData;
|
||||
expect(form.has("image_url")).toBe(false);
|
||||
const file = form.get("image_file") as File;
|
||||
expect((await sharp(Buffer.from(await file.arrayBuffer())).metadata()).width).toBe(Math.min(width, 4096));
|
||||
return Response.json({ data: { task_id: "task-file" } });
|
||||
}
|
||||
if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
|
||||
return new Response(image, { headers: { "content-type": "image/png" } });
|
||||
});
|
||||
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a create timeout preserves its cause and never creates another paid task", async () => {
|
||||
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
|
||||
let creates = 0;
|
||||
let abort: Error;
|
||||
const service = rmbgLoader(async () => { creates++; throw abort; });
|
||||
abort = new service.RmbgServiceError("request timed out");
|
||||
abort.name = "AbortError";
|
||||
let failure: Error | undefined;
|
||||
try {
|
||||
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
|
||||
} catch (error) { failure = error as Error; }
|
||||
expect(creates).toBe(1);
|
||||
expect(failure?.message).toContain("AOS创建任务超时(120秒)");
|
||||
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_CREATE_TIMEOUT", outcome: "unknown" });
|
||||
});
|
||||
|
||||
test("poll and result download timeouts retain their original task and distinct advice", () => {
|
||||
const poll = getRequestFailureDetails("抠图处理失败:AOS查询任务超时,结果尚未确认(任务 task-1)");
|
||||
const download = getRequestFailureDetails("抠图处理失败:AOS结果下载超时,任务已完成(任务 task-1)");
|
||||
expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" });
|
||||
expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" });
|
||||
expect(download.recommendation).toContain("取回原任务结果");
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user