1、增加图片视频
2、优化代码结构 3、up主下载分类逻辑可配置
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
namespace dy.extension
|
||||
{
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.image
|
||||
{
|
||||
|
||||
public class DownloadHelper
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public DownloadHelper(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(60); // 下载超时30秒
|
||||
_httpClient.DefaultRequestHeaders.Add("Accept-Language", "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36");
|
||||
_httpClient.DefaultRequestHeaders.Add("Referer", "https://www.douyin.com");
|
||||
}
|
||||
|
||||
/// <summary>下载网络文件到指定路径</summary>
|
||||
public async Task DownloadFileAsync(string url, string savePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) throw new ArgumentNullException(nameof(url));
|
||||
if (string.IsNullOrEmpty(savePath)) throw new ArgumentNullException(nameof(savePath));
|
||||
|
||||
// 创建目录(如果不存在)
|
||||
var directory = Path.GetDirectoryName(savePath)!;
|
||||
if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
// 下载文件
|
||||
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode(); // 非2xx状态码抛出异常
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
|
||||
await stream.CopyToAsync(fileStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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
|
||||
{
|
||||
/// 测试环境windows
|
||||
private readonly string _ffmpegExecutablePath = "E:\\down\\ffmpeg\\bin\\ffmpeg";
|
||||
private readonly string _ffprobeExecutablePath = "E:\\down\\ffmpeg\\bin\\ffprobe";
|
||||
// Docker环境linux
|
||||
//private readonly string _ffmpegExecutablePath = "ffmpeg";
|
||||
//private readonly string _ffprobeExecutablePath = "ffprobe";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
namespace dy.image
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// 合并多张图片+音频为视频
|
||||
/// </summary>
|
||||
public class ImageMergeToVideoService
|
||||
{
|
||||
private readonly DownloadHelper _downloadHelper;
|
||||
private readonly FFmpegHelper _fFmpegHelper;
|
||||
public ImageMergeToVideoService(DownloadHelper downloadHelper, FFmpegHelper fFmpegHelper)
|
||||
{
|
||||
_downloadHelper = downloadHelper;
|
||||
_fFmpegHelper = fFmpegHelper;
|
||||
}
|
||||
public async Task<bool> MergeToVideo(string rootPath, MediaMergeRequest request,string outputVideoPath,string fileNamefolder)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
// 创建唯一临时目录(避免并发冲突)
|
||||
var tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString());
|
||||
try
|
||||
{
|
||||
// 1. 下载图片
|
||||
var (rawImages, error) = await DownloadMediaAsync(request.ImageUrls, Path.Combine(tempDir, "raw-images"), "image_", "webp");
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
Serilog.Log.Error($"{error}");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < rawImages.Count(); i++)
|
||||
{
|
||||
string sourcePath = rawImages[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(fileNamefolder, destFileName);
|
||||
File.Copy(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 下载音频
|
||||
var (rawAudios, audioError) = await DownloadMediaAsync(request.AudioUrls, Path.Combine(tempDir, "raw-audios"), "audio_", "mp3");
|
||||
if (!string.IsNullOrEmpty(audioError))
|
||||
{
|
||||
Serilog.Log.Error($"{audioError}");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < rawAudios.Count(); i++)
|
||||
{
|
||||
string sourcePath = rawAudios[i];
|
||||
// 重命名为有规律的文件名,如 temp_001.mp3, temp_002.mp3
|
||||
string extension = Path.GetExtension(sourcePath);
|
||||
string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0
|
||||
string destPath = Path.Combine(fileNamefolder, destFileName);
|
||||
File.Copy(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. 合成视频
|
||||
//var outputVideoPath = Path.Combine(tempDir, "output", $"merged-video.{request.OutputFormat.ToLower()}");
|
||||
|
||||
// 2. 创建帮助类实例
|
||||
// 在Docker容器内,FFmpeg通常在PATH中,所以直接用 "ffmpeg" 即可
|
||||
|
||||
// 根据图片数量调整每张图片显示时长
|
||||
if (request.ImageUrls.Count <= 3)
|
||||
{
|
||||
request.ImageDurationPerSecond = 5;
|
||||
}
|
||||
if (request.ImageUrls.Count > 20)
|
||||
{
|
||||
request.ImageDurationPerSecond = 2;
|
||||
}
|
||||
// 3. (可选)自定义视频参数
|
||||
_fFmpegHelper.VideoWidth = 1080;
|
||||
_fFmpegHelper.VideoHeight = 1920;
|
||||
_fFmpegHelper.ImageDisplayDurationSeconds = request.ImageDurationPerSecond;
|
||||
_fFmpegHelper.OutputFrameRate = 30;
|
||||
|
||||
// 4. 创建进度
|
||||
var progress = new Progress<double>(p =>
|
||||
{
|
||||
Console.WriteLine($"进度: {p:F2}%");
|
||||
});
|
||||
|
||||
// 5. 执行合成任务
|
||||
using (var cancellationTokenSource = new CancellationTokenSource())
|
||||
{
|
||||
string resultPath = await _fFmpegHelper.CreateVideoFromImagesAndAudioAsync(
|
||||
rawImages,
|
||||
rawAudios[0],
|
||||
outputVideoPath,
|
||||
progress,
|
||||
cancellationTokenSource.Token);
|
||||
|
||||
Console.WriteLine($"视频合成成功!文件已保存至: {resultPath}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理临时目录(无论成功失败)
|
||||
if (Directory.Exists(tempDir))
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error($"{ex.StackTrace}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>通用媒体下载方法</summary>
|
||||
private async Task<(string[] SuccessPaths, string ErrorMsg)> DownloadMediaAsync(
|
||||
List<string> urls, string saveDir, string prefix, string ext)
|
||||
{
|
||||
var successPaths = new List<string>();
|
||||
for (var i = 0; i < urls.Count; i++)
|
||||
{
|
||||
var url = urls[i];
|
||||
var fileExt = ext ?? Path.GetExtension(url).TrimStart('.') ?? "png";
|
||||
var fileName = $"{prefix}{i + 1}.{fileExt}";
|
||||
var savePath = Path.Combine(saveDir, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
await _downloadHelper.DownloadFileAsync(url, savePath);
|
||||
successPaths.Add(savePath);
|
||||
Console.WriteLine($"下载成功:{url} → {savePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = $"下载失败:{url},错误:{ex.Message}";
|
||||
Console.WriteLine(error);
|
||||
return (Array.Empty<string>(), error);
|
||||
}
|
||||
}
|
||||
return (successPaths.ToArray(), null);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace dy.image
|
||||
{
|
||||
public class MediaMergeRequest
|
||||
{
|
||||
/// <summary>网络图片地址数组(必填)</summary>
|
||||
public List<string> ImageUrls { get; set; }
|
||||
|
||||
/// <summary>网络MP3地址数组(必填)</summary>
|
||||
public List<string> AudioUrls { get; set; }
|
||||
|
||||
/// <summary>每张图片显示时长(秒,默认3秒)</summary>
|
||||
public int ImageDurationPerSecond { get; set; } = 3;
|
||||
|
||||
/// <summary>视频分辨率(格式:1920x1080,默认1920x1080)</summary>
|
||||
public int VideoWidth { get; set; } = 1080;
|
||||
public int VideoHeight { get; set; } = 1920;
|
||||
|
||||
/// <summary>输出视频格式(默认mp4)</summary>
|
||||
public string OutputFormat { get; set; } = "mp4";
|
||||
|
||||
/// <summary>视频帧率(默认25)</summary>
|
||||
public int VideoFps { get; set; } = 25;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user