use-scene-generator.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. 'use client';
  2. import { useCallback, useRef } from 'react';
  3. import { useStageStore } from '@/lib/store/stage';
  4. import { getCurrentModelConfig } from '@/lib/utils/model-config';
  5. import { useSettingsStore } from '@/lib/store/settings';
  6. import { db } from '@/lib/utils/database';
  7. import type { SceneOutline, PdfImage, ImageMapping } from '@/lib/types/generation';
  8. import type { AgentInfo } from '@/lib/generation/generation-pipeline';
  9. import type { Scene } from '@/lib/types/stage';
  10. import type { Action, SpeechAction } from '@/lib/types/action';
  11. import type { TTSProviderId } from '@/lib/audio/types';
  12. import { splitLongSpeechActions } from '@/lib/audio/tts-utils';
  13. import { generateMediaForOutlines } from '@/lib/media/media-orchestrator';
  14. import { createLogger } from '@/lib/logger';
  15. const log = createLogger('SceneGenerator');
  16. interface SceneContentResult {
  17. success: boolean;
  18. content?: unknown;
  19. effectiveOutline?: SceneOutline;
  20. error?: string;
  21. }
  22. interface SceneActionsResult {
  23. success: boolean;
  24. scene?: Scene;
  25. previousSpeeches?: string[];
  26. error?: string;
  27. }
  28. function getApiHeaders(): HeadersInit {
  29. const config = getCurrentModelConfig();
  30. const settings = useSettingsStore.getState();
  31. const imageProviderConfig = settings.imageProvidersConfig?.[settings.imageProviderId];
  32. const videoProviderConfig = settings.videoProvidersConfig?.[settings.videoProviderId];
  33. return {
  34. 'Content-Type': 'application/json',
  35. 'x-model': config.modelString || '',
  36. 'x-api-key': config.apiKey || '',
  37. 'x-base-url': config.baseUrl || '',
  38. 'x-provider-type': config.providerType || '',
  39. // Image generation provider
  40. 'x-image-provider': settings.imageProviderId || '',
  41. 'x-image-model': settings.imageModelId || '',
  42. 'x-image-api-key': imageProviderConfig?.apiKey || '',
  43. 'x-image-base-url': imageProviderConfig?.baseUrl || '',
  44. // Video generation provider
  45. 'x-video-provider': settings.videoProviderId || '',
  46. 'x-video-model': settings.videoModelId || '',
  47. 'x-video-api-key': videoProviderConfig?.apiKey || '',
  48. 'x-video-base-url': videoProviderConfig?.baseUrl || '',
  49. // Media generation toggles
  50. 'x-image-generation-enabled': String(settings.imageGenerationEnabled ?? false),
  51. 'x-video-generation-enabled': String(settings.videoGenerationEnabled ?? false),
  52. };
  53. }
  54. /** Call POST /api/generate/scene-content (step 1) */
  55. async function fetchSceneContent(
  56. params: {
  57. outline: SceneOutline;
  58. allOutlines: SceneOutline[];
  59. stageId: string;
  60. pdfImages?: PdfImage[];
  61. imageMapping?: ImageMapping;
  62. stageInfo: {
  63. name: string;
  64. description?: string;
  65. language?: string;
  66. style?: string;
  67. };
  68. agents?: AgentInfo[];
  69. },
  70. signal?: AbortSignal,
  71. ): Promise<SceneContentResult> {
  72. const response = await fetch('/api/generate/scene-content', {
  73. method: 'POST',
  74. headers: getApiHeaders(),
  75. body: JSON.stringify(params),
  76. signal,
  77. });
  78. if (!response.ok) {
  79. const data = await response.json().catch(() => ({ error: 'Request failed' }));
  80. return { success: false, error: data.error || `HTTP ${response.status}` };
  81. }
  82. return response.json();
  83. }
  84. /** Call POST /api/generate/scene-actions (step 2) */
  85. async function fetchSceneActions(
  86. params: {
  87. outline: SceneOutline;
  88. allOutlines: SceneOutline[];
  89. content: unknown;
  90. stageId: string;
  91. agents?: AgentInfo[];
  92. previousSpeeches?: string[];
  93. userProfile?: string;
  94. },
  95. signal?: AbortSignal,
  96. ): Promise<SceneActionsResult> {
  97. const response = await fetch('/api/generate/scene-actions', {
  98. method: 'POST',
  99. headers: getApiHeaders(),
  100. body: JSON.stringify(params),
  101. signal,
  102. });
  103. if (!response.ok) {
  104. const data = await response.json().catch(() => ({ error: 'Request failed' }));
  105. return { success: false, error: data.error || `HTTP ${response.status}` };
  106. }
  107. return response.json();
  108. }
  109. /** Generate TTS for one speech action and store in IndexedDB */
  110. export async function generateAndStoreTTS(
  111. audioId: string,
  112. text: string,
  113. signal?: AbortSignal,
  114. ): Promise<void> {
  115. const settings = useSettingsStore.getState();
  116. if (settings.ttsProviderId === 'browser-native-tts') return;
  117. const ttsProviderConfig = settings.ttsProvidersConfig?.[settings.ttsProviderId];
  118. const response = await fetch('/api/generate/tts', {
  119. method: 'POST',
  120. headers: { 'Content-Type': 'application/json' },
  121. body: JSON.stringify({
  122. text,
  123. audioId,
  124. ttsProviderId: settings.ttsProviderId,
  125. ttsModelId: ttsProviderConfig?.modelId,
  126. ttsVoice: settings.ttsVoice,
  127. ttsSpeed: settings.ttsSpeed,
  128. ttsApiKey: ttsProviderConfig?.apiKey || undefined,
  129. ttsBaseUrl: ttsProviderConfig?.baseUrl || undefined,
  130. }),
  131. signal,
  132. });
  133. const data = await response
  134. .json()
  135. .catch(() => ({ success: false, error: response.statusText || 'Invalid TTS response' }));
  136. if (!response.ok || !data.success || !data.base64 || !data.format) {
  137. const err = new Error(
  138. data.details || data.error || `TTS request failed: HTTP ${response.status}`,
  139. );
  140. log.warn('TTS failed for', audioId, ':', err);
  141. throw err;
  142. }
  143. const binary = atob(data.base64);
  144. const bytes = new Uint8Array(binary.length);
  145. for (let i = 0; i < binary.length; i++) {
  146. bytes[i] = binary.charCodeAt(i);
  147. }
  148. const blob = new Blob([bytes], { type: `audio/${data.format}` });
  149. await db.audioFiles.put({
  150. id: audioId,
  151. blob,
  152. format: data.format,
  153. createdAt: Date.now(),
  154. });
  155. }
  156. /** Generate TTS for all speech actions in a scene. Returns result. */
  157. async function generateTTSForScene(
  158. scene: Scene,
  159. signal?: AbortSignal,
  160. ): Promise<{ success: boolean; failedCount: number; error?: string }> {
  161. const providerId = useSettingsStore.getState().ttsProviderId;
  162. scene.actions = splitLongSpeechActions(scene.actions || [], providerId);
  163. const speechActions = scene.actions.filter(
  164. (a): a is SpeechAction => a.type === 'speech' && !!a.text,
  165. );
  166. if (speechActions.length === 0) return { success: true, failedCount: 0 };
  167. let failedCount = 0;
  168. let lastError: string | undefined;
  169. for (const action of speechActions) {
  170. const audioId = `tts_${action.id}`;
  171. action.audioId = audioId;
  172. try {
  173. await generateAndStoreTTS(audioId, action.text, signal);
  174. } catch (error) {
  175. failedCount++;
  176. lastError = error instanceof Error ? error.message : `TTS failed for action ${action.id}`;
  177. log.warn('TTS generation failed:', {
  178. providerId,
  179. actionId: action.id,
  180. textLength: action.text.length,
  181. error: lastError,
  182. });
  183. }
  184. }
  185. return {
  186. success: failedCount === 0,
  187. failedCount,
  188. error: lastError,
  189. };
  190. }
  191. export interface UseSceneGeneratorOptions {
  192. onSceneGenerated?: (scene: Scene, index: number) => void;
  193. onSceneFailed?: (outline: SceneOutline, error: string) => void;
  194. onPhaseChange?: (phase: 'content' | 'actions', outline: SceneOutline) => void;
  195. onComplete?: () => void;
  196. }
  197. export interface GenerationParams {
  198. pdfImages?: PdfImage[];
  199. imageMapping?: ImageMapping;
  200. stageInfo: {
  201. name: string;
  202. description?: string;
  203. language?: string;
  204. style?: string;
  205. };
  206. agents?: AgentInfo[];
  207. userProfile?: string;
  208. }
  209. export function useSceneGenerator(options: UseSceneGeneratorOptions = {}) {
  210. const abortRef = useRef(false);
  211. const generatingRef = useRef(false);
  212. const mediaAbortRef = useRef<AbortController | null>(null);
  213. const fetchAbortRef = useRef<AbortController | null>(null);
  214. const lastParamsRef = useRef<GenerationParams | null>(null);
  215. const generateRemainingRef = useRef<((params: GenerationParams) => Promise<void>) | null>(null);
  216. const store = useStageStore;
  217. const generateRemaining = useCallback(
  218. async (params: GenerationParams) => {
  219. lastParamsRef.current = params;
  220. if (generatingRef.current) return;
  221. generatingRef.current = true;
  222. abortRef.current = false;
  223. const removeGeneratingOutline = (outlineId: string) => {
  224. const current = store.getState().generatingOutlines;
  225. if (!current.some((o) => o.id === outlineId)) return;
  226. store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId));
  227. };
  228. // Create a new AbortController for this generation run
  229. fetchAbortRef.current = new AbortController();
  230. const signal = fetchAbortRef.current.signal;
  231. const state = store.getState();
  232. const { outlines, scenes, stage } = state;
  233. const startEpoch = state.generationEpoch;
  234. if (!stage || outlines.length === 0) {
  235. generatingRef.current = false;
  236. return;
  237. }
  238. store.getState().setGenerationStatus('generating');
  239. // Determine pending outlines
  240. const completedOrders = new Set(scenes.map((s) => s.order));
  241. const pending = outlines
  242. .filter((o) => !completedOrders.has(o.order))
  243. .sort((a, b) => a.order - b.order);
  244. if (pending.length === 0) {
  245. store.getState().setGenerationStatus('completed');
  246. store.getState().setGeneratingOutlines([]);
  247. options.onComplete?.();
  248. generatingRef.current = false;
  249. return;
  250. }
  251. store.getState().setGeneratingOutlines(pending);
  252. // Launch media generation in parallel — does not block content/action generation
  253. mediaAbortRef.current = new AbortController();
  254. generateMediaForOutlines(outlines, stage.id, mediaAbortRef.current.signal).catch((err) => {
  255. log.warn('Media generation error:', err);
  256. });
  257. // Get previousSpeeches from last completed scene
  258. let previousSpeeches: string[] = [];
  259. const sortedScenes = [...scenes].sort((a, b) => a.order - b.order);
  260. if (sortedScenes.length > 0) {
  261. const lastScene = sortedScenes[sortedScenes.length - 1];
  262. previousSpeeches = (lastScene.actions || [])
  263. .filter((a): a is SpeechAction => a.type === 'speech')
  264. .map((a) => a.text);
  265. }
  266. // Serial generation loop — two-step per outline
  267. try {
  268. let pausedByFailureOrAbort = false;
  269. for (const outline of pending) {
  270. if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
  271. store.getState().setGenerationStatus('paused');
  272. pausedByFailureOrAbort = true;
  273. break;
  274. }
  275. store.getState().setCurrentGeneratingOrder(outline.order);
  276. // Step 1: Generate content
  277. options.onPhaseChange?.('content', outline);
  278. const contentResult = await fetchSceneContent(
  279. {
  280. outline,
  281. allOutlines: outlines,
  282. stageId: stage.id,
  283. pdfImages: params.pdfImages,
  284. imageMapping: params.imageMapping,
  285. stageInfo: params.stageInfo,
  286. agents: params.agents,
  287. },
  288. signal,
  289. );
  290. if (!contentResult.success || !contentResult.content) {
  291. if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
  292. pausedByFailureOrAbort = true;
  293. break;
  294. }
  295. store.getState().addFailedOutline(outline);
  296. options.onSceneFailed?.(outline, contentResult.error || 'Content generation failed');
  297. store.getState().setGenerationStatus('paused');
  298. pausedByFailureOrAbort = true;
  299. break;
  300. }
  301. if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
  302. store.getState().setGenerationStatus('paused');
  303. pausedByFailureOrAbort = true;
  304. break;
  305. }
  306. // Step 2: Generate actions + assemble scene
  307. options.onPhaseChange?.('actions', outline);
  308. const actionsResult = await fetchSceneActions(
  309. {
  310. outline: contentResult.effectiveOutline || outline,
  311. allOutlines: outlines,
  312. content: contentResult.content,
  313. stageId: stage.id,
  314. agents: params.agents,
  315. previousSpeeches,
  316. userProfile: params.userProfile,
  317. },
  318. signal,
  319. );
  320. if (actionsResult.success && actionsResult.scene) {
  321. const scene = actionsResult.scene;
  322. const settings = useSettingsStore.getState();
  323. // TTS generation — failure means the whole scene fails
  324. if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') {
  325. const ttsResult = await generateTTSForScene(scene, signal);
  326. if (!ttsResult.success) {
  327. if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
  328. pausedByFailureOrAbort = true;
  329. break;
  330. }
  331. store.getState().addFailedOutline(outline);
  332. options.onSceneFailed?.(outline, ttsResult.error || 'TTS generation failed');
  333. store.getState().setGenerationStatus('paused');
  334. pausedByFailureOrAbort = true;
  335. break;
  336. }
  337. }
  338. // Epoch changed — stage switched, discard this scene
  339. if (store.getState().generationEpoch !== startEpoch) {
  340. pausedByFailureOrAbort = true;
  341. break;
  342. }
  343. removeGeneratingOutline(outline.id);
  344. store.getState().addScene(scene);
  345. options.onSceneGenerated?.(scene, outline.order);
  346. previousSpeeches = actionsResult.previousSpeeches || [];
  347. } else {
  348. if (abortRef.current || store.getState().generationEpoch !== startEpoch) {
  349. pausedByFailureOrAbort = true;
  350. break;
  351. }
  352. store.getState().addFailedOutline(outline);
  353. options.onSceneFailed?.(outline, actionsResult.error || 'Actions generation failed');
  354. store.getState().setGenerationStatus('paused');
  355. pausedByFailureOrAbort = true;
  356. break;
  357. }
  358. }
  359. if (!abortRef.current && !pausedByFailureOrAbort) {
  360. store.getState().setGenerationStatus('completed');
  361. store.getState().setGeneratingOutlines([]);
  362. options.onComplete?.();
  363. }
  364. } catch (err: unknown) {
  365. // AbortError is expected when stop() is called — don't treat as failure
  366. if (err instanceof DOMException && err.name === 'AbortError') {
  367. log.info('Generation aborted');
  368. store.getState().setGenerationStatus('paused');
  369. } else {
  370. throw err;
  371. }
  372. } finally {
  373. generatingRef.current = false;
  374. fetchAbortRef.current = null;
  375. }
  376. },
  377. [options, store],
  378. );
  379. // Keep ref in sync so retrySingleOutline can call it
  380. generateRemainingRef.current = generateRemaining;
  381. const stop = useCallback(() => {
  382. abortRef.current = true;
  383. store.getState().bumpGenerationEpoch();
  384. fetchAbortRef.current?.abort();
  385. mediaAbortRef.current?.abort();
  386. }, [store]);
  387. const isGenerating = useCallback(() => generatingRef.current, []);
  388. /** Retry a single failed outline from scratch (content → actions → TTS). */
  389. const retrySingleOutline = useCallback(
  390. async (outlineId: string) => {
  391. const state = store.getState();
  392. const outline = state.failedOutlines.find((o) => o.id === outlineId);
  393. const params = lastParamsRef.current;
  394. if (!outline || !state.stage || !params) return;
  395. const removeGeneratingOutline = () => {
  396. const current = store.getState().generatingOutlines;
  397. if (!current.some((o) => o.id === outlineId)) return;
  398. store.getState().setGeneratingOutlines(current.filter((o) => o.id !== outlineId));
  399. };
  400. // Remove from failed list and mark as generating
  401. store.getState().retryFailedOutline(outlineId);
  402. store.getState().setGenerationStatus('generating');
  403. const currentGenerating = store.getState().generatingOutlines;
  404. if (!currentGenerating.some((o) => o.id === outline.id)) {
  405. store.getState().setGeneratingOutlines([...currentGenerating, outline]);
  406. }
  407. const abortController = new AbortController();
  408. const signal = abortController.signal;
  409. try {
  410. // Step 1: Content
  411. const contentResult = await fetchSceneContent(
  412. {
  413. outline,
  414. allOutlines: state.outlines,
  415. stageId: state.stage.id,
  416. pdfImages: params.pdfImages,
  417. imageMapping: params.imageMapping,
  418. stageInfo: params.stageInfo,
  419. agents: params.agents,
  420. },
  421. signal,
  422. );
  423. if (!contentResult.success || !contentResult.content) {
  424. store.getState().addFailedOutline(outline);
  425. return;
  426. }
  427. // Step 2: Actions
  428. const sortedScenes = [...store.getState().scenes].sort((a, b) => a.order - b.order);
  429. const lastScene = sortedScenes[sortedScenes.length - 1];
  430. const previousSpeeches = lastScene
  431. ? (lastScene.actions || [])
  432. .filter((a): a is SpeechAction => a.type === 'speech')
  433. .map((a) => a.text)
  434. : [];
  435. const actionsResult = await fetchSceneActions(
  436. {
  437. outline: contentResult.effectiveOutline || outline,
  438. allOutlines: state.outlines,
  439. content: contentResult.content,
  440. stageId: state.stage.id,
  441. agents: params.agents,
  442. previousSpeeches,
  443. userProfile: params.userProfile,
  444. },
  445. signal,
  446. );
  447. if (!actionsResult.success || !actionsResult.scene) {
  448. store.getState().addFailedOutline(outline);
  449. return;
  450. }
  451. // Step 3: TTS
  452. const settings = useSettingsStore.getState();
  453. if (settings.ttsEnabled && settings.ttsProviderId !== 'browser-native-tts') {
  454. const ttsResult = await generateTTSForScene(actionsResult.scene, signal);
  455. if (!ttsResult.success) {
  456. store.getState().addFailedOutline(outline);
  457. return;
  458. }
  459. }
  460. removeGeneratingOutline();
  461. store.getState().addScene(actionsResult.scene);
  462. // Resume remaining generation if there are pending outlines
  463. if (store.getState().generatingOutlines.length > 0 && lastParamsRef.current) {
  464. generateRemainingRef.current?.(lastParamsRef.current);
  465. }
  466. } catch (err) {
  467. if (!(err instanceof DOMException && err.name === 'AbortError')) {
  468. store.getState().addFailedOutline(outline);
  469. }
  470. }
  471. },
  472. [store],
  473. );
  474. return { generateRemaining, retrySingleOutline, stop, isGenerating };
  475. }