route.ts 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. /**
  2. * Image Generation API
  3. *
  4. * Generates an image from a text prompt using the specified provider.
  5. * Called by the client during media generation after slides are produced.
  6. *
  7. * POST /api/generate/image
  8. *
  9. * Headers:
  10. * x-image-provider: ImageProviderId (default: 'seedream')
  11. * x-api-key: string (optional, server fallback)
  12. * x-base-url: string (optional, server fallback)
  13. *
  14. * Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? }
  15. * Response: { success: boolean, result?: ImageGenerationResult, error?: string }
  16. */
  17. import { NextRequest } from 'next/server';
  18. import { generateImage, aspectRatioToDimensions } from '@/lib/media/image-providers';
  19. import { resolveImageApiKey, resolveImageBaseUrl } from '@/lib/server/provider-config';
  20. import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types';
  21. import { createLogger } from '@/lib/logger';
  22. import { apiError, apiSuccess } from '@/lib/server/api-response';
  23. import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
  24. const log = createLogger('ImageGeneration API');
  25. export const maxDuration = 60;
  26. export async function POST(request: NextRequest) {
  27. try {
  28. const body = (await request.json()) as ImageGenerationOptions;
  29. if (!body.prompt) {
  30. return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
  31. }
  32. const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId;
  33. const clientApiKey = request.headers.get('x-api-key') || undefined;
  34. const clientBaseUrl = request.headers.get('x-base-url') || undefined;
  35. const clientModel = request.headers.get('x-image-model') || undefined;
  36. if (clientBaseUrl && process.env.NODE_ENV === 'production') {
  37. const ssrfError = await validateUrlForSSRF(clientBaseUrl);
  38. if (ssrfError) {
  39. return apiError('INVALID_URL', 403, ssrfError);
  40. }
  41. }
  42. const apiKey = clientBaseUrl
  43. ? clientApiKey || ''
  44. : resolveImageApiKey(providerId, clientApiKey);
  45. if (!apiKey) {
  46. return apiError(
  47. 'MISSING_API_KEY',
  48. 401,
  49. `No API key configured for image provider: ${providerId}`,
  50. );
  51. }
  52. const baseUrl = clientBaseUrl ? clientBaseUrl : resolveImageBaseUrl(providerId, clientBaseUrl);
  53. // Resolve dimensions from aspect ratio if not explicitly set
  54. if (!body.width && !body.height && body.aspectRatio) {
  55. const dims = aspectRatioToDimensions(body.aspectRatio);
  56. body.width = dims.width;
  57. body.height = dims.height;
  58. }
  59. log.info(
  60. `Generating image: provider=${providerId}, model=${clientModel || 'default'}, ` +
  61. `prompt="${body.prompt.slice(0, 80)}...", size=${body.width ?? 'auto'}x${body.height ?? 'auto'}`,
  62. );
  63. const result = await generateImage({ providerId, apiKey, baseUrl, model: clientModel }, body);
  64. return apiSuccess({ result });
  65. } catch (error) {
  66. const message = error instanceof Error ? error.message : String(error);
  67. // Detect content safety filter rejections (e.g. Seedream OutputImageSensitiveContentDetected)
  68. if (message.includes('SensitiveContent') || message.includes('sensitive information')) {
  69. log.warn(`Image blocked by content safety filter: ${message}`);
  70. return apiError('CONTENT_SENSITIVE', 400, message);
  71. }
  72. log.error(
  73. `Image generation failed [provider=${request.headers.get('x-image-provider') ?? 'seedream'}, model=${request.headers.get('x-image-model') ?? 'default'}]:`,
  74. error,
  75. );
  76. return apiError('INTERNAL_ERROR', 500, message);
  77. }
  78. }