398 lines
18 KiB
C#
398 lines
18 KiB
C#
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);
|
||
}
|
||
|
||
}
|
||
}
|