const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); function registerAudioExtractionRoutes(app, deps) { app.post('/api/remix/extract-audio', (req, res) => { const { videoId } = req.body || {}; if (!videoId) { return res.status(400).json({ error: '缺少 videoId 参数' }); } const manifest = deps.readManifest(); const video = manifest.find(v => v.id === videoId); if (!video) { return res.status(404).json({ error: `未找到视频: ${videoId}` }); } const videoPath = deps.resolveVideoPath(video.filename); if (!fs.existsSync(videoPath)) { return res.status(404).json({ error: `视频文件不存在: ${video.filename}` }); } const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`; const outputFilename = `${safeBaseName}-${Date.now()}.wav`; const outputPath = path.join(deps.dataRemixAssetDir, outputFilename); const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', outputPath]; const proc = spawn('ffmpeg', ffmpegArgs, { cwd: deps.projectRoot }); let stderr = ''; proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('error', (err) => { deps.removeFileIfExists(outputPath); const message = /ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量' : `无法启动 ffmpeg:${err.message}`; res.status(500).json({ error: message }); }); proc.on('close', (code) => { if (code !== 0 || !fs.existsSync(outputPath)) { deps.removeFileIfExists(outputPath); const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || ''; const normalizedMessage = /ffmpeg/i.test(stderr) && /not recognized|not found|no such file/i.test(stderr) ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量' : `音频提取失败${lastErrorLine ? `: ${lastErrorLine}` : ''}`; return res.status(500).json({ error: normalizedMessage }); } res.setHeader('Content-Type', 'audio/wav'); res.setHeader('Content-Disposition', `attachment; filename="${outputFilename}"`); const stream = fs.createReadStream(outputPath); stream.on('close', () => { deps.removeFileIfExists(outputPath); }); stream.on('error', () => { deps.removeFileIfExists(outputPath); if (!res.headersSent) { res.status(500).json({ error: '音频文件读取失败' }); } else { res.end(); } }); stream.pipe(res); }); }); app.post('/api/extract-audio-mp3', (req, res) => { const { videoId } = req.body || {}; if (!videoId) { return res.status(400).json({ error: '缺少 videoId 参数' }); } const manifest = deps.readManifest(); const video = manifest.find(v => v.id === videoId); if (!video) { console.warn(`⚠️ extract-audio-mp3: 未找到 videoId=${videoId}`); return res.status(404).json({ error: `未找到视频: ${videoId}` }); } const videoPath = deps.resolveVideoPath(video.filename); if (!fs.existsSync(videoPath)) { console.warn(`⚠️ extract-audio-mp3: 视频文件不存在: ${videoPath}`); return res.status(404).json({ error: `视频文件不存在: ${video.filename}` }); } const safeBaseName = path.basename(video.filename, path.extname(video.filename)).replace(/[^a-zA-Z0-9._-]/g, '_') || `audio-${Date.now()}`; const outputFilename = `${safeBaseName}-${Date.now()}.m4a`; const outputPath = path.join(deps.dataRemixAssetDir, outputFilename); const outputMimeType = 'audio/mp4'; console.log(`🎵 开始音频提取: ${videoPath} → ${outputPath}`); const ffmpegArgs = ['-y', '-i', videoPath, '-vn', '-c:a', 'aac', '-b:a', '64k', '-ar', '16000', '-ac', '1', outputPath]; const proc = spawn('ffmpeg', ffmpegArgs, { cwd: deps.projectRoot }); let stderr = ''; let responded = false; const safeRespond = (status, body) => { if (responded) return; responded = true; res.status(status).json(body); }; proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('error', (err) => { deps.removeFileIfExists(outputPath); const message = /ENOENT/i.test(err.message) ? '未检测到 ffmpeg,请先安装 ffmpeg 并确保已加入系统 PATH 环境变量' : `无法启动 ffmpeg:${err.message}`; console.error(`❌ extract-audio-mp3 spawn error: ${err.message}`); safeRespond(500, { error: message }); }); proc.on('close', (code) => { if (code !== 0 || !fs.existsSync(outputPath)) { const tail = stderr.split('\n').filter(Boolean).slice(-5).join(' | '); console.error(`❌ ffmpeg exit code=${code} 输出文件存在=${fs.existsSync(outputPath)}`); console.error(` stderr 末尾: ${tail}`); deps.removeFileIfExists(outputPath); const lastErrorLine = stderr.split('\n').filter(Boolean).slice(-1)[0] || ''; return safeRespond(500, { error: `音频提取失败 (exit=${code})${lastErrorLine ? `: ${lastErrorLine}` : ''}` }); } const stats = fs.statSync(outputPath); const sizeMB = (stats.size / 1024 / 1024).toFixed(2); console.log(`🎵 音频提取完成: ${outputFilename} (${sizeMB}MB)`); const audioBuffer = fs.readFileSync(outputPath); const audioBase64 = audioBuffer.toString('base64'); deps.removeFileIfExists(outputPath); safeRespond(200, { success: true, audio: { base64: audioBase64, mimeType: outputMimeType, sizeMB: parseFloat(sizeMB), filename: outputFilename, }, }); }); }); } module.exports = { registerAudioExtractionRoutes };