优化动态图视频合成 -处理多尺寸视频合并时变形的问题

This commit is contained in:
jianzhichu
2026-01-17 00:51:33 +08:00
parent dc38a25239
commit d9cb721f64
6 changed files with 277 additions and 117 deletions
@@ -3,7 +3,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl> <_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
<History>True|2026-01-16T13:00:57.7239379Z||;True|2026-01-16T21:00:03.3912989+08:00||;True|2026-01-16T20:56:08.5505592+08:00||;True|2026-01-16T20:46:32.3067302+08:00||;True|2026-01-15T22:18:57.9835738+08:00||;True|2026-01-15T22:07:25.4753938+08:00||;True|2026-01-15T22:07:14.8243754+08:00||;True|2026-01-15T21:40:13.2613927+08:00||;True|2026-01-15T20:57:56.6291682+08:00||;True|2026-01-15T20:57:47.1363536+08:00||;True|2026-01-15T20:08:44.4710883+08:00||;True|2026-01-15T20:04:51.8284314+08:00||;False|2026-01-15T20:04:45.3258155+08:00||;True|2026-01-15T19:59:11.7907672+08:00||;True|2026-01-15T01:10:48.9066941+08:00||;True|2026-01-15T01:03:23.6216975+08:00||;True|2026-01-15T01:03:16.3364591+08:00||;False|2026-01-15T01:03:10.5654436+08:00||;True|2026-01-15T00:49:21.6559622+08:00||;True|2026-01-15T00:10:42.7288439+08:00||;True|2026-01-15T00:06:28.4241682+08:00||;True|2026-01-13T22:34:15.2372411+08:00||;True|2026-01-13T22:26:46.4522947+08:00||;True|2026-01-13T22:15:56.9735179+08:00||;True|2026-01-13T22:15:51.8250677+08:00||;False|2026-01-13T22:15:46.6608690+08:00||;True|2026-01-13T22:03:06.4683237+08:00||;False|2026-01-13T22:03:01.2036027+08:00||;True|2026-01-13T21:36:05.8776209+08:00||;True|2026-01-13T21:33:02.0766289+08:00||;False|2026-01-13T21:32:57.1976141+08:00||;True|2026-01-13T19:14:35.2519439+08:00||;True|2026-01-13T15:07:50.0836745+08:00||;False|2026-01-13T15:06:35.5929685+08:00||;True|2026-01-13T10:21:41.6012776+08:00||;True|2026-01-13T09:57:25.4788847+08:00||;True|2026-01-13T09:53:14.4900360+08:00||;True|2026-01-13T09:23:53.6465295+08:00||;True|2026-01-12T22:04:14.5886955+08:00||;False|2026-01-12T22:04:08.2008101+08:00||;True|2026-01-12T21:53:46.9947176+08:00||;True|2026-01-12T21:11:15.8358024+08:00||;True|2026-01-12T21:09:51.8663228+08:00||;True|2026-01-11T21:25:07.5052845+08:00||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;True|2025-12-26T22:01:16.1660100+08:00||;True|2025-12-25T00:50:26.1116465+08:00||;True|2025-12-25T00:48:27.3087708+08:00||;True|2025-12-25T00:47:38.4835720+08:00||;</History> <History>True|2026-01-16T16:45:04.0498090Z||;True|2026-01-16T21:00:57.7239379+08:00||;True|2026-01-16T21:00:03.3912989+08:00||;True|2026-01-16T20:56:08.5505592+08:00||;True|2026-01-16T20:46:32.3067302+08:00||;True|2026-01-15T22:18:57.9835738+08:00||;True|2026-01-15T22:07:25.4753938+08:00||;True|2026-01-15T22:07:14.8243754+08:00||;True|2026-01-15T21:40:13.2613927+08:00||;True|2026-01-15T20:57:56.6291682+08:00||;True|2026-01-15T20:57:47.1363536+08:00||;True|2026-01-15T20:08:44.4710883+08:00||;True|2026-01-15T20:04:51.8284314+08:00||;False|2026-01-15T20:04:45.3258155+08:00||;True|2026-01-15T19:59:11.7907672+08:00||;True|2026-01-15T01:10:48.9066941+08:00||;True|2026-01-15T01:03:23.6216975+08:00||;True|2026-01-15T01:03:16.3364591+08:00||;False|2026-01-15T01:03:10.5654436+08:00||;True|2026-01-15T00:49:21.6559622+08:00||;True|2026-01-15T00:10:42.7288439+08:00||;True|2026-01-15T00:06:28.4241682+08:00||;True|2026-01-13T22:34:15.2372411+08:00||;True|2026-01-13T22:26:46.4522947+08:00||;True|2026-01-13T22:15:56.9735179+08:00||;True|2026-01-13T22:15:51.8250677+08:00||;False|2026-01-13T22:15:46.6608690+08:00||;True|2026-01-13T22:03:06.4683237+08:00||;False|2026-01-13T22:03:01.2036027+08:00||;True|2026-01-13T21:36:05.8776209+08:00||;True|2026-01-13T21:33:02.0766289+08:00||;False|2026-01-13T21:32:57.1976141+08:00||;True|2026-01-13T19:14:35.2519439+08:00||;True|2026-01-13T15:07:50.0836745+08:00||;False|2026-01-13T15:06:35.5929685+08:00||;True|2026-01-13T10:21:41.6012776+08:00||;True|2026-01-13T09:57:25.4788847+08:00||;True|2026-01-13T09:53:14.4900360+08:00||;True|2026-01-13T09:23:53.6465295+08:00||;True|2026-01-12T22:04:14.5886955+08:00||;False|2026-01-12T22:04:08.2008101+08:00||;True|2026-01-12T21:53:46.9947176+08:00||;True|2026-01-12T21:11:15.8358024+08:00||;True|2026-01-12T21:09:51.8663228+08:00||;True|2026-01-11T21:25:07.5052845+08:00||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;True|2025-12-26T22:01:16.1660100+08:00||;True|2025-12-25T00:50:26.1116465+08:00||;True|2025-12-25T00:48:27.3087708+08:00||;True|2025-12-25T00:47:38.4835720+08:00||;</History>
<LastFailureDetails /> <LastFailureDetails />
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+11
View File
@@ -0,0 +1,11 @@
namespace dy.net.dto
{
public class DouyinDynamicVideoDto
{
public string Path { get; set; }
public int Height { get; set; }
public int Width { get; set; }
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<ActiveDebugProfile>tiny.ddns</ActiveDebugProfile> <ActiveDebugProfile>tiny.ddns</ActiveDebugProfile>
<NameOfLastUsedPublishProfile>E:\code\dysync\Properties\PublishProfiles\fn.pubxml</NameOfLastUsedPublishProfile> <NameOfLastUsedPublishProfile>E:\code\dysync\Properties\PublishProfiles\docker.pubxml</NameOfLastUsedPublishProfile>
<Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID> <Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID>
<Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath> <Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath>
</PropertyGroup> </PropertyGroup>
+39 -31
View File
@@ -448,10 +448,10 @@ namespace dy.net.job
var videos = new List<DouyinVideo>(); var videos = new List<DouyinVideo>();
foreach (var item in data.AwemeList) foreach (var item in data.AwemeList)
{ {
//if (item.AwemeId != "7440444948844956985") if (item.AwemeId != "7440444948844956985")
//{ {
// continue; continue;
//} }
//if (!item.Desc.Contains("小房车正式启程")) //if (!item.Desc.Contains("小房车正式启程"))
//{ //{
// continue; // continue;
@@ -495,7 +495,7 @@ namespace dy.net.job
{ {
//处理多个视频-组合的图文视频--类似动图。 //处理多个视频-组合的图文视频--类似动图。
List<string> dynamicVideoUrls = new List<string>(); List<DouyinDynamicVideoDto> dynamicVideoUrls = new List<DouyinDynamicVideoDto>();
// 当需要下载动态视频时,获取其他URL // 当需要下载动态视频时,获取其他URL
if (config.DownDynamicVideo && item.Images != null && item.Images.Count > 0) if (config.DownDynamicVideo && item.Images != null && item.Images.Count > 0)
{ {
@@ -507,7 +507,17 @@ namespace dy.net.job
{ {
var targetUrl = btv.PlayAddr?.UrlList?.FirstOrDefault(x => x.StartsWith("https://www.douyin.com/aweme/v1/play")); var targetUrl = btv.PlayAddr?.UrlList?.FirstOrDefault(x => x.StartsWith("https://www.douyin.com/aweme/v1/play"));
if (targetUrl != null) if (targetUrl != null)
dynamicVideoUrls.Add(targetUrl); {
var height = btv.PlayAddr?.Height ?? 1920;
var width = btv.PlayAddr?.Width ?? 1080;
DouyinDynamicVideoDto info = new DouyinDynamicVideoDto
{
Path = targetUrl,
Height = height,
Width = width
};
dynamicVideoUrls.Add(info);
}
} }
} }
} }
@@ -522,37 +532,34 @@ namespace dy.net.job
{ {
if (!string.IsNullOrEmpty(dynamicVideo.DynamicVideos)) if (!string.IsNullOrEmpty(dynamicVideo.DynamicVideos))
{ {
var dynamicVideos = JsonConvert.DeserializeObject<List<string>>(dynamicVideo.DynamicVideos); var dynamicVideos = JsonConvert.DeserializeObject<List<DouyinDynamicVideoDto>>(dynamicVideo.DynamicVideos);
Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频..."); Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频...");
if (dynamicVideos != null && dynamicVideos.Count > 0) if (dynamicVideos != null && dynamicVideos.Count > 0)
{ {
int width = 1080;
int height = 1920;
var bit = item?.Video?.BitRate?.FirstOrDefault();
if (bit != null)
{
width = bit.PlayAddr.Width;
height = bit.PlayAddr.Height;
}
var savePath = DouyinFileNameHelper.RemoveNumberSuffix(dynamicVideo.VideoSavePath); var savePath = DouyinFileNameHelper.RemoveNumberSuffix(dynamicVideo.VideoSavePath);
//下载音频文件 //音频文件下载地址
var mp3Url= item?.Music?.PlayUrl?.UrlList?.FirstOrDefault(); var mp3Url= item?.Music?.PlayUrl?.UrlList?.FirstOrDefault();
var outPath = await douyinMergeVideoService.MergeMultipleVideosAsync(dynamicVideos,mp3Url, savePath,cookie.Cookies, width, height); var outPath = await douyinMergeVideoService.MergeMultipleVideosAsync(dynamicVideos,mp3Url, savePath,cookie.Cookies);
if(!string.IsNullOrWhiteSpace(outPath)) if(!string.IsNullOrWhiteSpace(outPath.mp4Path))
{ {
if (File.Exists(outPath)) if (File.Exists(outPath.mp4Path))
{ {
dynamicVideo.VideoSavePath = outPath; dynamicVideo.VideoSavePath = outPath.mp4Path;
if (!config.KeepDynamicVideo) if (!config.KeepDynamicVideo)
{ {
if (File.Exists(outPath.mp3Path))
{
File.Delete(outPath.mp3Path);
}
//不保留原视频-删除 //不保留原视频-删除
foreach (var opath in dynamicVideos) foreach (var opath in dynamicVideos)
{ {
if (File.Exists(opath)) if (File.Exists(opath.Path))
File.Delete(opath); File.Delete(opath.Path);
} }
} }
} }
@@ -829,7 +836,7 @@ namespace dy.net.job
protected async Task<DouyinVideo> ProcessDynamicVideo(List<string> dynamicUrls, DouyinCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed = null) protected async Task<DouyinVideo> ProcessDynamicVideo(List<DouyinDynamicVideoDto> dynamicUrls, DouyinCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed = null)
{ {
@@ -848,28 +855,29 @@ namespace dy.net.job
// 随机延迟 // 随机延迟
int i = 1; int i = 1;
List<string> dynamicSavePaths = new List<string>(); List<DouyinDynamicVideoDto> dynamicSavePaths = new List<DouyinDynamicVideoDto>();
foreach (var dynamicUrl in dynamicUrls) foreach (var dynamicUrl in dynamicUrls)
{ {
// 下载动态视频 // 下载动态视频
var dynamicSavePath = savePath.Replace(".mp4", $"_00{i}.mp4"); var dynamicSavePath = savePath.Replace(".mp4", $"_00{i}.mp4");
i++; DouyinDynamicVideoDto v = new DouyinDynamicVideoDto() { Height = dynamicUrl.Height, Width = dynamicUrl.Width,Path= dynamicSavePath };
i++;
// 如果文件已存在,跳过 // 如果文件已存在,跳过
if (File.Exists(dynamicSavePath)) if (File.Exists(dynamicSavePath))
{ {
//Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载."); //Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载.");
dynamicSavePaths.Add(dynamicSavePath); dynamicSavePaths.Add(v);
continue; continue;
} }
var (Success, ActualSavePath) = await douyinHttpClientService.DownloadAsync(dynamicUrl, dynamicSavePath, cookie.Cookies); var (Success, ActualSavePath) = await douyinHttpClientService.DownloadAsync(dynamicUrl.Path, dynamicSavePath, cookie.Cookies);
if (!Success) if (!Success)
{ {
Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{dynamicSavePath}]-00{i},下载失败!!!"); Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{dynamicSavePath}]-00{i},下载失败!!!");
} }
else else
{ {
dynamicSavePaths.Add(dynamicSavePath); dynamicSavePaths.Add(v);
Log.Debug($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{dynamicSavePath}]-00{i},下载完成."); Log.Debug($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{dynamicSavePath}]-00{i},下载完成.");
} }
await Task.Delay(_random.Next(1, 4) * 1000); await Task.Delay(_random.Next(1, 4) * 1000);
@@ -890,10 +898,10 @@ namespace dy.net.job
{ {
Width = item?.Images?.FirstOrDefault()?.Width ?? 0, Width = item?.Images?.FirstOrDefault()?.Width ?? 0,
Height = item?.Images?.FirstOrDefault()?.Height ?? 0, Height = item?.Images?.FirstOrDefault()?.Height ?? 0,
DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths) // 合成视频的文件大小 DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths.Select(x=>x.Path).ToList()) // 合成视频的文件大小
} }
}; };
return await CreateVideoEntity(config, cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault(), saveFolder, tag1, tag2, tag3, "", "", data, dynamicSavePaths); return await CreateVideoEntity(config, cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault()?.Path, saveFolder, tag1, tag2, tag3, "", "", data, dynamicSavePaths);
} }
@@ -1285,7 +1293,7 @@ namespace dy.net.job
/// <returns>创建的视频实体对象</returns> /// <returns>创建的视频实体对象</returns>
private async Task<DouyinVideo> CreateVideoEntity(AppConfig config, private async Task<DouyinVideo> CreateVideoEntity(AppConfig config,
DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder, DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder,
string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data, List<string> dynamicVideos = null) string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data, List<DouyinDynamicVideoDto> dynamicVideos = null)
{ {
var diffs = GetVideoEntityDifferences(cookie, item); var diffs = GetVideoEntityDifferences(cookie, item);
+7 -8
View File
@@ -36,17 +36,14 @@ namespace dy.net.service
/// <param name="audioPath"></param> /// <param name="audioPath"></param>
/// <param name="savePath"></param> /// <param name="savePath"></param>
/// <param name="ck"></param> /// <param name="ck"></param>
/// <param name="width"></param>
/// <param name="height"></param>
/// <returns></returns> /// <returns></returns>
public async Task<string> MergeMultipleVideosAsync( public async Task<(string mp4Path,string mp3Path)> MergeMultipleVideosAsync(
List<string> videoFilePaths, List<DouyinDynamicVideoDto> videoFilePaths,
string audioPath, string audioPath,
string savePath, string savePath,
string ck, string ck)
int width = 1080,
int height = 1920)
{ {
string mp3Path = string.Empty;
string mergMusicPath = GetRandomMergeMusic(); string mergMusicPath = GetRandomMergeMusic();
if (!string.IsNullOrWhiteSpace(audioPath)) if (!string.IsNullOrWhiteSpace(audioPath))
{ {
@@ -54,10 +51,12 @@ namespace dy.net.service
if (SuccessPaths != null && SuccessPaths.Length > 0) if (SuccessPaths != null && SuccessPaths.Length > 0)
{ {
mergMusicPath = SuccessPaths[0]; mergMusicPath = SuccessPaths[0];
mp3Path= SuccessPaths[0];
} }
} }
var ffmpeg = new FFmpegHelper(); var ffmpeg = new FFmpegHelper();
return await ffmpeg.MergeMultipleVideosAsync(videoFilePaths, mergMusicPath, savePath, width, height); var mp4Path= await ffmpeg.MergeMultipleVideosAsync(videoFilePaths, mergMusicPath, savePath);
return (mp4Path, mp3Path);
} }
+217 -75
View File
@@ -1,18 +1,19 @@
using Serilog; using dy.net.dto;
using Serilog;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace dy.net.utils namespace dy.net.utils
{ {
public class FFmpegHelper : IDisposable public class FFmpegHelper : IDisposable
{ {
private string _ffmpegExecutablePath; private string _ffmpegExecutablePath;
private string _ffprobeExecutablePath; private string _ffprobeExecutablePath;
@@ -53,7 +54,6 @@ namespace dy.net.utils
public string AudioCodec { get; set; } = "aac"; public string AudioCodec { get; set; } = "aac";
public string AudioBitrate { get; set; } = "192k"; public string AudioBitrate { get; set; } = "192k";
/// <summary> /// <summary>
/// 将多张图片和一个音频文件合成为视频(最终终极版)。 /// 将多张图片和一个音频文件合成为视频(最终终极版)。
/// </summary> /// </summary>
@@ -141,7 +141,6 @@ namespace dy.net.utils
Serilog.Log.Error($"获取音频时长失败,将使用图片总时长 ({audioDurationSeconds:F2}s) 作为视频时长: {ex.Message}"); Serilog.Log.Error($"获取音频时长失败,将使用图片总时长 ({audioDurationSeconds:F2}s) 作为视频时长: {ex.Message}");
} }
// 计算图片序列需要循环的次数 // 计算图片序列需要循环的次数
int loopCount = 1; int loopCount = 1;
int imageCount = imageList.Count; // 要循环的图片总数(对应loop的size参数) int imageCount = imageList.Count; // 要循环的图片总数(对应loop的size参数)
@@ -218,7 +217,6 @@ namespace dy.net.utils
{ {
throw new InvalidOperationException("视频合成失败,未生成输出文件。"); throw new InvalidOperationException("视频合成失败,未生成输出文件。");
} }
} }
finally finally
{ {
@@ -232,48 +230,173 @@ namespace dy.net.utils
/// <summary> /// <summary>
/// 合并多个视频文件为一个MP4视频(嵌入指定音频文件 /// 将单个视频转封装为统一编码/分辨率的临时视频(解决concat合并兼容问题
/// </summary>
/// <param name="videoDto">待转封装的视频Dto</param>
/// <param name="targetWidth">目标输出宽度</param>
/// <param name="targetHeight">目标输出高度</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>统一参数后的临时视频路径</returns>
private async Task<string> CreateUnifiedTempVideoAsync(
DouyinDynamicVideoDto 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<string>
{
"-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;
}
}
/// <summary>
/// 合并多个视频文件为一个MP4视频(嵌入指定音频文件)【已优化:移除冗余width/height参数,自动提取基准分辨率】
/// </summary> /// </summary>
/// <param name="videoFilePaths">待合并的视频路径列表(按合并顺序排列)</param> /// <param name="videoFilePaths">待合并的视频路径列表(按合并顺序排列)</param>
/// <param name="audioPath">音频文件(可为null/空,为空则保留视频原音频(若有))</param> /// <param name="audioPath">音频文件(可为null/空,为空则保留视频原音频(若有))</param>
/// <param name="savePath">输出视频的保存路径</param> /// <param name="savePath">输出视频的保存路径</param>
/// <param name="width">输出视频宽度(自动修正为偶数)</param>
/// <param name="height">输出视频高度(自动修正为偶数)</param>
/// <param name="progress">进度回调</param> /// <param name="progress">进度回调</param>
/// <param name="cancellationToken">取消令牌</param> /// <param name="cancellationToken">取消令牌</param>
/// <returns>输出视频路径</returns> /// <returns>输出视频路径</returns>
// 移除了 width = 1080 和 height = 1920 两个传入参数
public async Task<string> MergeMultipleVideosAsync( public async Task<string> MergeMultipleVideosAsync(
List<string> videoFilePaths, List<DouyinDynamicVideoDto> videoFilePaths,
string audioPath, string audioPath,
string savePath, string savePath,
int width = 1080,
int height = 1920,
IProgress<double> progress = null, IProgress<double> progress = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
List<DouyinDynamicVideoDto> validVideoList = new List<DouyinDynamicVideoDto>();
List<string> tempUnifiedVideoPaths = new List<string>();
// 输入验证 // 输入验证
if (videoFilePaths == null || !videoFilePaths.Any()) if (videoFilePaths == null || !videoFilePaths.Any())
throw new ArgumentException("视频路径列表不能为空。", nameof(videoFilePaths));
foreach (var videoPath in videoFilePaths)
{ {
if (!File.Exists(videoPath)) //Serilog.Log.Error("待合并视频列表为空。");
throw new FileNotFoundException("视频文件未找到。", videoPath); 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)) if (string.IsNullOrEmpty(savePath))
throw new ArgumentNullException(nameof(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); bool hasCustomAudio = !string.IsNullOrEmpty(audioPath) && File.Exists(audioPath);
//if (!string.IsNullOrEmpty(audioPath) && !hasCustomAudio)
// throw new FileNotFoundException("指定的音频文件未找到。", audioPath);
// 自动修正分辨率为偶数(H264编码要求 // 核心修改:提取第一个有效视频的分辨率作为基准输出分辨率(替代传入的width/height
if (width % 2 != 0) width++; // 进阶:动态计算最优分辨率(取所有有效视频的最大宽、最大高)
if (height % 2 != 0) 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;
// 步骤1:创建临时文件列表(FFmpeg合并视频需要先生成文件列表) //Log.Debug($"动态计算最优分辨率:{targetWidth}x{targetHeight}(适配所有视频)");
// 步骤1:创建临时文件列表
string tempListFile = Path.Combine(AppContext.BaseDirectory, "temp", $"{Guid.NewGuid()}.txt"); string tempListFile = Path.Combine(AppContext.BaseDirectory, "temp", $"{Guid.NewGuid()}.txt");
var tempDir = Path.GetDirectoryName(tempListFile); var tempDir = Path.GetDirectoryName(tempListFile);
if (!Directory.Exists(tempDir)) if (!Directory.Exists(tempDir))
@@ -283,87 +406,97 @@ namespace dy.net.utils
try try
{ {
// 生成FFmpeg识别的文件列表(格式:file '绝对路径' // 转封装所有有效视频为统一参数的临时视频(使用基准分辨率
var fileListContent = new StringBuilder(); List<string> unifiedVideoPaths = new List<string>();
foreach (var videoPath in videoFilePaths) 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("'", "\\'"); string escapedPath = videoPath.Replace("\\", "/").Replace("'", "\\'");
fileListContent.AppendLine($"file '{escapedPath}'"); fileListContent.AppendLine($"file '{escapedPath}'");
} }
File.WriteAllText(tempListFile, fileListContent.ToString(), new UTF8Encoding(false)); File.WriteAllText(tempListFile, fileListContent.ToString(), new UTF8Encoding(false));
// 步骤2构建FFmpeg合并+音频嵌入参数 // 构建FFmpeg合并参数(使用基准分辨率targetWidth/targetHeight
var arguments = new List<string>(); var arguments = new List<string>();
// 核心修改:支持双输入(视频列表 + 自定义音频)
if (hasCustomAudio) if (hasCustomAudio)
{ {
// 输入1:视频文件列表(仅读取视频流,忽略原音频)
arguments.AddRange(new[] arguments.AddRange(new[]
{ {
"-y", // 覆盖输出文件 "-y", "-f", "concat", "-safe", "0",
"-f", "concat", "-i", tempListFile, "-i", audioPath,
"-safe", "0", "-map", "0:v", "-map", "1:a",
"-i", tempListFile, "-shortest", "-filter:a", "apad=pad_len=0"
// 输入2:自定义音频文件
"-i", audioPath,
// 映射流:输入0的视频流 → 输出视频流;输入1的音频流 → 输出音频流
"-map", "0:v",
"-map", "1:a",
// 音频同步:保证视频与音频时长匹配(若音频较短,循环音频;若较长,截断音频)
"-shortest", // 以较短的流时长为准,避免无意义的空帧/静音
"-filter:a", "apad=pad_len=0" // 音频补全,防止音频时长略短于视频导致的截断
}); });
} }
else else
{ {
// 无自定义音频:仅合并视频,保留原视频音频(若有)
arguments.AddRange(new[] arguments.AddRange(new[]
{ {
"-y", // 覆盖输出文件 "-y", "-f", "concat", "-safe", "0",
"-f", "concat",
"-safe", "0",
"-i", tempListFile, "-i", tempListFile,
// 映射流:保留原视频的视频流和音频流 "-map", "0:v", "-map", "0:a?",
"-map", "0:v",
"-map", "0:a?", // "?" 表示若存在音频流则映射,不存在则忽略,避免报错
"-shortest" "-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[] arguments.AddRange(new[]
{ {
"-c:v", VideoCodec, "-c:v", VideoCodec, "-preset", VideoPreset, "-crf", $"{VideoCrf}",
"-preset", VideoPreset, "-pix_fmt", "yuv420p", "-profile:v", "main"
"-crf", $"{VideoCrf}",
"-s", $"{width}x{height}", // 统一输出分辨率
"-pix_fmt", "yuv420p", // 兼容所有播放器
"-profile:v", "main"
}); });
// 音频编码参数(无论是否自定义音频,统一编码格式保证兼容性)
arguments.AddRange(new[] arguments.AddRange(new[]
{ {
"-c:a", AudioCodec, "-c:a", AudioCodec, "-b:a", AudioBitrate,
"-b:a", AudioBitrate, "-ac", "2", "-ar", "44100"
"-ac", "2", // 立体声
"-ar", "44100" // 标准采样率
}); });
// 封装优化
arguments.AddRange(new[] arguments.AddRange(new[]
{ {
"-f", "mp4", "-f", "mp4", "-movflags", "+faststart", savePath
"-movflags", "+faststart" // 适合网络播放(将moov原子移至文件头部)
}); });
// 输出路径 // 执行FFmpeg命令
arguments.Add(savePath);
//Log.Error($"savePath={savePath}");
// 执行FFmpeg合并+音频嵌入命令
await ExecuteFFmpegAsync(arguments, progress, cancellationToken); await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
// 验证输出文件 // 验证输出文件
@@ -375,15 +508,25 @@ namespace dy.net.utils
else else
{ {
Log.Error("视频合成失败,未生成输出文件。"); Log.Error("视频合成失败,未生成输出文件。");
return ""; return string.Empty;
} }
} }
finally finally
{ {
// 清理临时文件 // 清理临时文件(原有逻辑不变)
if (File.Exists(tempListFile)) if (File.Exists(tempListFile))
{ {
File.Delete(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}"); }
}
} }
} }
} }
@@ -463,5 +606,4 @@ namespace dy.net.utils
_ffmpegProcess?.Dispose(); _ffmpegProcess?.Dispose();
} }
} }
} }