From a27e2eaaeb577102b09316474551a234a3d2d64a Mon Sep 17 00:00:00 2001 From: xiangbing Date: Thu, 1 Jan 2026 15:39:54 +0800 Subject: [PATCH] feat: update --- Conversions/StatusToEnabledConverter.cs | 25 ++ Models/ExtractWindowModel.cs | 17 +- Models/VideoModel.cs | 153 ++++++- Services/Video/VideoProcess.cs | 153 ++++++- ViewModels/ExtractWindowViewModel.cs | 239 +++++++++-- ViewModels/VideoViewModel.cs | 212 +++++++++- Views/ExtractWindow.xaml | 6 +- Views/LoginWindow.xaml.cs | 48 +++ Views/VideoPreviewWindow.xaml | 86 ++++ Views/VideoPreviewWindow.xaml.cs | 170 ++++++++ Views/VideoWindow.xaml | 28 +- docs/README.md | 97 +++++ docs/快速开始.md | 244 ++++++++++++ docs/架构说明.md | 509 ++++++++++++++++++++++++ docs/项目文档.md | 402 +++++++++++++++++++ 15 files changed, 2302 insertions(+), 87 deletions(-) create mode 100644 Conversions/StatusToEnabledConverter.cs create mode 100644 Views/VideoPreviewWindow.xaml create mode 100644 Views/VideoPreviewWindow.xaml.cs create mode 100644 docs/README.md create mode 100644 docs/快速开始.md create mode 100644 docs/架构说明.md create mode 100644 docs/项目文档.md diff --git a/Conversions/StatusToEnabledConverter.cs b/Conversions/StatusToEnabledConverter.cs new file mode 100644 index 0000000..63f9e68 --- /dev/null +++ b/Conversions/StatusToEnabledConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace VideoConcat.Conversions +{ + public class StatusToEnabledConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string status) + { + // 只有状态为"拼接成功"时才启用预览按钮 + return status == "拼接成功"; + } + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} + diff --git a/Models/ExtractWindowModel.cs b/Models/ExtractWindowModel.cs index 9d42ec3..8d252a3 100644 --- a/Models/ExtractWindowModel.cs +++ b/Models/ExtractWindowModel.cs @@ -31,6 +31,7 @@ namespace VideoConcat.Models private bool _isCanOperate = false; private bool _isStart = false; private string[] _videos = []; + private int _extractCount = 1; private Dispatcher _dispatcher; public string[] videos @@ -141,9 +142,23 @@ namespace VideoConcat.Models _dispatcher = Dispatcher.CurrentDispatcher; } + public int ExtractCount + { + get => _extractCount; + set + { + _extractCount = value; + OnPropertyChanged(); + SetCanStart(); + } + } + public void SetCanStart() { - CanExtractFrame = false; + // 有视频文件且生成个数大于0时,可以抽帧 + CanExtractFrame = videos.Length > 0 && _extractCount > 0; + + // 有视频文件时可以修改 if (videos.Length > 0) { CanModify = true; diff --git a/Models/VideoModel.cs b/Models/VideoModel.cs index eb312f4..31be907 100644 --- a/Models/VideoModel.cs +++ b/Models/VideoModel.cs @@ -110,14 +110,153 @@ namespace VideoConcat.Models } - public class ConcatVideo + public class ConcatVideo : INotifyPropertyChanged { - public int Index { set; get; } - public string FileName { set; get; } = ""; - public string Size { set; get; } = ""; - public int Seconds { set; get; } - public string Status { set; get; } = ""; - public string Progress { set; get; } = ""; + private int _index; + private string _fileName = ""; + private string _filePath = ""; + private string _size = ""; + private int _seconds; + private string _status = ""; + private string _progress = ""; + + public int Index + { + get => _index; + set + { + _index = value; + OnPropertyChanged(); + } + } + + public string FileName + { + get => _fileName; + set + { + _fileName = value; + OnPropertyChanged(); + } + } + + public string FilePath + { + get => _filePath; + set + { + _filePath = value; + OnPropertyChanged(); + } + } + + public string Size + { + get => _size; + set + { + _size = value; + OnPropertyChanged(); + } + } + + public int Seconds + { + get => _seconds; + set + { + _seconds = value; + OnPropertyChanged(); + } + } + + public string Status + { + get => _status; + set + { + _status = value; + OnPropertyChanged(); + } + } + + public string Progress + { + get => _progress; + set + { + _progress = value; + OnPropertyChanged(); + // 同时更新进度条数值 + UpdateProgressValue(); + } + } + + private double _progressValue = 0; + public double ProgressValue + { + get => _progressValue; + set + { + _progressValue = value; + OnPropertyChanged(); + } + } + + private void UpdateProgressValue() + { + // 从进度文本中提取百分比数值 + if (string.IsNullOrEmpty(_progress)) + { + ProgressValue = 0; + return; + } + + // 处理各种进度格式: "50%", "50", "拼接中...", "100%", "失败" 等 + if (_progress.EndsWith("%")) + { + string percentStr = _progress.Replace("%", "").Trim(); + if (double.TryParse(percentStr, out double percent)) + { + ProgressValue = percent; + } + else + { + ProgressValue = 0; + } + } + else if (_progress == "拼接成功" || _progress == "100%") + { + ProgressValue = 100; + } + else if (_progress == "拼接失败" || _progress == "失败") + { + ProgressValue = 0; + } + else if (_progress.Contains("中") || _progress.Contains("...")) + { + // 处理中状态,保持当前进度或设置为中间值 + if (ProgressValue < 10) + { + ProgressValue = 10; // 开始处理时显示10% + } + } + else + { + // 尝试解析为数字 + if (double.TryParse(_progress, out double value)) + { + ProgressValue = value; + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } public ObservableCollection FolderInfos diff --git a/Services/Video/VideoProcess.cs b/Services/Video/VideoProcess.cs index 5cf42d8..e99dbc4 100644 --- a/Services/Video/VideoProcess.cs +++ b/Services/Video/VideoProcess.cs @@ -74,29 +74,64 @@ namespace VideoConcat.Services.Video double frameDuration = Math.Round(1.0 / frameRate, 6); // 一帧时长(秒) int totalFram = (int)(totalDuration * frameRate); + // 确保视频时长足够(至少20秒) + if (totalDuration < 20) + { + LogUtils.Error($"视频时长太短({totalDuration}秒),无法抽帧"); + return false; + } + var random = new Random(); - var randomFrame = random.Next(20, (int)totalDuration); + // 随机选择要删除的帧时间点(避开开头和结尾) + int maxFrameTime = (int)totalDuration - 5; // 确保有足够的空间 + int minFrameTime = 20; // 从20秒开始 + if (maxFrameTime <= minFrameTime) + { + maxFrameTime = minFrameTime + 1; + } + var randomFrame = random.Next(minFrameTime, maxFrameTime); //return RemoveVideoFrame(inputPath, outputPath, randomFrame); string videoPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4"); string videoPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4"); + + LogUtils.Info($"开始抽帧:输入={inputPath}, 输出={outputPath}, 删除帧时间={randomFrame}秒"); + bool hasSubVideo1 = SubVideo(inputPath, videoPart1, 0, randomFrame - 0.016); - bool hasSubVideo2 = SubVideo(inputPath, videoPart2, randomFrame, totalDuration); - if (!hasSubVideo1 || !hasSubVideo2) + if (!hasSubVideo1) { + LogUtils.Error($"裁剪第一部分视频失败:{videoPart1}"); + return false; + } + + bool hasSubVideo2 = SubVideo(inputPath, videoPart2, randomFrame, totalDuration); + if (!hasSubVideo2) + { + LogUtils.Error($"裁剪第二部分视频失败:{videoPart2}"); return false; } - + LogUtils.Info($"视频裁剪成功,开始合并:{videoPart1} + {videoPart2} -> {outputPath}"); bool isJoinSuccess = JoinVideo(outputPath, [videoPart1, videoPart2]); if (!isJoinSuccess) { + LogUtils.Error($"合并视频失败:{outputPath}"); return false; } - return false; + // 验证输出文件是否存在 + if (File.Exists(outputPath)) + { + LogUtils.Info($"抽帧成功:{outputPath}"); + return true; + } + else + { + LogUtils.Error($"抽帧失败:输出文件不存在 - {outputPath}"); + return false; + } } catch (Exception ex) @@ -107,12 +142,37 @@ namespace VideoConcat.Services.Video } finally { - string[] files = Directory.GetFiles(tempDir); - foreach (string file in files) + // 清理临时目录 + try { - File.Delete(file); + if (Directory.Exists(tempDir)) + { + string[] files = Directory.GetFiles(tempDir); + foreach (string file in files) + { + try + { + File.Delete(file); + } + catch + { + // 忽略删除失败 + } + } + try + { + Directory.Delete(tempDir); + } + catch + { + // 忽略删除失败 + } + } + } + catch + { + // 忽略清理错误 } - File.Delete(tempDir); } } @@ -139,10 +199,45 @@ namespace VideoConcat.Services.Video public static bool SubVideo(string inputPath, string outputPath, Double startSec, Double endSec) { - return FFMpegArguments - .FromFileInput(inputPath, true, options => options.Seek(TimeSpan.FromSeconds(startSec)).EndSeek(TimeSpan.FromSeconds(endSec))) - .OutputToFile(outputPath, true, options => options.CopyChannel()) //.WithCustomArgument("-an") 去掉音频 - .ProcessSynchronously(); + try + { + // 计算时长 + double duration = endSec - startSec; + if (duration <= 0) + { + LogUtils.Error($"SubVideo失败:时长无效,开始={startSec}秒, 结束={endSec}秒"); + return false; + } + + LogUtils.Info($"SubVideo:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒"); + + // 使用 -ss 和 -t 参数进行裁剪 + bool result = FFMpegArguments + .FromFileInput(inputPath, true, options => options + .Seek(TimeSpan.FromSeconds(startSec)) + .WithCustomArgument($"-t {duration}")) // 指定时长而不是结束时间 + .OutputToFile(outputPath, true, options => options + .CopyChannel()) // 复制流,不重新编码 + .ProcessSynchronously(); + + // 验证输出文件是否存在 + if (result && File.Exists(outputPath)) + { + FileInfo fileInfo = new FileInfo(outputPath); + LogUtils.Info($"SubVideo成功:{outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB"); + return true; + } + else + { + LogUtils.Error($"SubVideo失败:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 结果={result}, 文件存在={File.Exists(outputPath)}"); + return false; + } + } + catch (Exception ex) + { + LogUtils.Error($"SubVideo异常:输入={inputPath}, 输出={outputPath}", ex); + return false; + } } @@ -156,7 +251,37 @@ namespace VideoConcat.Services.Video public static bool JoinVideo(string outPutPath, string[] videoParts) { - return FFMpeg.Join(outPutPath, videoParts); + try + { + // 验证所有输入文件是否存在 + foreach (var part in videoParts) + { + if (!File.Exists(part)) + { + LogUtils.Error($"JoinVideo失败:输入文件不存在 - {part}"); + return false; + } + } + + bool result = FFMpeg.Join(outPutPath, videoParts); + + // 验证输出文件是否存在 + if (result && File.Exists(outPutPath)) + { + LogUtils.Info($"JoinVideo成功:{outPutPath}"); + return true; + } + else + { + LogUtils.Error($"JoinVideo失败:输出文件不存在 - {outPutPath}"); + return false; + } + } + catch (Exception ex) + { + LogUtils.Error($"JoinVideo异常:输出={outPutPath}", ex); + return false; + } } public async Task ProcessVideo(string inputPath, string outputPath, string tempDir) diff --git a/ViewModels/ExtractWindowViewModel.cs b/ViewModels/ExtractWindowViewModel.cs index 3563e88..c1dc0b9 100644 --- a/ViewModels/ExtractWindowViewModel.cs +++ b/ViewModels/ExtractWindowViewModel.cs @@ -2,6 +2,7 @@ using FFMpegCore.Enums; using Microsoft.Expression.Drawing.Core; using System.IO; +using System.Threading; using System.Windows; using System.Windows.Forms; using System.Windows.Input; @@ -44,8 +45,8 @@ namespace VideoConcat.ViewModels { DoExcue = obj => { - FolderBrowserDialog folderBrowserDialog = new(); - if (folderBrowserDialog.ShowDialog() == DialogResult.OK) + System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new(); + if (folderBrowserDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) { ExtractWindowModel.FolderPath = folderBrowserDialog.SelectedPath; LogUtils.Info($"获取视频文件夹,视频路径:{ExtractWindowModel.FolderPath}"); @@ -59,45 +60,213 @@ namespace VideoConcat.ViewModels { DoExcue = obj => { - ExtractWindowModel.HelpInfo = ""; - - - SemaphoreSlim semaphore = new(10); // Limit to 3 threads - - List _tasks = []; - - ExtractWindowModel.videos.ForEach(async (video) => + // 在后台任务中执行异步操作 + Task.Run(async () => { - await semaphore.WaitAsync(); // Wait when more than 3 threads are running - var _task = Task.Run(async () => + int extractCount = ExtractWindowModel.ExtractCount; + if (extractCount <= 0) { - try + ExtractWindowModel.Dispatcher.Invoke(() => { - // 实例化并调用 - var remover = new VideoProcess(); - // 删除4秒处的帧(需根据实际帧位置调整) - string _tmpPath = Path.GetDirectoryName(video) ?? ""; - string _tmpFileName = $"{(new Random()).Next(10000, 99999)}{Path.GetFileName(video)}"; + MessageBox.Show("请输入有效的生成个数(大于0)!", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + }); + return; + } - string outPath = Path.Combine(_tmpPath, "out"); - if (!Path.Exists(outPath)) - { - Directory.CreateDirectory(outPath); - } - - await VideoProcess.RemoveFrameRandomeAsync(video, $"{_tmpPath}\\out\\{_tmpFileName}"); - } - finally - { - semaphore.Release(); // Work is done, signal to semaphore that more work is possible - } + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"开始处理,每个视频将生成 {extractCount} 个抽帧视频..."; + ExtractWindowModel.IsStart = true; + ExtractWindowModel.IsCanOperate = false; }); - _tasks.Add(_task); - }); - Task.WhenAll(_tasks).ContinueWith((task) => - { - ExtractWindowModel.HelpInfo = "全部完成!"; + SemaphoreSlim semaphore = new(10); // 限制并发数量 + + List _tasks = []; + int totalTasks = ExtractWindowModel.videos.Length * extractCount; + int completedTasks = 0; + System.Collections.Concurrent.ConcurrentBag errorMessages = new(); // 收集错误信息 + + // 对每个视频生成指定数量的抽帧视频 + foreach (var video in ExtractWindowModel.videos) + { + for (int i = 1; i <= extractCount; i++) + { + int currentIndex = i; // 闭包变量 + string currentVideo = video; // 闭包变量 + + await semaphore.WaitAsync(); + var _task = Task.Run(async () => + { + try + { + string _tmpPath = Path.GetDirectoryName(currentVideo) ?? ""; + if (string.IsNullOrEmpty(_tmpPath)) + { + LogUtils.Error($"无法获取视频目录:{currentVideo}"); + Interlocked.Increment(ref completedTasks); + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + }); + return; + } + + string originalFileName = Path.GetFileNameWithoutExtension(currentVideo); + string extension = Path.GetExtension(currentVideo); + + // 生成唯一的文件名:原文件名_序号.扩展名 + string _tmpFileName = $"{originalFileName}_{currentIndex:D4}{extension}"; + + string outPath = Path.Combine(_tmpPath, "out"); + LogUtils.Info($"准备创建输出目录:{outPath}"); + + try + { + if (!Directory.Exists(outPath)) + { + Directory.CreateDirectory(outPath); + LogUtils.Info($"已创建输出目录:{outPath}"); + } + } + catch (Exception ex) + { + LogUtils.Error($"创建输出目录失败:{outPath}", ex); + Interlocked.Increment(ref completedTasks); + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + }); + return; + } + + string outputPath = Path.Combine(outPath, _tmpFileName); + LogUtils.Info($"开始抽帧:输入={currentVideo}, 输出={outputPath}"); + + // 如果文件已存在,跳过 + if (File.Exists(outputPath)) + { + LogUtils.Info($"文件已存在,跳过:{outputPath}"); + Interlocked.Increment(ref completedTasks); + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + }); + return; + } + + // 先检查视频时长 + try + { + var mediaInfo = await FFProbe.AnalyseAsync(currentVideo); + double totalDuration = mediaInfo.Duration.TotalSeconds; + + if (totalDuration < 20) + { + string videoName = Path.GetFileName(currentVideo); + string errorMsg = $"视频时长太短:{videoName}({totalDuration:F2}秒),无法抽帧(需要至少20秒)"; + LogUtils.Error(errorMsg); + errorMessages.Add(errorMsg); + + Interlocked.Increment(ref completedTasks); + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + }); + return; + } + } + catch (Exception ex) + { + LogUtils.Error($"检查视频时长失败:{currentVideo}", ex); + // 继续处理,让 RemoveFrameRandomeAsync 来处理错误 + } + + bool success = await VideoProcess.RemoveFrameRandomeAsync(currentVideo, outputPath); + + // 再次检查文件是否存在 + if (File.Exists(outputPath)) + { + FileInfo fileInfo = new FileInfo(outputPath); + LogUtils.Info($"抽帧成功:{currentVideo} -> {outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB"); + } + else if (success) + { + LogUtils.Warn($"抽帧返回成功但文件不存在:{outputPath}"); + } + else + { + string videoName = Path.GetFileName(currentVideo); + string errorMsg = $"抽帧失败:{videoName}"; + LogUtils.Error($"{errorMsg} -> {outputPath}"); + errorMessages.Add(errorMsg); + } + + // 更新完成计数 + Interlocked.Increment(ref completedTasks); + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + }); + } + catch (Exception ex) + { + string videoName = Path.GetFileName(currentVideo); + string errorMsg = $"抽帧异常:{videoName} (第{currentIndex}个) - {ex.Message}"; + LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex); + errorMessages.Add(errorMsg); + Interlocked.Increment(ref completedTasks); + ExtractWindowModel.Dispatcher.Invoke(() => + { + ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + }); + } + finally + { + semaphore.Release(); + } + }); + _tasks.Add(_task); + } + } + + await Task.WhenAll(_tasks); + + // 统计实际生成的文件数量 + int actualFileCount = 0; + string outputDir = ""; + if (ExtractWindowModel.videos.Length > 0) + { + string firstVideo = ExtractWindowModel.videos[0]; + string videoDir = Path.GetDirectoryName(firstVideo) ?? ""; + outputDir = Path.Combine(videoDir, "out"); + + if (Directory.Exists(outputDir)) + { + actualFileCount = Directory.GetFiles(outputDir, "*.mp4").Length; + LogUtils.Info($"输出目录 {outputDir} 中共有 {actualFileCount} 个视频文件"); + } + } + + // 构建最终信息 + string summaryInfo = $"全部完成! 共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件\n输出目录:{outputDir}"; + + ExtractWindowModel.Dispatcher.Invoke(() => + { + // 如果有错误信息,显示汇总 + if (errorMessages.Count > 0) + { + string errorSummary = string.Join("\n", errorMessages); + ExtractWindowModel.HelpInfo = $"{summaryInfo}\n\n错误信息(共{errorMessages.Count}个):\n{errorSummary}"; + } + else + { + ExtractWindowModel.HelpInfo = summaryInfo; + } + ExtractWindowModel.IsStart = false; + ExtractWindowModel.IsCanOperate = true; + }); + LogUtils.Info($"抽帧处理完成,共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件,输出目录:{outputDir}"); }); } }, diff --git a/ViewModels/VideoViewModel.cs b/ViewModels/VideoViewModel.cs index f1446c6..7993bc5 100644 --- a/ViewModels/VideoViewModel.cs +++ b/ViewModels/VideoViewModel.cs @@ -9,6 +9,11 @@ using FFMpegCore.Enums; using static VideoConcat.Models.VideoModel; using System.Windows.Threading; using System.Windows; +using System.Diagnostics; +using System.Linq; +// 明确使用 WPF 的 OpenFileDialog,避免与 Windows Forms 冲突 +using OpenFileDialog = Microsoft.Win32.OpenFileDialog; +using System.Windows.Forms; namespace VideoConcat.ViewModels { @@ -46,8 +51,8 @@ namespace VideoConcat.ViewModels { DoExcue = obj => { - FolderBrowserDialog folderBrowserDialog = new(); - if (folderBrowserDialog.ShowDialog() == DialogResult.OK) + System.Windows.Forms.FolderBrowserDialog folderBrowserDialog = new(); + if (folderBrowserDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) { VideoModel.FolderPath = folderBrowserDialog.SelectedPath; LogUtils.Info($"获取视频文件夹,视频路径:{VideoModel.FolderPath}"); @@ -60,7 +65,7 @@ namespace VideoConcat.ViewModels { DoExcue = obj => { - // 创建一个 OpenFileDialog 实例 + // 创建一个 OpenFileDialog 实例(使用 WPF 的 OpenFileDialog) OpenFileDialog openFileDialog = new() { // 设置文件对话框的标题 @@ -70,12 +75,12 @@ namespace VideoConcat.ViewModels }; - // 显示文件对话框并获取结果 - DialogResult result = openFileDialog.ShowDialog(); + // 显示文件对话框并获取结果(WPF 返回 bool?) + bool? result = openFileDialog.ShowDialog(); // 检查用户是否点击了打开按钮 - if (result == DialogResult.OK) + if (result == true) { // 获取用户选择的文件路径列表 VideoModel.AuditImagePath = openFileDialog.FileName; @@ -185,18 +190,58 @@ namespace VideoConcat.ViewModels LogUtils.Info("开始拼接视频"); + // 预先创建所有ConcatVideo项,用于显示进度 + VideoModel.Dispatcher.Invoke(() => + { + for (int i = 0; i < result.Count; i++) + { + List combination = result[i]; + string firstVideoPath = combination[0]; + string firstVideoName = Path.GetFileNameWithoutExtension(firstVideoPath); + string _tempFileName = $"{firstVideoName}_{i + 1:D4}.mp4"; + string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName); + + VideoModel.ConcatVideos.Add(new ConcatVideo() + { + Index = i + 1, + FileName = _tempFileName, + FilePath = _outPutName, + Size = "-", + Seconds = 0, + Status = "处理中", + Progress = "0%", + }); + } + }); List taskList = []; semaphore = new(10); - foreach (List combination in result) + for (int combinationIndex = 0; combinationIndex < result.Count; combinationIndex++) { + List combination = result[combinationIndex]; + int currentIndex = combinationIndex + 1; // 序号从1开始,在闭包外计算 await semaphore.WaitAsync(); var _task = Task.Run(() => { - string _tempFileName = $"{DateTime.Now:yyyyMMddHHmmss}{random.Next(100000, 999999)}.mp4"; + // 获取第一个视频的文件名(不含扩展名) + string firstVideoPath = combination[0]; + string firstVideoName = Path.GetFileNameWithoutExtension(firstVideoPath); + string _tempFileName = $"{firstVideoName}_{currentIndex:D4}.mp4"; string _outPutName = Path.Combine($"{VideoModel.FolderPath}", "output", _tempFileName); ; + // 更新进度为"准备中" + VideoModel.Dispatcher.Invoke(() => + { + var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex); + if (videoItem != null) + { + videoItem.Progress = "准备中..."; + videoItem.Status = "处理中"; + // 进度值会在UpdateProgressValue中自动更新 + } + }); + try { @@ -209,9 +254,37 @@ namespace VideoConcat.ViewModels bool _isSuccess = false; + // 更新进度为"拼接中" + VideoModel.Dispatcher.Invoke(() => + { + var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex); + if (videoItem != null) + { + videoItem.Progress = "0%"; + videoItem.Status = "拼接中"; + } + }); try { + // 计算总时长用于进度计算 + double totalDuration = 0; + try + { + foreach (var videoPath in combination) + { + var analysis = FFProbe.Analyse(videoPath); + totalDuration += analysis.Duration.TotalSeconds; + } + } + catch + { + totalDuration = 100; // 默认值 + } + + int currentIndexForProgress = currentIndex; + double processedDuration = 0; + _isSuccess = FFMpegArguments .FromConcatInput(temporaryVideoParts) .OutputToFile(_outPutName, true, options => options @@ -223,6 +296,43 @@ namespace VideoConcat.ViewModels .WithAudioSamplingRate(44100) // 强制采样率 .WithCustomArgument("-movflags +faststart -analyzeduration 100M -probesize 100M") ) + .NotifyOnOutput((output) => + { + // 解析FFmpeg输出获取进度 + // FFmpeg输出格式: time=00:00:05.00 bitrate=... + if (output.Contains("time=")) + { + try + { + int timeIndex = output.IndexOf("time="); + if (timeIndex >= 0) + { + string timeStr = output.Substring(timeIndex + 5, 11); // time=HH:MM:SS.mm + if (TimeSpan.TryParse(timeStr, out TimeSpan currentTime)) + { + processedDuration = currentTime.TotalSeconds; + int progressPercent = totalDuration > 0 + ? (int)((processedDuration / totalDuration) * 100) + : 0; + progressPercent = Math.Min(100, Math.Max(0, progressPercent)); + + VideoModel.Dispatcher.Invoke(() => + { + var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndexForProgress); + if (videoItem != null) + { + videoItem.Progress = $"{progressPercent}%"; + } + }); + } + } + } + catch + { + // 忽略解析错误 + } + } + }) .ProcessSynchronously(); } catch (Exception ex) @@ -233,20 +343,35 @@ namespace VideoConcat.ViewModels //bool _isSuccess = FFMpeg.Join(_outPutName, [.. combination]); + // 更新拼接结果 VideoModel.Dispatcher.Invoke(() => { - IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName); - FileInfo fileInfo = new(_outPutName); - - VideoModel.ConcatVideos.Add(new ConcatVideo() + var videoItem = VideoModel.ConcatVideos.FirstOrDefault(v => v.Index == currentIndex); + if (videoItem != null && File.Exists(_outPutName)) { - Index = VideoModel.ConcatVideos.Count + 1, - FileName = _tempFileName, - Size = $"{fileInfo.Length / 1024 / 1024}MB", - Seconds = ((int)_mediaInfo.Duration.TotalSeconds), - Status = _isSuccess ? "拼接成功" : "拼接失败", - Progress = "100%", - }); + try + { + IMediaAnalysis _mediaInfo = FFProbe.Analyse(_outPutName); + FileInfo fileInfo = new(_outPutName); + + videoItem.FilePath = _outPutName; + videoItem.Size = $"{fileInfo.Length / 1024 / 1024}MB"; + videoItem.Seconds = ((int)_mediaInfo.Duration.TotalSeconds); + videoItem.Status = _isSuccess ? "拼接成功" : "拼接失败"; + videoItem.Progress = _isSuccess ? "100%" : "失败"; + } + catch (Exception ex) + { + videoItem.Status = "拼接失败"; + videoItem.Progress = "失败"; + LogUtils.Error($"分析视频信息失败:{_outPutName}", ex); + } + } + else if (videoItem != null) + { + videoItem.Status = "拼接失败"; + videoItem.Progress = "失败"; + } }); @@ -258,7 +383,10 @@ namespace VideoConcat.ViewModels // 配置 FFmpeg 二进制文件位置(如果 FFmpeg 不在系统路径中) // GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "path/to/ffmpeg/bin" }); - string _outPutNameImg = $"{VideoModel.FolderPath}\\output\\{DateTime.Now:yyyyMMddHHmmss}{random.Next(100000, 999999)}.mp4"; + // 使用相同的命名规则,添加 _img 后缀表示带水印 + string firstVideoPathName = combination[0]; + string firstVideoNameHas = Path.GetFileNameWithoutExtension(firstVideoPathName); + string _outPutNameImg = Path.Combine($"{VideoModel.FolderPath}", "output", $"{firstVideoNameHas}_{currentIndex:D4}_img.mp4"); string _customArg = "-filter_complex \"[0:v][1:v] overlay=0:H-h\" "; // 使用 FFMpegArguments 构建命令 @@ -304,11 +432,51 @@ namespace VideoConcat.ViewModels { //结束时间 DateTime endTime = DateTime.Now; - LogUtils.Info($"所有视频拼接完成,用时{(endTime - startTime).TotalSeconds}秒"); + double elapsedSeconds = (endTime - startTime).TotalSeconds; + + // 统计成功和失败的数量 + int successCount = 0; + int failCount = 0; + string outputDir = ""; + + VideoModel.Dispatcher.Invoke(() => + { + successCount = VideoModel.ConcatVideos.Count(v => v.Status == "拼接成功"); + failCount = VideoModel.ConcatVideos.Count(v => v.Status == "拼接失败"); + + if (VideoModel.ConcatVideos.Count > 0) + { + var firstVideo = VideoModel.ConcatVideos.FirstOrDefault(v => !string.IsNullOrEmpty(v.FilePath)); + if (firstVideo != null && !string.IsNullOrEmpty(firstVideo.FilePath)) + { + outputDir = Path.GetDirectoryName(firstVideo.FilePath) ?? ""; + } + } + }); + + LogUtils.Info($"所有视频拼接完成,用时{elapsedSeconds:F2}秒,成功{successCount}个,失败{failCount}个"); VideoModel.IsStart = false; VideoCombine.Cleanup(VideoCombine.mustClearPath); - MessageBox.Show("所有视频拼接完成"); + + // 构建提示信息 + string message = $"所有视频拼接完成!\n\n"; + message += $"成功:{successCount} 个\n"; + if (failCount > 0) + { + message += $"失败:{failCount} 个\n"; + } + message += $"用时:{elapsedSeconds:F2} 秒\n"; + if (!string.IsNullOrEmpty(outputDir)) + { + message += $"\n输出目录:\n{outputDir}"; + } + + VideoModel.Dispatcher.Invoke(() => + { + MessageBox.Show(message, "拼接完成", MessageBoxButton.OK, MessageBoxImage.Information); + }); + VideoCombine.mustClearPath = []; }); }); diff --git a/Views/ExtractWindow.xaml b/Views/ExtractWindow.xaml index 0c48535..7b0edaf 100644 --- a/Views/ExtractWindow.xaml +++ b/Views/ExtractWindow.xaml @@ -29,10 +29,12 @@ - +