| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- function registerVideoCompositeRoutes(app, deps) {
- const { fs, path, spawn, dataDir, removeFileIfExists } = deps;
- const compositeDir = path.join(dataDir, 'composite');
- if (!fs.existsSync(compositeDir)) fs.mkdirSync(compositeDir, { recursive: true });
- async function downloadFile(url, destPath) {
- const response = await fetch(url, { redirect: 'follow' });
- if (!response.ok) throw new Error(`下载失败 (${response.status}): ${url}`);
- const buffer = Buffer.from(await response.arrayBuffer());
- fs.writeFileSync(destPath, buffer);
- return destPath;
- }
- function getMediaDuration(mediaPath) {
- return new Promise((resolve, reject) => {
- const proc = spawn('ffprobe', [
- '-v', 'error', '-show_entries', 'format=duration',
- '-of', 'default=noprint_wrappers=1:nokey=1', mediaPath,
- ]);
- let stdout = '';
- proc.stdout.on('data', d => stdout += d.toString());
- proc.on('error', reject);
- proc.on('close', code => {
- const dur = parseFloat(stdout.trim());
- if (code !== 0 || isNaN(dur)) reject(new Error('无法获取媒体时长'));
- else resolve(dur);
- });
- });
- }
- const getAudioDuration = getMediaDuration;
- function convertImageToJpg(inputPath, outputPath) {
- return new Promise((resolve, reject) => {
- const proc = spawn('ffmpeg', ['-y', '-i', inputPath, '-frames:v', '1', outputPath]);
- let stderr = '';
- proc.stderr.on('data', d => stderr += d.toString());
- proc.on('error', reject);
- proc.on('close', code => {
- if (code !== 0) reject(new Error(`图片转换失败: ${stderr.split('\n').filter(Boolean).slice(-1)[0]}`));
- else resolve(outputPath);
- });
- });
- }
- const SEG_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black';
- const SEG_AUDIO_ARGS = ['-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '2'];
- function createSegmentVideo(imagePath, audioPath, outputPath, duration) {
- return new Promise(async (resolve, reject) => {
- try {
- const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
- await convertImageToJpg(imagePath, jpgPath);
- const args = [
- '-y',
- '-loop', '1', '-i', jpgPath,
- '-i', audioPath,
- '-c:v', 'h264_mf',
- ...SEG_AUDIO_ARGS,
- '-vf', SEG_VF,
- '-pix_fmt', 'yuv420p',
- '-t', String(duration),
- '-shortest',
- outputPath,
- ];
- const proc = spawn('ffmpeg', args);
- let stderr = '';
- proc.stderr.on('data', d => stderr += d.toString());
- proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
- proc.on('close', code => {
- removeFileIfExists(jpgPath);
- if (code !== 0) reject(new Error(`片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
- else resolve(outputPath);
- });
- } catch (err) {
- reject(err);
- }
- });
- }
- function createVideoAudioSegment(videoPath, audioPath, outputPath, audioDuration, videoDuration) {
- return new Promise((resolve, reject) => {
- const target = Number(audioDuration);
- if (!target || target <= 0) {
- reject(new Error('createVideoAudioSegment 需要正数 audioDuration'));
- return;
- }
- let vfChain = SEG_VF;
- let needPad = false;
- if (typeof videoDuration === 'number' && videoDuration > 0 && videoDuration < target - 0.05) {
- const padSec = (target - videoDuration).toFixed(3);
- vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`;
- needPad = true;
- }
- const args = [
- '-y',
- '-i', videoPath,
- '-i', audioPath,
- '-map', '0:v:0',
- '-map', '1:a:0',
- '-c:v', 'h264_mf',
- '-vf', vfChain,
- '-pix_fmt', 'yuv420p',
- ...SEG_AUDIO_ARGS,
- '-t', String(target),
- outputPath,
- ];
- console.log(` ↳ case B align: audio=${target.toFixed(2)}s, video=${videoDuration ? videoDuration.toFixed(2) + 's' : '?'}, ${needPad ? 'PAD freeze last frame' : 'TRIM/EXACT'}`);
- const proc = spawn('ffmpeg', args);
- let stderr = '';
- proc.stderr.on('data', d => stderr += d.toString());
- proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
- proc.on('close', code => {
- if (code !== 0) reject(new Error(`视频+音频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
- else resolve(outputPath);
- });
- });
- }
- function createVideoOnlySegment(videoPath, outputPath) {
- return new Promise((resolve, reject) => {
- const args = [
- '-y',
- '-i', videoPath,
- '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
- '-map', '0:v:0',
- '-map', '1:a:0',
- '-c:v', 'h264_mf',
- '-vf', SEG_VF,
- '-pix_fmt', 'yuv420p',
- ...SEG_AUDIO_ARGS,
- '-shortest',
- outputPath,
- ];
- const proc = spawn('ffmpeg', args);
- let stderr = '';
- proc.stderr.on('data', d => stderr += d.toString());
- proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
- proc.on('close', code => {
- if (code !== 0) reject(new Error(`纯视频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
- else resolve(outputPath);
- });
- });
- }
- function createImageOnlySegment(imagePath, outputPath, duration) {
- return new Promise(async (resolve, reject) => {
- try {
- const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
- await convertImageToJpg(imagePath, jpgPath);
- const args = [
- '-y',
- '-loop', '1', '-i', jpgPath,
- '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
- '-map', '0:v:0',
- '-map', '1:a:0',
- '-c:v', 'h264_mf',
- '-vf', SEG_VF,
- '-pix_fmt', 'yuv420p',
- ...SEG_AUDIO_ARGS,
- '-t', String(duration),
- '-shortest',
- outputPath,
- ];
- const proc = spawn('ffmpeg', args);
- let stderr = '';
- proc.stderr.on('data', d => stderr += d.toString());
- proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
- proc.on('close', code => {
- removeFileIfExists(jpgPath);
- if (code !== 0) reject(new Error(`静音图片片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
- else resolve(outputPath);
- });
- } catch (err) {
- reject(err);
- }
- });
- }
- async function composeOneSegment({ seg, jobDir, i }) {
- const segVideoPath = path.join(jobDir, `seg-${i}.mp4`);
- const hasImage = !!seg.imageUrl;
- const hasVideo = !!seg.videoUrl;
- const hasAudio = !!seg.audioUrl;
- if (!hasImage && !hasVideo) {
- throw new Error(`片段 ${seg.id != null ? seg.id : i} 缺少 imageUrl/videoUrl`);
- }
- let audioPath = null;
- let audioDuration = null;
- if (hasAudio) {
- const ext = (seg.audioUrl.match(/\.(mp3|wav|aac|ogg|m4a)/i) || ['.mp3'])[0] || '.mp3';
- audioPath = path.join(jobDir, `audio-${i}${ext}`);
- await downloadFile(seg.audioUrl, audioPath);
- audioDuration = await getAudioDuration(audioPath);
- }
- if (hasVideo) {
- const videoPath = path.join(jobDir, `video-${i}.mp4`);
- await downloadFile(seg.videoUrl, videoPath);
- let videoDuration;
- try {
- videoDuration = await getMediaDuration(videoPath);
- } catch (e) {
- console.warn(` ⚠️ 片段 ${i + 1} 探测视频时长失败,退化为 -shortest 对齐:`, e?.message || e);
- }
- if (hasAudio) {
- await createVideoAudioSegment(videoPath, audioPath, segVideoPath, audioDuration, videoDuration);
- return { path: segVideoPath, duration: audioDuration, mode: 'B' };
- }
- await createVideoOnlySegment(videoPath, segVideoPath);
- return { path: segVideoPath, duration: null, mode: 'C' };
- }
- const ext = (seg.imageUrl.match(/\.(jpg|jpeg|png|webp|gif)/i) || ['.jpg'])[0] || '.jpg';
- const imgPath = path.join(jobDir, `img-${i}${ext}`);
- await downloadFile(seg.imageUrl, imgPath);
- if (hasAudio) {
- await createSegmentVideo(imgPath, audioPath, segVideoPath, audioDuration);
- return { path: segVideoPath, duration: audioDuration, mode: 'A' };
- }
- const dur = Math.max(0.5, Number(seg.duration) || 3);
- await createImageOnlySegment(imgPath, segVideoPath, dur);
- return { path: segVideoPath, duration: dur, mode: 'D' };
- }
- function concatVideos(segmentPaths, outputPath) {
- return new Promise((resolve, reject) => {
- const listPath = outputPath + '.txt';
- const listContent = segmentPaths.map(p => `file '${p.replace(/\\/g, '/')}'`).join('\n');
- fs.writeFileSync(listPath, listContent, 'utf-8');
- const args = [
- '-y', '-f', 'concat', '-safe', '0',
- '-i', listPath,
- '-c', 'copy',
- outputPath,
- ];
- const proc = spawn('ffmpeg', args);
- let stderr = '';
- proc.stderr.on('data', d => stderr += d.toString());
- proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
- proc.on('close', code => {
- removeFileIfExists(listPath);
- if (code !== 0) reject(new Error(`视频拼接失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
- else resolve(outputPath);
- });
- });
- }
- app.post('/api/video/composite', async (req, res) => {
- const { segments, title } = req.body;
- if (!Array.isArray(segments) || segments.length === 0) {
- return res.status(400).json({ error: '缺少 segments 参数' });
- }
- const jobId = `VG-${Date.now()}`;
- const jobDir = path.join(compositeDir, jobId);
- fs.mkdirSync(jobDir, { recursive: true });
- try {
- console.log(`🎬 开始合成视频: ${jobId}, ${segments.length} 个片段`);
- const segmentPaths = [];
- for (let i = 0; i < segments.length; i++) {
- const seg = segments[i];
- if (!seg.imageUrl && !seg.videoUrl) {
- console.warn(`⚠️ 片段 ${seg.id || i} 缺少 imageUrl/videoUrl,跳过`);
- continue;
- }
- console.log(` 📥 处理片段 ${i + 1}/${segments.length}...`);
- const { path: segPath, duration, mode } = await composeOneSegment({ seg, jobDir, i });
- console.log(` 🎞️ 已完成片段 ${i + 1}/${segments.length} [mode=${mode}${duration ? `, ${duration.toFixed(1)}s` : ''}]`);
- segmentPaths.push(segPath);
- }
- if (segmentPaths.length === 0) {
- return res.status(400).json({ error: '没有有效的素材片段' });
- }
- const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50);
- const finalFilename = `${safeTitle}-${jobId}.mp4`;
- const finalPath = path.join(compositeDir, finalFilename);
- console.log(` 🔗 拼接 ${segmentPaths.length} 个片段...`);
- if (segmentPaths.length === 1) {
- fs.copyFileSync(segmentPaths[0], finalPath);
- } else {
- await concatVideos(segmentPaths, finalPath);
- }
- try {
- fs.rmSync(jobDir, { recursive: true, force: true });
- } catch {}
- const fileSize = fs.statSync(finalPath).size;
- console.log(`✅ 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
- res.json({
- success: true,
- videoUrl: `/api/video/composite/${finalFilename}`,
- filename: finalFilename,
- size: fileSize,
- segments: segmentPaths.length,
- });
- } catch (err) {
- console.error('❌ 视频合成失败:', err.message);
- try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
- res.status(500).json({ error: `视频合成失败: ${err.message}` });
- }
- });
- app.post('/api/video/composite/stream', async (req, res) => {
- return res.status(410).json({
- error: 'deprecated: client now composes in browser via ffmpeg.wasm; see BrowserFfmpegService.composeVideo()',
- });
- });
- app.get('/api/video/composite/:filename', (req, res) => {
- const filePath = path.join(compositeDir, req.params.filename);
- if (!fs.existsSync(filePath)) {
- return res.status(404).json({ error: '视频文件不存在' });
- }
- res.setHeader('Content-Type', 'video/mp4');
- fs.createReadStream(filePath).pipe(res);
- });
- }
- module.exports = { registerVideoCompositeRoutes };
|