Files
jianzhichu 2db61f4958 修复up主视频下载,不下载图文视频的bug
增加图文视频下载配置(是否合成视频,是否单独下载图片,单独下载音频)
2025-11-24 22:47:06 +08:00

400 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace dy.image
{
public class FFmpegHelper : IDisposable
{
#if DEBUG
// Debug 环境,通常是 Windows
private readonly string _ffmpegExecutablePath = "E:\\down\\ffmpeg\\bin\\ffmpeg.exe";
private readonly string _ffprobeExecutablePath = "E:\\down\\ffmpeg\\bin\\ffprobe.exe";
#else
// Release 环境,通常是 Docker Linux
private readonly string _ffmpegExecutablePath = "ffmpeg";
private readonly string _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";
/// <summary>
/// 将多张图片和一个音频文件合成为视频(最终终极版)。
/// </summary>
public async Task<string> CreateVideoFromImagesAndAudioAsync(
IEnumerable<string> imageFilePaths,
string audioFilePath,
string outputVideoPath,
IProgress<double> 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))
throw new FileNotFoundException("图片文件未找到。", imagePath);
}
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];
// // 重命名为有规律的文件名,如 temp_001.jpg, temp_002.png
// string extension = Path.GetExtension(sourcePath);
// string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0
// string destPath = Path.Combine(tempImageDir, destFileName);
// File.Copy(sourcePath, destPath);
// }
// // 关键步骤 2: 构建符合你成功经验的 FFmpeg 命令
// string imageSequencePattern = Path.Combine(tempImageDir, "temp_%03d" + Path.GetExtension(imageList[0]));
// double imageFps = Math.Round(1.0 / ImageDisplayDurationSeconds, 2); ; // 例如 1/3 = 0.333... fps
// // 音频滤镜:如果音频比图片长则截断,比图片短则循环
// string audioFilter = await GetAudioFilterAsync(audioFilePath, imageList.Count * ImageDisplayDurationSeconds);
// // 构建 FFmpeg 参数列表
// var arguments = new List<string>
// {
// "-y", // 覆盖输出文件
// // --- 输入图片序列的配置 ---
// "-f", "image2", // 明确指定输入为图片序列
// "-vcodec", "webp", // 强制使用 WebP 解码器
// "-r", imageFps.ToString(CultureInfo.InvariantCulture), // 设置图片播放速度
// $"-i", $"\"{imageSequencePattern}\"", // 图片序列的路径模式
// // --- 输入音频的配置 ---
// audioFilter, // 应用音频滤镜(可能为空)
// $"-i", $"\"{audioFilePath}\"", // 音频文件路径
// // --- 视频编码配置 ---
// "-c:v", VideoCodec, // 视频编码器 (如 libx264)
// "-preset", VideoPreset, // 编码预设 (如 medium)
// $"-crf", $"{VideoCrf}", // 视频质量因子
// $"-s", $"{VideoWidth}x{VideoHeight}", // 输出视频分辨率
// "-pix_fmt", "yuv420p", // 像素格式,确保兼容性
// // --- 音频编码配置 ---
// "-c:a", AudioCodec, // 音频编码器 (如 aac)
// $"-b:a", $"{AudioBitrate}", // 音频比特率 (如 192k)
// // --- 输出文件 ---
// $"\"{outputVideoPath}\"" // 最终输出视频路径
// };
// string args = string.Join(" ", arguments);
// Console.WriteLine($"执行FFmpeg命令: {_ffmpegExecutablePath} {args}");
// // 执行命令
// await ExecuteFFmpegAsync(args, progress, cancellationToken);
// if (File.Exists(outputVideoPath))
// {
// return outputVideoPath;
// }
// else
// {
// throw new InvalidOperationException("视频合成失败,未生成输出文件。");
// }
//}
//finally
//{
// // 关键步骤 3: 清理临时文件
// if (Directory.Exists(tempImageDir))
// {
// Directory.Delete(tempImageDir, recursive: true);
// }
//}
// 关键步骤 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];
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[0]));
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)
{
Console.WriteLine($"获取音频时长失败,将使用图片总时长 ({audioDurationSeconds:F2}s) 作为视频时长: {ex.Message}");
}
// 计算图片序列需要循环的次数
int loopCount = 1;
double singleLoopDuration = imageList.Count * ImageDisplayDurationSeconds;
if (audioDurationSeconds > singleLoopDuration)
{
loopCount = (int)Math.Ceiling(audioDurationSeconds / singleLoopDuration);
Console.WriteLine($"图片序列将循环 {loopCount} 次以匹配音频时长");
}
// --- 修正点 2: 重新构建 FFmpeg 参数列表 ---
var arguments = new List<string>
{
"-y", // 覆盖输出文件
// --- 所有输入文件放在最前面 ---
// 图片序列输入
"-f", "image2",
"-r", imageFps.ToString(CultureInfo.InvariantCulture),
"-i", $"\"{imageSequencePattern}\"",
// 音频输入
"-i", $"\"{audioFilePath}\"",
// --- 修正点 3: 使用 filter_complex 对视频流进行循环 ---
// [0:v] 表示第一个输入(图片序列)的视频流
// loop={loopCount-1} 表示循环 (次数-1) 次
// [v] 是处理后的视频流的别名
"-filter_complex", $"\"[0:v]loop={loopCount - 1}[v]\"",
// --- 修正点 4: 明确映射输出流 ---
// 将处理后的视频流 [v] 映射到输出
"-map", "\"[v]\"",
// 将第二个输入(音频文件)的音频流映射到输出
"-map", "\"1:a\"",
// --- 视频编码配置 ---
"-c:v", VideoCodec,
"-preset", VideoPreset,
"-crf", $"{VideoCrf}",
"-s", $"{VideoWidth}x{VideoHeight}",
"-pix_fmt", "yuv420p",
// --- 音频编码配置 ---
"-c:a", AudioCodec,
"-b:a", $"{AudioBitrate}",
// --- 修正点 5: 使用 -shortest 参数 ---
// 确保视频和音频同时结束,即使循环次数计算得不完全精确
"-shortest",
// --- 输出文件 ---
$"\"{outputVideoPath}\""
};
string args = string.Join(" ", arguments);
Console.WriteLine($"执行FFmpeg命令: {_ffmpegExecutablePath} {args}");
// 执行命令
await ExecuteFFmpegAsync(args, progress, cancellationToken);
if (File.Exists(outputVideoPath))
{
return outputVideoPath;
}
else
{
throw new InvalidOperationException("视频合成失败,未生成输出文件。");
}
}
finally
{
// 清理临时目录
if (Directory.Exists(tempImageDir))
{
Directory.Delete(tempImageDir, recursive: true);
}
}
}
/// <summary>
/// 生成音频滤镜,用于循环或截断音频
/// </summary>
private async Task<string> GetAudioFilterAsync(string audioFilePath, double imageTotalDurationSeconds)
{
// 使用 ffprobe 获取音频时长
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,
StandardOutputEncoding = System.Text.Encoding.UTF8
};
using (var process = new Process { StartInfo = startInfo })
{
process.Start();
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (double.TryParse(output, out double audioDurationSeconds))
{
Console.WriteLine($"音频时长: {audioDurationSeconds:F2}s, 图片总时长: {imageTotalDurationSeconds:F2}s");
if (audioDurationSeconds < imageTotalDurationSeconds)
{
// 音频较短,需要循环
double loopCount = Math.Ceiling(imageTotalDurationSeconds / audioDurationSeconds);
return $"-filter_complex \"[1:a]loop={loopCount - 1}:size={Math.Round(audioDurationSeconds * 44100)}:start=0[a]\" -map \"[a]\"";
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"获取音频时长失败,将不使用音频滤镜: {ex.Message}");
}
// 音频较长或获取时长失败,不使用滤镜(默认截断)
return "";
}
/// <summary>
/// 异步执行FFmpeg命令
/// </summary>
private async Task ExecuteFFmpegAsync(string arguments, IProgress<double> progress, CancellationToken cancellationToken)
{
if (_ffmpegProcess != null && !_ffmpegProcess.HasExited)
{
throw new InvalidOperationException("已有一个FFmpeg进程正在运行。");
}
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var startInfo = new ProcessStartInfo
{
FileName = _ffmpegExecutablePath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = System.Text.Encoding.UTF8,
StandardErrorEncoding = System.Text.Encoding.UTF8
};
_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}。请查看控制台输出获取详细错误信息。");
}
}
finally
{
_ffmpegProcess?.Dispose();
_ffmpegProcess = null;
}
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_ffmpegProcess?.Dispose();
}
}
}