media-orchestrator.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. /**
  2. * Media Generation Orchestrator
  3. *
  4. * Dispatches media generation API calls for all mediaGenerations across outlines.
  5. * Runs entirely on the frontend — calls /api/generate/image and /api/generate/video,
  6. * fetches result blobs, stores in IndexedDB, and updates the Zustand store.
  7. */
  8. import { useMediaGenerationStore } from '@/lib/store/media-generation';
  9. import { useSettingsStore } from '@/lib/store/settings';
  10. import { db, mediaFileKey } from '@/lib/utils/database';
  11. import type { SceneOutline } from '@/lib/types/generation';
  12. import type { MediaGenerationRequest } from '@/lib/media/types';
  13. import { createLogger } from '@/lib/logger';
  14. const log = createLogger('MediaOrchestrator');
  15. /** Error with a structured errorCode from the API */
  16. class MediaApiError extends Error {
  17. errorCode?: string;
  18. constructor(message: string, errorCode?: string) {
  19. super(message);
  20. this.errorCode = errorCode;
  21. }
  22. }
  23. /**
  24. * Launch media generation for all mediaGenerations declared in outlines.
  25. * Runs in parallel with content/action generation — does not block.
  26. */
  27. export async function generateMediaForOutlines(
  28. outlines: SceneOutline[],
  29. stageId: string,
  30. abortSignal?: AbortSignal,
  31. ): Promise<void> {
  32. const settings = useSettingsStore.getState();
  33. const store = useMediaGenerationStore.getState();
  34. // Collect all media requests
  35. const allRequests: MediaGenerationRequest[] = [];
  36. for (const outline of outlines) {
  37. if (!outline.mediaGenerations) continue;
  38. for (const mg of outline.mediaGenerations) {
  39. // Filter by enabled flags
  40. if (mg.type === 'image' && !settings.imageGenerationEnabled) continue;
  41. if (mg.type === 'video' && !settings.videoGenerationEnabled) continue;
  42. // Skip already completed or permanently failed (restored from DB)
  43. const existing = store.getTask(mg.elementId);
  44. if (existing?.status === 'done' || existing?.status === 'failed') continue;
  45. allRequests.push(mg);
  46. }
  47. }
  48. if (allRequests.length === 0) return;
  49. // Enqueue all as pending
  50. useMediaGenerationStore.getState().enqueueTasks(stageId, allRequests);
  51. // Process requests serially — image/video APIs have limited concurrency
  52. for (const req of allRequests) {
  53. if (abortSignal?.aborted) break;
  54. await generateSingleMedia(req, stageId, abortSignal);
  55. }
  56. }
  57. /**
  58. * Retry a single failed media task.
  59. */
  60. export async function retryMediaTask(elementId: string): Promise<void> {
  61. const store = useMediaGenerationStore.getState();
  62. const task = store.getTask(elementId);
  63. if (!task || task.status !== 'failed') return;
  64. // Check if the corresponding generation type is still enabled in global settings
  65. const settings = useSettingsStore.getState();
  66. if (task.type === 'image' && !settings.imageGenerationEnabled) {
  67. store.markFailed(elementId, 'Generation disabled', 'GENERATION_DISABLED');
  68. return;
  69. }
  70. if (task.type === 'video' && !settings.videoGenerationEnabled) {
  71. store.markFailed(elementId, 'Generation disabled', 'GENERATION_DISABLED');
  72. return;
  73. }
  74. // Remove persisted failure record from DB so a fresh result can be written
  75. const dbKey = mediaFileKey(task.stageId, elementId);
  76. await db.mediaFiles.delete(dbKey).catch(() => {});
  77. store.markPendingForRetry(elementId);
  78. await generateSingleMedia(
  79. {
  80. type: task.type,
  81. prompt: task.prompt,
  82. elementId: task.elementId,
  83. aspectRatio: task.params.aspectRatio as MediaGenerationRequest['aspectRatio'],
  84. style: task.params.style,
  85. },
  86. task.stageId,
  87. );
  88. }
  89. // ==================== Internal ====================
  90. async function generateSingleMedia(
  91. req: MediaGenerationRequest,
  92. stageId: string,
  93. abortSignal?: AbortSignal,
  94. ): Promise<void> {
  95. const store = useMediaGenerationStore.getState();
  96. store.markGenerating(req.elementId);
  97. try {
  98. let resultUrl: string;
  99. let posterUrl: string | undefined;
  100. let mimeType: string;
  101. if (req.type === 'image') {
  102. const result = await callImageApi(req, abortSignal);
  103. resultUrl = result.url;
  104. mimeType = 'image/png';
  105. } else {
  106. const result = await callVideoApi(req, abortSignal);
  107. resultUrl = result.url;
  108. posterUrl = result.poster;
  109. mimeType = 'video/mp4';
  110. }
  111. if (abortSignal?.aborted) return;
  112. // Fetch blob from URL
  113. const blob = await fetchAsBlob(resultUrl);
  114. const posterBlob = posterUrl ? await fetchAsBlob(posterUrl).catch(() => undefined) : undefined;
  115. // Store in IndexedDB
  116. await db.mediaFiles.put({
  117. id: mediaFileKey(stageId, req.elementId),
  118. stageId,
  119. type: req.type,
  120. blob,
  121. mimeType,
  122. size: blob.size,
  123. poster: posterBlob,
  124. prompt: req.prompt,
  125. params: JSON.stringify({
  126. aspectRatio: req.aspectRatio,
  127. style: req.style,
  128. }),
  129. createdAt: Date.now(),
  130. });
  131. // Update store with object URL
  132. const objectUrl = URL.createObjectURL(blob);
  133. const posterObjectUrl = posterBlob ? URL.createObjectURL(posterBlob) : undefined;
  134. useMediaGenerationStore.getState().markDone(req.elementId, objectUrl, posterObjectUrl);
  135. } catch (err) {
  136. if (abortSignal?.aborted) return;
  137. const message = err instanceof Error ? err.message : String(err);
  138. const errorCode = err instanceof MediaApiError ? err.errorCode : undefined;
  139. log.error(`Failed ${req.elementId}:`, message);
  140. useMediaGenerationStore.getState().markFailed(req.elementId, message, errorCode);
  141. // Persist non-retryable failures to IndexedDB so they survive page refresh
  142. if (errorCode) {
  143. await db.mediaFiles
  144. .put({
  145. id: mediaFileKey(stageId, req.elementId),
  146. stageId,
  147. type: req.type,
  148. blob: new Blob(), // empty placeholder
  149. mimeType: req.type === 'image' ? 'image/png' : 'video/mp4',
  150. size: 0,
  151. prompt: req.prompt,
  152. params: JSON.stringify({
  153. aspectRatio: req.aspectRatio,
  154. style: req.style,
  155. }),
  156. error: message,
  157. errorCode,
  158. createdAt: Date.now(),
  159. })
  160. .catch(() => {}); // best-effort
  161. }
  162. }
  163. }
  164. async function callImageApi(
  165. req: MediaGenerationRequest,
  166. abortSignal?: AbortSignal,
  167. ): Promise<{ url: string }> {
  168. const settings = useSettingsStore.getState();
  169. const providerConfig = settings.imageProvidersConfig?.[settings.imageProviderId];
  170. const response = await fetch('/api/generate/image', {
  171. method: 'POST',
  172. headers: {
  173. 'Content-Type': 'application/json',
  174. 'x-image-provider': settings.imageProviderId || '',
  175. 'x-image-model': settings.imageModelId || '',
  176. 'x-api-key': providerConfig?.apiKey || '',
  177. 'x-base-url': providerConfig?.baseUrl || '',
  178. },
  179. body: JSON.stringify({
  180. prompt: req.prompt,
  181. aspectRatio: req.aspectRatio,
  182. style: req.style,
  183. }),
  184. signal: abortSignal,
  185. });
  186. if (!response.ok) {
  187. const data = await response.json().catch(() => ({}));
  188. throw new MediaApiError(data.error || `Image API returned ${response.status}`, data.errorCode);
  189. }
  190. const data = await response.json();
  191. if (!data.success)
  192. throw new MediaApiError(data.error || 'Image generation failed', data.errorCode);
  193. // Result may have url or base64
  194. const url =
  195. data.result?.url || (data.result?.base64 ? `data:image/png;base64,${data.result.base64}` : '');
  196. if (!url) throw new Error('No image URL in response');
  197. return { url };
  198. }
  199. async function callVideoApi(
  200. req: MediaGenerationRequest,
  201. abortSignal?: AbortSignal,
  202. ): Promise<{ url: string; poster?: string }> {
  203. const settings = useSettingsStore.getState();
  204. const providerConfig = settings.videoProvidersConfig?.[settings.videoProviderId];
  205. const response = await fetch('/api/generate/video', {
  206. method: 'POST',
  207. headers: {
  208. 'Content-Type': 'application/json',
  209. 'x-video-provider': settings.videoProviderId || '',
  210. 'x-video-model': settings.videoModelId || '',
  211. 'x-api-key': providerConfig?.apiKey || '',
  212. 'x-base-url': providerConfig?.baseUrl || '',
  213. },
  214. body: JSON.stringify({
  215. prompt: req.prompt,
  216. aspectRatio: req.aspectRatio,
  217. }),
  218. signal: abortSignal,
  219. });
  220. if (!response.ok) {
  221. const data = await response.json().catch(() => ({}));
  222. throw new MediaApiError(data.error || `Video API returned ${response.status}`, data.errorCode);
  223. }
  224. const data = await response.json();
  225. if (!data.success)
  226. throw new MediaApiError(data.error || 'Video generation failed', data.errorCode);
  227. const url = data.result?.url;
  228. if (!url) throw new Error('No video URL in response');
  229. return { url, poster: data.result?.poster };
  230. }
  231. async function fetchAsBlob(url: string): Promise<Blob> {
  232. // For data URLs, convert directly
  233. if (url.startsWith('data:')) {
  234. const res = await fetch(url);
  235. return res.blob();
  236. }
  237. // For remote URLs, proxy through our server to bypass CORS restrictions
  238. if (url.startsWith('http://') || url.startsWith('https://')) {
  239. const res = await fetch('/api/proxy-media', {
  240. method: 'POST',
  241. headers: { 'Content-Type': 'application/json' },
  242. body: JSON.stringify({ url }),
  243. });
  244. if (!res.ok) {
  245. const data = await res.json().catch(() => ({}));
  246. throw new Error(data.error || `Proxy fetch failed: ${res.status}`);
  247. }
  248. return res.blob();
  249. }
  250. // Relative URLs (shouldn't happen, but handle gracefully)
  251. const res = await fetch(url);
  252. if (!res.ok) throw new Error(`Failed to fetch blob: ${res.status}`);
  253. return res.blob();
  254. }