fix: validate external api image resolution

This commit is contained in:
134 2026-06-15 18:17:50 +08:00
parent 65aac6c14d
commit 71dcd5026b
2 changed files with 116 additions and 8 deletions

View File

@ -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,

View File

@ -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<string> {
return uploadRemoteImageToStorage(normalizedUrl);
}
async function probeRemoteImageDimensions(
imageUrl: string,
): Promise<ImageDimensionProbe> {
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<string, Promise<ImageDimensionProbe>>();
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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@ -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,