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,
|
ensureHostedReferenceImageUrls,
|
||||||
generateImagesWithFailover,
|
generateImagesWithFailover,
|
||||||
getRequesterIp,
|
getRequesterIp,
|
||||||
|
normalizeExternalImageApiImageSize,
|
||||||
resolveExternalImageApiOwnerUserId,
|
resolveExternalImageApiOwnerUserId,
|
||||||
serializeExternalImageApiModel,
|
serializeExternalImageApiModel,
|
||||||
uploadExternalImageApiFile,
|
uploadExternalImageApiFile,
|
||||||
@ -298,10 +299,11 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
releaseExternalImageApiSlot = await acquireExternalImageApiSlot();
|
||||||
const payload = await parseRequestPayload(request);
|
const payload = await parseRequestPayload(request);
|
||||||
|
const imageSize = normalizeExternalImageApiImageSize(payload.imageSize);
|
||||||
const candidateModels = await buildExternalImageApiModelCandidates({
|
const candidateModels = await buildExternalImageApiModelCandidates({
|
||||||
referenceImageCount: payload.referenceImages?.length || 0,
|
referenceImageCount: payload.referenceImages?.length || 0,
|
||||||
aspectRatio: payload.aspectRatio,
|
aspectRatio: payload.aspectRatio,
|
||||||
imageSize: payload.imageSize,
|
imageSize,
|
||||||
});
|
});
|
||||||
const selectedModel = candidateModels[0];
|
const selectedModel = candidateModels[0];
|
||||||
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
const ownerUserId = await resolveExternalImageApiOwnerUserId(
|
||||||
@ -323,12 +325,13 @@ export async function POST(request: Request) {
|
|||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
aspectRatio: payload.aspectRatio || null,
|
aspectRatio: payload.aspectRatio || null,
|
||||||
imageSize: payload.imageSize || null,
|
imageSize,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
requesterIp,
|
requesterIp,
|
||||||
requesterUserAgent,
|
requesterUserAgent,
|
||||||
requestPayload: {
|
requestPayload: {
|
||||||
...payload,
|
...payload,
|
||||||
|
imageSize,
|
||||||
candidateModels: candidateModels.map(serializeExternalImageApiModel),
|
candidateModels: candidateModels.map(serializeExternalImageApiModel),
|
||||||
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
apiKeyFingerprint: buildExternalImageApiKeyFingerprint(
|
||||||
authResult.accessKey,
|
authResult.accessKey,
|
||||||
@ -345,7 +348,7 @@ export async function POST(request: Request) {
|
|||||||
negativePrompt: payload.negativePrompt || null,
|
negativePrompt: payload.negativePrompt || null,
|
||||||
imageCount: payload.imageCount || 1,
|
imageCount: payload.imageCount || 1,
|
||||||
aspectRatio: payload.aspectRatio || null,
|
aspectRatio: payload.aspectRatio || null,
|
||||||
imageSize: payload.imageSize || null,
|
imageSize,
|
||||||
referenceImageUrls: payload.referenceImages || [],
|
referenceImageUrls: payload.referenceImages || [],
|
||||||
});
|
});
|
||||||
const actualModel = generationResult.usedModel;
|
const actualModel = generationResult.usedModel;
|
||||||
@ -367,7 +370,7 @@ export async function POST(request: Request) {
|
|||||||
optimizedPrompt: payload.prompt,
|
optimizedPrompt: payload.prompt,
|
||||||
requestedImageCount: payload.imageCount || images.length,
|
requestedImageCount: payload.imageCount || images.length,
|
||||||
aspectRatio: payload.aspectRatio,
|
aspectRatio: payload.aspectRatio,
|
||||||
imageSize: payload.imageSize,
|
imageSize,
|
||||||
sourceImageUrls: payload.referenceImages || [],
|
sourceImageUrls: payload.referenceImages || [],
|
||||||
relationType: "edited_from",
|
relationType: "edited_from",
|
||||||
skipBilling: true,
|
skipBilling: true,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import sharp from "sharp";
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@ -64,12 +65,25 @@ type ExternalImageApiGeneratedImage = {
|
|||||||
imageSize: string;
|
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");
|
const logger = createLogger("external-image-api");
|
||||||
|
|
||||||
function normalizeText(value: unknown) {
|
function normalizeText(value: unknown) {
|
||||||
return String(value || "").trim();
|
return String(value || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeExternalImageApiImageSize(value?: string | null) {
|
||||||
|
return normalizeText(value) || DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
function mimeTypeToExtension(mimeType: string) {
|
function mimeTypeToExtension(mimeType: string) {
|
||||||
const normalized = mimeType.toLowerCase();
|
const normalized = mimeType.toLowerCase();
|
||||||
if (normalized.includes("png")) return "png";
|
if (normalized.includes("png")) return "png";
|
||||||
@ -195,6 +209,92 @@ async function ensureHostedImageUrl(imageUrl: string): Promise<string> {
|
|||||||
return uploadRemoteImageToStorage(normalizedUrl);
|
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 {
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
@ -497,7 +597,7 @@ function parseGeneratedImageMarkdownMeta(alt: string, fallbackUrl = "") {
|
|||||||
const fallbackOriginalUrl = inferOriginalImageUrl(normalizeText(fallbackUrl));
|
const fallbackOriginalUrl = inferOriginalImageUrl(normalizeText(fallbackUrl));
|
||||||
const originalUrl =
|
const originalUrl =
|
||||||
extractOriginalImageUrlFromAlt(normalizedAlt, fallbackUrl) || fallbackOriginalUrl;
|
extractOriginalImageUrlFromAlt(normalizedAlt, fallbackUrl) || fallbackOriginalUrl;
|
||||||
let imageSize = "2K";
|
let imageSize = DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE;
|
||||||
|
|
||||||
if (normalizedAlt.includes("thumb|||")) {
|
if (normalizedAlt.includes("thumb|||")) {
|
||||||
const parts = normalizedAlt
|
const parts = normalizedAlt
|
||||||
@ -519,7 +619,7 @@ function parseGeneratedImageMarkdownMeta(alt: string, fallbackUrl = "") {
|
|||||||
|
|
||||||
export async function ensureHostedImageMarkdown(
|
export async function ensureHostedImageMarkdown(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
imageSize = "2K",
|
imageSize = DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE,
|
||||||
) {
|
) {
|
||||||
const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
|
const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
|
||||||
const matches = [...markdown.matchAll(pattern)];
|
const matches = [...markdown.matchAll(pattern)];
|
||||||
@ -576,7 +676,7 @@ export function extractGeneratedImagesFromMarkdown(markdown: string) {
|
|||||||
const metadata = parseGeneratedImageMarkdownMeta(alt, thumbnailUrl);
|
const metadata = parseGeneratedImageMarkdownMeta(alt, thumbnailUrl);
|
||||||
const originalUrl =
|
const originalUrl =
|
||||||
metadata.originalUrl || inferOriginalImageUrl(thumbnailUrl) || thumbnailUrl;
|
metadata.originalUrl || inferOriginalImageUrl(thumbnailUrl) || thumbnailUrl;
|
||||||
const imageSize = metadata.imageSize || "2K";
|
const imageSize = metadata.imageSize || DEFAULT_EXTERNAL_IMAGE_API_IMAGE_SIZE;
|
||||||
return {
|
return {
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
@ -920,7 +1020,7 @@ export async function generateImagesWithFailover(params: {
|
|||||||
|
|
||||||
const hostedMarkdown = await ensureHostedImageMarkdown(
|
const hostedMarkdown = await ensureHostedImageMarkdown(
|
||||||
markdown,
|
markdown,
|
||||||
params.imageSize || "2K",
|
normalizeExternalImageApiImageSize(params.imageSize),
|
||||||
);
|
);
|
||||||
const images = extractGeneratedImagesFromMarkdown(hostedMarkdown);
|
const images = extractGeneratedImagesFromMarkdown(hostedMarkdown);
|
||||||
|
|
||||||
@ -928,6 +1028,11 @@ export async function generateImagesWithFailover(params: {
|
|||||||
throw new Error("模型返回结果里未解析到图片");
|
throw new Error("模型返回结果里未解析到图片");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertGeneratedOriginalImagesAreLargeEnough(
|
||||||
|
images,
|
||||||
|
normalizeExternalImageApiImageSize(params.imageSize),
|
||||||
|
);
|
||||||
|
|
||||||
attempts.push({
|
attempts.push({
|
||||||
modelId: candidate.model.id,
|
modelId: candidate.model.id,
|
||||||
modelRuntimeId: candidate.model.modelId,
|
modelRuntimeId: candidate.model.modelId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user