fix: validate external api image resolution
This commit is contained in:
parent
65aac6c14d
commit
71dcd5026b
11
app/api/external/image/generate/route.ts
vendored
11
app/api/external/image/generate/route.ts
vendored
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user