Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

115 lines
4.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 35 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);
}
}