import express from 'express'; import ffmpeg from 'fluent-ffmpeg'; import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = express.Router(); // 临时文件目录 const TEMP_DIR = path.join(__dirname, '../temp'); // 确保临时目录存在 async function ensureTempDir() { try { await fs.mkdir(TEMP_DIR, { recursive: true }); } catch (err) { console.error('创建临时目录失败:', err); } } ensureTempDir(); /** * 下载资源到本地 */ async function downloadResource(url, outputPath) { // 处理 base64 图片 (data:image/svg+xml,...) if (url.startsWith('data:')) { const matches = url.match(/^data:image\/(\w+);base64,(.+)$/); if (matches) { const buffer = Buffer.from(matches[2], 'base64'); await fs.writeFile(outputPath, buffer); return outputPath; } // 处理 URL 编码的 SVG if (url.startsWith('data:image/svg+xml,')) { const svgContent = decodeURIComponent(url.replace('data:image/svg+xml,', '')); await fs.writeFile(outputPath, svgContent); return outputPath; } throw new Error('不支持的 base64 格式'); } // 处理普通 HTTP URL const response = await axios({ method: 'GET', url: url, responseType: 'arraybuffer', timeout: 60000, }); await fs.writeFile(outputPath, response.data); return outputPath; } /** * 获取音频时长 */ function getAudioDuration(audioPath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(audioPath, (err, metadata) => { if (err) { reject(err); return; } const duration = metadata.format.duration; resolve(duration); }); }); } /** * 合成单个视频片段 */ async function composeSegment(imagePath, audioPath, outputPath, width, height) { const audioDuration = await getAudioDuration(audioPath); return new Promise((resolve, reject) => { ffmpeg() .input(imagePath) .loop(1) .input(audioPath) .outputOptions([ '-tune stillimage', '-c:a aac', '-b:a 192k', '-pix_fmt yuv420p', '-shortest' ]) .videoFilter(`scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`) .duration(audioDuration + 0.5) .output(outputPath) .on('start', (commandLine) => { console.log('FFmpeg 命令:', commandLine); }) .on('end', () => resolve(outputPath)) .on('error', (err, stdout, stderr) => { console.error('FFmpeg 错误输出:', stderr); reject(err); }) .run(); }); } /** * 合并多个视频片段 */ async function mergeSegments(segmentPaths, outputPath) { const listPath = path.join(TEMP_DIR, `concat_${uuidv4()}.txt`); // Windows 路径需要转义反斜杠或使用正斜杠 const listContent = segmentPaths.map(p => { // 将反斜杠替换为正斜杠,FFmpeg 更兼容 const normalizedPath = p.replace(/\\/g, '/'); return `file '${normalizedPath}'`; }).join('\n'); await fs.writeFile(listPath, listContent); console.log('合并列表内容:\n', listContent); return new Promise((resolve, reject) => { ffmpeg() .input(listPath) .inputOptions(['-f concat', '-safe 0']) .outputOptions(['-c copy']) .output(outputPath) .on('start', (commandLine) => { console.log('FFmpeg 合并命令:', commandLine); }) .on('end', async () => { // 清理列表文件 try { await fs.unlink(listPath); } catch (e) { console.error('清理列表文件失败:', e); } resolve(outputPath); }) .on('error', (err, stdout, stderr) => { console.error('FFmpeg 合并错误:', stderr); reject(err); }) .run(); }); } /** * POST /api/video/compose * 合成视频 * * Body: { * slides: [{ imageUrl, audioUrl }], * width: 1920, * height: 1080 * } */ router.post('/compose', async (req, res) => { const { slides, width = 1920, height = 1080 } = req.body; if (!slides || !Array.isArray(slides) || slides.length === 0) { return res.status(400).json({ error: '缺少 slides 参数或格式错误' }); } const taskId = uuidv4(); const taskDir = path.join(TEMP_DIR, taskId); console.log(`[${taskId}] 开始处理视频合成任务,共 ${slides.length} 个片段`); try { // 创建任务目录 await fs.mkdir(taskDir, { recursive: true }); const segmentPaths = []; // 逐个处理片段 for (let i = 0; i < slides.length; i++) { const slide = slides[i]; console.log(`[${taskId}] 处理第 ${i + 1}/${slides.length} 个片段`); if (!slide.imageUrl || !slide.audioUrl) { console.warn(`[${taskId}] 第 ${i + 1} 个片段缺少 imageUrl 或 audioUrl,跳过`); continue; } // 下载图片和音频 const imagePath = path.join(taskDir, `image_${i}.png`); const audioPath = path.join(taskDir, `audio_${i}.mp3`); const segmentPath = path.join(taskDir, `segment_${i}.mp4`); console.log(`[${taskId}] 下载图片: ${slide.imageUrl.substring(0, 50)}...`); console.log(`[${taskId}] 下载音频: ${slide.audioUrl.substring(0, 50)}...`); await downloadResource(slide.imageUrl, imagePath); await downloadResource(slide.audioUrl, audioPath); // 检查文件是否成功创建 try { const imageStats = await fs.stat(imagePath); const audioStats = await fs.stat(audioPath); console.log(`[${taskId}] 图片大小: ${imageStats.size} bytes, 音频大小: ${audioStats.size} bytes`); } catch (e) { console.error(`[${taskId}] 文件检查失败:`, e); } // 合成片段 await composeSegment(imagePath, audioPath, segmentPath, width, height); segmentPaths.push(segmentPath); console.log(`[${taskId}] 第 ${i + 1} 个片段合成完成`); } if (segmentPaths.length === 0) { throw new Error('没有有效的片段可以合并'); } // 合并所有片段 console.log(`[${taskId}] 开始合并 ${segmentPaths.length} 个片段`); const finalPath = path.join(taskDir, 'output.mp4'); await mergeSegments(segmentPaths, finalPath); console.log(`[${taskId}] 视频合成完成: ${finalPath}`); // 返回视频文件 res.setHeader('Content-Type', 'video/mp4'); res.setHeader('Content-Disposition', 'attachment; filename="question-explanation.mp4"'); const videoBuffer = await fs.readFile(finalPath); res.send(videoBuffer); // 清理临时文件(延迟删除,确保响应已发送) setTimeout(async () => { try { await fs.rm(taskDir, { recursive: true, force: true }); console.log(`[${taskId}] 临时文件已清理`); } catch (e) { console.error(`[${taskId}] 清理临时文件失败:`, e); } }, 1000); } catch (error) { console.error(`[${taskId}] 视频合成失败:`, error); // 清理临时文件 try { await fs.rm(taskDir, { recursive: true, force: true }); } catch (e) { // 忽略清理错误 } res.status(500).json({ error: '视频合成失败', message: error.message }); } }); /** * GET /api/video/health * 检查视频服务状态 */ router.get('/health', (req, res) => { // 检查 ffmpeg 是否可用 ffmpeg.getAvailableFormats((err, formats) => { if (err) { return res.status(500).json({ status: 'error', message: 'FFmpeg 不可用', error: err.message }); } res.json({ status: 'ok', message: '视频合成服务正常运行', formats: formats ? Object.keys(formats).length : 'unknown' }); }); }); export default router;