Files
douyin/service/DouyinMergeVideoService.cs
T

398 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using dy.net.extension;
using dy.net.model.dto;
using dy.net.utils;
using Serilog;
using System.Security.Cryptography;
namespace dy.net.service
{
/// <summary>
/// 合并多张图片+音频为视频
/// </summary>
public class DouyinMergeVideoService
{
private readonly DouyinHttpClientService douyinHttpClientService;
public DouyinMergeVideoService(DouyinHttpClientService douyinHttpClientService)
{
this.douyinHttpClientService = douyinHttpClientService;
}
// 1. 并发控制:限制同时运行的 FFmpeg 进程数(建议设为 1,FFmpeg 单进程更稳定)
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(1, 1);
// 重试次数配置
private const int RetryCount = 3;
// 重试间隔(毫秒)
private const int RetryDelay = 1000;
/// <summary>
/// 多视频合成一个视频
/// </summary>
/// <param name="videoFilePaths"></param>
/// <param name="audioPath"></param>
/// <param name="savePath"></param>
/// <param name="ck"></param>
/// <returns></returns>
public async Task<(string mp4Path, string mp3Path)> MergeMultipleVideosAsync(
List<DouyinMergeVideoDto> videoFilePaths,
string audioPath,
string savePath,
string ck)
{
string mp3Path = string.Empty;
string mergMusicPath = GetRandomMergeMusic();
if (!string.IsNullOrWhiteSpace(audioPath))
{
var (SuccessPaths, _) = await DownloadMediaAsync(new List<DouyinMergeVideoDto> { new DouyinMergeVideoDto { Path= audioPath } }, Path.GetDirectoryName(savePath), "audio_", "mp3", ck);
if (SuccessPaths != null && SuccessPaths.Count > 0)
{
mergMusicPath = SuccessPaths.FirstOrDefault()?.Path;
mp3Path = SuccessPaths.FirstOrDefault()?.Path;
}
}
var ffmpeg = new FFmpegHelper();
var mp4Path = await ffmpeg.MergeMultipleVideosAsync(videoFilePaths, mergMusicPath, savePath);
return (mp4Path, mp3Path);
}
private static string GetRandomMergeMusic()
{
var allowedExtensions = new HashSet<string> { ".mp3", ".wav" };
string mergMusic = Path.Combine(AppContext.BaseDirectory, "mp3_0", "silent_10.mp3");//默认音频
var mp3folder = Path.Combine(AppContext.BaseDirectory, "mp3");
if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder))
{
mp3folder = ServiceExtension.FnDataFolder;//飞牛数据目录
}
var customMusics = Directory.GetFiles(mp3folder)
.Where(filePath =>
allowedExtensions.Contains(Path.GetExtension(filePath).ToLowerInvariant()) &&
Path.GetFileNameWithoutExtension(filePath) != "silent_10")
.ToList();
if (customMusics != null && customMusics.Any())
{
if (customMusics.Count == 1)
return customMusics.FirstOrDefault();
// 使用加密级别的随机数生成器(RNGCryptoServiceProvider)获取高随机性的索引
using (var rng = RandomNumberGenerator.Create())
{
byte[] randomNumber = new byte[4];
rng.GetBytes(randomNumber);
// 将随机字节转换为非负整数,并取模得到有效索引
int randomIndex = Math.Abs(BitConverter.ToInt32(randomNumber, 0)) % customMusics.Count();
return customMusics.ToList()[randomIndex];
}
}
return mergMusic;
}
/// <summary>
/// 视频合成(优化后:解决 FFmpeg 进程冲突,支持容错重试)
/// </summary>
public async Task<bool> MergeToVideo(string cookie, string rootPath, MediaMergeRequest request,
string outputVideoPath, string fileNamefolder, bool mergeImg2Viedo, bool downImage = false,
bool downMp3 = false, Action<string> reportWarning = null, Action<string> reportError = null)
{
// 输入参数校验(避免无效执行)
if (request == null || request.ImageUrls == null || request.ImageUrls.Count == 0)
{
Log.Error("图片URL列表为空,无法合成视频");
reportError?.Invoke("图片 URL 列表为空");
return false;
}
//if (mergeImg2Viedo && (request.AudioUrls == null || request.AudioUrls.Count == 0))
//{
// Log.Error("合成视频时音频URL列表为空");
// return false;
//}
string tempDir = null;
try
{
// 创建唯一临时目录(避免并发冲突)
tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir); // 确保目录存在
// 1. 下载图片
var (rawImages, imageError) = await DownloadMediaAsync(
request.ImageUrls, Path.Combine(tempDir, "raw-images"), "image_", "webp", cookie);
if (!string.IsNullOrEmpty(imageError) || rawImages == null || rawImages.Count == 0)
{
var reason = imageError ?? "未下载到任何图片";
Log.Error($"图片下载失败:{reason}");
reportError?.Invoke($"图片下载失败:{reason}");
return false;
}
// 保存下载的图片(如果需要)
if (downImage)
{
await SaveDownloadedFilesAsync(rawImages, fileNamefolder, "jpg", Path.GetFileNameWithoutExtension(outputVideoPath));
}
// 2. 下载音频
List<DouyinMergeVideoDto> rawAudios = new List<DouyinMergeVideoDto>();
var audioWarningReported = false;
if ((mergeImg2Viedo || downMp3) && request.AudioUrls != null && request.AudioUrls.Count > 0)
{
var (audios, audioError) = await DownloadMediaAsync(
request.AudioUrls.Select(x => new DouyinMergeVideoDto { Path = x })?.ToList(), Path.Combine(tempDir, "raw-audios"), "audio_", "mp3", cookie);
if (!string.IsNullOrEmpty(audioError) || audios == null || audios.Count == 0)
{
var warning = downMp3
? "作品音频不可用,已降级处理,未生成单独音频文件"
: "作品音频不可用,图文视频已降级为无声视频";
Log.Warning("音频下载失败:{Reason}{Fallback}",
audioError ?? "未下载到任何音频", warning);
reportWarning?.Invoke(warning);
audioWarningReported = true;
}
else rawAudios = audios;
// 保存下载的音频(如果需要)
if (downMp3 && rawAudios.Count > 0)
{
var ext = Path.GetExtension(rawAudios.FirstOrDefault()?.Path);
await SaveDownloadedFilesAsync(rawAudios, fileNamefolder, ext, Path.GetFileNameWithoutExtension(outputVideoPath));
}
}
// 不合成视频,直接返回成功
if (!mergeImg2Viedo)
{
Log.Debug($"根据系统配置设置不下载图文视频-[{outputVideoPath}],{(downImage ? "下载图片" : "不下载图片")},{(downMp3 ? "下载音频" : "不下载音频")}");
// When only an audio attachment was requested, an unavailable audio source
// leaves no persistent artifact and must remain a real item-level failure.
var hasArtifact = downImage || !downMp3 || rawAudios.Count > 0;
if (!hasArtifact) reportError?.Invoke("音频下载失败,且当前任务未配置保存图片");
return hasArtifact;
}
// 3. 调整图片显示时长
AdjustImageDuration(request);
// 4. 容错重试:处理 FFmpeg 进程冲突等临时错误
bool mergeSuccess = await RetryOnFfmpegConflictAsync(async () =>
{
// 并发控制:等待前一个 FFmpeg 进程完成
await _ffmpegSemaphore.WaitAsync();
try
{
// 每次合成创建独立的 FFmpegHelper 实例(避免状态共享)
var ffmpegHelper = new FFmpegHelper();
// 配置视频参数(独立实例,无共享冲突)
ffmpegHelper.VideoWidth = request.VideoWidth > 0 ? request.VideoWidth : 1080;
ffmpegHelper.VideoHeight = request.VideoHeight > 0 ? request.VideoHeight : 1920;
ffmpegHelper.ImageDisplayDurationSeconds = request.ImageDurationPerSecond;
ffmpegHelper.OutputFrameRate = 30;
// 进度回调
var progress = new Progress<double>(p =>
{
Log.Debug($"视频合成进度:{p:F2}%");
});
// 超时控制:避免 FFmpeg 进程无限运行(300秒=5分钟)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300));
// 执行合成(确保每个图片都参与)
if (rawAudios.Count == 0)
{
if (!audioWarningReported)
{
var warning = downMp3
? "作品没有可用音频,已生成无声视频,未生成单独音频文件"
: "作品没有可用音频,已生成无声视频";
reportWarning?.Invoke(warning);
audioWarningReported = true;
}
Log.Warning("图文作品没有可用音频,将直接生成无声视频");
}
string resultPath = await ffmpegHelper.CreateVideoFromImagesAndAudioAsync(
rawImages, // 所有下载的图片(确保无遗漏)
rawAudios.FirstOrDefault()?.Path, // 音频缺失时 FFmpegHelper 会生成无声视频
outputVideoPath,
ffmpegHelper.VideoWidth,
ffmpegHelper.VideoHeight,
progress,
cts.Token);
Log.Debug($"视频合成成功!文件路径:{resultPath}");
return !string.IsNullOrEmpty(resultPath) && File.Exists(resultPath);
}
finally
{
// 释放信号量,允许下一个任务执行
_ffmpegSemaphore.Release();
}
});
if (!mergeSuccess) reportError?.Invoke("FFmpeg 未生成有效的视频文件");
return mergeSuccess;
}
catch (Exception ex)
{
Log.Error(ex, "视频合成过程中发生未处理异常");
reportError?.Invoke($"{ex.GetType().Name}{ex.GetBaseException().Message}");
return false;
}
finally
{
// 安全清理临时目录(确保 FFmpeg 进程已释放文件句柄)
await SafeCleanTempDirAsync(tempDir);
}
}
/// <summary>
/// 保存下载的文件(图片/音频)到目标目录
/// </summary>
private static async Task SaveDownloadedFilesAsync(List<DouyinMergeVideoDto> sourcePaths, string targetFolder, string defaultExt, string videoFileName)
{
if (sourcePaths == null || sourcePaths.Count == 0) return;
Directory.CreateDirectory(targetFolder); // 确保目标目录存在
// 异步保存(不阻塞主线程)
await Task.WhenAll(sourcePaths.Select(async (sourcePath, index) =>
{
if (!File.Exists(sourcePath.Path)) return;
string extension = Path.GetExtension(sourcePath.Path) ?? $".{defaultExt}";
string destFileName = $"{videoFileName}{index + 1:D3}{extension}";
string destPath = Path.Combine(targetFolder, destFileName);
// 文件不存在时才复制,避免覆盖
if (!File.Exists(destPath))
{
await Task.Run(() => File.Copy(sourcePath.Path, destPath, overwrite: false));
Log.Debug($"已保存文件:{destPath}");
}
}));
}
/// <summary>
/// FFmpeg 进程冲突时重试
/// </summary>
private async Task<bool> RetryOnFfmpegConflictAsync(Func<Task<bool>> action)
{
int retryCount = 0;
while (retryCount < RetryCount)
{
try
{
return await action();
}
catch (Exception ex)
{
// 识别 FFmpeg 进程冲突相关异常(根据实际异常信息调整条件)
if (ex.Message.Contains("FFmpeg 进程正在运行") ||
ex.Message.Contains("进程已在运行") ||
ex.Message.Contains("文件被另一个进程占用"))
{
retryCount++;
Log.Warning($"FFmpeg 进程冲突,第 {retryCount}/{RetryCount} 次重试... 异常信息:{ex.Message}");
await Task.Delay(RetryDelay * retryCount); // 重试间隔递增
continue;
}
// 非进程冲突异常,直接抛出
throw;
}
}
Log.Error($"FFmpeg 进程冲突,重试 {RetryCount} 次后仍失败");
return false;
}
/// <summary>
/// 安全清理临时目录(避免文件被占用)
/// </summary>
private async Task SafeCleanTempDirAsync(string tempDir)
{
if (string.IsNullOrEmpty(tempDir) || !Directory.Exists(tempDir))
return;
try
{
// 延迟清理:给 FFmpeg 进程足够时间释放文件句柄(1秒)
await Task.Delay(1000);
Directory.Delete(tempDir, recursive: true);
//Log.Debug($"临时目录已清理:{tempDir}");
}
catch (Exception ex)
{
// 清理失败时备份目录,避免占用磁盘空间
string backupDir = $"{tempDir}_backup_{Guid.NewGuid().ToString("N")}";
Directory.Move(tempDir, backupDir);
Log.Error(ex, $"临时目录清理失败,已备份至:{backupDir}");
}
}
/// <summary>
/// 调整图片显示时长
/// </summary>
private static void AdjustImageDuration(MediaMergeRequest request)
{
if (request.ImageUrls.Count <= 3)
request.ImageDurationPerSecond = 3;
else if (request.ImageUrls.Count > 20)
request.ImageDurationPerSecond = 2;
// 中间数量保持原有配置
}
/// <summary>通用媒体下载方法</summary>
private async Task<(List<DouyinMergeVideoDto> SuccessPaths, string ErrorMsg)> DownloadMediaAsync(
List<DouyinMergeVideoDto> urls, string saveDir, string prefix, string ext, string cookie)
{
var successPaths = new List<DouyinMergeVideoDto>();
var failures = new List<string>();
for (var i = 0; i < urls.Count; i++)
{
var url = urls[i];
var fileExt = ext ?? Path.GetExtension(url.Path).TrimStart('.') ?? "png";
var fileName = $"{prefix}{i + 1}.{fileExt}";
var savePath = Path.Combine(saveDir, fileName);
try
{
var result = await douyinHttpClientService.DownloadAsync(url.Path, savePath, cookie);
if (result.Success)
{
successPaths.Add(new DouyinMergeVideoDto
{
Path = result.ActualSavePath,
Height = url.Height,
Width =
url.Width
});
}
else
{
failures.Add($"{result.SourceHost ?? "未知来源"}/HTTP {result.HttpStatusCode?.ToString() ?? "-"}/{result.FailureKind}{result.Message}");
Serilog.Log.Warning("合成素材下载失败:Host={Host}, Status={Status}, Kind={Kind}, Target={Target}",
result.SourceHost, result.HttpStatusCode, result.FailureKind, savePath);
}
//Console.WriteLine($"下载成功:{url} → {savePath}");
}
catch (Exception ex)
{
var error = $"合成素材下载失败:{ex.GetType().Name}{ex.GetBaseException().Message}";
Serilog.Log.Error("合成素材下载异常:Type={ExceptionType}, Target={Target}", ex.GetType().Name, savePath);
return (new List<DouyinMergeVideoDto>(), error);
}
}
return successPaths.Count == 0 && failures.Count > 0
? (successPaths, string.Join("", failures.Take(3)))
: (successPaths, null);
}
}
}