route.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. /**
  2. * Video Generation API
  3. *
  4. * Generates a video from a text prompt using the specified provider.
  5. * Uses async task pattern (submit → poll) so maxDuration is set to 5 minutes.
  6. *
  7. * POST /api/generate/video
  8. *
  9. * Headers:
  10. * x-video-provider: VideoProviderId (default: 'seedance')
  11. * x-video-model: string (optional model override)
  12. * x-api-key: string (optional, server fallback)
  13. * x-base-url: string (optional, server fallback)
  14. *
  15. * Body: { prompt, duration?, aspectRatio?, resolution? }
  16. * Response: { success: boolean, result?: VideoGenerationResult, error?: string }
  17. */
  18. import { NextRequest } from 'next/server';
  19. import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers';
  20. import { resolveVideoApiKey, resolveVideoBaseUrl } from '@/lib/server/provider-config';
  21. import type { VideoProviderId, VideoGenerationOptions } from '@/lib/media/types';
  22. import { createLogger } from '@/lib/logger';
  23. import { apiError, apiSuccess } from '@/lib/server/api-response';
  24. import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
  25. const log = createLogger('VideoGeneration API');
  26. export const maxDuration = 300;
  27. export async function POST(request: NextRequest) {
  28. try {
  29. const body = (await request.json()) as VideoGenerationOptions;
  30. if (!body.prompt) {
  31. return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
  32. }
  33. const providerId = (request.headers.get('x-video-provider') || 'seedance') as VideoProviderId;
  34. const clientApiKey = request.headers.get('x-api-key') || undefined;
  35. const clientBaseUrl = request.headers.get('x-base-url') || undefined;
  36. const clientModel = request.headers.get('x-video-model') || undefined;
  37. if (clientBaseUrl && process.env.NODE_ENV === 'production') {
  38. const ssrfError = await validateUrlForSSRF(clientBaseUrl);
  39. if (ssrfError) {
  40. return apiError('INVALID_URL', 403, ssrfError);
  41. }
  42. }
  43. const apiKey = clientBaseUrl
  44. ? clientApiKey || ''
  45. : resolveVideoApiKey(providerId, clientApiKey);
  46. if (!apiKey) {
  47. return apiError(
  48. 'MISSING_API_KEY',
  49. 401,
  50. `No API key configured for video provider: ${providerId}`,
  51. );
  52. }
  53. const baseUrl = clientBaseUrl ? clientBaseUrl : resolveVideoBaseUrl(providerId, clientBaseUrl);
  54. // Normalize options against provider capabilities
  55. const options = normalizeVideoOptions(providerId, body);
  56. log.info(
  57. `Generating video: provider=${providerId}, model=${clientModel || 'default'}, ` +
  58. `prompt="${body.prompt.slice(0, 80)}...", duration=${options.duration ?? 'auto'}, ` +
  59. `aspect=${options.aspectRatio ?? 'auto'}, resolution=${options.resolution ?? 'auto'}`,
  60. );
  61. const result = await generateVideo(
  62. { providerId, apiKey, baseUrl, model: clientModel },
  63. options,
  64. );
  65. log.info(
  66. `Video generated: url=${result.url ? 'yes' : 'no'}, ${result.width}x${result.height}, ${result.duration}s`,
  67. );
  68. return apiSuccess({ result });
  69. } catch (error) {
  70. const message = error instanceof Error ? error.message : String(error);
  71. // Detect content safety filter rejections (e.g. Seedance SensitiveContent errors)
  72. if (message.includes('SensitiveContent') || message.includes('sensitive information')) {
  73. log.warn(`Video blocked by content safety filter: ${message}`);
  74. return apiError('CONTENT_SENSITIVE', 400, message);
  75. }
  76. log.error(
  77. `Video generation failed [provider=${request.headers.get('x-video-provider') ?? 'kling'}, model=${request.headers.get('x-video-model') ?? 'default'}]:`,
  78. error,
  79. );
  80. return apiError('INTERNAL_ERROR', 500, message);
  81. }
  82. }