fix(image-tools): restore dedicated processing fallbacks

This commit is contained in:
134 2026-08-11 13:47:13 +08:00
parent 835b60f61c
commit 4a6afb753d
3 changed files with 373 additions and 29 deletions

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import {
createMediaAssetRelation,
getAiProvidersByType,
getMediaAssetsByUserAndUrls,
getSystemSetting,
upsertMediaAsset,
@ -10,6 +11,7 @@ import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { createFalClient } from "@fal-ai/client";
// 变清晰API配置类型
type EnhanceApiConfig = {
@ -319,6 +321,59 @@ async function processWithRealESRGANBase64(
};
}
async function processWithFalEsrgan(
imageBase64: string,
requestedScaleFactor: number,
): Promise<{ imageBase64: string; processingTime: number }> {
const providers = await getAiProvidersByType({
type: "fal-image",
enabledOnly: true,
});
const provider = providers.find((item) => item.apiKey?.trim());
if (!provider?.apiKey) {
throw new Error("未配置可用的 FAL 专用高清服务");
}
const scale = Math.max(2, Math.min(8, requestedScaleFactor || 4));
const model = scale <= 2 ? "RealESRGAN_x2plus" : "RealESRGAN_x4plus";
const credentials = provider.apiKey
.trim()
.replace(/^(?:Key|Bearer)\s+/i, "");
const fal = createFalClient({ credentials });
const startedAt = Date.now();
const result: any = await fal.subscribe("fal-ai/esrgan", {
input: {
image_url: `data:image/jpeg;base64,${imageBase64}`,
scale,
model,
output_format: "png",
},
logs: true,
timeout: 180_000,
});
const outputUrl = result?.data?.image?.url || result?.image?.url;
if (!outputUrl) {
throw new Error("FAL ESRGAN 专用高清服务未返回图片");
}
const outputResponse = await fetch(outputUrl, {
cache: "no-store",
signal: AbortSignal.timeout(120_000),
});
if (!outputResponse.ok) {
throw new Error(
`FAL ESRGAN 高清结果下载失败(HTTP ${outputResponse.status})`,
);
}
return {
imageBase64: Buffer.from(await outputResponse.arrayBuffer()).toString(
"base64",
),
processingTime: (Date.now() - startedAt) / 1000,
};
}
async function processWithVip98kEnhance(params: {
userId: string;
imageBase64: string;
@ -634,6 +689,42 @@ async function finalizeEnhanceTaskResult(params: {
};
}
async function executeFalEsrganFallback(params: {
userId: string;
imageBase64: string;
prompt: string;
chatId: string | null;
messageId: string | null;
sourceImageUrl: string | null;
requestedScaleFactor?: number;
reason: string;
}) {
const scaleFactor = params.requestedScaleFactor || 4;
console.warn("[Enhance-FAL-ESRGAN] 启用专用高清备用链路", {
reason: params.reason.slice(0, 500),
scaleFactor,
});
const result = await processWithFalEsrgan(params.imageBase64, scaleFactor);
const payload = await finalizeEnhanceTaskResult({
userId: params.userId,
taskResult: {
status: 200,
data: { image_base64: result.imageBase64 },
},
prompt: params.prompt || "图片变清晰处理",
chatId: params.chatId,
messageId: params.messageId,
sourceImageUrls: params.sourceImageUrl
? [params.sourceImageUrl]
: undefined,
});
return {
...payload,
processing_time: result.processingTime,
api_type: "fal-esrgan",
};
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -1026,6 +1117,21 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error("[Enhance-RealESRGAN] ❌ 处理失败:", error);
try {
const fallbackPayload = await executeFalEsrganFallback({
userId,
imageBase64,
prompt,
chatId,
messageId,
sourceImageUrl,
requestedScaleFactor,
reason: error instanceof Error ? error.message : "RealESRGAN 备用服务失败",
});
return NextResponse.json(fallbackPayload);
} catch (falError) {
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
}
return NextResponse.json(
{
error: "Real-ESRGAN变清晰处理失败",
@ -1180,6 +1286,22 @@ export async function POST(request: NextRequest) {
enhanceUrl,
processingTime,
});
try {
const fallbackPayload = await executeFalEsrganFallback({
userId,
imageBase64,
prompt,
chatId,
messageId,
sourceImageUrl,
requestedScaleFactor,
reason: fetchError instanceof Error ? fetchError.message : "AOS 高清任务创建失败",
});
return NextResponse.json(fallbackPayload);
} catch (falError) {
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
}
// 检查是否是网络连接错误
if (fetchError instanceof Error) {
@ -1246,6 +1368,21 @@ export async function POST(request: NextRequest) {
return buildEnhanceTaskPendingResponse(taskId);
}
const detail = pollError instanceof Error ? pollError.message : "轮询失败";
try {
const fallbackPayload = await executeFalEsrganFallback({
userId,
imageBase64,
prompt,
chatId,
messageId,
sourceImageUrl,
requestedScaleFactor,
reason: detail,
});
return NextResponse.json(fallbackPayload);
} catch (falError) {
console.error("[Enhance-FAL-ESRGAN] 专用高清备用链路失败:", falError);
}
const friendlyMessage = getAosFriendlyErrorMessage(detail);
return NextResponse.json(
{ error: "变清晰处理失败", detail: friendlyMessage || detail },

View File

@ -12,6 +12,10 @@ import {
} from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { FAL_NANO_BANANA_2_EDIT_MODEL_ID } from "@/lib/ai/fal-image-models";
export const maxDuration = 600;
// PicWish API 需要公网可访问的 HTTP/HTTPS URL
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
@ -115,11 +119,10 @@ function buildWatermarkTaskPendingResponse(taskId: string) {
);
}
async function finalizeWatermarkTaskResult(params: {
taskId: string;
async function persistWatermarkResult(params: {
processedImageUrl: string;
userId: string;
startedAt?: number;
apiKey: string;
sourceImageUrls?: string[];
}): Promise<{
success: boolean;
@ -127,17 +130,8 @@ async function finalizeWatermarkTaskResult(params: {
thumbnail_url: string;
processing_time?: number;
}> {
const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS);
if (pollResult.pending) {
throw new Error("WATERMARK_TASK_PENDING");
}
if (!pollResult.success || !pollResult.imageUrl) {
throw new Error(pollResult.error || "去水印处理失败");
}
const processedImageUrl = pollResult.imageUrl;
try {
const imageResponse = await fetch(processedImageUrl);
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
}
@ -217,7 +211,7 @@ async function finalizeWatermarkTaskResult(params: {
}
}
} catch (assetError) {
console.error("[WatermarkRemove] ⚠️ MediaAsset 注册失败:", assetError);
console.error("[WatermarkRemove] MediaAsset 注册失败:", assetError);
}
}
@ -232,14 +226,85 @@ async function finalizeWatermarkTaskResult(params: {
console.error("[WatermarkRemove] 上传结果失败,回退原始URL:", uploadError);
return {
success: true,
original_url: processedImageUrl,
thumbnail_url: processedImageUrl,
original_url: params.processedImageUrl,
thumbnail_url: params.processedImageUrl,
processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
};
}
}
function shouldUseFalWatermarkFallback(status: number, detail: string) {
return (
status === 401 ||
status === 402 ||
status === 403 ||
status === 429 ||
status >= 500 ||
/quota|credit|balance|exceed|expired|invalid|unauthor/i.test(detail)
);
}
async function removeWatermarkWithFal(params: {
imageUrl: string;
userId: string;
startedAt: number;
sourceImageUrls?: string[];
}) {
console.warn("[WatermarkRemove] AOS unavailable; using FAL Nano Banana 2 edit fallback");
const result = await generateCanvasImage({
prompt: [
"仅移除图片中后期叠加的水印、平台角标和覆盖式文字标记。",
"必须完整保留原图主体、商品、背景、构图、尺寸、颜色、光影和清晰度。",
"用周围真实纹理自然补全被遮挡区域,不得新增文字、标志或其他物体。",
].join(""),
model: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
referenceImages: [params.imageUrl],
allowFailover: false,
debugLabel: "watermark-remove-fal-fallback",
});
const processedImageUrl = result.images[0]?.originalUrl;
if (!processedImageUrl) {
throw new Error("FAL 去水印未返回可用图片");
}
return persistWatermarkResult({
processedImageUrl,
userId: params.userId,
startedAt: params.startedAt,
sourceImageUrls: params.sourceImageUrls,
});
}
async function finalizeWatermarkTaskResult(params: {
taskId: string;
userId: string;
startedAt?: number;
apiKey: string;
sourceImageUrls?: string[];
}): Promise<{
success: boolean;
original_url: string;
thumbnail_url: string;
processing_time?: number;
}> {
const pollResult = await pollForResult(params.taskId, params.apiKey, WATERMARK_SYNC_WAIT_TIMEOUT_MS);
if (pollResult.pending) {
throw new Error("WATERMARK_TASK_PENDING");
}
if (!pollResult.success || !pollResult.imageUrl) {
throw new Error(pollResult.error || "去水印处理失败");
}
return persistWatermarkResult({
processedImageUrl: pollResult.imageUrl,
userId: params.userId,
startedAt: params.startedAt,
sourceImageUrls: params.sourceImageUrls,
});
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -306,13 +371,6 @@ export async function POST(request: NextRequest) {
// 获取API配置
const config = await getWatermarkRemoveConfig();
if (!config?.apiKey) {
return NextResponse.json(
{ error: "去水印服务未配置,请联系管理员在系统设置中配置API密钥" },
{ status: 500 }
);
}
console.log("[WatermarkRemove] 使用原图URL:", image_url.substring(0, 100) + "...");
// 处理图片URL,确保获取公网可访问的URL
@ -396,6 +454,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const sourceImageUrls =
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
? [image_url]
: undefined;
if (!config?.apiKey) {
const fallbackResult = await removeWatermarkWithFal({
imageUrl: publicImageUrl,
userId: session.user.id,
startedAt: startTime,
sourceImageUrls,
});
if (pointsCharge.chargeable) {
await pointsCharge.consume({ note: "FAL 去水印备用链路消耗积分" });
}
return NextResponse.json(fallbackResult);
}
// 创建去水印任务
const formData = new FormData();
formData.append("sync", "0");
@ -415,8 +491,24 @@ export async function POST(request: NextRequest) {
if (!createResponse.ok) {
const errorText = await createResponse.text();
console.error("[WatermarkRemove] 创建任务失败:", errorText);
if (shouldUseFalWatermarkFallback(createResponse.status, errorText)) {
try {
const fallbackResult = await removeWatermarkWithFal({
imageUrl: publicImageUrl,
userId: session.user.id,
startedAt: startTime,
sourceImageUrls,
});
if (pointsCharge.chargeable) {
await pointsCharge.consume({ note: "FAL 去水印备用链路消耗积分" });
}
return NextResponse.json(fallbackResult);
} catch (fallbackError) {
console.error("[WatermarkRemove] FAL 备用链路失败:", fallbackError);
}
}
return NextResponse.json(
{ error: "创建去水印任务失败" },
{ error: "去水印服务暂时不可用,请稍后重试" },
{ status: 500 }
);
}
@ -448,10 +540,7 @@ export async function POST(request: NextRequest) {
userId: session.user.id,
startedAt: startTime,
apiKey: config.apiKey,
sourceImageUrls:
typeof image_url === "string" && /^https?:\/\//i.test(image_url)
? [image_url]
: undefined,
sourceImageUrls,
});
return NextResponse.json(result);
} catch (error) {

View File

@ -18,6 +18,16 @@ type ExtendConfig = {
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const DASHSCOPE_MIN_IMAGE_EDGE = 512;
const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const DASHSCOPE_SUPPORTED_IMAGE_FORMATS = new Set([
"jpeg",
"jpg",
"png",
"heif",
"webp",
]);
export interface ExecuteExtendJobParams {
userId: string;
@ -126,6 +136,105 @@ async function uploadImageToS3ForAPI(imageBase64: string): Promise<string> {
}
}
async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<string> {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
"下载待拓展图片失败",
502,
`${response.status} ${response.statusText}`,
);
}
const sourceBuffer = Buffer.from(await response.arrayBuffer());
const sharp = await import("sharp");
const image = sharp.default(sourceBuffer, { failOn: "none" });
const metadata = await image.metadata();
const width = Number(metadata.width || 0);
const height = Number(metadata.height || 0);
const format = String(metadata.format || "").toLowerCase();
if (!width || !height) {
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
}
const withinEdgeLimits =
width >= DASHSCOPE_MIN_IMAGE_EDGE &&
height >= DASHSCOPE_MIN_IMAGE_EDGE &&
width <= DASHSCOPE_MAX_IMAGE_EDGE &&
height <= DASHSCOPE_MAX_IMAGE_EDGE;
const withinFileLimit = sourceBuffer.length <= DASHSCOPE_MAX_IMAGE_BYTES;
if (
withinEdgeLimits &&
withinFileLimit &&
DASHSCOPE_SUPPORTED_IMAGE_FORMATS.has(format)
) {
return imageUrl;
}
const downscale = Math.min(
1,
DASHSCOPE_MAX_IMAGE_EDGE / width,
DASHSCOPE_MAX_IMAGE_EDGE / height,
);
let targetWidth = Math.max(1, Math.round(width * downscale));
let targetHeight = Math.max(1, Math.round(height * downscale));
if (Math.min(targetWidth, targetHeight) < DASHSCOPE_MIN_IMAGE_EDGE) {
const upscale =
DASHSCOPE_MIN_IMAGE_EDGE / Math.min(targetWidth, targetHeight);
targetWidth = Math.max(1, Math.round(targetWidth * upscale));
targetHeight = Math.max(1, Math.round(targetHeight * upscale));
}
const resizeScale = Math.min(
1,
DASHSCOPE_MAX_IMAGE_EDGE / targetWidth,
DASHSCOPE_MAX_IMAGE_EDGE / targetHeight,
);
targetWidth = Math.max(1, Math.round(targetWidth * resizeScale));
targetHeight = Math.max(1, Math.round(targetHeight * resizeScale));
const canvasWidth = Math.max(DASHSCOPE_MIN_IMAGE_EDGE, targetWidth);
const canvasHeight = Math.max(DASHSCOPE_MIN_IMAGE_EDGE, targetHeight);
const left = Math.floor((canvasWidth - targetWidth) / 2);
const top = Math.floor((canvasHeight - targetHeight) / 2);
const normalizedBuffer = await image
.rotate()
.resize(targetWidth, targetHeight, { fit: "fill" })
.flatten({ background: "#ffffff" })
.extend({
left,
right: canvasWidth - targetWidth - left,
top,
bottom: canvasHeight - targetHeight - top,
background: "#ffffff",
})
.jpeg({ quality: 88, mozjpeg: true })
.toBuffer();
const normalizedArrayBuffer = normalizedBuffer.buffer.slice(
normalizedBuffer.byteOffset,
normalizedBuffer.byteOffset + normalizedBuffer.byteLength,
) as ArrayBuffer;
const uploaded = await uploadFile(
`extend-dashscope-input-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`,
normalizedArrayBuffer,
);
console.info("[Extend Service] Normalized DashScope input image", {
sourceWidth: width,
sourceHeight: height,
sourceBytes: sourceBuffer.length,
sourceFormat: format,
targetWidth: canvasWidth,
targetHeight: canvasHeight,
targetBytes: normalizedBuffer.length,
});
return uploaded.url;
}
function isTemporaryVip98kExpansionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
@ -365,8 +474,17 @@ export async function executeExtendJob(
throw new ExtendServiceError("无法获取输入图片URL", 400);
}
const preparedInputImageUrl =
config.provider === "vip98k"
? inputImageUrl
: await normalizeDashscopeExpansionInput(inputImageUrl);
const startTime = Date.now();
const extendedImageUrl = await extendImage(inputImageUrl, config, xScale, yScale);
const extendedImageUrl = await extendImage(
preparedInputImageUrl,
config,
xScale,
yScale,
);
const imageResponse = await fetch(extendedImageUrl);
if (!imageResponse.ok) {
throw new ExtendServiceError(