using dy.net.model.dto;
using Serilog;
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace dy.net.utils
{
public class FFmpegHelper : IDisposable
{
private string _ffmpegExecutablePath;
private string _ffprobeExecutablePath;
public FFmpegHelper()
{
if (Appsettings.Get("deploy") == "fn")
{
_ffmpegExecutablePath = "/usr/bin/ffmpeg";
_ffprobeExecutablePath = "/usr/bin/ffprobe";
}
else
{
#if DEBUG
// Debug 环境,通常是 Windows
_ffmpegExecutablePath = "E:\\down\\ffmpeg\\bin\\ffmpeg.exe";
_ffprobeExecutablePath = "E:\\down\\ffmpeg\\bin\\ffprobe.exe";
#else
// Release 环境,通常是 Docker Linux
_ffmpegExecutablePath = "ffmpeg";
_ffprobeExecutablePath = "ffprobe";
#endif
}
}
private Process _ffmpegProcess;
private CancellationTokenSource _cancellationTokenSource;
// 视频参数
public int VideoWidth { get; set; } = 1080;
public int VideoHeight { get; set; } = 1920;
public int OutputFrameRate { get; set; } = 30;
public int ImageDisplayDurationSeconds { get; set; } = 2;
// 编码参数
public string VideoCodec { get; set; } = "libx264";
public string VideoPreset { get; set; } = "medium";
public int VideoCrf { get; set; } = 23;
public string AudioCodec { get; set; } = "aac";
public string AudioBitrate { get; set; } = "192k";
///
/// 将多张图片和一个音频文件合成为视频(最终终极版)。
///
//public async Task CreateVideoFromImagesAndAudioAsync(
// IEnumerable imageFilePaths,
// string audioFilePath,
// string outputVideoPath,
// int VideoWidth = 1080,
// int Height = 1920,
// IProgress progress = null,
// CancellationToken cancellationToken = default)
//{
// // 输入验证
// if (imageFilePaths == null || !imageFilePaths.Any())
// throw new ArgumentException("图片路径列表不能为空。", nameof(imageFilePaths));
// if (string.IsNullOrEmpty(audioFilePath) || !File.Exists(audioFilePath))
// throw new FileNotFoundException("音频文件未找到。", audioFilePath);
// if (string.IsNullOrEmpty(outputVideoPath))
// throw new ArgumentNullException(nameof(outputVideoPath));
// foreach (var imagePath in imageFilePaths)
// {
// if (!File.Exists(imagePath.Path))
// throw new FileNotFoundException("图片文件未找到。", imagePath.Path);
// }
// var outputDirectory = Path.GetDirectoryName(outputVideoPath);
// if (!string.IsNullOrEmpty(outputDirectory) && !Directory.Exists(outputDirectory))
// {
// Directory.CreateDirectory(outputDirectory);
// }
// // 关键步骤 1: 创建临时目录并生成有序图片序列
// string tempImageDir = Path.Combine(AppContext.BaseDirectory, "temp", Guid.NewGuid().ToString());
// Directory.CreateDirectory(tempImageDir);
// var imageList = imageFilePaths.ToList();
// try
// {
// for (int i = 0; i < imageList.Count; i++)
// {
// string sourcePath = imageList[i].Path;
// string extension = Path.GetExtension(sourcePath);
// string destFileName = $"temp_{i + 1:D3}{extension}";
// string destPath = Path.Combine(tempImageDir, destFileName);
// File.Copy(sourcePath, destPath);
// }
// string imageSequencePattern = Path.Combine(tempImageDir, "temp_%03d" + Path.GetExtension(imageList.FirstOrDefault().Path));
// double imageFps = Math.Round(1.0 / ImageDisplayDurationSeconds, 2);
// // --- 修正点 1: 整合音频时长获取逻辑 ---
// // 我们不再需要 GetAudioFilterAsync,而是直接获取时长用于计算视频循环
// double audioDurationSeconds = imageList.Count * ImageDisplayDurationSeconds; // 默认值为图片总时长
// try
// {
// // 使用 ffprobe 获取音频时长
// var startInfo = new ProcessStartInfo
// {
// FileName = _ffprobeExecutablePath,
// Arguments = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{audioFilePath}\"",
// RedirectStandardOutput = true,
// RedirectStandardError = true,
// UseShellExecute = false,
// CreateNoWindow = true
// };
// using (var process = new Process { StartInfo = startInfo })
// {
// process.Start();
// string output = await process.StandardOutput.ReadToEndAsync();
// await process.WaitForExitAsync();
// if (double.TryParse(output, out double duration))
// {
// audioDurationSeconds = duration;
// //Console.WriteLine($"成功获取音频时长: {audioDurationSeconds:F2}s");
// }
// }
// }
// catch (Exception ex)
// {
// Serilog.Log.Error($"获取音频时长失败,将使用图片总时长 ({audioDurationSeconds:F2}s) 作为视频时长: {ex.Message}");
// }
// // 计算图片序列需要循环的次数
// int loopCount = 1;
// int imageCount = imageList.Count; // 要循环的图片总数(对应loop的size参数)
// double singleLoopDuration = imageCount * ImageDisplayDurationSeconds;
// if (audioDurationSeconds > singleLoopDuration)
// {
// loopCount = (int)Math.Ceiling(audioDurationSeconds / singleLoopDuration);
// //Console.WriteLine($"图片序列将循环 {loopCount} 次以匹配音频时长");
// }
// // H264编码要求宽高必须为偶数 20260103
// if (VideoWidth % 2 != 0)
// {
// VideoWidth++;
// }
// if (VideoHeight % 2 != 0)
// {
// VideoHeight++;
// }
// var arguments = new List
// {
// "-y", // 覆盖输出文件
// // 图片序列输入:保留原有逻辑,修复帧率含义(-r 是输出帧率,输入用-framerate更准确)
// "-f", "image2",
// "-framerate", imageFps.ToString(CultureInfo.InvariantCulture), // 输入帧率(图片播放速度)
// "-start_number", "0", // 序列起始编号
// "-i", imageSequencePattern, // 图片序列模板(无需引号,后续用ProcessStartInfo传参更安全)
// // 音频输入
// "-i", audioFilePath,
// // 滤镜修复:补充size参数(核心!解决Number of frames to loop is not set报错)
// // loop=循环次数:size=要循环的帧数;loop=-1表示无限循环(配合-shortest更简洁)
// "-filter_complex", $"[0:v]loop=loop={loopCount - 1}:size={imageCount}[v]",
// // 流映射
// "-map", "[v]",
// "-map", "1:a",
// // 视频编码:保留你的变量,确保兼容性
// "-c:v", VideoCodec,
// "-preset", VideoPreset,
// "-crf", $"{VideoCrf}",
// "-s", $"{VideoWidth}x{VideoHeight}",
// "-pix_fmt", "yuv420p", // 兼容所有播放器
// "-profile:v", "main",
// // 音频编码:标准化参数,避免无效值
// "-c:a", AudioCodec,
// "-b:a", $"{AudioBitrate}",
// "-ac", "2", // 立体声
// "-ar", "44100", // 标准采样率
// // 封装优化
// "-f", "mp4",
// "-movflags", "+faststart",
// // 同步参数:确保视频时长匹配音频
// "-shortest",
// // 输出路径
// outputVideoPath
// };
// // 执行命令
// await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
// if (File.Exists(outputVideoPath))
// {
// return outputVideoPath;
// }
// else
// {
// throw new InvalidOperationException("视频合成失败,未生成输出文件。");
// }
// }
// finally
// {
// // 清理临时目录
// if (Directory.Exists(tempImageDir))
// {
// Directory.Delete(tempImageDir, recursive: true);
// }
// }
//}
public async Task CreateVideoFromImagesAndAudioAsync(
IEnumerable imageFilePaths,
string audioFilePath,
string outputVideoPath,
int VideoWidth = 1080,
int VideoHeight = 1920,
IProgress progress = null,
CancellationToken cancellationToken = default)
{
// 输入验证
if (imageFilePaths == null || !imageFilePaths.Any())
throw new ArgumentException("图片路径列表不能为空。", nameof(imageFilePaths));
if (string.IsNullOrEmpty(outputVideoPath))
throw new ArgumentNullException(nameof(outputVideoPath));
var hasAudio = !string.IsNullOrWhiteSpace(audioFilePath) && File.Exists(audioFilePath);
foreach (var imagePath in imageFilePaths)
{
if (!File.Exists(imagePath.Path))
throw new FileNotFoundException("图片文件未找到。", imagePath.Path);
// 验证图片宽高字段有效性
if (imagePath.Width <= 0 || imagePath.Height <= 0)
throw new ArgumentException($"图片 {imagePath.Path} 的宽高必须大于0", nameof(imageFilePaths));
}
var outputDirectory = Path.GetDirectoryName(outputVideoPath);
if (!string.IsNullOrEmpty(outputDirectory) && !Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
// 关键步骤 1: 创建临时目录并生成有序图片序列
string tempImageDir = Path.Combine(AppContext.BaseDirectory, "temp", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempImageDir);
var imageList = imageFilePaths.ToList();
try
{
for (int i = 0; i < imageList.Count; i++)
{
string sourcePath = imageList[i].Path;
string extension = Path.GetExtension(sourcePath);
string destFileName = $"temp_{i + 1:D3}{extension}";
string destPath = Path.Combine(tempImageDir, destFileName);
File.Copy(sourcePath, destPath, true);
}
string imageSequencePattern = Path.Combine(tempImageDir, "temp_%03d" + Path.GetExtension(imageList.FirstOrDefault().Path));
double imageFps = Math.Round(1.0 / ImageDisplayDurationSeconds, 2);
// 获取音频时长
double audioDurationSeconds = imageList.Count * ImageDisplayDurationSeconds;
if (hasAudio) try
{
var startInfo = new ProcessStartInfo
{
FileName = _ffprobeExecutablePath,
Arguments = $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{audioFilePath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (var process = new Process { StartInfo = startInfo })
{
process.Start();
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (double.TryParse(output, out double duration))
{
audioDurationSeconds = duration;
}
}
}
catch (Exception ex)
{
Log.Error($"获取音频时长失败,将使用图片总时长 ({audioDurationSeconds:F2}s) 作为视频时长: {ex.Message}");
}
// 计算图片序列循环次数
int loopCount = 1;
int imageCount = imageList.Count;
double singleLoopDuration = imageCount * ImageDisplayDurationSeconds;
if (audioDurationSeconds > singleLoopDuration)
{
loopCount = (int)Math.Ceiling(audioDurationSeconds / singleLoopDuration);
}
// H264编码要求宽高为偶数
if (VideoWidth % 2 != 0) VideoWidth++;
if (VideoHeight % 2 != 0) VideoHeight++;
// ========== 核心优化:图片适配滤镜 ==========
// scale=iw*min({VideoWidth}/iw\,{VideoHeight}/ih):ih*min({VideoWidth}/iw\,{VideoHeight}/ih) 等比例缩放
// pad={VideoWidth}:{VideoHeight}:(ow-iw)/2:(oh-ih)/2:black 居中填充黑色背景
string fitFilter = $"scale=iw*min({VideoWidth}/iw\\,{VideoHeight}/ih):ih*min({VideoWidth}/iw\\,{VideoHeight}/ih),pad={VideoWidth}:{VideoHeight}:(ow-iw)/2:(oh-ih)/2:black";
// 组合最终滤镜:先适配尺寸,再循环
string filterComplex = $"[0:v]{fitFilter},loop=loop={loopCount - 1}:size={imageCount}[v]";
var arguments = BuildImageVideoArguments(
imageSequencePattern, hasAudio ? audioFilePath : null, outputVideoPath,
imageFps, filterComplex, VideoWidth, VideoHeight,
VideoCodec, VideoPreset, VideoCrf, AudioCodec, AudioBitrate);
// 执行FFmpeg命令
await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
if (!File.Exists(outputVideoPath))
{
throw new InvalidOperationException("视频合成失败,未生成输出文件。");
}
return outputVideoPath;
}
finally
{
// 清理临时目录(增加异常捕获,避免删除失败导致程序崩溃)
if (Directory.Exists(tempImageDir))
{
try
{
Directory.Delete(tempImageDir, recursive: true);
}
catch (Exception ex)
{
Log.Warning($"清理临时目录失败: {ex.Message}");
}
}
}
}
internal static List BuildImageVideoArguments(
string imageSequencePattern,
string audioFilePath,
string outputVideoPath,
double imageFps,
string filterComplex,
int videoWidth,
int videoHeight,
string videoCodec = "libx264",
string videoPreset = "medium",
int videoCrf = 23,
string audioCodec = "aac",
string audioBitrate = "192k")
{
if (string.IsNullOrWhiteSpace(imageSequencePattern)) throw new ArgumentNullException(nameof(imageSequencePattern));
if (string.IsNullOrWhiteSpace(outputVideoPath)) throw new ArgumentNullException(nameof(outputVideoPath));
if (string.IsNullOrWhiteSpace(filterComplex)) throw new ArgumentNullException(nameof(filterComplex));
var hasAudio = !string.IsNullOrWhiteSpace(audioFilePath) && File.Exists(audioFilePath);
var arguments = new List
{
"-y",
"-f", "image2",
"-framerate", imageFps.ToString(CultureInfo.InvariantCulture),
"-start_number", "1",
"-i", imageSequencePattern
};
if (hasAudio) arguments.AddRange(new[] { "-i", audioFilePath });
arguments.AddRange(new[]
{
"-filter_complex", filterComplex,
"-map", "[v]",
"-c:v", videoCodec,
"-preset", videoPreset,
"-crf", $"{videoCrf}",
"-s", $"{videoWidth}x{videoHeight}",
"-pix_fmt", "yuv420p",
"-profile:v", "main"
});
if (hasAudio)
{
arguments.AddRange(new[]
{
"-map", "1:a",
"-c:a", audioCodec,
"-b:a", audioBitrate,
"-ac", "2",
"-ar", "44100"
});
}
else arguments.Add("-an");
arguments.AddRange(new[] { "-f", "mp4", "-movflags", "+faststart" });
if (hasAudio) arguments.Add("-shortest");
arguments.Add(outputVideoPath);
return arguments;
}
///
/// 将单个视频转封装为统一编码/分辨率的临时视频(解决concat合并兼容问题)
///
/// 待转封装的视频Dto
/// 目标输出宽度
/// 目标输出高度
/// 取消令牌
/// 统一参数后的临时视频路径
private async Task CreateUnifiedTempVideoAsync(
DouyinMergeVideoDto videoDto,
int targetWidth, // 外部传入的动态目标宽(已取所有视频的最大值,偶数)
int targetHeight, // 外部传入的动态目标高(已取所有视频的最大值,偶数)
CancellationToken cancellationToken)
{
// 1. 生成临时视频路径
string tempVideoDir = Path.Combine(AppContext.BaseDirectory, "temp");
if (!Directory.Exists(tempVideoDir))
{
Directory.CreateDirectory(tempVideoDir);
}
string tempVideoPath = Path.Combine(tempVideoDir, $"{Guid.NewGuid()}.mp4");
// 2. 构建【动态适配不固定尺寸】的极简滤镜字符串(核心修改)
// 无需写死尺寸,直接使用传入的动态目标分辨率(已保证为偶数)
// scale=targetWidth:-2:宽度最大为动态目标宽,高度按原比例自动缩放,-2强制高度为偶数(满足H264)
// pad=targetWidth:targetHeight:居中补黑边到动态目标分辨率,缩放后尺寸≤目标尺寸,无矛盾
// 核心:动态双向限制滤镜(不写死任何尺寸,使用传入的targetWidth/targetHeight)
// scale={0}:{1}:force_original_aspect_ratio=decrease:双向限制不超出动态目标尺寸,保持原比例
// pad={0}:{1}:动态填充到目标尺寸,无尺寸矛盾,无硬编码
string videoFilter = string.Format(CultureInfo.InvariantCulture,
"scale={0}:{1}:force_original_aspect_ratio=decrease,pad={0}:{1}:(ow-iw)/2:(oh-ih)/2:black",
targetWidth, // 动态传入的目标宽度(无硬编码)
targetHeight // 动态传入的目标高度(无硬编码)
);
// 3. 打印滤镜字符串(用于调试,验证动态尺寸是否正确)
//Log.Debug($"待执行的动态滤镜(适配不固定尺寸):{videoFilter}");
// 4. 构建FFmpeg参数(无需对滤镜做任何转义,保留原有编码配置)
var arguments = new List
{
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", videoDto.Path,
"-vf", // 滤镜参数标识(独立参数)
videoFilter, // 动态滤镜字符串(独立参数,ArgumentList自动处理逗号)
"-c:v", VideoCodec,
"-preset", VideoPreset,
"-crf", $"{VideoCrf}",
"-pix_fmt", "yuv420p",
"-profile:v", "main",
// 关键帧计算也适配动态目标宽,保留原有逻辑
"-g", $"{(targetWidth < 1080 ? 60 : 120)}",
"-sc_threshold", "0",
"-c:a", AudioCodec,
"-b:a", AudioBitrate,
"-ac", "2",
"-ar", "44100",
"-strict", "-2",
"-f", "mp4",
"-movflags", "+faststart",
tempVideoPath
};
try
{
// 5. 复用现有ExecuteFFmpegAsync(已使用ArgumentList,自动处理特殊字符)
await ExecuteFFmpegAsync(arguments, null, cancellationToken);
if (File.Exists(tempVideoPath))
{
//Log.Debug($"视频转封装为统一参数成功,临时路径:{tempVideoPath}");
return tempVideoPath;
}
else
{
//Log.Error($"视频转封装失败,未生成临时文件,原路径:{videoDto.Path}");
return string.Empty;
}
}
catch (Exception ex)
{
Log.Error(ex, $"视频转封装异常,原路径:{videoDto.Path}");
if (File.Exists(tempVideoPath))
{
File.Delete(tempVideoPath);
}
return string.Empty;
}
}
///
/// 合并多个视频文件为一个MP4视频(嵌入指定音频文件)【已优化:移除冗余width/height参数,自动提取基准分辨率】
///
/// 待合并的视频路径列表(按合并顺序排列)
/// 音频文件(可为null/空,为空则保留视频原音频(若有))
/// 输出视频的保存路径
/// 进度回调
/// 取消令牌
/// 输出视频路径
// 移除了 width = 1080 和 height = 1920 两个传入参数
public async Task MergeMultipleVideosAsync(
List videoFilePaths,
string audioPath,
string savePath,
IProgress progress = null,
CancellationToken cancellationToken = default)
{
List validVideoList = new List();
List tempUnifiedVideoPaths = new List();
// 输入验证
if (videoFilePaths == null || !videoFilePaths.Any())
{
//Serilog.Log.Error("待合并视频列表为空。");
return string.Empty;
}
// 筛选有效视频 + 修正Dto中的视频自身宽高为偶数(H264编码要求)
foreach (var videoDto in videoFilePaths)
{
if (videoDto == null || string.IsNullOrEmpty(videoDto.Path) || !File.Exists(videoDto.Path))
{
//Serilog.Log.Error($"视频文件未找到:{videoDto?.Path ?? "空路径"}");
continue;
}
// 修正视频自身宽高(无效值赋予类默认分辨率,偶数修正)
videoDto.Width = videoDto.Width <= 0 ? this.VideoWidth : (videoDto.Width % 2 != 0 ? videoDto.Width + 1 : videoDto.Width);
videoDto.Height = videoDto.Height <= 0 ? this.VideoHeight : (videoDto.Height % 2 != 0 ? videoDto.Height + 1 : videoDto.Height);
validVideoList.Add(videoDto);
}
// 无有效视频直接返回
if (!validVideoList.Any())
{
Serilog.Log.Error("无可用的有效视频文件,无法进行合并。");
return string.Empty;
}
if (string.IsNullOrEmpty(savePath))
{
//Serilog.Log.Error("合并视频输出文件名为空");
return string.Empty;
}
// 自动创建输出目录
var saveDir = Path.GetDirectoryName(savePath);
if (!string.IsNullOrEmpty(saveDir) && !Directory.Exists(saveDir))
{
Directory.CreateDirectory(saveDir);
}
// 音频文件验证
bool hasCustomAudio = !string.IsNullOrEmpty(audioPath) && File.Exists(audioPath);
// 核心修改:提取第一个有效视频的分辨率作为基准输出分辨率(替代传入的width/height)
// 进阶:动态计算最优分辨率(取所有有效视频的最大宽、最大高)
int targetWidth = validVideoList.Max(v => v.Width);
int targetHeight = validVideoList.Max(v => v.Height);
// 再次确认偶数(双重保障,避免Dto修正逻辑遗漏)
targetWidth = targetWidth % 2 != 0 ? targetWidth + 1 : targetWidth;
targetHeight = targetHeight % 2 != 0 ? targetHeight + 1 : targetHeight;
//Log.Debug($"动态计算最优分辨率:{targetWidth}x{targetHeight}(适配所有视频)");
// 步骤1:创建临时文件列表
string tempListFile = Path.Combine(AppContext.BaseDirectory, "temp", $"{Guid.NewGuid()}.txt");
var tempDir = Path.GetDirectoryName(tempListFile);
if (!Directory.Exists(tempDir))
{
Directory.CreateDirectory(tempDir);
}
try
{
// 转封装所有有效视频为统一参数的临时视频(使用基准分辨率)
List unifiedVideoPaths = new List();
foreach (var videoDto in validVideoList)
{
if (cancellationToken.IsCancellationRequested)
{
//Log.Error("视频合并流程被取消,终止临时视频转封装");
return string.Empty;
}
// 传入基准分辨率(替代原来的传入参数width/height)
string tempUnifiedVideo = await CreateUnifiedTempVideoAsync(videoDto, targetWidth, targetHeight, cancellationToken);
if (string.IsNullOrEmpty(tempUnifiedVideo) || !File.Exists(tempUnifiedVideo))
{
//Log.Error($"视频转封装失败,跳过该视频:{videoDto.Path}");
continue;
}
tempUnifiedVideoPaths.Add(tempUnifiedVideo);
unifiedVideoPaths.Add(tempUnifiedVideo);
}
if (!unifiedVideoPaths.Any())
{
Log.Error("统一转码成功的视频为空,无法进行下一步合并操作");
return string.Empty;
}
// 生成FFmpeg识别的文件列表
var fileListContent = new StringBuilder();
foreach (var videoPath in unifiedVideoPaths)
{
string escapedPath = videoPath.Replace("\\", "/").Replace("'", "\\'");
fileListContent.AppendLine($"file '{escapedPath}'");
}
File.WriteAllText(tempListFile, fileListContent.ToString(), new UTF8Encoding(false));
// 构建FFmpeg合并参数(使用基准分辨率targetWidth/targetHeight)
var arguments = new List();
if (hasCustomAudio)
{
arguments.AddRange(new[]
{
"-y", "-f", "concat", "-safe", "0",
"-i", tempListFile, "-i", audioPath,
"-map", "0:v", "-map", "1:a",
"-shortest", "-filter:a", "apad=pad_len=0"
});
}
else
{
arguments.AddRange(new[]
{
"-y", "-f", "concat", "-safe", "0",
"-i", tempListFile,
"-map", "0:v", "-map", "0:a?",
"-shortest"
});
}
// 核心:动态双向限制滤镜(不写死任何尺寸,使用传入的targetWidth/targetHeight)
// scale={0}:{1}:force_original_aspect_ratio=decrease:双向限制不超出动态目标尺寸,保持原比例
// pad={0}:{1}:动态填充到目标尺寸,无尺寸矛盾,无硬编码
string videoFilter = string.Format(CultureInfo.InvariantCulture,
"scale={0}:{1}:force_original_aspect_ratio=decrease,pad={0}:{1}:(ow-iw)/2:(oh-ih)/2:black",
targetWidth, // 动态传入的目标宽度(无硬编码)
targetHeight // 动态传入的目标高度(无硬编码)
);
arguments.AddRange(new[] { "-vf", videoFilter });
// 后续编码参数、执行逻辑均不变(使用基准分辨率)
arguments.AddRange(new[]
{
"-c:v", VideoCodec, "-preset", VideoPreset, "-crf", $"{VideoCrf}",
"-pix_fmt", "yuv420p", "-profile:v", "main"
});
arguments.AddRange(new[]
{
"-c:a", AudioCodec, "-b:a", AudioBitrate,
"-ac", "2", "-ar", "44100"
});
arguments.AddRange(new[]
{
"-f", "mp4", "-movflags", "+faststart", savePath
});
// 执行FFmpeg命令
await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
// 验证输出文件
if (File.Exists(savePath))
{
Log.Debug($"视频合成成功:{savePath}");
return savePath;
}
else
{
Log.Error("视频合成失败,未生成输出文件。");
return string.Empty;
}
}
finally
{
// 清理临时文件(原有逻辑不变)
if (File.Exists(tempListFile))
{
try { File.Delete(tempListFile); }
catch (IOException ex) { Log.Warning($"清理临时文件列表失败:{ex.Message}"); }
}
foreach (var tempVideoPath in tempUnifiedVideoPaths)
{
if (File.Exists(tempVideoPath))
{
try { File.Delete(tempVideoPath); }
catch (IOException ex) { Log.Warning($"清理临时视频失败:{ex.Message}"); }
}
}
}
}
///
/// 异步执行FFmpeg命令
///
private async Task ExecuteFFmpegAsync(List arguments, IProgress progress, CancellationToken cancellationToken)
{
if (_ffmpegProcess != null && !_ffmpegProcess.HasExited)
{
throw new InvalidOperationException("已有一个FFmpeg进程正在运行。");
}
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var startInfo = new ProcessStartInfo
{
FileName = _ffmpegExecutablePath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = System.Text.Encoding.UTF8,
StandardErrorEncoding = System.Text.Encoding.UTF8
};
foreach (var arg in arguments)
{
startInfo.ArgumentList.Add(arg);
}
_ffmpegProcess = new Process { StartInfo = startInfo };
_ffmpegProcess.ErrorDataReceived += (sender, e) =>
{
if (string.IsNullOrEmpty(e.Data)) return;
//Console.WriteLine($"FFmpeg: {e.Data}");
};
try
{
_ffmpegProcess.Start();
_ffmpegProcess.BeginErrorReadLine();
using (_cancellationTokenSource.Token.Register(() =>
{
if (_ffmpegProcess != null && !_ffmpegProcess.HasExited)
{
try { _ffmpegProcess.Kill(); } catch { }
}
}))
{
await _ffmpegProcess.WaitForExitAsync(_cancellationTokenSource.Token);
}
if (_cancellationTokenSource.Token.IsCancellationRequested)
{
throw new OperationCanceledException("FFmpeg进程被用户取消。", _cancellationTokenSource.Token);
}
if (_ffmpegProcess.ExitCode != 0)
{
throw new InvalidOperationException($"FFmpeg执行失败,退出码: {_ffmpegProcess.ExitCode}。请查看控制台输出获取详细错误信息。执行命令:ffmpeg {string.Join(" ", arguments)}");
}
}
finally
{
_ffmpegProcess?.Dispose();
_ffmpegProcess = null;
}
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_ffmpegProcess?.Dispose();
}
}
}