AI.Demo/server/routes/video.js

285 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;