audio-extraction.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. const fs = require('fs');
  2. const path = require('path');
  3. const { spawn } = require('child_process');
  4. function registerAudioExtractionRoutes(app, deps) {
  5. app.post('/api/remix/extract-audio', (req, res) => {
  6. const { videoId } = req.body || {};
  7. if (!videoId) {
  8. return res.status(400).json({ error: '缺少 videoId 参数' });
  9. }
  10. const manifest = deps.readManifest();
  11. const video = manifest.find(v => v.id === videoId);
  12. if (!video) {
  13. return res.status(404).json({ error: `未找到视频: ${videoId}` });
  14. }
  15. const videoPath = deps.resolveVideoPath(video.filename);
  16. if (!fs.existsSync(videoPath)) {
  17. return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
  18. }
  19. const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`;
  20. const outputFilename = `${safeBaseName}-${Date.now()}.wav`;
  21. const outputPath = path.join(deps.dataRemixAssetDir, outputFilename);
  22. const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', outputPath];
  23. const proc = spawn('ffmpeg', ffmpegArgs, { cwd: deps.projectRoot });
  24. let stderr = '';
  25. proc.stderr.on('data', (data) => {
  26. stderr += data.toString();
  27. });
  28. proc.on('error', (err) => {
  29. deps.removeFileIfExists(outputPath);
  30. const message = /ENOENT/i.test(err.message)
  31. ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
  32. : `无法启动 ffmpeg:${err.message}`;
  33. res.status(500).json({ error: message });
  34. });
  35. proc.on('close', (code) => {
  36. if (code !== 0 || !fs.existsSync(outputPath)) {
  37. deps.removeFileIfExists(outputPath);
  38. const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
  39. const normalizedMessage = /ffmpeg/i.test(stderr) && /not recognized|not found|no such file/i.test(stderr)
  40. ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
  41. : `音频提取失败${lastErrorLine ? `: ${lastErrorLine}` : ''}`;
  42. return res.status(500).json({ error: normalizedMessage });
  43. }
  44. res.setHeader('Content-Type', 'audio/wav');
  45. res.setHeader('Content-Disposition', `attachment; filename="${outputFilename}"`);
  46. const stream = fs.createReadStream(outputPath);
  47. stream.on('close', () => {
  48. deps.removeFileIfExists(outputPath);
  49. });
  50. stream.on('error', () => {
  51. deps.removeFileIfExists(outputPath);
  52. if (!res.headersSent) {
  53. res.status(500).json({ error: '音频文件读取失败' });
  54. } else {
  55. res.end();
  56. }
  57. });
  58. stream.pipe(res);
  59. });
  60. });
  61. app.post('/api/extract-audio-mp3', (req, res) => {
  62. const { videoId } = req.body || {};
  63. if (!videoId) {
  64. return res.status(400).json({ error: '缺少 videoId 参数' });
  65. }
  66. const manifest = deps.readManifest();
  67. const video = manifest.find(v => v.id === videoId);
  68. if (!video) {
  69. console.warn(`⚠️ extract-audio-mp3: 未找到 videoId=${videoId}`);
  70. return res.status(404).json({ error: `未找到视频: ${videoId}` });
  71. }
  72. const videoPath = deps.resolveVideoPath(video.filename);
  73. if (!fs.existsSync(videoPath)) {
  74. console.warn(`⚠️ extract-audio-mp3: 视频文件不存在: ${videoPath}`);
  75. return res.status(404).json({ error: `视频文件不存在: ${video.filename}` });
  76. }
  77. const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`;
  78. const outputFilename = `${safeBaseName}-${Date.now()}.m4a`;
  79. const outputPath = path.join(deps.dataRemixAssetDir, outputFilename);
  80. const outputMimeType = 'audio/mp4';
  81. console.log(`🎵 开始音频提取: ${videoPath} → ${outputPath}`);
  82. const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath];
  83. const proc = spawn('ffmpeg', ffmpegArgs, { cwd: deps.projectRoot });
  84. let stderr = '';
  85. let responded = false;
  86. const safeRespond = (status, body) => {
  87. if (responded) return;
  88. responded = true;
  89. res.status(status).json(body);
  90. };
  91. proc.stderr.on('data', (data) => { stderr += data.toString(); });
  92. proc.on('error', (err) => {
  93. deps.removeFileIfExists(outputPath);
  94. const message = /ENOENT/i.test(err.message)
  95. ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量'
  96. : `无法启动 ffmpeg:${err.message}`;
  97. console.error(`❌ extract-audio-mp3 spawn error: ${err.message}`);
  98. safeRespond(500, { error: message });
  99. });
  100. proc.on('close', (code) => {
  101. if (code !== 0 || !fs.existsSync(outputPath)) {
  102. const tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
  103. console.error(`❌ ffmpeg exit code=${code} 输出文件存在=${fs.existsSync(outputPath)}`);
  104. console.error(` stderr 末尾: ${tail}`);
  105. deps.removeFileIfExists(outputPath);
  106. const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || '';
  107. return safeRespond(500, { error: `音频提取失败 (exit=${code})${lastErrorLine ? `: ${lastErrorLine}` : ''}` });
  108. }
  109. const stats = fs.statSync(outputPath);
  110. const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
  111. console.log(`🎵 音频提取完成: ${outputFilename} (${sizeMB}MB)`);
  112. const audioBuffer = fs.readFileSync(outputPath);
  113. const audioBase64 = audioBuffer.toString('base64');
  114. deps.removeFileIfExists(outputPath);
  115. safeRespond(200, {
  116. success: true,
  117. audio: {
  118. base64: audioBase64,
  119. mimeType: outputMimeType,
  120. sizeMB: parseFloat(sizeMB),
  121. filename: outputFilename,
  122. },
  123. });
  124. });
  125. });
  126. }
  127. module.exports = { registerAudioExtractionRoutes };