diff --git a/app/api/external/image/generate/route.ts b/app/api/external/image/generate/route.ts index b37f18ef6..b6bf59261 100644 --- a/app/api/external/image/generate/route.ts +++ b/app/api/external/image/generate/route.ts @@ -12,6 +12,7 @@ import { ensureHostedReferenceImageUrls, generateImagesWithFailover, getRequesterIp, + normalizeExternalImageApiImageSize, resolveExternalImageApiOwnerUserId, serializeExternalImageApiModel, uploadExternalImageApiFile, @@ -298,10 +299,11 @@ export async function POST(request: Request) { try { releaseExternalImageApiSlot = await acquireExternalImageApiSlot(); const payload = await parseRequestPayload(request); + const imageSize = normalizeExternalImageApiImageSize(payload.imageSize); const candidateModels = await buildExternalImageApiModelCandidates({ referenceImageCount: payload.referenceImages?.length || 0, aspectRatio: payload.aspectRatio, - imageSize: payload.imageSize, + imageSize, }); const selectedModel = candidateModels[0]; const ownerUserId = await resolveExternalImageApiOwnerUserId( @@ -323,12 +325,13 @@ export async function POST(request: Request) { negativePrompt: payload.negativePrompt || null, imageCount: payload.imageCount || 1, aspectRatio: payload.aspectRatio || null, - imageSize: payload.imageSize || null, + imageSize, status: "pending", requesterIp, requesterUserAgent, requestPayload: { ...payload, + imageSize, candidateModels: candidateModels.map(serializeExternalImageApiModel), apiKeyFingerprint: buildExternalImageApiKeyFingerprint( authResult.accessKey, @@ -345,7 +348,7 @@ export async function POST(request: Request) { negativePrompt: payload.negativePrompt || null, imageCount: payload.imageCount || 1, aspectRatio: payload.aspectRatio || null, - imageSize: payload.imageSize || null, + imageSize, referenceImageUrls: payload.referenceImages || [], }); const actualModel = generationResult.usedModel; @@ -367,7 +370,7 @@ export async function POST(request: Request) { optimizedPrompt: payload.prompt, requestedImageCount: payload.imageCount || images.length, aspectRatio: payload.aspectRatio, - imageSize: payload.imageSize, + imageSize, sourceImageUrls: payload.referenceImages || [], relationType: "edited_from", skipBilling: true, diff --git a/lib/external-image-api/service.ts b/lib/external-image-api/service.ts index 4d244ac60..8e8cdd62f 100644 --- a/lib/external-image-api/service.ts +++ b/lib/external-image-api/service.ts @@ -1,5 +1,6 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { sql } from "drizzle-orm"; +import sharp from "sharp"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification"; import { db } from "@/lib/db"; @@ -64,12 +65,25 @@ type ExternalImageApiGeneratedImage = { imageSize: string; }; +type ImageDimensionProbe = { + width: number; + height: number; + bytes: number; + contentType: string; +}; + +export const DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE = "1K"; + const logger = createLogger("external-image-api"); function normalizeText(value: unknown) { return String(value || "").trim(); } +export function normalizeExternalImageApiImageSize(value?: string | null) { + return normalizeText(value) || DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE; +} + function mimeTypeToExtension(mimeType: string) { const normalized = mimeType.toLowerCase(); if (normalized.includes("png")) return "png"; @@ -195,6 +209,92 @@ async function ensureHostedImageUrl(imageUrl: string): Promise { return uploadRemoteImageToStorage(normalizedUrl); } +async function probeRemoteImageDimensions( + imageUrl: string, +): Promise { + const normalizedUrl = normalizeText(imageUrl); + if (!normalizedUrl) { + throw new Error("图片地址为空"); + } + + const response = await fetch(normalizedUrl, { + method: "GET", + cache: "no-store", + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error(`图片地址不可访问: HTTP ${response.status}`); + } + + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + if (contentType && !contentType.startsWith("image/")) { + throw new Error(`图片地址返回了非图片内容: ${contentType}`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (!buffer.length) { + throw new Error("图片内容为空"); + } + + const metadata = await sharp(buffer).metadata(); + if (!metadata.width || !metadata.height) { + throw new Error("无法识别图片尺寸"); + } + + return { + width: metadata.width, + height: metadata.height, + bytes: buffer.byteLength, + contentType, + }; +} + +function getMinimumGeneratedOriginalLongestEdge(imageSize?: string | null) { + const normalized = normalizeText(imageSize).toUpperCase(); + const exactSize = normalized.match(/(\d{3,5})\s*[X×]\s*(\d{3,5})/); + if (exactSize) { + const width = Number(exactSize[1]); + const height = Number(exactSize[2]); + if (Number.isFinite(width) && Number.isFinite(height)) { + return Math.max(768, Math.round(Math.max(width, height) * 0.65)); + } + } + + if (normalized.includes("4K")) return 1800; + if (normalized.includes("2K")) return 1000; + if (normalized.includes("1K")) return 768; + return 768; +} + +async function assertGeneratedOriginalImagesAreLargeEnough( + images: ExternalImageApiGeneratedImage[], + imageSize?: string | null, +) { + const minimumLongestEdge = getMinimumGeneratedOriginalLongestEdge(imageSize); + const requestedLabel = normalizeExternalImageApiImageSize(imageSize); + const probeCache = new Map>(); + + for (const image of images) { + const originalUrl = normalizeText(image.originalUrl || image.url); + if (!originalUrl) { + throw new Error("模型返回结果缺少原图地址"); + } + + if (!probeCache.has(originalUrl)) { + probeCache.set(originalUrl, probeRemoteImageDimensions(originalUrl)); + } + + const dimensions = await probeCache.get(originalUrl)!; + const longestEdge = Math.max(dimensions.width, dimensions.height); + if (longestEdge < minimumLongestEdge) { + throw new Error( + `模型返回原图尺寸过小: ${dimensions.width}x${dimensions.height},未达到 ${requestedLabel} 输出要求`, + ); + } + } +} + function toRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as Record; @@ -497,7 +597,7 @@ function parseGeneratedImageMarkdownMeta(alt: string, fallbackUrl = "") { const fallbackOriginalUrl = inferOriginalImageUrl(normalizeText(fallbackUrl)); const originalUrl = extractOriginalImageUrlFromAlt(normalizedAlt, fallbackUrl) || fallbackOriginalUrl; - let imageSize = "2K"; + let imageSize = DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE; if (normalizedAlt.includes("thumb|||")) { const parts = normalizedAlt @@ -519,7 +619,7 @@ function parseGeneratedImageMarkdownMeta(alt: string, fallbackUrl = "") { export async function ensureHostedImageMarkdown( markdown: string, - imageSize = "2K", + imageSize = DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE, ) { const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim; const matches = [...markdown.matchAll(pattern)]; @@ -576,7 +676,7 @@ export function extractGeneratedImagesFromMarkdown(markdown: string) { const metadata = parseGeneratedImageMarkdownMeta(alt, thumbnailUrl); const originalUrl = metadata.originalUrl || inferOriginalImageUrl(thumbnailUrl) || thumbnailUrl; - const imageSize = metadata.imageSize || "2K"; + const imageSize = metadata.imageSize || DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE; return { id: index + 1, url: originalUrl, @@ -920,7 +1020,7 @@ export async function generateImagesWithFailover(params: { const hostedMarkdown = await ensureHostedImageMarkdown( markdown, - params.imageSize || "2K", + normalizeExternalImageApiImageSize(params.imageSize), ); const images = extractGeneratedImagesFromMarkdown(hostedMarkdown); @@ -928,6 +1028,11 @@ export async function generateImagesWithFailover(params: { throw new Error("模型返回结果里未解析到图片"); } + await assertGeneratedOriginalImagesAreLargeEnough( + images, + normalizeExternalImageApiImageSize(params.imageSize), + ); + attempts.push({ modelId: candidate.model.id, modelRuntimeId: candidate.model.modelId,