285 lines
7.9 KiB
JavaScript
285 lines
7.9 KiB
JavaScript
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;
|