"use strict"; /** * 视频生成模块 - FFmpeg 命令封装 * 实现 Ken Burns 效果、图片+音频合成等功能 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateVideo = generateVideo; exports.generateVideoWithBgm = generateVideoWithBgm; exports.generateSlideshow = generateSlideshow; exports.getVideoInfo = getVideoInfo; const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg")); const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const OUTPUT_DIR = path_1.default.join(process.cwd(), 'public', 'videos'); const TEMP_DIR = path_1.default.join(process.cwd(), 'temp'); // 确保目录存在 function ensureDirectories() { if (!fs_1.default.existsSync(OUTPUT_DIR)) fs_1.default.mkdirSync(OUTPUT_DIR, { recursive: true }); if (!fs_1.default.existsSync(TEMP_DIR)) fs_1.default.mkdirSync(TEMP_DIR, { recursive: true }); } /** * 合成图片+音频生成最终视频 */ async function generateVideo(imagePath, audioPath, outputPath, config) { ensureDirectories(); return new Promise((resolve, reject) => { const command = (0, fluent_ffmpeg_1.default)(); command .input(imagePath) .inputOptions(['-loop', '1']) .input(audioPath); // 构建视频滤镜 const videoFilters = []; // Ken Burns 效果 if (config.kenburns?.enabled) { const kb = config.kenburns; videoFilters.push({ filter: 'zoompan', options: { z: `if(gte(zoom,${kb.minZoom}),min(zoom+0.001,${kb.maxZoom}),${kb.minZoom})`, x: 'iw/2-(iw/zoom/2)', y: 'ih/2-(ih/zoom/2)', d: 1, s: `${config.video.width}x${config.video.height}`, fps: config.video.fps, }, }); } // 缩放和填充 videoFilters.push({ filter: 'scale', options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`, }); videoFilters.push({ filter: 'pad', options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`, }); // 字幕 if (config.subtitle) { const sub = config.subtitle; const yPos = sub.position === 'top' ? 20 : sub.position === 'center' ? (config.video.height - 40) / 2 : config.video.height - 60; videoFilters.push({ filter: 'drawtext', options: { text: sub.text, fontsize: sub.fontSize || 24, fontcolor: sub.fontColor || 'white', borderw: 2, bordercolor: 'black', x: '(w-text_w)/2', y: yPos, }, }); } command.videoFilters(videoFilters); command .outputOptions([`-af volume=${config.audio.volume}`]) .outputOptions([ '-c:v libx264', '-preset fast', '-crf 18', '-c:a aac', '-b:a 192k', '-pix_fmt yuv420p', '-shortest', ]) .output(outputPath); command.on('progress', (progress) => { if (progress.percent) console.log(`Processing: ${progress.percent.toFixed(1)}% done`); }); command.on('end', () => { fluent_ffmpeg_1.default.ffprobe(outputPath, (err, metadata) => { if (err) { reject(err); return; } const stats = fs_1.default.statSync(outputPath); resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size, }); }); }); command.on('error', reject); command.run(); }); } /** * 合成带背景音乐的混音视频 */ async function generateVideoWithBgm(imagePath, audioPath, bgmPath, outputPath, config) { ensureDirectories(); return new Promise((resolve, reject) => { const command = (0, fluent_ffmpeg_1.default)(); command .input(imagePath) .inputOptions(['-loop 1']) .input(audioPath) .input(bgmPath) .inputOptions(['-stream_loop -1']); const videoFilters = []; if (config.kenburns?.enabled) { const kb = config.kenburns; videoFilters.push({ filter: 'zoompan', options: { z: `if(gte(zoom,${kb.minZoom}),min(zoom+0.001,${kb.maxZoom}),${kb.minZoom})`, x: 'iw/2-(iw/zoom/2)', y: 'ih/2-(ih/zoom/2)', d: 1, s: `${config.video.width}x${config.video.height}`, fps: config.video.fps, }, }); } videoFilters.push({ filter: 'scale', options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`, }); videoFilters.push({ filter: 'pad', options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`, }); command.videoFilters(videoFilters); // 音频混音 const bgm = config.bgm; command.audioFilters(`[1:a]volume=${config.audio.volume}[main];[2:a]volume=${bgm.volume}[bgm];[main][bgm]amix=inputs=2:duration=longest[aout]`); command .outputOptions([ '-tune stillimage', '-c:v libx264', '-preset fast', '-crf 18', '-c:a aac', '-b:a 192k', '-pix_fmt yuv420p', '-shortest', '-map 0:v', '-map [aout]', ]) .output(outputPath); command.on('end', () => { fluent_ffmpeg_1.default.ffprobe(outputPath, (err, metadata) => { if (err) { reject(err); return; } const stats = fs_1.default.statSync(outputPath); resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size }); }); }); command.on('error', reject); command.run(); }); } /** * 生成多图轮播视频 */ async function generateSlideshow(images, audioPath, outputPath, config) { ensureDirectories(); const concatListPath = path_1.default.join(TEMP_DIR, `concat_${Date.now()}.txt`); const concatList = images.map((img) => `file '${img.path}'`).join('\n'); fs_1.default.writeFileSync(concatListPath, concatList); return new Promise((resolve, reject) => { const command = (0, fluent_ffmpeg_1.default)(); command .input(concatListPath) .inputOptions(['-f concat', '-safe 0']) .input(audioPath); const videoFilters = []; videoFilters.push({ filter: 'scale', options: `${config.video.width}:${config.video.height}:force_original_aspect_ratio=decrease`, }); videoFilters.push({ filter: 'pad', options: `${config.video.width}:${config.video.height}:(ow-iw)/2:(oh-ih)/2:color=black`, }); if (config.subtitle) { const sub = config.subtitle; const yPos = sub.position === 'top' ? 20 : sub.position === 'center' ? (config.video.height - 40) / 2 : config.video.height - 60; videoFilters.push({ filter: 'drawtext', options: { text: sub.text, fontsize: sub.fontSize || 24, fontcolor: sub.fontColor || 'white', borderw: 2, bordercolor: 'black', x: '(w-text_w)/2', y: yPos, }, }); } command.videoFilters(videoFilters); command .outputOptions([ '-c:v libx264', '-preset fast', '-crf 18', '-c:a aac', '-b:a 192k', '-pix_fmt yuv420p', '-shortest', ]) .output(outputPath); command.on('end', () => { fs_1.default.unlinkSync(concatListPath); fluent_ffmpeg_1.default.ffprobe(outputPath, (err, metadata) => { if (err) { reject(err); return; } const stats = fs_1.default.statSync(outputPath); resolve({ duration: Math.round(metadata.format.duration || 0), fileSize: stats.size }); }); }); command.on('error', (err) => { fs_1.default.unlinkSync(concatListPath); reject(err); }); command.run(); }); } /** * 获取视频信息 */ async function getVideoInfo(filePath) { return new Promise((resolve, reject) => { fluent_ffmpeg_1.default.ffprobe(filePath, (err, metadata) => { if (err) { reject(err); return; } const videoStream = metadata.streams.find((s) => s.codec_type === 'video'); const stats = fs_1.default.statSync(filePath); resolve({ duration: Math.round(metadata.format.duration || 0), width: videoStream?.width || 0, height: videoStream?.height || 0, size: stats.size, }); }); }); }