book-generator.service.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. /**
  2. * 书籍生成编排服务
  3. * 负责按顺序执行生成步骤并推送进度
  4. */
  5. import { bookStore } from './book-generator.store';
  6. import { pushBatchGenerationProgress } from '../../services/websocket.service.js';
  7. import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
  8. import { mergeChapterAudios } from '../player/player.service';
  9. import { prisma } from '../../models';
  10. import { advanceChapter, regenerateChapter } from './stage-manager';
  11. import { estimateBookWords, estimateAudioMinutesFromWords, atomicReserveQuota, releaseQuota, getUserMonthlyCost, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service';
  12. // 步骤类型
  13. export type GenerationStep = 'generate_content' | 'generate_audio' | 'merge_audio' | 'generate_video' | 'merge_video';
  14. // 取消标志
  15. const cancellationFlags = new Map<string, boolean>();
  16. /**
  17. * 设置取消标志
  18. */
  19. export function setCancellationFlag(taskId: string): void {
  20. cancellationFlags.set(taskId, true);
  21. }
  22. /**
  23. * 清除取消标志
  24. */
  25. export function clearCancellationFlag(taskId: string): void {
  26. cancellationFlags.delete(taskId);
  27. }
  28. /**
  29. * 检查是否已取消
  30. */
  31. export function isTaskCancelled(taskId: string): boolean {
  32. return cancellationFlags.get(taskId) === true;
  33. }
  34. /**
  35. * 批量生成编排器
  36. */
  37. export class BatchGenerationOrchestrator {
  38. private taskId: string;
  39. private bookId: string;
  40. private steps: GenerationStep[];
  41. constructor(taskId: string, bookId: string, steps: GenerationStep[]) {
  42. this.taskId = taskId;
  43. this.bookId = bookId;
  44. this.steps = steps;
  45. }
  46. /**
  47. * 推送进度
  48. */
  49. private pushProgress(step: string, progress: number, message: string): void {
  50. pushBatchGenerationProgress(this.taskId, step, progress);
  51. console.log(`[BatchGen][${this.taskId}] ${step}: ${progress}% - ${message}`);
  52. }
  53. /**
  54. * 检查是否已取消
  55. */
  56. private checkCancellation(): void {
  57. if (isTaskCancelled(this.taskId)) {
  58. console.log(`[BatchGen][${this.taskId}] 任务已取消`);
  59. throw new Error('TASK_CANCELLED');
  60. }
  61. }
  62. /**
  63. * 执行所有步骤
  64. */
  65. async execute(): Promise<{ success: boolean; completedSteps: GenerationStep[]; failedStep?: string; error?: string }> {
  66. const completedSteps: GenerationStep[] = [];
  67. try {
  68. // 获取书籍信息
  69. const book = await bookStore.getById(this.bookId);
  70. if (!book) {
  71. return { success: false, completedSteps, failedStep: 'init', error: '书籍不存在' };
  72. }
  73. // 执行每个步骤
  74. for (let i = 0; i < this.steps.length; i++) {
  75. this.checkCancellation();
  76. const step = this.steps[i];
  77. const stepProgress = Math.round((i / this.steps.length) * 100);
  78. this.pushProgress(step, stepProgress, '开始执行');
  79. try {
  80. switch (step) {
  81. case 'generate_content':
  82. await this.executeGenerateContent();
  83. break;
  84. case 'generate_audio':
  85. await this.executeGenerateAudio();
  86. break;
  87. case 'merge_audio':
  88. await this.executeMergeAudio();
  89. break;
  90. case 'generate_video':
  91. await this.executeGenerateVideo();
  92. break;
  93. case 'merge_video':
  94. await this.executeMergeVideo();
  95. break;
  96. default:
  97. console.warn(`[BatchGen][${this.taskId}] 未知步骤: ${step}`);
  98. }
  99. completedSteps.push(step);
  100. this.pushProgress(step, 100, '执行完成');
  101. // 步骤间检查取消
  102. this.checkCancellation();
  103. } catch (error: any) {
  104. if (error.message === 'TASK_CANCELLED') {
  105. return { success: false, completedSteps, failedStep: step, error: '用户取消' };
  106. }
  107. console.error(`[BatchGen][${this.taskId}] 步骤 ${step} 执行失败:`, error);
  108. return { success: false, completedSteps, failedStep: step, error: error.message };
  109. }
  110. }
  111. // 更新书籍状态
  112. await bookStore.update(this.bookId, { progress: 100 });
  113. return { success: true, completedSteps };
  114. } catch (error: any) {
  115. console.error(`[BatchGen][${this.taskId}] 执行失败:`, error);
  116. return { success: false, completedSteps, error: error.message };
  117. } finally {
  118. // 清理取消标志
  119. clearCancellationFlag(this.taskId);
  120. }
  121. }
  122. /**
  123. * 执行内容生成步骤
  124. * 使用 LangGraph 生成书籍内容
  125. */
  126. private async executeGenerateContent(): Promise<void> {
  127. this.pushProgress('generate_content', 10, '检查书籍状态');
  128. const book = await bookStore.getById(this.bookId);
  129. if (!book) throw new Error('书籍不存在');
  130. // 如果书籍已有内容,则跳过
  131. const chapters = await bookStore.getChapterTree(this.bookId);
  132. const completedContent = chapters.filter((c: any) => c.genStage === 'content_completed');
  133. if (completedContent.length > 0) {
  134. this.pushProgress('generate_content', 50, `已有 ${completedContent.length} 个章节完成内容生成,跳过`);
  135. return;
  136. }
  137. // 预估费用并预留配额
  138. const userId = (book as any).userId;
  139. let reservedAmount = 0;
  140. if (userId) {
  141. try {
  142. const bookScale = book.bookScale || '1000';
  143. const wordEstimate = estimateBookWords(bookScale);
  144. const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg);
  145. const estimatedCost = AUDIO_BILLING_CONFIG.pricing.monthly * audioMinutes + 0.1; // +LLM预估缓冲
  146. const quotaOk = await atomicReserveQuota(userId, estimatedCost);
  147. if (!quotaOk) {
  148. throw new Error('月度额度不足,请升级套餐');
  149. }
  150. reservedAmount = estimatedCost;
  151. console.log(`[BatchGen][${this.taskId}] 配额预留成功: ¥${estimatedCost.toFixed(4)}, userId=${userId}`);
  152. } catch (err: any) {
  153. console.error(`[BatchGen][${this.taskId}] 配额预留失败:`, err.message);
  154. throw err;
  155. }
  156. }
  157. this.pushProgress('generate_content', 20, '开始生成内容');
  158. // 调用 LangGraph 生成内容
  159. const { langGraphGenerator, resolveGenLevel } = await import('./index.js');
  160. // 异步执行生成,不阻塞
  161. langGraphGenerator.generate(this.bookId, book.description, book.bookScale || '1000', resolveGenLevel(book.bookScale || '1000'))
  162. .then(() => {
  163. console.log(`[BatchGen][${this.taskId}] 内容生成完成`);
  164. })
  165. .catch((err) => {
  166. console.error(`[BatchGen][${this.taskId}] 内容生成失败:`, err);
  167. });
  168. // 等待内容生成完成(轮询检查)
  169. let maxWaitTime = 3600 * 1000; // 最多等待60分钟
  170. let waited = 0;
  171. const checkInterval = 5000; // 每5秒检查一次
  172. let generationSuccess = false;
  173. try {
  174. while (waited < maxWaitTime) {
  175. this.checkCancellation();
  176. const currentChapters = await bookStore.getChapterTree(this.bookId);
  177. const leafNodes = currentChapters.filter((c: any) => c.level === Math.max(...currentChapters.map((ch: any) => ch.level || 0)));
  178. const completedCount = leafNodes.filter((c: any) => c.genStage === 'content_completed').length;
  179. const totalCount = leafNodes.length;
  180. if (totalCount > 0) {
  181. const progress = 20 + Math.round((completedCount / totalCount) * 60);
  182. this.pushProgress('generate_content', Math.min(progress, 90), `内容生成中: ${completedCount}/${totalCount}`);
  183. }
  184. // 检查是否全部完成
  185. if (totalCount > 0 && completedCount >= totalCount) {
  186. this.pushProgress('generate_content', 95, '内容生成完成');
  187. generationSuccess = true;
  188. return;
  189. }
  190. // 检查书籍状态
  191. const currentBook = await bookStore.getById(this.bookId);
  192. if (currentBook?.genStage === 'video_completed') {
  193. this.pushProgress('generate_content', 95, '内容生成完成');
  194. generationSuccess = true;
  195. return;
  196. }
  197. if (currentBook?.genStage === 'failed') {
  198. throw new Error('内容生成失败: ' + (currentBook.error || '未知错误'));
  199. }
  200. await this.sleep(checkInterval);
  201. waited += checkInterval;
  202. }
  203. throw new Error('内容生成超时');
  204. } finally {
  205. // 生成完成后,调整配额(多退少补)
  206. if (userId && reservedAmount > 0) {
  207. try {
  208. // 等待异步日志写入完成(AI调用日志是异步写入的)
  209. await this.sleep(2000);
  210. const actualCost = await getUserMonthlyCost(userId);
  211. const diff = actualCost - reservedAmount;
  212. if (Math.abs(diff) > 0.001) {
  213. if (diff > 0) {
  214. // 实际消耗超过预留,需要追加预留
  215. await atomicReserveQuota(userId, diff);
  216. console.log(`[BatchGen][${this.taskId}] 配额追加: ¥${diff.toFixed(4)}, userId=${userId}`);
  217. } else {
  218. // 实际消耗少于预留,退回多余部分
  219. await releaseQuota(userId, Math.abs(diff));
  220. console.log(`[BatchGen][${this.taskId}] 配额退回: ¥${Math.abs(diff).toFixed(4)}, userId=${userId}`);
  221. }
  222. }
  223. } catch (err) {
  224. console.error(`[BatchGen][${this.taskId}] 配额调整失败:`, err);
  225. }
  226. }
  227. }
  228. }
  229. /**
  230. * 执行音频生成步骤
  231. */
  232. private async executeGenerateAudio(): Promise<void> {
  233. this.pushProgress('generate_audio', 10, '开始生成音频');
  234. const chapters = await bookStore.getChapterTree(this.bookId);
  235. const maxLevel = chapters.length > 0
  236. ? Math.max(...chapters.map((c: any) => c.level || 0))
  237. : 0;
  238. // 获取叶节点
  239. const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
  240. // 检查内容状态 - 叶节点必须有content且genStage为content_completed
  241. const leafNodesWithContent = leafNodes.filter((c: any) => c.content && c.genStage === 'content_completed');
  242. if (leafNodesWithContent.length === 0) {
  243. throw new Error('没有内容生成完成的章节,请先生成内容');
  244. }
  245. this.pushProgress('generate_audio', 20, `开始生成 ${leafNodesWithContent.length} 个章节音频`);
  246. // 异步生成所有叶节点音频
  247. const generationPromises: Promise<void>[] = [];
  248. for (const sub of leafNodesWithContent) {
  249. generationPromises.push(
  250. bookStore.generateChapterAudioById(sub.id, 1)
  251. .then(() => {
  252. console.log(`[BatchGen][${this.taskId}] 章节 ${sub.number} 音频生成完成`);
  253. })
  254. .catch((err) => {
  255. console.error(`[BatchGen][${this.taskId}] 章节 ${sub.number} 音频生成失败:`, err);
  256. }) as Promise<void>
  257. );
  258. }
  259. // 等待音频生成完成(轮询检查)
  260. let maxWaitTime = 3600 * 1000; // 最多等待60分钟
  261. let waited = 0;
  262. const checkInterval = 3000; // 每3秒检查一次
  263. while (waited < maxWaitTime) {
  264. this.checkCancellation();
  265. const currentChapters = await bookStore.getChapterTree(this.bookId);
  266. const currentLeafNodes = currentChapters.filter((c: any) => c.level === maxLevel);
  267. const completedCount = currentLeafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '').length;
  268. const totalCount = currentLeafNodes.length;
  269. if (totalCount > 0) {
  270. const progress = 20 + Math.round((completedCount / totalCount) * 70);
  271. this.pushProgress('generate_audio', Math.min(progress, 95), `音频生成中: ${completedCount}/${totalCount}`);
  272. }
  273. // 检查是否全部完成
  274. if (totalCount > 0 && completedCount >= totalCount) {
  275. this.pushProgress('generate_audio', 100, '音频生成完成');
  276. return;
  277. }
  278. await this.sleep(checkInterval);
  279. waited += checkInterval;
  280. }
  281. throw new Error('音频生成超时');
  282. }
  283. /**
  284. * 执行音频合并步骤
  285. */
  286. private async executeMergeAudio(): Promise<void> {
  287. this.pushProgress('merge_audio', 10, '开始合并音频');
  288. const chapters = await bookStore.getChapterTree(this.bookId);
  289. const maxLevel = chapters.length > 0
  290. ? Math.max(...chapters.map((c: any) => c.level || 0))
  291. : 0;
  292. if (maxLevel <= 1) {
  293. this.pushProgress('merge_audio', 100, '书籍层级不足,跳过音频合并');
  294. return;
  295. }
  296. // 获取叶节点
  297. const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
  298. // 检查所有叶节点是否都有音频
  299. const leafNodesWithAudio = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '');
  300. if (leafNodesWithAudio.length !== leafNodes.length) {
  301. throw new Error(`并非所有章节都已完成音频生成,无法合并 (${leafNodesWithAudio.length}/${leafNodes.length})`);
  302. }
  303. this.pushProgress('merge_audio', 20, `开始合并 ${leafNodes.length} 个章节音频`);
  304. // 构建 sectionId → chapterId 映射(3层树需要)
  305. const sectionToChapter = new Map<number, number>();
  306. if (maxLevel >= 3) {
  307. const sections = chapters.filter((c: any) => c.level === 2);
  308. for (const sec of sections) {
  309. if (sec.parentId != null) {
  310. sectionToChapter.set(sec.id, sec.parentId);
  311. }
  312. }
  313. }
  314. // 按章(level=1)分组叶节点
  315. const groupedByChapter: { [key: number]: any[] } = {};
  316. leafNodesWithAudio.forEach(node => {
  317. let chapterId: number | null = null;
  318. if (maxLevel === 2) {
  319. chapterId = node.parentId;
  320. } else if (maxLevel === 3 && node.parentId != null) {
  321. chapterId = sectionToChapter.get(node.parentId) || null;
  322. }
  323. if (chapterId) {
  324. if (!groupedByChapter[chapterId]) {
  325. groupedByChapter[chapterId] = [];
  326. }
  327. groupedByChapter[chapterId].push(node);
  328. }
  329. });
  330. const chapterIds = Object.keys(groupedByChapter);
  331. const totalChapters = chapterIds.length;
  332. let processedChapters = 0;
  333. // 对每个章下的叶节点音频进行合并
  334. for (const chapterId of chapterIds) {
  335. this.checkCancellation();
  336. const chId = parseInt(chapterId);
  337. const childNodes = groupedByChapter[chId];
  338. if (childNodes.length > 0) {
  339. try {
  340. const mergedAudioUrl = await mergeChapterAudios(chId);
  341. if (mergedAudioUrl) {
  342. console.log(`[BatchGen][${this.taskId}] 章 ${chId} 音频合并完成`);
  343. }
  344. } catch (error) {
  345. console.error(`[BatchGen][${this.taskId}] 章 ${chId} 音频合并失败:`, error);
  346. }
  347. }
  348. processedChapters++;
  349. const progress = 20 + Math.round((processedChapters / totalChapters) * 70);
  350. this.pushProgress('merge_audio', Math.min(progress, 95), `音频合并中: ${processedChapters}/${totalChapters}`);
  351. }
  352. this.pushProgress('merge_audio', 100, '音频合并完成');
  353. }
  354. /**
  355. * 执行视频生成步骤
  356. */
  357. private async executeGenerateVideo(): Promise<void> {
  358. this.pushProgress('generate_video', 10, '开始生成视频');
  359. const chapters = await bookStore.getChapterTree(this.bookId);
  360. const maxLevel = chapters.length > 0
  361. ? Math.max(...chapters.map((c: any) => c.level || 0))
  362. : 0;
  363. // 获取叶节点
  364. const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
  365. // 过滤出有音频的叶节点
  366. const leafNodesWithAudio = leafNodes.filter((c: any) => c.audioUrl && c.audioUrl !== '');
  367. if (leafNodesWithAudio.length === 0) {
  368. throw new Error('没有音频生成完成的章节,请先生成音频');
  369. }
  370. this.pushProgress('generate_video', 20, `开始生成 ${leafNodesWithAudio.length} 个章节视频`);
  371. // 设置所有有音频的叶节点视频状态为生成中
  372. for (const sub of leafNodesWithAudio) {
  373. await advanceChapter(sub.id, 'video_generating').catch(() => {});
  374. }
  375. // 异步生成所有叶节点视频
  376. for (const sub of leafNodesWithAudio) {
  377. this.checkCancellation();
  378. try {
  379. const project = await createVideoProjectFromBook(
  380. parseInt(this.bookId),
  381. sub.id,
  382. 1
  383. );
  384. if (!project) {
  385. console.error(`[BatchGen][${this.taskId}] 章节 ${sub.number} 视频项目创建失败`);
  386. await regenerateChapter(sub.id, 'failed').catch(() => {});
  387. continue;
  388. }
  389. const result = await generateVideoForProject(project.id);
  390. if (result.success && result.outputUrl) {
  391. await bookStore.updateChapterById(sub.id, {
  392. videoUrl: result.outputUrl,
  393. videoDuration: result.duration,
  394. });
  395. await advanceChapter(sub.id, 'video_completed');
  396. console.log(`[BatchGen][${this.taskId}] 章节 ${sub.number} 视频生成成功`);
  397. } else {
  398. await regenerateChapter(sub.id, 'failed').catch(() => {});
  399. }
  400. } catch (error) {
  401. console.error(`[BatchGen][${this.taskId}] 章节 ${sub.number} 视频生成异常:`, error);
  402. await regenerateChapter(sub.id, 'failed').catch(() => {});
  403. }
  404. }
  405. // 等待视频生成完成(轮询检查)
  406. let maxWaitTime = 3600 * 1000; // 最多等待60分钟
  407. let waited = 0;
  408. const checkInterval = 5000; // 每5秒检查一次
  409. while (waited < maxWaitTime) {
  410. this.checkCancellation();
  411. const currentChapters = await bookStore.getChapterTree(this.bookId);
  412. const currentLeafNodes = currentChapters.filter((c: any) => c.level === maxLevel);
  413. const completedCount = currentLeafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== '').length;
  414. const totalCount = currentLeafNodes.length;
  415. if (totalCount > 0) {
  416. const progress = 20 + Math.round((completedCount / totalCount) * 70);
  417. this.pushProgress('generate_video', Math.min(progress, 95), `视频生成中: ${completedCount}/${totalCount}`);
  418. }
  419. // 检查是否全部完成
  420. if (totalCount > 0 && completedCount >= totalCount) {
  421. this.pushProgress('generate_video', 100, '视频生成完成');
  422. return;
  423. }
  424. await this.sleep(checkInterval);
  425. waited += checkInterval;
  426. }
  427. throw new Error('视频生成超时');
  428. }
  429. /**
  430. * 执行视频合并步骤
  431. */
  432. private async executeMergeVideo(): Promise<void> {
  433. this.pushProgress('merge_video', 10, '开始合并视频');
  434. const chapters = await bookStore.getChapterTree(this.bookId);
  435. const maxLevel = chapters.length > 0
  436. ? Math.max(...chapters.map((c: any) => c.level || 0))
  437. : 0;
  438. if (maxLevel <= 1) {
  439. this.pushProgress('merge_video', 100, '书籍层级不足,跳过视频合并');
  440. return;
  441. }
  442. // 获取叶节点
  443. const leafNodes = chapters.filter((c: any) => c.level === maxLevel);
  444. // 检查所有叶节点是否都有视频
  445. const leafNodesWithVideo = leafNodes.filter((c: any) => c.videoUrl && c.videoUrl !== '');
  446. if (leafNodesWithVideo.length !== leafNodes.length) {
  447. throw new Error(`并非所有章节都已完成视频生成,无法合并 (${leafNodesWithVideo.length}/${leafNodes.length})`);
  448. }
  449. this.pushProgress('merge_video', 20, `开始合并 ${leafNodes.length} 个章节视频`);
  450. // 按父节点分组叶节点
  451. const groupedByParent: { [key: number]: any[] } = {};
  452. leafNodesWithVideo.forEach(node => {
  453. if (node.parentId != null) {
  454. if (!groupedByParent[node.parentId]) {
  455. groupedByParent[node.parentId] = [];
  456. }
  457. groupedByParent[node.parentId].push(node);
  458. }
  459. });
  460. const totalParents = Object.keys(groupedByParent).length;
  461. let processedParents = 0;
  462. // 对每个父节点下的叶节点视频进行合并
  463. for (const parentId in groupedByParent) {
  464. this.checkCancellation();
  465. const childNodes = groupedByParent[parentId];
  466. if (childNodes.length > 0) {
  467. const parentChapter = chapters.find((c: any) => c.id === parseInt(parentId));
  468. if (parentChapter) {
  469. try {
  470. // 简化处理:直接使用第一个视频或标记为已完成
  471. // 实际的视频合并需要FFmpeg处理
  472. const firstChildVideo = childNodes[0].videoUrl;
  473. if (firstChildVideo) {
  474. await bookStore.updateChapterById(parentChapter.id, {
  475. videoUrl: firstChildVideo,
  476. });
  477. console.log(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 视频处理完成`);
  478. }
  479. } catch (error) {
  480. console.error(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 视频处理失败:`, error);
  481. }
  482. }
  483. }
  484. processedParents++;
  485. const progress = 20 + Math.round((processedParents / totalParents) * 70);
  486. this.pushProgress('merge_video', Math.min(progress, 95), `视频处理中: ${processedParents}/${totalParents}`);
  487. }
  488. this.pushProgress('merge_video', 100, '视频合并完成');
  489. }
  490. /**
  491. * 休眠辅助函数
  492. */
  493. private sleep(ms: number): Promise<void> {
  494. return new Promise(resolve => setTimeout(resolve, ms));
  495. }
  496. }
  497. /**
  498. * 创建批量生成任务
  499. */
  500. export async function createBatchGenerationTask(
  501. bookId: string,
  502. steps: GenerationStep[]
  503. ): Promise<{ taskId: string; orchestrator: BatchGenerationOrchestrator }> {
  504. const taskId = `batch_${bookId}_${Date.now()}`;
  505. const orchestrator = new BatchGenerationOrchestrator(taskId, bookId, steps);
  506. return { taskId, orchestrator };
  507. }