thu-maic--openmaic
115 行
4.3 KiB
TypeScript
115 行
4.3 KiB
TypeScript
/**
|
|
* Image Generation API
|
|
*
|
|
* Generates an image from a text prompt using the specified provider.
|
|
* Called by the client during media generation after slides are produced.
|
|
*
|
|
* POST /api/generate/image
|
|
*
|
|
* Headers:
|
|
* x-image-provider: ImageProviderId (default: 'seedream')
|
|
* x-api-key: string (optional, server fallback)
|
|
* x-base-url: string (optional, server fallback)
|
|
*
|
|
* Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? }
|
|
* Response: { success: boolean, result?: ImageGenerationResult, error?: string }
|
|
*/
|
|
|
|
import { NextRequest } from 'next/server';
|
|
import { recordGenerationUsage } from '@/lib/server/usage-storage';
|
|
import {
|
|
generateImage,
|
|
aspectRatioToDimensions,
|
|
IMAGE_PROVIDERS,
|
|
} from '@/lib/media/image-providers';
|
|
import {
|
|
isServerConfiguredProvider,
|
|
resolveImageApiKey,
|
|
resolveImageBaseUrl,
|
|
} from '@/lib/server/provider-config';
|
|
import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
|
|
|
const log = createLogger('ImageGeneration API');
|
|
|
|
// The ComfyUI adapter polls up to GENERATION_TIMEOUT_MS (5 min) and real
|
|
// workflows can take 3–5 min. 60s would let platforms that enforce maxDuration
|
|
// (e.g. Vercel) kill the request ~4 min before the adapter finishes. 300s is
|
|
// the practical ceiling on most managed platforms and matches the poll budget.
|
|
// (Self-hosted Node servers ignore this value entirely.)
|
|
export const maxDuration = 300;
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const body = (await request.json()) as ImageGenerationOptions;
|
|
|
|
if (!body.prompt) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
|
|
}
|
|
|
|
const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId;
|
|
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
|
|
const managed = isServerConfiguredProvider('image', providerId);
|
|
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
|
|
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
|
|
const clientModel = request.headers.get('x-image-model') || undefined;
|
|
|
|
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
|
|
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
|
|
const apiKey = resolveImageApiKey(providerId, clientApiKey);
|
|
const provider = IMAGE_PROVIDERS[providerId];
|
|
if (provider?.requiresApiKey && !apiKey) {
|
|
return apiError(
|
|
'MISSING_API_KEY',
|
|
401,
|
|
`No API key configured for image provider: ${providerId}`,
|
|
);
|
|
}
|
|
|
|
const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
|
|
|
|
// Resolve dimensions from aspect ratio if not explicitly set
|
|
if (!body.width && !body.height && body.aspectRatio) {
|
|
const dims = aspectRatioToDimensions(body.aspectRatio);
|
|
body.width = dims.width;
|
|
body.height = dims.height;
|
|
}
|
|
|
|
log.info(
|
|
`Generating image: provider=${providerId}, model=${clientModel || 'default'}, ` +
|
|
`prompt="${body.prompt.slice(0, 80)}...", size=${body.width ?? 'auto'}x${body.height ?? 'auto'}`,
|
|
);
|
|
|
|
const result = await generateImage({ providerId, apiKey, baseUrl, model: clientModel }, body);
|
|
|
|
void recordGenerationUsage({
|
|
kind: 'image',
|
|
unit: 'image',
|
|
providerId,
|
|
modelId: clientModel,
|
|
quantity: 1,
|
|
});
|
|
|
|
return apiSuccess({ result });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
// Detect content safety filter rejections (e.g. Seedream OutputImageSensitiveContentDetected)
|
|
if (message.includes('SensitiveContent') || message.includes('sensitive information')) {
|
|
log.warn(`Image blocked by content safety filter: ${message}`);
|
|
return apiError('CONTENT_SENSITIVE', 400, message);
|
|
}
|
|
log.error(
|
|
`Image generation failed [provider=${request.headers.get('x-image-provider') ?? 'seedream'}, model=${request.headers.get('x-image-model') ?? 'default'}]:`,
|
|
error,
|
|
);
|
|
return apiError('INTERNAL_ERROR', 500, message);
|
|
}
|
|
}
|