video-generator.ffmpeg.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. "use strict";
  2. /**
  3. * 视频生成模块 - FFmpeg 命令封装
  4. * 实现 Ken Burns 效果、图片+音频合成等功能
  5. */
  6. var __importDefault = (this && this.__importDefault) || function (mod) {
  7. return (mod && mod.__esModule) ? mod : { "default": mod };
  8. };
  9. Object.defineProperty(exports, "__esModule", { value: true });
  10. exports.generateVideo = generateVideo;
  11. exports.generateVideoWithBgm = generateVideoWithBgm;
  12. exports.generateSlideshow = generateSlideshow;
  13. exports.getVideoInfo = getVideoInfo;
  14. const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
  15. const path_1 = __importDefault(require("path"));
  16. const fs_1 = __importDefault(require("fs"));
  17. const OUTPUT_DIR = path_1.default.join(process.cwd(), 'public', 'videos');
  18. const TEMP_DIR = path_1.default.join(process.cwd(), 'temp');
  19. // 确保目录存在
  20. function ensureDirectories() {
  21. if (!fs_1.default.existsSync(OUTPUT_DIR))
  22. fs_1.default.mkdirSync(OUTPUT_DIR, { recursive: true });
  23. if (!fs_1.default.existsSync(TEMP_DIR))
  24. fs_1.default.mkdirSync(TEMP_DIR, { recursive: true });
  25. }
  26. /**
  27. * 合成图片+音频生成最终视频
  28. */
  29. async function generateVideo(imagePath, audioPath, outputPath, config) {
  30. ensureDirectories();
  31. return new Promise((resolve, reject) => {
  32. const command = (0, fluent_ffmpeg_1.default)();
  33. command
  34. .input(imagePath)
  35. .inputOptions(['-loop', '1'])
  36. .input(audioPath);
  37. // 构建视频滤镜
  38. const videoFilters = [];
  39. // Ken Burns 效果
  40. if (config.kenburns?.enabled) {
  41. const kb = config.kenburns;
  42. videoFilters.push({
  43. filter: 'zoompan',
  44. options: {
  45. z: `if(gte(zoom,${kb.minZoom}),min(zoom+0.001,${kb.maxZoom}),${kb.minZoom})`,
  46. x: 'iw/2-(iw/zoom/2)',
  47. y: 'ih/2-(ih/zoom/2)',
  48. d: 1,
  49. s: `${config.video.width}x${config.video.height}`,
  50. fps: config.video.fps,
  51. },
  52. });
  53. }
  54. // 缩放和填充
  55. videoFilters.push({
  56. filter: 'scale',
  57. options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`,
  58. });
  59. videoFilters.push({
  60. filter: 'pad',
  61. options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
  62. });
  63. // 字幕
  64. if (config.subtitle) {
  65. const sub = config.subtitle;
  66. const yPos = sub.position === 'top' ? 20 : sub.position === 'center' ? (config.video.height - 40) / 2 : config.video.height - 60;
  67. videoFilters.push({
  68. filter: 'drawtext',
  69. options: {
  70. text: sub.text,
  71. fontsize: sub.fontSize || 24,
  72. fontcolor: sub.fontColor || 'white',
  73. borderw: 2,
  74. bordercolor: 'black',
  75. x: '(w-text_w)/2',
  76. y: yPos,
  77. },
  78. });
  79. }
  80. command.videoFilters(videoFilters);
  81. command
  82. .outputOptions([`-af volume=${config.audio.volume}`])
  83. .outputOptions([
  84. '-c:v libx264',
  85. '-preset fast',
  86. '-crf 18',
  87. '-c:a aac',
  88. '-b:a 192k',
  89. '-pix_fmt yuv420p',
  90. '-shortest',
  91. ])
  92. .output(outputPath);
  93. command.on('progress', (progress) => {
  94. if (progress.percent)
  95. console.log(`Processing: ${progress.percent.toFixed(1)}% done`);
  96. });
  97. command.on('end', () => {
  98. fluent_ffmpeg_1.default.ffprobe(outputPath, (err, metadata) => {
  99. if (err) {
  100. reject(err);
  101. return;
  102. }
  103. const stats = fs_1.default.statSync(outputPath);
  104. resolve({
  105. duration: Math.round(metadata.format.duration || 0),
  106. fileSize: stats.size,
  107. });
  108. });
  109. });
  110. command.on('error', reject);
  111. command.run();
  112. });
  113. }
  114. /**
  115. * 合成带背景音乐的混音视频
  116. */
  117. async function generateVideoWithBgm(imagePath, audioPath, bgmPath, outputPath, config) {
  118. ensureDirectories();
  119. return new Promise((resolve, reject) => {
  120. const command = (0, fluent_ffmpeg_1.default)();
  121. command
  122. .input(imagePath)
  123. .inputOptions(['-loop 1'])
  124. .input(audioPath)
  125. .input(bgmPath)
  126. .inputOptions(['-stream_loop -1']);
  127. const videoFilters = [];
  128. if (config.kenburns?.enabled) {
  129. const kb = config.kenburns;
  130. videoFilters.push({
  131. filter: 'zoompan',
  132. options: {
  133. z: `if(gte(zoom,${kb.minZoom}),min(zoom+0.001,${kb.maxZoom}),${kb.minZoom})`,
  134. x: 'iw/2-(iw/zoom/2)',
  135. y: 'ih/2-(ih/zoom/2)',
  136. d: 1,
  137. s: `${config.video.width}x${config.video.height}`,
  138. fps: config.video.fps,
  139. },
  140. });
  141. }
  142. videoFilters.push({
  143. filter: 'scale',
  144. options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`,
  145. });
  146. videoFilters.push({
  147. filter: 'pad',
  148. options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
  149. });
  150. command.videoFilters(videoFilters);
  151. // 音频混音
  152. const bgm = config.bgm;
  153. command.audioFilters(`[1:a]volume=${config.audio.volume}[main];[2:a]volume=${bgm.volume}[bgm];[main][bgm]amix=inputs=2:duration=longest[aout]`);
  154. command
  155. .outputOptions([
  156. '-tune stillimage',
  157. '-c:v libx264',
  158. '-preset fast',
  159. '-crf 18',
  160. '-c:a aac',
  161. '-b:a 192k',
  162. '-pix_fmt yuv420p',
  163. '-shortest',
  164. '-map 0:v',
  165. '-map [aout]',
  166. ])
  167. .output(outputPath);
  168. command.on('end', () => {
  169. fluent_ffmpeg_1.default.ffprobe(outputPath, (err, metadata) => {
  170. if (err) {
  171. reject(err);
  172. return;
  173. }
  174. const stats = fs_1.default.statSync(outputPath);
  175. resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size });
  176. });
  177. });
  178. command.on('error', reject);
  179. command.run();
  180. });
  181. }
  182. /**
  183. * 生成多图轮播视频
  184. */
  185. async function generateSlideshow(images, audioPath, outputPath, config) {
  186. ensureDirectories();
  187. const concatListPath = path_1.default.join(TEMP_DIR, `concat_${Date.now()}.txt`);
  188. const concatList = images.map((img) => `file '${img.path}'`).join('\n');
  189. fs_1.default.writeFileSync(concatListPath, concatList);
  190. return new Promise((resolve, reject) => {
  191. const command = (0, fluent_ffmpeg_1.default)();
  192. command
  193. .input(concatListPath)
  194. .inputOptions(['-f concat', '-safe 0'])
  195. .input(audioPath);
  196. const videoFilters = [];
  197. videoFilters.push({
  198. filter: 'scale',
  199. options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`,
  200. });
  201. videoFilters.push({
  202. filter: 'pad',
  203. options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`,
  204. });
  205. if (config.subtitle) {
  206. const sub = config.subtitle;
  207. const yPos = sub.position === 'top' ? 20 : sub.position === 'center' ? (config.video.height - 40) / 2 : config.video.height - 60;
  208. videoFilters.push({
  209. filter: 'drawtext',
  210. options: {
  211. text: sub.text,
  212. fontsize: sub.fontSize || 24,
  213. fontcolor: sub.fontColor || 'white',
  214. borderw: 2,
  215. bordercolor: 'black',
  216. x: '(w-text_w)/2',
  217. y: yPos,
  218. },
  219. });
  220. }
  221. command.videoFilters(videoFilters);
  222. command
  223. .outputOptions([
  224. '-c:v libx264',
  225. '-preset fast',
  226. '-crf 18',
  227. '-c:a aac',
  228. '-b:a 192k',
  229. '-pix_fmt yuv420p',
  230. '-shortest',
  231. ])
  232. .output(outputPath);
  233. command.on('end', () => {
  234. fs_1.default.unlinkSync(concatListPath);
  235. fluent_ffmpeg_1.default.ffprobe(outputPath, (err, metadata) => {
  236. if (err) {
  237. reject(err);
  238. return;
  239. }
  240. const stats = fs_1.default.statSync(outputPath);
  241. resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size });
  242. });
  243. });
  244. command.on('error', (err) => {
  245. fs_1.default.unlinkSync(concatListPath);
  246. reject(err);
  247. });
  248. command.run();
  249. });
  250. }
  251. /**
  252. * 获取视频信息
  253. */
  254. async function getVideoInfo(filePath) {
  255. return new Promise((resolve, reject) => {
  256. fluent_ffmpeg_1.default.ffprobe(filePath, (err, metadata) => {
  257. if (err) {
  258. reject(err);
  259. return;
  260. }
  261. const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
  262. const stats = fs_1.default.statSync(filePath);
  263. resolve({
  264. duration: Math.round(metadata.format.duration || 0),
  265. width: videoStream?.width || 0,
  266. height: videoStream?.height || 0,
  267. size: stats.size,
  268. });
  269. });
  270. });
  271. }