classroom-media-generation.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * Server-side media and TTS generation for classrooms.
  3. *
  4. * Generates image/video files and TTS audio for a classroom,
  5. * writes them to disk, and returns serving URL mappings.
  6. */
  7. import { promises as fs } from 'fs';
  8. import path from 'path';
  9. import { createLogger } from '@/lib/logger';
  10. import { CLASSROOMS_DIR } from '@/lib/server/classroom-storage';
  11. import { generateImage } from '@/lib/media/image-providers';
  12. import { generateVideo, normalizeVideoOptions } from '@/lib/media/video-providers';
  13. import { generateTTS } from '@/lib/audio/tts-providers';
  14. import { DEFAULT_TTS_VOICES, DEFAULT_TTS_MODELS, TTS_PROVIDERS } from '@/lib/audio/constants';
  15. import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
  16. import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
  17. import { isMediaPlaceholder } from '@/lib/store/media-generation';
  18. import {
  19. getServerImageProviders,
  20. getServerVideoProviders,
  21. getServerTTSProviders,
  22. resolveImageApiKey,
  23. resolveImageBaseUrl,
  24. resolveVideoApiKey,
  25. resolveVideoBaseUrl,
  26. resolveTTSApiKey,
  27. resolveTTSBaseUrl,
  28. } from '@/lib/server/provider-config';
  29. import type { SceneOutline } from '@/lib/types/generation';
  30. import type { Scene } from '@/lib/types/stage';
  31. import type { SpeechAction } from '@/lib/types/action';
  32. import type { ImageProviderId } from '@/lib/media/types';
  33. import type { VideoProviderId } from '@/lib/media/types';
  34. import type { TTSProviderId } from '@/lib/audio/types';
  35. import { splitLongSpeechActions } from '@/lib/audio/tts-utils';
  36. const log = createLogger('ClassroomMedia');
  37. // ---------------------------------------------------------------------------
  38. // Helpers
  39. // ---------------------------------------------------------------------------
  40. async function ensureDir(dir: string) {
  41. await fs.mkdir(dir, { recursive: true });
  42. }
  43. const DOWNLOAD_TIMEOUT_MS = 120_000; // 2 minutes
  44. const DOWNLOAD_MAX_SIZE = 100 * 1024 * 1024; // 100 MB
  45. async function downloadToBuffer(url: string): Promise<Buffer> {
  46. const resp = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
  47. if (!resp.ok) throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
  48. const contentLength = Number(resp.headers.get('content-length') || 0);
  49. if (contentLength > DOWNLOAD_MAX_SIZE) {
  50. throw new Error(`File too large: ${contentLength} bytes (max ${DOWNLOAD_MAX_SIZE})`);
  51. }
  52. return Buffer.from(await resp.arrayBuffer());
  53. }
  54. function mediaServingUrl(baseUrl: string, classroomId: string, subPath: string): string {
  55. return `${baseUrl}/api/classroom-media/${classroomId}/${subPath}`;
  56. }
  57. // ---------------------------------------------------------------------------
  58. // Image / Video generation
  59. // ---------------------------------------------------------------------------
  60. export async function generateMediaForClassroom(
  61. outlines: SceneOutline[],
  62. classroomId: string,
  63. baseUrl: string,
  64. ): Promise<Record<string, string>> {
  65. const mediaDir = path.join(CLASSROOMS_DIR, classroomId, 'media');
  66. await ensureDir(mediaDir);
  67. // Collect all media generation requests from outlines
  68. const requests = outlines.flatMap((o) => o.mediaGenerations ?? []);
  69. if (requests.length === 0) return {};
  70. // Resolve providers
  71. const imageProviderIds = Object.keys(getServerImageProviders());
  72. const videoProviderIds = Object.keys(getServerVideoProviders());
  73. const mediaMap: Record<string, string> = {};
  74. // Separate image and video requests, generate each type sequentially
  75. // but run the two types in parallel (providers often have limited concurrency).
  76. const imageRequests = requests.filter((r) => r.type === 'image' && imageProviderIds.length > 0);
  77. const videoRequests = requests.filter((r) => r.type === 'video' && videoProviderIds.length > 0);
  78. const generateImages = async () => {
  79. for (const req of imageRequests) {
  80. try {
  81. const providerId = imageProviderIds[0] as ImageProviderId;
  82. const apiKey = resolveImageApiKey(providerId);
  83. if (!apiKey) {
  84. log.warn(`No API key for image provider "${providerId}", skipping ${req.elementId}`);
  85. continue;
  86. }
  87. const providerConfig = IMAGE_PROVIDERS[providerId];
  88. const model = providerConfig?.models?.[0]?.id;
  89. const result = await generateImage(
  90. { providerId, apiKey, baseUrl: resolveImageBaseUrl(providerId), model },
  91. { prompt: req.prompt, aspectRatio: req.aspectRatio || '16:9' },
  92. );
  93. let buf: Buffer;
  94. let ext: string;
  95. if (result.base64) {
  96. buf = Buffer.from(result.base64, 'base64');
  97. ext = 'png';
  98. } else if (result.url) {
  99. buf = await downloadToBuffer(result.url);
  100. const urlExt = path.extname(new URL(result.url).pathname).replace('.', '');
  101. ext = ['png', 'jpg', 'jpeg', 'webp'].includes(urlExt) ? urlExt : 'png';
  102. } else {
  103. log.warn(`Image generation returned no data for ${req.elementId}`);
  104. continue;
  105. }
  106. const filename = `${req.elementId}.${ext}`;
  107. await fs.writeFile(path.join(mediaDir, filename), buf);
  108. mediaMap[req.elementId] = mediaServingUrl(baseUrl, classroomId, `media/${filename}`);
  109. log.info(`Generated image: ${filename}`);
  110. } catch (err) {
  111. log.warn(`Image generation failed for ${req.elementId}:`, err);
  112. }
  113. }
  114. };
  115. const generateVideos = async () => {
  116. for (const req of videoRequests) {
  117. try {
  118. const providerId = videoProviderIds[0] as VideoProviderId;
  119. const apiKey = resolveVideoApiKey(providerId);
  120. if (!apiKey) {
  121. log.warn(`No API key for video provider "${providerId}", skipping ${req.elementId}`);
  122. continue;
  123. }
  124. const providerConfig = VIDEO_PROVIDERS[providerId];
  125. const model = providerConfig?.models?.[0]?.id;
  126. const normalized = normalizeVideoOptions(providerId, {
  127. prompt: req.prompt,
  128. aspectRatio: (req.aspectRatio as '16:9' | '4:3' | '1:1' | '9:16') || '16:9',
  129. });
  130. const result = await generateVideo(
  131. { providerId, apiKey, baseUrl: resolveVideoBaseUrl(providerId), model },
  132. normalized,
  133. );
  134. const buf = await downloadToBuffer(result.url);
  135. const filename = `${req.elementId}.mp4`;
  136. await fs.writeFile(path.join(mediaDir, filename), buf);
  137. mediaMap[req.elementId] = mediaServingUrl(baseUrl, classroomId, `media/${filename}`);
  138. log.info(`Generated video: ${filename}`);
  139. } catch (err) {
  140. log.warn(`Video generation failed for ${req.elementId}:`, err);
  141. }
  142. }
  143. };
  144. await Promise.all([generateImages(), generateVideos()]);
  145. return mediaMap;
  146. }
  147. // ---------------------------------------------------------------------------
  148. // Placeholder replacement in scene content
  149. // ---------------------------------------------------------------------------
  150. export function replaceMediaPlaceholders(scenes: Scene[], mediaMap: Record<string, string>): void {
  151. if (Object.keys(mediaMap).length === 0) return;
  152. for (const scene of scenes) {
  153. if (scene.type !== 'slide') continue;
  154. const canvas = (
  155. scene.content as {
  156. canvas?: { elements?: Array<{ id: string; src?: string; type?: string }> };
  157. }
  158. )?.canvas;
  159. if (!canvas?.elements) continue;
  160. for (const el of canvas.elements) {
  161. if (
  162. (el.type === 'image' || el.type === 'video') &&
  163. typeof el.src === 'string' &&
  164. isMediaPlaceholder(el.src) &&
  165. mediaMap[el.src]
  166. ) {
  167. el.src = mediaMap[el.src];
  168. }
  169. }
  170. }
  171. }
  172. // ---------------------------------------------------------------------------
  173. // TTS generation
  174. // ---------------------------------------------------------------------------
  175. export async function generateTTSForClassroom(
  176. scenes: Scene[],
  177. classroomId: string,
  178. baseUrl: string,
  179. ): Promise<void> {
  180. const audioDir = path.join(CLASSROOMS_DIR, classroomId, 'audio');
  181. await ensureDir(audioDir);
  182. // Resolve TTS provider (exclude browser-native-tts)
  183. const ttsProviderIds = Object.keys(getServerTTSProviders()).filter(
  184. (id) => id !== 'browser-native-tts',
  185. );
  186. if (ttsProviderIds.length === 0) {
  187. log.warn('No server TTS provider configured, skipping TTS generation');
  188. return;
  189. }
  190. const providerId = ttsProviderIds[0] as TTSProviderId;
  191. const apiKey = resolveTTSApiKey(providerId);
  192. if (!apiKey) {
  193. log.warn(`No API key for TTS provider "${providerId}", skipping TTS generation`);
  194. return;
  195. }
  196. const ttsBaseUrl = resolveTTSBaseUrl(providerId) || TTS_PROVIDERS[providerId]?.defaultBaseUrl;
  197. const voice = DEFAULT_TTS_VOICES[providerId] || 'default';
  198. const format = TTS_PROVIDERS[providerId]?.supportedFormats?.[0] || 'mp3';
  199. for (const scene of scenes) {
  200. if (!scene.actions) continue;
  201. // Split long speech actions into multiple shorter ones before TTS generation,
  202. // mirroring the client-side approach. Each sub-action gets its own audio file.
  203. scene.actions = splitLongSpeechActions(scene.actions, providerId);
  204. for (const action of scene.actions) {
  205. if (action.type !== 'speech' || !(action as SpeechAction).text) continue;
  206. const speechAction = action as SpeechAction;
  207. const audioId = `tts_${action.id}`;
  208. try {
  209. const result = await generateTTS(
  210. {
  211. providerId,
  212. modelId: DEFAULT_TTS_MODELS[providerId] || '',
  213. apiKey,
  214. baseUrl: ttsBaseUrl,
  215. voice,
  216. speed: speechAction.speed,
  217. },
  218. speechAction.text,
  219. );
  220. const filename = `${audioId}.${format}`;
  221. await fs.writeFile(path.join(audioDir, filename), result.audio);
  222. speechAction.audioId = audioId;
  223. speechAction.audioUrl = mediaServingUrl(baseUrl, classroomId, `audio/${filename}`);
  224. log.info(`Generated TTS: ${filename} (${result.audio.length} bytes)`);
  225. } catch (err) {
  226. log.warn(`TTS generation failed for action ${action.id}:`, err);
  227. }
  228. }
  229. }
  230. }