From 4a6afb753d0c433ea505ef25fede3d6922e1db6f Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 11 Aug 2026 13:47:13 +0800 Subject: [PATCH] fix(image-tools): restore dedicated processing fallbacks --- app/api/enhance/route.ts | 137 ++++++++++++++++++++++++++++ app/api/watermark-remove/route.ts | 145 ++++++++++++++++++++++++------ lib/ai/extend/service.ts | 120 ++++++++++++++++++++++++- 3 files changed, 373 insertions(+), 29 deletions(-) diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts index 2f998684b..ea888b293 100644 --- a/app/api/enhance/route.ts +++ b/app/api/enhance/route.ts @@ -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 }, diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index b22886db2..2c1227008 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -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) { diff --git a/lib/ai/extend/service.ts b/lib/ai/extend/service.ts index 76c2d238c..f2189e236 100644 --- a/lib/ai/extend/service.ts +++ b/lib/ai/extend/service.ts @@ -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 { } } +async function normalizeDashscopeExpansionInput(imageUrl: string): Promise { + 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(