diff --git a/Models/ExtractWindowModel.cs b/Models/ExtractWindowModel.cs index 8d252a3..43a6d4b 100644 --- a/Models/ExtractWindowModel.cs +++ b/Models/ExtractWindowModel.cs @@ -19,7 +19,94 @@ using static VideoConcat.Models.VideoModel; namespace VideoConcat.Models { + /// + /// 抽帧处理方式 + /// + public enum ExtractFrameMode + { + DeleteFrame = 0, // 删除帧 + AddTransparentImage = 1 // 添加透明图 + } + /// + /// 处理任务信息(用于表格显示) + /// + public class ExtractTaskItem : INotifyPropertyChanged + { + private string _index = ""; + private string _fileName = ""; + private string _fullFileName = ""; + private string _status = ""; + private string _originalSize = ""; + private string _outputSize = ""; + private string _sizeChange = ""; + private string _duration = ""; + private string _progress = ""; + + public string Index + { + get => _index; + set { _index = value; OnPropertyChanged(); } + } + + public string FileName + { + get => _fileName; + set { _fileName = value; OnPropertyChanged(); } + } + + /// + /// 完整文件名(用于ToolTip显示) + /// + public string FullFileName + { + get => _fullFileName; + set { _fullFileName = value; OnPropertyChanged(); } + } + + public string Status + { + get => _status; + set { _status = value; OnPropertyChanged(); } + } + + public string OriginalSize + { + get => _originalSize; + set { _originalSize = value; OnPropertyChanged(); } + } + + public string OutputSize + { + get => _outputSize; + set { _outputSize = value; OnPropertyChanged(); } + } + + public string SizeChange + { + get => _sizeChange; + set { _sizeChange = value; OnPropertyChanged(); } + } + + public string Duration + { + get => _duration; + set { _duration = value; OnPropertyChanged(); } + } + + public string Progress + { + get => _progress; + set { _progress = value; OnPropertyChanged(); } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } public class ExtractWindowModel : INotifyPropertyChanged { @@ -33,6 +120,8 @@ namespace VideoConcat.Models private string[] _videos = []; private int _extractCount = 1; private Dispatcher _dispatcher; + private ExtractFrameMode _extractFrameMode = ExtractFrameMode.DeleteFrame; + private ObservableCollection _taskItems = new(); public string[] videos { @@ -153,6 +242,72 @@ namespace VideoConcat.Models } } + public ExtractFrameMode ExtractFrameMode + { + get => _extractFrameMode; + set + { + _extractFrameMode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsDeleteFrameMode)); + OnPropertyChanged(nameof(IsAddTransparentImageMode)); + } + } + + /// + /// 是否选择删除帧模式(用于RadioButton绑定) + /// + public bool IsDeleteFrameMode + { + get => _extractFrameMode == ExtractFrameMode.DeleteFrame; + set + { + if (value && _extractFrameMode != ExtractFrameMode.DeleteFrame) + { + ExtractFrameMode = ExtractFrameMode.DeleteFrame; + } + } + } + + /// + /// 是否选择添加透明图模式(用于RadioButton绑定) + /// + public bool IsAddTransparentImageMode + { + get => _extractFrameMode == ExtractFrameMode.AddTransparentImage; + set + { + if (value && _extractFrameMode != ExtractFrameMode.AddTransparentImage) + { + ExtractFrameMode = ExtractFrameMode.AddTransparentImage; + } + } + } + + /// + /// 抽帧按钮的文本(固定显示"操作") + /// + public string ExtractButtonText + { + get + { + return "操作"; + } + } + + /// + /// 任务列表(用于表格显示) + /// + public ObservableCollection TaskItems + { + get => _taskItems; + set + { + _taskItems = value; + OnPropertyChanged(); + } + } + public void SetCanStart() { // 有视频文件且生成个数大于0时,可以抽帧 diff --git a/Services/Video/VideoProcess.cs b/Services/Video/VideoProcess.cs index 7549863..397526a 100644 --- a/Services/Video/VideoProcess.cs +++ b/Services/Video/VideoProcess.cs @@ -289,13 +289,15 @@ namespace VideoConcat.Services.Video LogUtils.Info($"SubVideo:输入={inputPath}, 输出={outputPath}, 开始={startSec}秒, 结束={endSec}秒, 时长={duration}秒"); - // 使用 -ss 和 -t 参数进行裁剪 + // 使用输入时的 -ss 参数(在输入参数中),这样可以更精确地定位,减少重新编码 + // 使用 -c copy 确保不重新编码 bool result = FFMpegArguments .FromFileInput(inputPath, true, options => options - .Seek(TimeSpan.FromSeconds(startSec)) - .WithCustomArgument($"-t {duration}")) // 指定时长而不是结束时间 + .WithCustomArgument($"-ss {startSec:F3}")) // 在输入时指定起始时间,更精确 .OutputToFile(outputPath, true, options => options - .CopyChannel()) // 复制流,不重新编码 + .WithCustomArgument($"-t {duration:F3}") // 指定时长 + .WithVideoCodec("copy") // 明确指定复制视频流 + .WithAudioCodec("copy")) // 明确指定复制音频流 .ProcessSynchronously(); // 验证输出文件是否存在 @@ -462,5 +464,522 @@ namespace VideoConcat.Services.Video ) .ProcessSynchronously(); } + + /// + /// 随机删除一个非关键帧 + /// + /// 输入文件路径 + /// 输出文件路径 + /// 当前处理的序号(用于确保每次删除位置不同) + /// 操作是否成功 + public static async Task RemoveNonKeyFrameAsync(string inputPath, string outputPath, int index = 0) + { + if (!File.Exists(inputPath)) + { + LogUtils.Error($"输入文件不存在:{inputPath}"); + return false; + } + + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + Directory.CreateDirectory(tempDir); + + // 1. 获取视频信息 + IMediaAnalysis mediaInfo = await FFProbe.AnalyseAsync(inputPath); + var videoStream = mediaInfo.PrimaryVideoStream; + if (videoStream == null) + { + LogUtils.Error("没有找到视频流"); + return false; + } + + double totalDuration = mediaInfo.Duration.TotalSeconds; + double frameRate = videoStream.FrameRate; + + // 确保视频时长足够(至少20秒) + if (totalDuration < 20) + { + LogUtils.Error($"视频时长太短({totalDuration:F2}秒),无法抽帧(需要至少20秒)"); + return false; + } + + // 计算总帧数 + int totalFrames = (int)(totalDuration * frameRate); + if (totalFrames <= 10) + { + LogUtils.Error($"视频帧数太少({totalFrames}帧),无法删除帧"); + return false; + } + + // 2. 找出非关键帧(避开前5帧和后5帧,以及关键帧) + // 使用index确保每次删除的位置不同(基于输入路径和序号生成不同位置) + // 关键帧通常是每30帧一个(取决于编码设置) + + // 避开前5%和后5%的帧,在这些范围内选择 + int startFrame = Math.Max(5, (int)(totalFrames * 0.05)); + int endFrame = Math.Min(totalFrames - 5, (int)(totalFrames * 0.95)); + int availableRange = endFrame - startFrame; + + // 使用输入路径的哈希值和index来确定删除位置,确保每次不同 + int hashSeed = inputPath.GetHashCode() + index * 997; // 997是质数,帮助分散 + var random = new Random(hashSeed); + + // 在可用范围内基于index均匀分布选择位置 + // 使用模运算确保index越大,位置也相应变化 + double ratio = (index % (availableRange / 3)) / (double)(availableRange / 3); // 使用1/3范围来分散 + int baseFrame = startFrame + (int)(ratio * availableRange); + + // 在baseFrame附近随机选择,但确保每次都不同 + int offsetRange = Math.Max(1, availableRange / 20); // 在baseFrame附近±5%范围内 + int offset = random.Next(-offsetRange, offsetRange); + int selectedFrame = baseFrame + offset; + + // 确保在有效范围内 + selectedFrame = Math.Max(startFrame, Math.Min(endFrame, selectedFrame)); + + // 确保不是关键帧位置(关键帧通常在0, 30, 60, 90...位置) + while (selectedFrame % 30 == 0 && selectedFrame < endFrame - 1) + { + selectedFrame++; + } + + // 如果还是关键帧位置,尝试减1 + if (selectedFrame % 30 == 0 && selectedFrame > startFrame) + { + selectedFrame--; + } + + double frameDuration = 1.0 / frameRate; // 一帧的时长 + + // 找到要删除帧所在GOP的起始关键帧位置 + // 关键帧通常每30帧一个(取决于编码设置) + int gopSize = 30; // 默认GOP大小 + int gopStartFrame = (selectedFrame / gopSize) * gopSize; // 找到当前帧所在GOP的起始关键帧 + + // 计算裁剪时间点 + // 策略:在关键帧边界处裁剪,确保可以完全使用copy模式 + // 第一部分:从开始到删除帧所在GOP的起始关键帧(包含关键帧) + // 第二部分:从删除帧的下一帧开始到结束 + double frameTime = gopStartFrame * frameDuration; // 在第一部分保留关键帧 + double nextFrameTime = (selectedFrame + 1) * frameDuration; // 第二部分从删除帧的下一帧开始 + + // 如果删除的帧就在或非常接近关键帧位置,调整策略 + if (selectedFrame <= gopStartFrame + 1) + { + // 如果删除的帧太靠近关键帧,从下一个GOP开始 + int nextGopStart = gopStartFrame + gopSize; + if (nextGopStart < totalFrames) + { + frameTime = gopStartFrame * frameDuration; + nextFrameTime = nextGopStart * frameDuration; + } + else + { + // 如果已经是最后一个GOP,调整到前一个GOP + int prevGopStart = Math.Max(0, gopStartFrame - gopSize); + frameTime = prevGopStart * frameDuration; + nextFrameTime = gopStartFrame * frameDuration; + } + } + + // 确保时间有效 + frameTime = Math.Max(0, frameTime); + nextFrameTime = Math.Min(totalDuration, nextFrameTime); + + // 确保第二部分时间大于第一部分 + if (nextFrameTime <= frameTime) + { + nextFrameTime = frameTime + frameDuration; + } + + LogUtils.Info($"开始删除非关键帧(在关键帧边界裁剪以保持copy模式):输入={inputPath}, 输出={outputPath}, 总帧数={totalFrames}, 删除帧编号={selectedFrame}, GOP起始帧={gopStartFrame}, 分割时间={frameTime:F3}秒和{nextFrameTime:F3}秒"); + + // 3. 使用时间分割方式直接删除帧,不重新编码 + // 将视频分为两部分:删除帧之前和之后,然后合并 + string videoPart1 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4"); + string videoPart2 = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4"); + string mergedVideo = Path.Combine(tempDir, $"{Guid.NewGuid()}.mp4"); + + // 分离音频(如有) + string audioPath = Path.Combine(tempDir, $"{Guid.NewGuid()}.aac"); + bool hasAudio = mediaInfo.PrimaryAudioStream != null; + + if (hasAudio) + { + bool audioExtracted = FFMpegArguments + .FromFileInput(inputPath) + .OutputToFile(audioPath, true, options => options + .WithCustomArgument("-vn -c:a copy")) + .ProcessSynchronously(); + + if (!audioExtracted || !File.Exists(audioPath)) + { + LogUtils.Warn("音频分离失败,继续仅处理视频"); + hasAudio = false; + } + } + + // 裁剪第一部分(从开始到删除帧之前) + bool part1Success = false; + if (frameTime > 0) + { + part1Success = SubVideo(inputPath, videoPart1, 0, frameTime); + if (!part1Success || !File.Exists(videoPart1)) + { + LogUtils.Error($"裁剪第一部分失败"); + return false; + } + } + + // 裁剪第二部分(从删除帧之后到结束) + bool part2Success = SubVideo(inputPath, videoPart2, nextFrameTime, totalDuration); + if (!part2Success || !File.Exists(videoPart2)) + { + LogUtils.Error($"裁剪第二部分失败"); + return false; + } + + // 合并两部分视频 + List videoParts = []; + if (frameTime > 0 && File.Exists(videoPart1)) + { + videoParts.Add(videoPart1); + } + if (File.Exists(videoPart2)) + { + videoParts.Add(videoPart2); + } + + if (videoParts.Count == 0) + { + LogUtils.Error("没有可合并的视频部分"); + return false; + } + + bool mergeSuccess = false; + if (videoParts.Count == 1) + { + // 如果只有一部分,直接复制 + File.Copy(videoParts[0], mergedVideo, true); + mergeSuccess = File.Exists(mergedVideo); + } + else + { + mergeSuccess = JoinVideo(mergedVideo, videoParts.ToArray()); + } + + if (!mergeSuccess || !File.Exists(mergedVideo)) + { + LogUtils.Error("合并视频失败"); + return false; + } + + // 如果有音频,合并音频和视频 + bool success = false; + if (hasAudio && File.Exists(audioPath)) + { + // 使用明确的 copy 参数,确保不重新编码 + success = await FFMpegArguments + .FromFileInput(mergedVideo) + .AddFileInput(audioPath) + .OutputToFile(outputPath, true, options => options + .WithVideoCodec("copy") // 明确指定复制视频流 + .WithAudioCodec("copy") // 明确指定复制音频流 + .WithCustomArgument("-shortest -y")) + .ProcessAsynchronously(); + } + else + { + // 没有音频,直接复制视频 + File.Copy(mergedVideo, outputPath, true); + success = File.Exists(outputPath); + } + + // 验证输出文件 + if (success && File.Exists(outputPath)) + { + FileInfo outputFileInfo = new FileInfo(outputPath); + FileInfo inputFileInfo = new FileInfo(inputPath); + + if (outputFileInfo.Length > 0) + { + try + { + var outputMediaInfo = await FFProbe.AnalyseAsync(outputPath); + double outputDuration = outputMediaInfo.Duration.TotalSeconds; + + long sizeDiff = outputFileInfo.Length - inputFileInfo.Length; + double sizeDiffMB = sizeDiff / 1024.0 / 1024.0; + string sizeChange = sizeDiff > 0 ? $"+{sizeDiffMB:F2}MB" : sizeDiff < 0 ? $"{sizeDiffMB:F2}MB" : "0MB"; + + LogUtils.Info($"删除非关键帧完成:输出文件={outputPath}, 原始大小={inputFileInfo.Length / 1024.0 / 1024.0:F2}MB, 输出大小={outputFileInfo.Length / 1024.0 / 1024.0:F2}MB ({sizeChange}), 时长={outputDuration:F2}秒"); + + if (sizeDiff > 0) + { + LogUtils.Warn($"注意:输出文件比原始文件大了 {sizeDiffMB:F2}MB,这可能是因为在非关键帧位置裁剪需要重新编码部分内容"); + } + } + catch (Exception ex) + { + LogUtils.Warn($"验证输出视频信息时出错(不影响结果):{ex.Message}"); + } + + return true; + } + else + { + LogUtils.Error($"输出文件存在但大小为0:{outputPath}"); + return false; + } + } + else + { + LogUtils.Error($"删除非关键帧失败:success={success}, 文件存在={File.Exists(outputPath)}, 输出路径={outputPath}"); + return false; + } + } + catch (Exception ex) + { + LogUtils.Error("删除非关键帧过程发生异常", ex); + return false; + } + finally + { + // 清理临时目录 + try + { + 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 { } + } + } + + /// + /// 在随机位置添加一个肉眼无法感知的小像素透明图 + /// + /// 输入文件路径 + /// 输出文件路径 + /// 操作是否成功 + public static async Task AddTransparentImageAsync(string inputPath, string outputPath) + { + if (!File.Exists(inputPath)) + { + LogUtils.Error($"输入文件不存在:{inputPath}"); + return false; + } + + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + Directory.CreateDirectory(tempDir); + + // 1. 获取视频信息 + IMediaAnalysis mediaInfo = await FFProbe.AnalyseAsync(inputPath); + var videoStream = mediaInfo.PrimaryVideoStream; + if (videoStream == null) + { + LogUtils.Error("没有找到视频流"); + return false; + } + + int videoWidth = videoStream.Width; + int videoHeight = videoStream.Height; + double totalDuration = mediaInfo.Duration.TotalSeconds; + + // 确保视频时长足够 + if (totalDuration < 1) + { + LogUtils.Error($"视频时长太短({totalDuration:F2}秒)"); + return false; + } + + // 2. 随机选择叠加位置(可以在视频的任意位置,1x1透明图肉眼无法感知) + var random = new Random((int)(DateTime.Now.Ticks % int.MaxValue) + inputPath.GetHashCode()); + // 完全随机选择位置,覆盖整个视频区域(0到宽度-1,0到高度-1) + int x = random.Next(0, videoWidth); + int y = random.Next(0, videoHeight); + + // 3. 创建1x1透明PNG文件(使用FFmpeg命令行直接执行) + string transparentImagePath = Path.Combine(tempDir, "transparent_1x1.png"); + + // 使用FFmpeg命令行创建透明PNG(通过System.Diagnostics.Process) + try + { + // 查找ffmpeg.exe路径 + string ffmpegPath = ""; + + // 方法1: 尝试从当前程序目录查找 + string currentDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? ""; + ffmpegPath = Path.Combine(currentDir, "ffmpeg.exe"); + + // 方法2: 如果在net8.0-windows目录下 + if (!File.Exists(ffmpegPath)) + { + string netDir = Path.Combine(currentDir, "net8.0-windows"); + ffmpegPath = Path.Combine(netDir, "ffmpeg.exe"); + } + + // 方法3: 从系统PATH查找 + if (!File.Exists(ffmpegPath)) + { + ffmpegPath = "ffmpeg.exe"; // 如果在PATH中 + } + + if (!File.Exists(ffmpegPath) && ffmpegPath != "ffmpeg.exe") + { + LogUtils.Error($"无法找到ffmpeg.exe,已尝试路径:{Path.Combine(currentDir, "ffmpeg.exe")}"); + return false; + } + + var processInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = ffmpegPath, + Arguments = $"-f lavfi -i \"color=c=0x00000000:s=1x1:d=0.04\" -frames:v 1 -pix_fmt rgba \"{transparentImagePath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (var process = System.Diagnostics.Process.Start(processInfo)) + { + if (process == null) + { + LogUtils.Error("无法启动ffmpeg进程"); + return false; + } + + process.WaitForExit(); + + if (process.ExitCode != 0) + { + string error = await process.StandardError.ReadToEndAsync(); + LogUtils.Error($"FFmpeg创建透明PNG失败,退出码:{process.ExitCode},错误:{error}"); + return false; + } + } + + if (!File.Exists(transparentImagePath)) + { + LogUtils.Error("透明PNG文件创建失败,文件不存在"); + return false; + } + + LogUtils.Info($"透明PNG文件已创建:{transparentImagePath}"); + } + catch (Exception ex) + { + LogUtils.Error($"创建透明PNG文件失败:{ex.Message}", ex); + return false; + } + + LogUtils.Info($"开始添加透明图:输入={inputPath}, 输出={outputPath}, 视频尺寸={videoWidth}x{videoHeight}, 叠加位置=({x},{y})"); + + // 4. 使用overlay过滤器在指定位置叠加透明图(在整个视频中持续存在) + var ffmpegArgs = FFMpegArguments + .FromFileInput(inputPath) + .AddFileInput(transparentImagePath) + .OutputToFile(outputPath, true, options => + { + // 使用overlay过滤器叠加透明图,在整个视频的所有帧中都存在 + // 透明图会自动循环以适应视频长度 + options.WithCustomArgument($"-filter_complex \"[0:v][1:v]overlay={x}:{y}:shortest=0[v]\""); + options.WithCustomArgument("-map \"[v]\""); + + // 视频编码:使用最快设置 + options.WithVideoCodec("libx264"); + options.WithCustomArgument("-preset ultrafast"); + options.WithCustomArgument("-tune zerolatency"); + options.WithConstantRateFactor(28); + options.WithCustomArgument("-g 30"); + options.WithCustomArgument("-threads 0"); + options.WithCustomArgument("-vsync cfr"); + + // 音频:直接复制 + options.WithCustomArgument("-map 0:a?"); + options.WithAudioCodec("copy"); + }); + + bool success = ffmpegArgs.ProcessSynchronously(); + + // 验证输出文件 + if (success && File.Exists(outputPath)) + { + FileInfo fileInfo = new FileInfo(outputPath); + if (fileInfo.Length > 0) + { + try + { + var outputMediaInfo = await FFProbe.AnalyseAsync(outputPath); + double outputDuration = outputMediaInfo.Duration.TotalSeconds; + LogUtils.Info($"添加透明图完成:输出文件={outputPath}, 大小={fileInfo.Length / 1024 / 1024:F2}MB, 时长={outputDuration:F2}秒"); + } + catch (Exception ex) + { + LogUtils.Warn($"验证输出视频信息时出错(不影响结果):{ex.Message}"); + } + + return true; + } + else + { + LogUtils.Error($"输出文件存在但大小为0:{outputPath}"); + return false; + } + } + else + { + LogUtils.Error($"添加透明图失败:success={success}, 文件存在={File.Exists(outputPath)}, 输出路径={outputPath}"); + return false; + } + } + catch (Exception ex) + { + LogUtils.Error("添加透明图过程发生异常", ex); + return false; + } + finally + { + // 清理临时目录 + try + { + 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 { } + } + } } } diff --git a/ViewModels/ExtractWindowViewModel.cs b/ViewModels/ExtractWindowViewModel.cs index c1dc0b9..83b9458 100644 --- a/ViewModels/ExtractWindowViewModel.cs +++ b/ViewModels/ExtractWindowViewModel.cs @@ -73,9 +73,14 @@ namespace VideoConcat.ViewModels return; } + string modeText = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2"; + DateTime startTime = DateTime.Now; + ExtractWindowModel.Dispatcher.Invoke(() => { - ExtractWindowModel.HelpInfo = $"开始处理,每个视频将生成 {extractCount} 个抽帧视频..."; + ExtractWindowModel.HelpInfo = $"处理方案:{modeText} | 视频数量:{ExtractWindowModel.videos.Length} 个 | 每个生成:{extractCount} 个 | 总任务数:{ExtractWindowModel.videos.Length * extractCount} 个 | 开始时间:{startTime:yyyy-MM-dd HH:mm:ss}"; + // 清空任务列表,以便重新开始 + ExtractWindowModel.TaskItems.Clear(); ExtractWindowModel.IsStart = true; ExtractWindowModel.IsCanOperate = false; }); @@ -86,6 +91,11 @@ namespace VideoConcat.ViewModels int totalTasks = ExtractWindowModel.videos.Length * extractCount; int completedTasks = 0; System.Collections.Concurrent.ConcurrentBag errorMessages = new(); // 收集错误信息 + System.Collections.Concurrent.ConcurrentBag successMessages = new(); // 收集成功信息 + long totalOriginalSize = 0; // 原始文件总大小 + long totalOutputSize = 0; // 输出文件总大小 + int successCount = 0; // 成功数量 + int skipCount = 0; // 跳过数量(已存在) // 对每个视频生成指定数量的抽帧视频 foreach (var video in ExtractWindowModel.videos) @@ -115,8 +125,9 @@ namespace VideoConcat.ViewModels string originalFileName = Path.GetFileNameWithoutExtension(currentVideo); string extension = Path.GetExtension(currentVideo); - // 生成唯一的文件名:原文件名_序号.扩展名 - string _tmpFileName = $"{originalFileName}_{currentIndex:D4}{extension}"; + // 生成唯一的文件名:原文件名_序号_方案.扩展名 + string modeSuffix = ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame ? "方案1" : "方案2"; + string _tmpFileName = $"{originalFileName}_{currentIndex:D4}_{modeSuffix}{extension}"; string outPath = Path.Combine(_tmpPath, "out"); LogUtils.Info($"准备创建输出目录:{outPath}"); @@ -143,35 +154,84 @@ namespace VideoConcat.ViewModels string outputPath = Path.Combine(outPath, _tmpFileName); LogUtils.Info($"开始抽帧:输入={currentVideo}, 输出={outputPath}"); - // 如果文件已存在,跳过 + // 定义显示用的变量(提前定义,避免作用域问题) + // 显示完整的文件名,让DataGrid的列宽和ToolTip来处理显示 + string displayFileName = originalFileName; + int currentCompleted; + double progressPercent; + + // 获取原始文件大小 + long originalSize = 0; + if (File.Exists(currentVideo)) + { + FileInfo originalFileInfo = new FileInfo(currentVideo); + originalSize = originalFileInfo.Length; + Interlocked.Add(ref totalOriginalSize, originalSize); + } + + // 如果文件已存在,跳过(不覆盖) if (File.Exists(outputPath)) { LogUtils.Info($"文件已存在,跳过:{outputPath}"); + Interlocked.Increment(ref skipCount); Interlocked.Increment(ref completedTasks); + + currentCompleted = completedTasks; + progressPercent = currentCompleted * 100.0 / totalTasks; + ExtractWindowModel.Dispatcher.Invoke(() => { - ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + var taskItem = new Models.ExtractTaskItem + { + Index = currentIndex.ToString(), + FileName = displayFileName, + FullFileName = _tmpFileName, + Status = "跳过", + OriginalSize = "--", + OutputSize = "--", + SizeChange = "--", + Duration = "--", + Progress = $"{progressPercent:F1}%" + }; + ExtractWindowModel.TaskItems.Add(taskItem); }); return; } - // 先检查视频时长 + // 先检查视频时长和获取视频信息 + IMediaAnalysis? mediaInfo = null; + double totalDuration = 0; try { - var mediaInfo = await FFProbe.AnalyseAsync(currentVideo); - double totalDuration = mediaInfo.Duration.TotalSeconds; + mediaInfo = await FFProbe.AnalyseAsync(currentVideo); + totalDuration = mediaInfo.Duration.TotalSeconds; - if (totalDuration < 20) + if (totalDuration < 20 && ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame) { string videoName = Path.GetFileName(currentVideo); - string errorMsg = $"视频时长太短:{videoName}({totalDuration:F2}秒),无法抽帧(需要至少20秒)"; + string errorMsg = $"视频时长太短:{videoName}({totalDuration:F2}秒),需要至少20秒"; LogUtils.Error(errorMsg); errorMessages.Add(errorMsg); Interlocked.Increment(ref completedTasks); + currentCompleted = completedTasks; + progressPercent = currentCompleted * 100.0 / totalTasks; + ExtractWindowModel.Dispatcher.Invoke(() => { - ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + var taskItem = new Models.ExtractTaskItem + { + Index = currentIndex.ToString(), + FileName = displayFileName, + FullFileName = $"{originalFileName}_{currentIndex:D4}{extension}", + Status = "失败", + OriginalSize = "--", + OutputSize = "--", + SizeChange = "--", + Duration = "--", + Progress = $"{progressPercent:F1}%" + }; + ExtractWindowModel.TaskItems.Add(taskItem); }); return; } @@ -179,46 +239,155 @@ namespace VideoConcat.ViewModels catch (Exception ex) { LogUtils.Error($"检查视频时长失败:{currentVideo}", ex); - // 继续处理,让 RemoveFrameRandomeAsync 来处理错误 + // 继续处理,让后续方法来处理错误 } - bool success = await VideoProcess.RemoveFrameRandomeAsync(currentVideo, outputPath); + // 记录开始时间 + DateTime taskStartTime = DateTime.Now; + + // 根据选择的处理方式调用相应方法 + bool success = false; + + if (ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.DeleteFrame) + { + // 方案1:随机删除一个非关键帧(传入index确保每次位置不同) + success = await VideoProcess.RemoveNonKeyFrameAsync(currentVideo, outputPath, currentIndex); + } + else if (ExtractWindowModel.ExtractFrameMode == Models.ExtractFrameMode.AddTransparentImage) + { + // 方案2:在随机位置添加透明图 + success = await VideoProcess.AddTransparentImageAsync(currentVideo, outputPath); + } + else + { + // 默认使用原来的方法(保留兼容性) + success = await VideoProcess.RemoveFrameRandomeAsync(currentVideo, outputPath); + } + + // 计算处理时间 + TimeSpan taskDuration = DateTime.Now - taskStartTime; + + // 更新完成计数 + Interlocked.Increment(ref completedTasks); + currentCompleted = completedTasks; + progressPercent = currentCompleted * 100.0 / totalTasks; // 再次检查文件是否存在 if (File.Exists(outputPath)) { - FileInfo fileInfo = new FileInfo(outputPath); - LogUtils.Info($"抽帧成功:{currentVideo} -> {outputPath}, 文件大小={fileInfo.Length / 1024 / 1024}MB"); + FileInfo outputFileInfo = new FileInfo(outputPath); + long outputSize = outputFileInfo.Length; + Interlocked.Add(ref totalOutputSize, outputSize); + Interlocked.Increment(ref successCount); + + double originalMB = originalSize / 1024.0 / 1024.0; + double outputMB = outputSize / 1024.0 / 1024.0; + double sizeDiff = outputSize - originalSize; + double sizeDiffMB = Math.Abs(sizeDiff) / 1024.0 / 1024.0; + string sizeChange = sizeDiff > 0 ? $"+{sizeDiffMB:F2}" : sizeDiff < 0 ? $"-{sizeDiffMB:F2}" : "0"; + string sizeChangeMB = sizeChange + "MB"; + + string originalSizeStr = $"{originalMB:F2}MB"; + string outputSizeStr = $"{outputMB:F2}MB"; + string durationStr = $"{taskDuration.TotalSeconds:F1}s"; + + successMessages.Add($"{originalFileName} (第{currentIndex}个) - 成功"); + LogUtils.Info($"处理成功:{currentVideo} -> {outputPath}, 大小={outputMB:F2}MB, 耗时={taskDuration.TotalSeconds:F2}秒"); + + // 更新表格行 + ExtractWindowModel.Dispatcher.Invoke(() => + { + var taskItem = new Models.ExtractTaskItem + { + Index = currentIndex.ToString(), + FileName = displayFileName, + FullFileName = _tmpFileName, + Status = "成功", + OriginalSize = originalSizeStr, + OutputSize = outputSizeStr, + SizeChange = sizeChangeMB, + Duration = durationStr, + Progress = $"{progressPercent:F1}%" + }; + ExtractWindowModel.TaskItems.Add(taskItem); + }); } else if (success) { - LogUtils.Warn($"抽帧返回成功但文件不存在:{outputPath}"); + LogUtils.Warn($"处理返回成功但文件不存在:{outputPath}"); + string errorMsg = $"{originalFileName} (第{currentIndex}个) - 返回成功但文件不存在"; + errorMessages.Add(errorMsg); + + ExtractWindowModel.Dispatcher.Invoke(() => + { + var taskItem = new Models.ExtractTaskItem + { + Index = currentIndex.ToString(), + FileName = displayFileName, + FullFileName = _tmpFileName, + Status = "警告", + OriginalSize = "--", + OutputSize = "--", + SizeChange = "--", + Duration = "--", + Progress = $"{progressPercent:F1}%" + }; + ExtractWindowModel.TaskItems.Add(taskItem); + }); } else { - string videoName = Path.GetFileName(currentVideo); - string errorMsg = $"抽帧失败:{videoName}"; + string errorMsg = $"{originalFileName} (第{currentIndex}个) - 处理失败"; LogUtils.Error($"{errorMsg} -> {outputPath}"); errorMessages.Add(errorMsg); + + ExtractWindowModel.Dispatcher.Invoke(() => + { + var taskItem = new Models.ExtractTaskItem + { + Index = currentIndex.ToString(), + FileName = displayFileName, + FullFileName = _tmpFileName, + Status = "失败", + OriginalSize = "--", + OutputSize = "--", + SizeChange = "--", + Duration = "--", + Progress = $"{progressPercent:F1}%" + }; + ExtractWindowModel.TaskItems.Add(taskItem); + }); } - - // 更新完成计数 - Interlocked.Increment(ref completedTasks); - ExtractWindowModel.Dispatcher.Invoke(() => - { - ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; - }); } catch (Exception ex) { + // 在异常处理中重新获取文件名等信息 + string originalFileNameForError = Path.GetFileNameWithoutExtension(currentVideo); string videoName = Path.GetFileName(currentVideo); string errorMsg = $"抽帧异常:{videoName} (第{currentIndex}个) - {ex.Message}"; LogUtils.Error($"抽帧失败:{currentVideo} (第{currentIndex}个)", ex); errorMessages.Add(errorMsg); + Interlocked.Increment(ref completedTasks); + int currentCompletedForError = completedTasks; + double progressPercentForError = currentCompletedForError * 100.0 / totalTasks; + string displayFileNameForError = originalFileNameForError.Length > 15 ? originalFileNameForError.Substring(0, 12) + "..." : originalFileNameForError.PadRight(15); + ExtractWindowModel.Dispatcher.Invoke(() => { - ExtractWindowModel.HelpInfo = $"处理中... ({completedTasks}/{totalTasks})"; + var taskItem = new Models.ExtractTaskItem + { + Index = currentIndex.ToString(), + FileName = displayFileNameForError, + FullFileName = $"{originalFileNameForError}_{currentIndex:D4}{Path.GetExtension(currentVideo)}", + Status = "异常", + OriginalSize = "--", + OutputSize = "--", + SizeChange = "--", + Duration = "--", + Progress = $"{progressPercentForError:F1}%" + }; + ExtractWindowModel.TaskItems.Add(taskItem); }); } finally @@ -232,6 +401,9 @@ namespace VideoConcat.ViewModels await Task.WhenAll(_tasks); + // 计算总耗时 + TimeSpan totalDuration = DateTime.Now - startTime; + // 统计实际生成的文件数量 int actualFileCount = 0; string outputDir = ""; @@ -248,25 +420,40 @@ namespace VideoConcat.ViewModels } } - // 构建最终信息 - string summaryInfo = $"全部完成! 共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件\n输出目录:{outputDir}"; + // 构建最终详细信息 + double totalOriginalMB = totalOriginalSize / 1024.0 / 1024.0; + double totalOutputMB = totalOutputSize / 1024.0 / 1024.0; + double totalSizeDiff = totalOutputSize - totalOriginalSize; + double totalSizeDiffMB = Math.Abs(totalSizeDiff) / 1024.0 / 1024.0; + string totalSizeChange = totalSizeDiff > 0 ? $"+{totalSizeDiffMB:F2}MB" : totalSizeDiff < 0 ? $"-{totalSizeDiffMB:F2}MB" : "0MB"; + + string summaryInfo = $"\n处理完成!\n" + + $"处理方案:{modeText} | 总任务数:{totalTasks} 个\n" + + $"成功数量:{successCount} 个 | 失败数量:{errorMessages.Count} 个 | 跳过数量:{skipCount} 个\n" + + $"实际生成:{actualFileCount} 个文件\n" + + $"总原始大小:{totalOriginalMB:F2}MB | 总输出大小:{totalOutputMB:F2}MB | 大小变化:{totalSizeChange}\n" + + $"总耗时:{totalDuration.TotalSeconds:F1}秒 ({totalDuration.TotalMinutes:F1}分钟) | 平均速度:{(successCount > 0 ? totalDuration.TotalSeconds / successCount : 0):F2}秒/个\n" + + $"输出目录:{outputDir}\n" + + $"完成时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}"; ExtractWindowModel.Dispatcher.Invoke(() => { + ExtractWindowModel.HelpInfo += $"\n{summaryInfo}"; + // 如果有错误信息,显示汇总 if (errorMessages.Count > 0) { - string errorSummary = string.Join("\n", errorMessages); - ExtractWindowModel.HelpInfo = $"{summaryInfo}\n\n错误信息(共{errorMessages.Count}个):\n{errorSummary}"; - } - else - { - ExtractWindowModel.HelpInfo = summaryInfo; + ExtractWindowModel.HelpInfo += $"\n错误信息(共{errorMessages.Count}个):"; + foreach (var error in errorMessages) + { + ExtractWindowModel.HelpInfo += $"\n • {error}"; + } } + ExtractWindowModel.IsStart = false; ExtractWindowModel.IsCanOperate = true; }); - LogUtils.Info($"抽帧处理完成,共处理 {completedTasks} 个任务,实际生成 {actualFileCount} 个视频文件,输出目录:{outputDir}"); + LogUtils.Info($"处理完成,成功={successCount}, 失败={errorMessages.Count}, 跳过={skipCount}, 总耗时={totalDuration.TotalSeconds:F1}秒"); }); } }, diff --git a/Views/ExtractWindow.xaml b/Views/ExtractWindow.xaml index 7b0edaf..029b9d2 100644 --- a/Views/ExtractWindow.xaml +++ b/Views/ExtractWindow.xaml @@ -23,23 +23,65 @@ - + - +