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 };