video-composite.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. function registerVideoCompositeRoutes(app, deps) {
  2. const { fs, path, spawn, dataDir, removeFileIfExists } = deps;
  3. const compositeDir = path.join(dataDir, 'composite');
  4. if (!fs.existsSync(compositeDir)) fs.mkdirSync(compositeDir, { recursive: true });
  5. async function downloadFile(url, destPath) {
  6. const response = await fetch(url, { redirect: 'follow' });
  7. if (!response.ok) throw new Error(`下载失败 (${response.status}): ${url}`);
  8. const buffer = Buffer.from(await response.arrayBuffer());
  9. fs.writeFileSync(destPath, buffer);
  10. return destPath;
  11. }
  12. function getMediaDuration(mediaPath) {
  13. return new Promise((resolve, reject) => {
  14. const proc = spawn('ffprobe', [
  15. '-v', 'error', '-show_entries', 'format=duration',
  16. '-of', 'default=noprint_wrappers=1:nokey=1', mediaPath,
  17. ]);
  18. let stdout = '';
  19. proc.stdout.on('data', d => stdout += d.toString());
  20. proc.on('error', reject);
  21. proc.on('close', code => {
  22. const dur = parseFloat(stdout.trim());
  23. if (code !== 0 || isNaN(dur)) reject(new Error('无法获取媒体时长'));
  24. else resolve(dur);
  25. });
  26. });
  27. }
  28. const getAudioDuration = getMediaDuration;
  29. function convertImageToJpg(inputPath, outputPath) {
  30. return new Promise((resolve, reject) => {
  31. const proc = spawn('ffmpeg', ['-y', '-i', inputPath, '-frames:v', '1', outputPath]);
  32. let stderr = '';
  33. proc.stderr.on('data', d => stderr += d.toString());
  34. proc.on('error', reject);
  35. proc.on('close', code => {
  36. if (code !== 0) reject(new Error(`图片转换失败: ${stderr.split('\n').filter(Boolean).slice(-1)[0]}`));
  37. else resolve(outputPath);
  38. });
  39. });
  40. }
  41. const SEG_VF = 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black';
  42. const SEG_AUDIO_ARGS = ['-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '2'];
  43. function createSegmentVideo(imagePath, audioPath, outputPath, duration) {
  44. return new Promise(async (resolve, reject) => {
  45. try {
  46. const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
  47. await convertImageToJpg(imagePath, jpgPath);
  48. const args = [
  49. '-y',
  50. '-loop', '1', '-i', jpgPath,
  51. '-i', audioPath,
  52. '-c:v', 'h264_mf',
  53. ...SEG_AUDIO_ARGS,
  54. '-vf', SEG_VF,
  55. '-pix_fmt', 'yuv420p',
  56. '-t', String(duration),
  57. '-shortest',
  58. outputPath,
  59. ];
  60. const proc = spawn('ffmpeg', args);
  61. let stderr = '';
  62. proc.stderr.on('data', d => stderr += d.toString());
  63. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  64. proc.on('close', code => {
  65. removeFileIfExists(jpgPath);
  66. if (code !== 0) reject(new Error(`片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  67. else resolve(outputPath);
  68. });
  69. } catch (err) {
  70. reject(err);
  71. }
  72. });
  73. }
  74. function createVideoAudioSegment(videoPath, audioPath, outputPath, audioDuration, videoDuration) {
  75. return new Promise((resolve, reject) => {
  76. const target = Number(audioDuration);
  77. if (!target || target <= 0) {
  78. reject(new Error('createVideoAudioSegment 需要正数 audioDuration'));
  79. return;
  80. }
  81. let vfChain = SEG_VF;
  82. let needPad = false;
  83. if (typeof videoDuration === 'number' && videoDuration > 0 && videoDuration < target - 0.05) {
  84. const padSec = (target - videoDuration).toFixed(3);
  85. vfChain += `,tpad=stop_mode=clone:stop_duration=${padSec}`;
  86. needPad = true;
  87. }
  88. const args = [
  89. '-y',
  90. '-i', videoPath,
  91. '-i', audioPath,
  92. '-map', '0:v:0',
  93. '-map', '1:a:0',
  94. '-c:v', 'h264_mf',
  95. '-vf', vfChain,
  96. '-pix_fmt', 'yuv420p',
  97. ...SEG_AUDIO_ARGS,
  98. '-t', String(target),
  99. outputPath,
  100. ];
  101. console.log(` ↳ case B align: audio=${target.toFixed(2)}s, video=${videoDuration ? videoDuration.toFixed(2) + 's' : '?'}, ${needPad ? 'PAD freeze last frame' : 'TRIM/EXACT'}`);
  102. const proc = spawn('ffmpeg', args);
  103. let stderr = '';
  104. proc.stderr.on('data', d => stderr += d.toString());
  105. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  106. proc.on('close', code => {
  107. if (code !== 0) reject(new Error(`视频+音频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  108. else resolve(outputPath);
  109. });
  110. });
  111. }
  112. function createVideoOnlySegment(videoPath, outputPath) {
  113. return new Promise((resolve, reject) => {
  114. const args = [
  115. '-y',
  116. '-i', videoPath,
  117. '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
  118. '-map', '0:v:0',
  119. '-map', '1:a:0',
  120. '-c:v', 'h264_mf',
  121. '-vf', SEG_VF,
  122. '-pix_fmt', 'yuv420p',
  123. ...SEG_AUDIO_ARGS,
  124. '-shortest',
  125. outputPath,
  126. ];
  127. const proc = spawn('ffmpeg', args);
  128. let stderr = '';
  129. proc.stderr.on('data', d => stderr += d.toString());
  130. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  131. proc.on('close', code => {
  132. if (code !== 0) reject(new Error(`纯视频片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  133. else resolve(outputPath);
  134. });
  135. });
  136. }
  137. function createImageOnlySegment(imagePath, outputPath, duration) {
  138. return new Promise(async (resolve, reject) => {
  139. try {
  140. const jpgPath = imagePath.replace(/\.[^.]+$/, '') + '_converted.jpg';
  141. await convertImageToJpg(imagePath, jpgPath);
  142. const args = [
  143. '-y',
  144. '-loop', '1', '-i', jpgPath,
  145. '-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
  146. '-map', '0:v:0',
  147. '-map', '1:a:0',
  148. '-c:v', 'h264_mf',
  149. '-vf', SEG_VF,
  150. '-pix_fmt', 'yuv420p',
  151. ...SEG_AUDIO_ARGS,
  152. '-t', String(duration),
  153. '-shortest',
  154. outputPath,
  155. ];
  156. const proc = spawn('ffmpeg', args);
  157. let stderr = '';
  158. proc.stderr.on('data', d => stderr += d.toString());
  159. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  160. proc.on('close', code => {
  161. removeFileIfExists(jpgPath);
  162. if (code !== 0) reject(new Error(`静音图片片段合成失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  163. else resolve(outputPath);
  164. });
  165. } catch (err) {
  166. reject(err);
  167. }
  168. });
  169. }
  170. async function composeOneSegment({ seg, jobDir, i }) {
  171. const segVideoPath = path.join(jobDir, `seg-${i}.mp4`);
  172. const hasImage = !!seg.imageUrl;
  173. const hasVideo = !!seg.videoUrl;
  174. const hasAudio = !!seg.audioUrl;
  175. if (!hasImage && !hasVideo) {
  176. throw new Error(`片段 ${seg.id != null ? seg.id : i} 缺少 imageUrl/videoUrl`);
  177. }
  178. let audioPath = null;
  179. let audioDuration = null;
  180. if (hasAudio) {
  181. const ext = (seg.audioUrl.match(/\.(mp3|wav|aac|ogg|m4a)/i) || ['.mp3'])[0] || '.mp3';
  182. audioPath = path.join(jobDir, `audio-${i}${ext}`);
  183. await downloadFile(seg.audioUrl, audioPath);
  184. audioDuration = await getAudioDuration(audioPath);
  185. }
  186. if (hasVideo) {
  187. const videoPath = path.join(jobDir, `video-${i}.mp4`);
  188. await downloadFile(seg.videoUrl, videoPath);
  189. let videoDuration;
  190. try {
  191. videoDuration = await getMediaDuration(videoPath);
  192. } catch (e) {
  193. console.warn(` ⚠️ 片段 ${i + 1} 探测视频时长失败,退化为 -shortest 对齐:`, e?.message || e);
  194. }
  195. if (hasAudio) {
  196. await createVideoAudioSegment(videoPath, audioPath, segVideoPath, audioDuration, videoDuration);
  197. return { path: segVideoPath, duration: audioDuration, mode: 'B' };
  198. }
  199. await createVideoOnlySegment(videoPath, segVideoPath);
  200. return { path: segVideoPath, duration: null, mode: 'C' };
  201. }
  202. const ext = (seg.imageUrl.match(/\.(jpg|jpeg|png|webp|gif)/i) || ['.jpg'])[0] || '.jpg';
  203. const imgPath = path.join(jobDir, `img-${i}${ext}`);
  204. await downloadFile(seg.imageUrl, imgPath);
  205. if (hasAudio) {
  206. await createSegmentVideo(imgPath, audioPath, segVideoPath, audioDuration);
  207. return { path: segVideoPath, duration: audioDuration, mode: 'A' };
  208. }
  209. const dur = Math.max(0.5, Number(seg.duration) || 3);
  210. await createImageOnlySegment(imgPath, segVideoPath, dur);
  211. return { path: segVideoPath, duration: dur, mode: 'D' };
  212. }
  213. function concatVideos(segmentPaths, outputPath) {
  214. return new Promise((resolve, reject) => {
  215. const listPath = outputPath + '.txt';
  216. const listContent = segmentPaths.map(p => `file '${p.replace(/\\/g, '/')}'`).join('\n');
  217. fs.writeFileSync(listPath, listContent, 'utf-8');
  218. const args = [
  219. '-y', '-f', 'concat', '-safe', '0',
  220. '-i', listPath,
  221. '-c', 'copy',
  222. outputPath,
  223. ];
  224. const proc = spawn('ffmpeg', args);
  225. let stderr = '';
  226. proc.stderr.on('data', d => stderr += d.toString());
  227. proc.on('error', err => reject(new Error(/ENOENT/i.test(err.message) ? '未检测到 ffmpeg' : err.message)));
  228. proc.on('close', code => {
  229. removeFileIfExists(listPath);
  230. if (code !== 0) reject(new Error(`视频拼接失败: ${stderr.split('\n').filter(Boolean).slice(-2).join(' ')}`));
  231. else resolve(outputPath);
  232. });
  233. });
  234. }
  235. app.post('/api/video/composite', async (req, res) => {
  236. const { segments, title } = req.body;
  237. if (!Array.isArray(segments) || segments.length === 0) {
  238. return res.status(400).json({ error: '缺少 segments 参数' });
  239. }
  240. const jobId = `VG-${Date.now()}`;
  241. const jobDir = path.join(compositeDir, jobId);
  242. fs.mkdirSync(jobDir, { recursive: true });
  243. try {
  244. console.log(`🎬 开始合成视频: ${jobId}, ${segments.length} 个片段`);
  245. const segmentPaths = [];
  246. for (let i = 0; i < segments.length; i++) {
  247. const seg = segments[i];
  248. if (!seg.imageUrl && !seg.videoUrl) {
  249. console.warn(`⚠️ 片段 ${seg.id || i} 缺少 imageUrl/videoUrl,跳过`);
  250. continue;
  251. }
  252. console.log(` 📥 处理片段 ${i + 1}/${segments.length}...`);
  253. const { path: segPath, duration, mode } = await composeOneSegment({ seg, jobDir, i });
  254. console.log(` 🎞️ 已完成片段 ${i + 1}/${segments.length} [mode=${mode}${duration ? `, ${duration.toFixed(1)}s` : ''}]`);
  255. segmentPaths.push(segPath);
  256. }
  257. if (segmentPaths.length === 0) {
  258. return res.status(400).json({ error: '没有有效的素材片段' });
  259. }
  260. const safeTitle = String(title || 'video').replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, '_').substring(0, 50);
  261. const finalFilename = `${safeTitle}-${jobId}.mp4`;
  262. const finalPath = path.join(compositeDir, finalFilename);
  263. console.log(` 🔗 拼接 ${segmentPaths.length} 个片段...`);
  264. if (segmentPaths.length === 1) {
  265. fs.copyFileSync(segmentPaths[0], finalPath);
  266. } else {
  267. await concatVideos(segmentPaths, finalPath);
  268. }
  269. try {
  270. fs.rmSync(jobDir, { recursive: true, force: true });
  271. } catch {}
  272. const fileSize = fs.statSync(finalPath).size;
  273. console.log(`✅ 视频合成完成: ${finalFilename} (${(fileSize / 1024 / 1024).toFixed(1)}MB)`);
  274. res.json({
  275. success: true,
  276. videoUrl: `/api/video/composite/${finalFilename}`,
  277. filename: finalFilename,
  278. size: fileSize,
  279. segments: segmentPaths.length,
  280. });
  281. } catch (err) {
  282. console.error('❌ 视频合成失败:', err.message);
  283. try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
  284. res.status(500).json({ error: `视频合成失败: ${err.message}` });
  285. }
  286. });
  287. app.post('/api/video/composite/stream', async (req, res) => {
  288. return res.status(410).json({
  289. error: 'deprecated: client now composes in browser via ffmpeg.wasm; see BrowserFfmpegService.composeVideo()',
  290. });
  291. });
  292. app.get('/api/video/composite/:filename', (req, res) => {
  293. const filePath = path.join(compositeDir, req.params.filename);
  294. if (!fs.existsSync(filePath)) {
  295. return res.status(404).json({ error: '视频文件不存在' });
  296. }
  297. res.setHeader('Content-Type', 'video/mp4');
  298. fs.createReadStream(filePath).pipe(res);
  299. });
  300. }
  301. module.exports = { registerVideoCompositeRoutes };